diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d619358 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Kin Lo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..030a2f9 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +goplurk +====== +A golang wrapper of [Plurk API 2.0](https://www.plurk.com/API) + +Getting started +---- +With Go module support, simply add the following import +``` +import "github.com/kinabcd/goplurk" +``` + +Basic usage +---- +```golang +package main + +import ( + "github.com/kinabcd/goplurk" +) + +var consumerToken = "..." +var consumerSecret = "..." +var accessToken = "..." +var accessSecret = "..." + +func main() { + client, _ := goplurk.NewClient(consumerToken, consumerSecret, accessToken, accessSecret) + client.Timeline.PlurkAdd("says", "somecontent") +} + +``` + +Advenced usage +---- +see **example/** for more usages + +Author +------ + +Kin Lo :: kinabcd@gmail.com :: @kinabcd diff --git a/api_profile.go b/api_profile.go new file mode 100644 index 0000000..9938659 --- /dev/null +++ b/api_profile.go @@ -0,0 +1,35 @@ +package goplurk + +import ( + "encoding/json" + "strconv" +) + +type APIProfile struct { + client *Client +} + +func (u *APIProfile) GetOwnProfile() (*Profile, error) { + resBytes, err := u.client.Engine.CallAPI("/APP/Profile/getOwnProfile", map[string]string{}) + if err != nil { + return nil, err + } + var userDate = &Profile{} + if err := json.Unmarshal(resBytes, userDate); err != nil { + return nil, err + } + return userDate, nil +} +func (u *APIProfile) GetPublicProfile(userId int64) (*Profile, error) { + resBytes, err := u.client.Engine.CallAPI("/APP/Profile/getPublicProfile", map[string]string{ + "user_id": strconv.FormatInt(userId, 10), + }) + if err != nil { + return nil, err + } + var userDate = &Profile{} + if err := json.Unmarshal(resBytes, userDate); err != nil { + return nil, err + } + return userDate, nil +} diff --git a/api_responses.go b/api_responses.go new file mode 100644 index 0000000..34816f9 --- /dev/null +++ b/api_responses.go @@ -0,0 +1,61 @@ +package goplurk + +import ( + "encoding/json" + "fmt" + "strconv" +) + +type APIResponses struct { + client *Client +} + +func (u *APIResponses) Get(plurkId int64, fromResoponse int64, count int64) (*Responses, error) { + var body = map[string]string{} + body["plurk_id"] = strconv.FormatInt(plurkId, 10) + body["from_response"] = strconv.FormatInt(fromResoponse, 10) + body["count"] = strconv.FormatInt(count, 10) + res, err := u.client.Engine.CallAPI("/APP/Responses/get", body) + if err != nil { + return nil, err + } + responses := Responses{} + if err := json.Unmarshal(res, &responses); err != nil { + return nil, fmt.Errorf("failed to unmarshal responses: %v, %s", err, string(res)) + } + + return &responses, nil +} +func (u *APIResponses) ResponseAdd(plurkId int64, qualifier string, content string) (*Response, error) { + if qualifier == "" { + qualifier = ":" + } + if content == "" { + return nil, fmt.Errorf("content can not be empty") + } + var body = map[string]string{} + body["plurk_id"] = strconv.FormatInt(plurkId, 10) + body["qualifier"] = qualifier + body["content"] = content + res, err := u.client.Engine.CallAPI("/APP/Responses/responseAdd", body) + if err != nil { + return nil, err + } + response := Response{} + if err := json.Unmarshal(res, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %v, %s", err, string(res)) + } + return &response, nil +} + +func (u *APIResponses) ResponseDelete(responseId int64, plurkId int64) error { + _, err := u.client.Engine.CallAPI("/APP/Responses/responseDelete", map[string]string{ + "response_id": strconv.FormatInt(responseId, 10), + "plurk_id": strconv.FormatInt(plurkId, 10), + }) + if err != nil { + return fmt.Errorf("failed to delete response: %v", err) + } + return nil + +} diff --git a/api_timeline.go b/api_timeline.go new file mode 100644 index 0000000..b48d4e8 --- /dev/null +++ b/api_timeline.go @@ -0,0 +1,108 @@ +package goplurk + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "golang.org/x/exp/maps" +) + +type APITimeline struct { + client *Client +} + +func (u *APITimeline) GetPlurk(plurkId int64) (*Plurk, error) { + resBytes, err := u.client.Engine.CallAPI("/APP/Timeline/getPlurk", map[string]string{ + "plurk_id": strconv.FormatInt(plurkId, 10), + }) + if err != nil { + return nil, fmt.Errorf("failed to get plurk: %v", err) + } + plurk := struct { + Plurk Plurk `json:"plurk"` + }{} + if err := json.Unmarshal(resBytes, &plurk); err != nil { + return nil, fmt.Errorf("failed to unmarshal plurk: %v, %s", err, string(resBytes)) + } + return &plurk.Plurk, nil +} + +func (u *APITimeline) PlurkAdd(qualifier string, content string, optionSets ...Options) (*Plurk, error) { + if qualifier == "" { + qualifier = ":" + } + if content == "" { + return nil, fmt.Errorf("content can not be empty") + } + var body = map[string]string{} + body["qualifier"] = qualifier + body["content"] = content + for _, optionSet := range optionSets { + maps.Copy(body, optionSet.Get()) + } + res, err := u.client.Engine.CallAPI("/APP/Timeline/plurkAdd", body) + if err != nil { + return nil, err + } + plurk := Plurk{} + if err := json.Unmarshal(res, &plurk); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %v, %s", err, string(res)) + } + return &plurk, nil +} + +func (u *APITimeline) PlurkDelete(plurkId int64) error { + _, err := u.client.Engine.CallAPI("/APP/Timeline/plurkDelete", map[string]string{ + "plurk_id": strconv.FormatInt(plurkId, 10), + }) + if err != nil { + return fmt.Errorf("failed to delete plurk: %v", err) + } + return nil +} + +func (u *APITimeline) MutePlurks(plurkIds []int64) error { + return u.opPlurk("/APP/Timeline/mutePlurks", plurkIds) +} + +func (u *APITimeline) UnmutePlurks(plurkIds []int64) error { + return u.opPlurk("/APP/Timeline/unmutePlurks", plurkIds) +} + +func (u *APITimeline) FavoritePlurks(plurkIds []int64) error { + return u.opPlurk("/APP/Timeline/favoritePlurks", plurkIds) +} + +func (u *APITimeline) UnfavoritePlurks(plurkIds []int64) error { + return u.opPlurk("/APP/Timeline/unfavoritePlurks", plurkIds) +} + +func (u *APITimeline) Replurk(plurkIds []int64) error { + return u.opPlurk("/APP/Timeline/replurk", plurkIds) +} + +func (u *APITimeline) Unreplurk(plurkIds []int64) error { + return u.opPlurk("/APP/Timeline/unreplurk", plurkIds) +} + +func (u *APITimeline) MarkAsRead(plurkIds []int64) error { + return u.opPlurk("/APP/Timeline/markAsRead", plurkIds) +} + +func (u *APITimeline) opPlurk(_url string, plurkIds []int64) error { + if len(plurkIds) != 0 { + plurkIdStrs := []string{} + for _, limited := range plurkIds { + plurkIdStrs = append(plurkIdStrs, strconv.FormatInt(limited, 10)) + } + _, err := u.client.Engine.CallAPI(_url, map[string]string{ + "ids": "[" + strings.Join(plurkIdStrs, ",") + "]", + }) + return err + } else { + return fmt.Errorf("plurkIds can not be empty") + } + +} diff --git a/api_users.go b/api_users.go new file mode 100644 index 0000000..4e42703 --- /dev/null +++ b/api_users.go @@ -0,0 +1,21 @@ +package goplurk + +import ( + "encoding/json" +) + +type APIUsers struct { + client *Client +} + +func (u *APIUsers) Me() (*User, error) { + resBytes, err := u.client.Engine.CallAPI("/APP/Users/me", map[string]string{}) + if err != nil { + return nil, err + } + var userDate = &User{} + if err := json.Unmarshal(resBytes, userDate); err != nil { + return nil, err + } + return userDate, nil +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..93ace17 --- /dev/null +++ b/client.go @@ -0,0 +1,114 @@ +package goplurk + +import ( + "fmt" + "io" + "net/http" + "net/url" + + "github.com/kinabcd/goplurk/oauth" +) + +var baseURL = "https://www.plurk.com" + +type Engine interface { + CallAPI(_url string, opt map[string]string) ([]byte, error) +} +type EngineImpl struct { + oauthClient *oauth.Client + Credentials *oauth.Credentials +} +type Client struct { + Users *APIUsers + Timeline *APITimeline + Responses *APIResponses + Profile *APIProfile + Engine Engine +} + +func NewClient(consumerToken string, consumerSecret string, accessToken string, accessSecret string) (*Client, error) { + if consumerToken == "" || consumerSecret == "" || accessToken == "" || accessSecret == "" { + return nil, fmt.Errorf("can not be authorized") + } + + return newClient(newOAuthClient(consumerToken, consumerSecret), &oauth.Credentials{ + Token: accessToken, + Secret: accessSecret, + }), nil +} + +func newClient(oauthClient *oauth.Client, credentials *oauth.Credentials) *Client { + client := &Client{} + client.Engine = &EngineImpl{ + oauthClient: oauthClient, + Credentials: credentials, + } + client.Users = &APIUsers{client: client} + client.Timeline = &APITimeline{client: client} + client.Responses = &APIResponses{client: client} + client.Profile = &APIProfile{client: client} + return client +} + +func newOAuthClient(consumerToken string, consumerSecret string) *oauth.Client { + return &oauth.Client{ + TemporaryCredentialRequestURI: "https://www.plurk.com/OAuth/request_token", + ResourceOwnerAuthorizationURI: "https://www.plurk.com/OAuth/authorize", + TokenRequestURI: "https://www.plurk.com/OAuth/access_token", + Credentials: oauth.Credentials{ + Token: consumerToken, + Secret: consumerSecret, + }, + } +} + +func (c *EngineImpl) CallAPI(_url string, opt map[string]string) ([]byte, error) { + var apiURL = baseURL + _url + param := make(url.Values) + for k, v := range opt { + param.Set(k, v) + } + c.oauthClient.SignForm(c.Credentials, "POST", apiURL, param) + res, err := http.PostForm(apiURL, url.Values(param)) + if err != nil { + return nil, fmt.Errorf("failed to call API: %s, %s, %v", apiURL, fmt.Sprint(param), err) + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to get response: %v", err) + } + if res.StatusCode != 200 { + return nil, fmt.Errorf("%s", string(body)) + } + return body, nil +} + +type OAuthRequest struct { + client *oauth.Client + Url string + temporaryCredentials *oauth.Credentials +} + +func NewOAuthRequest(consumerToken string, consumerSecret string) (*OAuthRequest, error) { + oauthClient := newOAuthClient(consumerToken, consumerSecret) + requestToken, err := oauthClient.RequestTemporaryCredentials(http.DefaultClient, "", nil) + if err != nil { + return nil, fmt.Errorf("failed to request temporary credentials: %v", err) + } + url := oauthClient.AuthorizationURL(requestToken, nil) + + return &OAuthRequest{ + client: oauthClient, + Url: url, + temporaryCredentials: requestToken, + }, nil +} + +func (c *OAuthRequest) SendPin(pin string) (*Client, string, string, error) { + credentials, _, err := c.client.RequestToken(http.DefaultClient, c.temporaryCredentials, pin) + if err != nil { + return nil, "", "", fmt.Errorf("failed to request accessToken: %v", err) + } + return newClient(c.client, credentials), credentials.Token, credentials.Secret, nil +} diff --git a/data.go b/data.go new file mode 100644 index 0000000..c1df04d --- /dev/null +++ b/data.go @@ -0,0 +1,111 @@ +package goplurk + +type User struct { + Id int64 `json:"id"` + NickName string `json:"nick_name"` + DisplayName string `json:"display_name"` + FullName string `json:"full_name"` + NameColor string `json:"name_color"` + Premium bool `json:"premium"` + HasProfileImage int64 `json:"has_profile_image"` + Avatar int64 `json:"avatar"` + ShowLocation int64 `json:"show_location"` + Location string `json:"location"` + Timezone string `json:"timezone"` + DefaultLang string `json:"default_lang"` + DateFormat int64 `json:"dateformat"` + DateOfBirth string `json:"date_of_birth"` + Birthday Birthday `json:"birthday"` + BdayPrivacy int64 `json:"bday_privacy"` + Gender int64 `json:"gender"` + Karma float64 `json:"karma"` + Recruited int64 `json:"recruited"` + Relationship string `json:"relationship"` + Status string `json:"status"` + TimelinePrivacy int64 `json:"timeline_privacy"` + VerifiedAccount bool `json:"verified_account"` + FriendListPrivacy string `json:"friend_list_privacy"` + EmailConfirmed bool `json:"email_confirmed"` + PhoneVerified *string `json:"phone_verified"` + PinnedPlurkId *string `json:"pinned_plurk_id"` + BackgroundId int64 `json:"background_id"` + ShowAds bool `json:"show_ads"` +} + +type Birthday struct { + Year int64 `json:"year"` + Month int64 `json:"month"` + Day int64 `json:"day"` +} + +type Plurk struct { + PlurkId int64 `json:"plurk_id"` + Qualifier string `json:"qualifier"` + QualifierTranslated string `json:"qualifier_translated"` + IsUnread int64 `json:"is_unread"` + PlurkType int64 `json:"plurk_type"` + UserId int64 `json:"user_id"` + OwnerId int64 `json:"owner_id"` + Posted string `json:"posted"` + NoComments int64 `json:"no_comments"` + Content string `json:"content"` + ContentRaw string `json:"content_raw"` + ResponseCount int64 `json:"response_count"` + ResponseSeen int64 `json:"responses_seen"` + LimitedTo string `json:"limited_to"` + Favorite bool `json:"favorite"` + FavoriteCount int64 `json:"favorite_count"` + Favorers []int64 `json:"favorers"` + Replurkable bool `json:"replurkable"` + Replurked bool `json:"replurked"` + ReplurkerId int64 `json:"replurker_id"` + ReplurkerCount int64 `json:"replurkers_count"` + Replurkers []int64 `json:"replurkers"` +} + +type Plurks struct { + Plurks []Plurk `json:"plurks"` + PlurkUsers []User `json:"plurk_users"` +} + +type Response struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + PlurkId int64 `json:"plurk_id"` + Content string `json:"content"` + ContentRaw string `json:"content_raw"` + Qualifier string `json:"qualifier"` + QualifierTranslated string `json:"qualifier_translated"` + Posted string `json:"posted"` + Lang string `json:"lang"` + LastEdited *string `json:"last_edited"` + Coins *string `json:"coins"` + Editability int64 `json:"editability"` +} + +type Responses struct { + Responses []Response `json:"responses"` + ResponsesSeen int64 `json:"responses_seen"` + ResponseCount int64 `json:"response_count"` + Friends map[string]User `json:"friends"` +} + +type Profile struct { + FriendsCount int64 `json:"friends_count"` + FansCount int64 `json:"fans_count"` + + UserInfo User `json:"user_info"` + Privacy string `json:"privacy"` + + Plurks []Plurk `json:"plurks"` + + // OwnProfileOnly + UnreadCount *int64 `json:"unread_count"` + PlurksUsers map[string]User `json:"plurks_users"` + + // PublicProfileOnly + AreFriends *bool `json:"are_friends"` + IsFan *bool `json:"is_fan"` + IsFollowing *bool `json:"is_following"` + HasReadPermission *bool `json:"has_read_permission"` +} diff --git a/example/oauth.go b/example/oauth.go new file mode 100644 index 0000000..e02774c --- /dev/null +++ b/example/oauth.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "log" + + "github.com/kinabcd/goplurk" +) + +func main() { + var consumerToken = "..." + var consumerSecret = "..." + oauthRequest, err := goplurk.NewOAuthRequest(consumerToken, consumerSecret) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + fmt.Println("Open the following URL and authorize it:", oauthRequest.Url) + + var pinCode string + fmt.Print("Input the PIN code: ") + fmt.Scan(&pinCode) + + if client, token, secret, err := oauthRequest.SendPin(pinCode); err == nil { + log.Println("AccessToken: " + token) + log.Println("AccessSecret: " + secret) + client.Users.Me() + } else { + log.Fatalf("Failed to create client: failed to SendPin: %v", err) + } +} diff --git a/example/plurkadd.go b/example/plurkadd.go new file mode 100644 index 0000000..dfc69f0 --- /dev/null +++ b/example/plurkadd.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/kinabcd/goplurk" +) + +func main() { + var consumerToken = "..." + var consumerSecret = "..." + var accessToken = "..." + var accessSecret = "..." + client, _ := goplurk.NewClient(consumerToken, consumerSecret, accessToken, accessSecret) + client.Timeline.PlurkAdd("says", "somecontent") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9681483 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/kinabcd/goplurk + +go 1.20 + +require golang.org/x/exp v0.0.0-20230321023759-10a507213a29 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4555300 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= diff --git a/oauth/README.md b/oauth/README.md new file mode 100644 index 0000000..635f997 --- /dev/null +++ b/oauth/README.md @@ -0,0 +1,34 @@ +# OAuth1 + +[![GoDoc](https://godoc.org/github.com/gomodule/oauth1/oauth?status.svg)](https://godoc.org/github.com/gomodule/oauth1/oauth) +[![Build Status](https://travis-ci.org/gomodule/oauth1.svg?branch=master)](https://travis-ci.org/gomodule/oauth1) + +OAuth1 is a [Go](https://golang.org/) client for the OAuth 1.0, OAuth 1.0a and +[RFC 5849](https://tools.ietf.org/html/rfc5849) Protocols. The package supports the following signatures: + +* HMAC-SHA1 +* HMAC-SHA256 +* PLAINTEXT +* RSA-SHA1 +* RSA-SHA256 + +## Installation + + go get github.com/gomodule/oauth1/oauth + +## License + +oauth1 is available under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). + +## Documentation + +- [Reference](http://godoc.org/github.com/gomodule/oauth1/oauth) +- Examples + - [Discogs](https://github.com/gomodule/oauth1/tree/master/examples/discogs) + - [Dropbox](https://github.com/gomodule/oauth1/tree/master/examples/dropbox) + - [Quickbooks](https://github.com/gomodule/oauth1/tree/master/examples/quickbooks) + - [SmugMug](https://github.com/gomodule/oauth1/tree/master/examples/smugmug) + - [Twitter on App Engine](https://github.com/gomodule/oauth1/tree/master/examples/appengine) + - [Twitter](https://github.com/gomodule/oauth1/tree/master/examples/twitter) + - [Twitter OOB](https://github.com/gomodule/oauth1/tree/master/examples/twitteroob) (a command line application using OOB authorization) + - [Yelp](https://github.com/gomodule/oauth1/tree/master/examples/yelp) diff --git a/oauth/oauth.go b/oauth/oauth.go new file mode 100644 index 0000000..f6d0604 --- /dev/null +++ b/oauth/oauth.go @@ -0,0 +1,736 @@ +// Copyright 2010 Gary Burd +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +// Package oauth is consumer interface for OAuth 1.0, OAuth 1.0a and RFC 5849. +// +// # Redirection-based Authorization +// +// This section outlines how to use the oauth package in redirection-based +// authorization (http://tools.ietf.org/html/rfc5849#section-2). +// +// Step 1: Create a Client using credentials and URIs provided by the server. +// The Client can be initialized once at application startup and stored in a +// package-level variable. +// +// Step 2: Request temporary credentials using the Client +// RequestTemporaryCredentials method. The callbackURL parameter is the URL of +// the callback handler in step 4. Save the returned credential secret so that +// it can be later found using credential token as a key. The secret can be +// stored in a database keyed by the token. Another option is to store the +// token and secret in session storage or a cookie. +// +// Step 3: Redirect the user to URL returned from AuthorizationURL method. The +// AuthorizationURL method uses the temporary credentials from step 2 and other +// parameters as specified by the server. +// +// Step 4: The server redirects back to the callback URL specified in step 2 +// with the temporary token and a verifier. Use the temporary token to find the +// temporary secret saved in step 2. Using the temporary token, temporary +// secret and verifier, request token credentials using the client RequestToken +// method. Save the returned credentials for later use in the application. +// +// # Signing Requests +// +// The Client type has two low-level methods for signing requests, SignForm and +// SetAuthorizationHeader. +// +// The SignForm method adds an OAuth signature to a form. The application makes +// an authenticated request by encoding the modified form to the query string +// or request body. +// +// The SetAuthorizationHeader method adds an OAuth signature to a request +// header. The SetAuthorizationHeader method is the only way to correctly sign +// a request if the application sets the URL Opaque field when making a +// request. +// +// The Get, Put, Post and Delete methods sign and invoke a request using the +// supplied net/http Client. These methods are easy to use, but not as flexible +// as constructing a request using one of the low-level methods. +// +// # Context With HTTP Client +// +// A context-enabled method can include a custom HTTP client in the +// context and execute an HTTP request using the included HTTP client. +// +// hc := &http.Client{Timeout: 2 * time.Second} +// ctx := context.WithValue(context.Background(), oauth.HTTPClient, hc) +// c := oauth.Client{ /* Any settings */ } +// resp, err := c.GetContext(ctx, &oauth.Credentials{}, rawurl, nil) +package oauth // import "github.com/gomodule/oauth1/oauth" + +import ( + "bytes" + "context" + "crypto" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "hash" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" +) + +// noscape[b] is true if b should not be escaped per section 3.6 of the RFC. +var noEscape = [256]bool{ + 'A': true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, + 'a': true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, + '0': true, true, true, true, true, true, true, true, true, true, + '-': true, + '.': true, + '_': true, + '~': true, +} + +// encode encodes string per section 3.6 of the RFC. If double is true, then +// the encoding is applied twice. +func encode(s string, double bool) []byte { + // Compute size of result. + m := 3 + if double { + m = 5 + } + n := 0 + for i := 0; i < len(s); i++ { + if noEscape[s[i]] { + n++ + } else { + n += m + } + } + + p := make([]byte, n) + + // Encode it. + j := 0 + for i := 0; i < len(s); i++ { + b := s[i] + if noEscape[b] { + p[j] = b + j++ + } else if double { + p[j] = '%' + p[j+1] = '2' + p[j+2] = '5' + p[j+3] = "0123456789ABCDEF"[b>>4] + p[j+4] = "0123456789ABCDEF"[b&15] + j += 5 + } else { + p[j] = '%' + p[j+1] = "0123456789ABCDEF"[b>>4] + p[j+2] = "0123456789ABCDEF"[b&15] + j += 3 + } + } + return p +} + +type keyValue struct{ key, value []byte } + +type byKeyValue []keyValue + +func (p byKeyValue) Len() int { return len(p) } +func (p byKeyValue) Swap(i, j int) { p[i], p[j] = p[j], p[i] } +func (p byKeyValue) Less(i, j int) bool { + sgn := bytes.Compare(p[i].key, p[j].key) + if sgn == 0 { + sgn = bytes.Compare(p[i].value, p[j].value) + } + return sgn < 0 +} + +func (p byKeyValue) appendValues(values url.Values) byKeyValue { + for k, vs := range values { + k := encode(k, true) + for _, v := range vs { + v := encode(v, true) + p = append(p, keyValue{k, v}) + } + } + return p +} + +// writeBaseString writes method, url, and params to w using the OAuth signature +// base string computation described in section 3.4.1 of the RFC. +func writeBaseString(w io.Writer, method string, u *url.URL, form url.Values, oauthParams map[string]string) { + // Method + w.Write(encode(strings.ToUpper(method), false)) + w.Write([]byte{'&'}) + + // URL + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Host) + + uNoQuery := *u + uNoQuery.RawQuery = "" + path := uNoQuery.RequestURI() + + switch { + case scheme == "http" && strings.HasSuffix(host, ":80"): + host = host[:len(host)-len(":80")] + case scheme == "https" && strings.HasSuffix(host, ":443"): + host = host[:len(host)-len(":443")] + } + + w.Write(encode(scheme, false)) + w.Write(encode("://", false)) + w.Write(encode(host, false)) + w.Write(encode(path, false)) + w.Write([]byte{'&'}) + + // Create sorted slice of encoded parameters. Parameter keys and values are + // double encoded in a single step. This is safe because double encoding + // does not change the sort order. + queryParams := u.Query() + p := make(byKeyValue, 0, len(form)+len(queryParams)+len(oauthParams)) + p = p.appendValues(form) + p = p.appendValues(queryParams) + for k, v := range oauthParams { + p = append(p, keyValue{encode(k, true), encode(v, true)}) + } + sort.Sort(p) + + // Write the parameters. + encodedAmp := encode("&", false) + encodedEqual := encode("=", false) + sep := false + for _, kv := range p { + if sep { + w.Write(encodedAmp) + } else { + sep = true + } + w.Write(kv.key) + w.Write(encodedEqual) + w.Write(kv.value) + } +} + +var nonceCounter uint64 + +func init() { + if err := binary.Read(rand.Reader, binary.BigEndian, &nonceCounter); err != nil { + // fallback to time if rand reader is broken + nonceCounter = uint64(time.Now().UnixNano()) + } +} + +// nonce returns a unique string. +func nonce() string { + return strconv.FormatUint(atomic.AddUint64(&nonceCounter, 1), 16) +} + +// SignatureMethod identifies a signature method. +type SignatureMethod int + +func (sm SignatureMethod) String() string { + switch sm { + case RSASHA1: + return "RSA-SHA1" + case RSASHA256: + return "RSA-SHA256" + case HMACSHA1: + return "HMAC-SHA1" + case HMACSHA256: + return "HMAC-SHA256" + case PLAINTEXT: + return "PLAINTEXT" + default: + return "unknown" + } +} + +const ( + HMACSHA1 SignatureMethod = iota // HMAC-SHA1 + RSASHA1 // RSA-SHA1 + PLAINTEXT // Plain text + HMACSHA256 // HMAC-256 + RSASHA256 // RSA-SHA256 +) + +// Credentials represents client, temporary and token credentials. +type Credentials struct { + Token string // Also known as consumer key or access token. + Secret string // Also known as consumer secret or access token secret. +} + +// Client represents an OAuth client. +type Client struct { + // Credentials specifies the client key and secret. + // Also known as the consumer key and secret + Credentials Credentials + + // TemporaryCredentialRequestURI is the endpoint used by the client to + // obtain a set of temporary credentials. Also known as the request token + // URL. + TemporaryCredentialRequestURI string + + // ResourceOwnerAuthorizationURI is the endpoint to which the resource + // owner is redirected to grant authorization. Also known as authorization + // URL. + ResourceOwnerAuthorizationURI string + + // TokenRequestURI is the endpoint used by the client to request a set of + // token credentials using a set of temporary credentials. Also known as + // access token URL. + TokenRequestURI string + + // RenewCredentialRequestURI is the endpoint the client uses to + // request a new set of token credentials using the old set of credentials. + RenewCredentialRequestURI string + + // TemporaryCredentialsMethod is the HTTP method used by the client to + // obtain a set of temporary credentials. If this field is the empty + // string, then POST is used. + TemporaryCredentialsMethod string + + // TokenCredentailsMethod is the HTTP method used by the client to request + // a set of token credentials. If this field is the empty string, then POST + // is used. + TokenCredentailsMethod string + + // Header specifies optional extra headers for requests. + Header http.Header + + // SignatureMethod specifies the method for signing a request. + SignatureMethod SignatureMethod + + // PrivateKey is the private key to use for RSA-SHA* signatures. This field + // must be set for RSA-SHA* signatures and ignored for other signature + // methods. + PrivateKey *rsa.PrivateKey +} + +type request struct { + credentials *Credentials + method string + u *url.URL + form url.Values + verifier string + sessionHandle string + callbackURL string +} + +var testHook = func(map[string]string) {} + +// oauthParams returns the OAuth request parameters for the given credentials, +// method, URL and application parameters. See +// http://tools.ietf.org/html/rfc5849#section-3.4 for more information about +// signatures. +func (c *Client) oauthParams(r *request) (map[string]string, error) { + oauthParams := map[string]string{ + "oauth_consumer_key": c.Credentials.Token, + "oauth_signature_method": c.SignatureMethod.String(), + "oauth_version": "1.0", + } + + if c.SignatureMethod != PLAINTEXT { + oauthParams["oauth_timestamp"] = strconv.FormatInt(time.Now().Unix(), 10) + oauthParams["oauth_nonce"] = nonce() + } + + if r.credentials != nil { + oauthParams["oauth_token"] = r.credentials.Token + } + + if r.verifier != "" { + oauthParams["oauth_verifier"] = r.verifier + } + + if r.sessionHandle != "" { + oauthParams["oauth_session_handle"] = r.sessionHandle + } + + if r.callbackURL != "" { + oauthParams["oauth_callback"] = r.callbackURL + } + + testHook(oauthParams) + + var ( + signature string + err error + ) + switch c.SignatureMethod { + case HMACSHA1: + signature = c.hmacSignature(r, sha1.New, oauthParams) + case HMACSHA256: + signature = c.hmacSignature(r, sha256.New, oauthParams) + case RSASHA1: + signature, err = c.rsaSignature(r, crypto.SHA1, oauthParams) + case RSASHA256: + signature, err = c.rsaSignature(r, crypto.SHA256, oauthParams) + case PLAINTEXT: + signature = c.plainTextSignature(r) + default: + err = errors.New("oauth: unknown signature method") + } + if err != nil { + return nil, err + } + + oauthParams["oauth_signature"] = signature + return oauthParams, nil +} + +func (c *Client) plainTextSignature(r *request) string { + signature := encode(c.Credentials.Secret, false) + signature = append(signature, '&') + if r.credentials != nil { + signature = append(signature, encode(r.credentials.Secret, false)...) + } + return string(signature) +} + +func (c *Client) hmacSignature(r *request, h func() hash.Hash, oauthParams map[string]string) string { + key := encode(c.Credentials.Secret, false) + key = append(key, '&') + if r.credentials != nil { + key = append(key, encode(r.credentials.Secret, false)...) + } + hm := hmac.New(h, key) + writeBaseString(hm, r.method, r.u, r.form, oauthParams) + return base64.StdEncoding.EncodeToString(hm.Sum(key[:0])) +} + +func (c *Client) rsaSignature(r *request, h crypto.Hash, oauthParams map[string]string) (string, error) { + if c.PrivateKey == nil { + return "", errors.New("oauth: private key not set") + } + w := h.New() + writeBaseString(w, r.method, r.u, r.form, oauthParams) + rawSignature, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, h, w.Sum(nil)) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(rawSignature), nil +} + +// SignForm adds an OAuth signature to form. The urlStr argument must not +// include a query string. +// +// See http://tools.ietf.org/html/rfc5849#section-3.5.2 for +// information about transmitting OAuth parameters in a request body and +// http://tools.ietf.org/html/rfc5849#section-3.5.2 for information about +// transmitting OAuth parameters in a query string. +func (c *Client) SignForm(credentials *Credentials, method, urlStr string, form url.Values) error { + u, err := url.Parse(urlStr) + switch { + case err != nil: + return err + case u.RawQuery != "": + return errors.New("oauth: urlStr argument to SignForm must not include a query string") + } + p, err := c.oauthParams(&request{credentials: credentials, method: method, u: u, form: form}) + if err != nil { + return err + } + for k, v := range p { + form.Set(k, v) + } + return nil +} + +// SignParam adds an OAuth signature to a form. +// +// Deprecated: Use SignForm instead. +func (c *Client) SignParam(credentials *Credentials, method, urlStr string, params url.Values) { + u, _ := url.Parse(urlStr) + u.RawQuery = "" + p, _ := c.oauthParams(&request{credentials: credentials, method: method, u: u, form: params}) + for k, v := range p { + params.Set(k, v) + } +} + +var oauthKeys = []string{ + "oauth_consumer_key", + "oauth_nonce", + "oauth_signature", + "oauth_signature_method", + "oauth_timestamp", + "oauth_token", + "oauth_version", + "oauth_callback", + "oauth_verifier", + "oauth_session_handle", +} + +func (c *Client) authorizationHeader(r *request) (string, error) { + p, err := c.oauthParams(r) + if err != nil { + return "", err + } + var h []byte + // Append parameters in a fixed order to support testing. + for _, k := range oauthKeys { + if v, ok := p[k]; ok { + if h == nil { + h = []byte(`OAuth `) + } else { + h = append(h, ", "...) + } + h = append(h, k...) + h = append(h, `="`...) + h = append(h, encode(v, false)...) + h = append(h, '"') + } + } + return string(h), nil +} + +// AuthorizationHeader returns the HTTP authorization header value for given +// method, URL and parameters. +// +// Deprecated: Use SetAuthorizationHeader instead. +func (c *Client) AuthorizationHeader(credentials *Credentials, method string, u *url.URL, params url.Values) string { + // Signing a request can return an error. This method is deprecated because + // this method does not return an error. + v, _ := c.authorizationHeader(&request{credentials: credentials, method: method, u: u, form: params}) + return v +} + +// SetAuthorizationHeader adds an OAuth signature to a request header. +// +// See http://tools.ietf.org/html/rfc5849#section-3.5.1 for information about +// transmitting OAuth parameters in an HTTP request header. +func (c *Client) SetAuthorizationHeader(header http.Header, credentials *Credentials, method string, u *url.URL, form url.Values) error { + v, err := c.authorizationHeader(&request{credentials: credentials, method: method, u: u, form: form}) + if err != nil { + return err + } + header.Set("Authorization", v) + return nil +} + +func (c *Client) do(ctx context.Context, urlStr string, r *request) (*http.Response, error) { + var body io.Reader + if r.method != http.MethodGet { + body = strings.NewReader(r.form.Encode()) + } + req, err := http.NewRequest(r.method, urlStr, body) + if err != nil { + return nil, err + } + if req.URL.RawQuery != "" { + return nil, errors.New("oauth: url must not contain a query string") + } + for k, v := range c.Header { + req.Header[k] = v + } + r.u = req.URL + auth, err := c.authorizationHeader(r) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + if r.method == http.MethodGet { + req.URL.RawQuery = r.form.Encode() + } else { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + req = req.WithContext(ctx) + client := contextClient(ctx) + return client.Do(req) +} + +// Get issues a GET to the specified URL with form added as a query string. +func (c *Client) Get(client *http.Client, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { + ctx := context.WithValue(context.Background(), HTTPClient, client) + return c.GetContext(ctx, credentials, urlStr, form) +} + +// GetContext uses Context to perform Get. +func (c *Client) GetContext(ctx context.Context, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { + return c.do(ctx, urlStr, &request{method: http.MethodGet, credentials: credentials, form: form}) +} + +// Post issues a POST with the specified form. +func (c *Client) Post(client *http.Client, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { + ctx := context.WithValue(context.Background(), HTTPClient, client) + return c.PostContext(ctx, credentials, urlStr, form) +} + +// PostContext uses Context to perform Post. +func (c *Client) PostContext(ctx context.Context, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { + return c.do(ctx, urlStr, &request{method: http.MethodPost, credentials: credentials, form: form}) +} + +// Delete issues a DELETE with the specified form. +func (c *Client) Delete(client *http.Client, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { + ctx := context.WithValue(context.Background(), HTTPClient, client) + return c.DeleteContext(ctx, credentials, urlStr, form) +} + +// DeleteContext uses Context to perform Delete. +func (c *Client) DeleteContext(ctx context.Context, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { + return c.do(ctx, urlStr, &request{method: http.MethodDelete, credentials: credentials, form: form}) +} + +// Put issues a PUT with the specified form. +func (c *Client) Put(client *http.Client, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { + ctx := context.WithValue(context.Background(), HTTPClient, client) + return c.PutContext(ctx, credentials, urlStr, form) +} + +// PutContext uses Context to perform Put. +func (c *Client) PutContext(ctx context.Context, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { + return c.do(ctx, urlStr, &request{method: http.MethodPut, credentials: credentials, form: form}) +} + +func (c *Client) requestCredentials(ctx context.Context, u string, r *request) (*Credentials, url.Values, error) { + if r.method == "" { + r.method = http.MethodPost + } + resp, err := c.do(ctx, u, r) + if err != nil { + return nil, nil, err + } + p, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, nil, RequestCredentialsError{StatusCode: resp.StatusCode, Header: resp.Header, + Body: p, msg: err.Error()} + } + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return nil, nil, RequestCredentialsError{StatusCode: resp.StatusCode, Header: resp.Header, + Body: p, msg: fmt.Sprintf("OAuth server status %d, %s", resp.StatusCode, string(p))} + } + m, err := url.ParseQuery(string(p)) + if err != nil { + return nil, nil, RequestCredentialsError{StatusCode: resp.StatusCode, Header: resp.Header, + Body: p, msg: err.Error()} + } + tokens := m["oauth_token"] + if len(tokens) == 0 || tokens[0] == "" { + return nil, nil, RequestCredentialsError{StatusCode: resp.StatusCode, Header: resp.Header, + Body: p, msg: "oauth: token missing from server result"} + } + secrets := m["oauth_token_secret"] + if len(secrets) == 0 { // allow "" as a valid secret. + return nil, nil, RequestCredentialsError{StatusCode: resp.StatusCode, Header: resp.Header, + Body: p, msg: "oauth: secret missing from server result"} + } + return &Credentials{Token: tokens[0], Secret: secrets[0]}, m, nil +} + +// RequestTemporaryCredentials requests temporary credentials from the server. +// See http://tools.ietf.org/html/rfc5849#section-2.1 for information about +// temporary credentials. +func (c *Client) RequestTemporaryCredentials(client *http.Client, callbackURL string, additionalParams url.Values) (*Credentials, error) { + ctx := context.WithValue(context.Background(), HTTPClient, client) + return c.RequestTemporaryCredentialsContext(ctx, callbackURL, additionalParams) +} + +// RequestTemporaryCredentialsContext uses Context to perform RequestTemporaryCredentials. +func (c *Client) RequestTemporaryCredentialsContext(ctx context.Context, callbackURL string, additionalParams url.Values) (*Credentials, error) { + credentials, _, err := c.requestCredentials(ctx, c.TemporaryCredentialRequestURI, + &request{method: c.TemporaryCredentialsMethod, form: additionalParams, callbackURL: callbackURL}) + return credentials, err +} + +// RequestToken requests token credentials from the server. See +// http://tools.ietf.org/html/rfc5849#section-2.3 for information about token +// credentials. +func (c *Client) RequestToken(client *http.Client, temporaryCredentials *Credentials, verifier string) (*Credentials, url.Values, error) { + ctx := context.WithValue(context.Background(), HTTPClient, client) + return c.RequestTokenContext(ctx, temporaryCredentials, verifier) +} + +// RequestTokenContext uses Context to perform RequestToken. +func (c *Client) RequestTokenContext(ctx context.Context, temporaryCredentials *Credentials, verifier string) (*Credentials, url.Values, error) { + return c.requestCredentials(ctx, c.TokenRequestURI, + &request{credentials: temporaryCredentials, method: c.TokenCredentailsMethod, verifier: verifier}) +} + +// RenewRequestCredentials requests new token credentials from the server. +// See http://wiki.oauth.net/w/page/12238549/ScalableOAuth#AccessTokenRenewal +// for information about access token renewal. +func (c *Client) RenewRequestCredentials(client *http.Client, credentials *Credentials, sessionHandle string) (*Credentials, url.Values, error) { + ctx := context.WithValue(context.Background(), HTTPClient, client) + return c.RenewRequestCredentialsContext(ctx, credentials, sessionHandle) +} + +// RenewRequestCredentialsContext uses Context to perform RenewRequestCredentials. +func (c *Client) RenewRequestCredentialsContext(ctx context.Context, credentials *Credentials, sessionHandle string) (*Credentials, url.Values, error) { + return c.requestCredentials(ctx, c.RenewCredentialRequestURI, &request{credentials: credentials, sessionHandle: sessionHandle}) +} + +// RequestTokenXAuth requests token credentials from the server using the xAuth protocol. +// See https://dev.twitter.com/oauth/xauth for information on xAuth. +func (c *Client) RequestTokenXAuth(client *http.Client, temporaryCredentials *Credentials, user, password string) (*Credentials, url.Values, error) { + ctx := context.WithValue(context.Background(), HTTPClient, client) + return c.RequestTokenXAuthContext(ctx, temporaryCredentials, user, password) +} + +// RequestTokenXAuthContext uses Context to perform RequestTokenXAuth. +func (c *Client) RequestTokenXAuthContext(ctx context.Context, temporaryCredentials *Credentials, user, password string) (*Credentials, url.Values, error) { + form := make(url.Values) + form.Set("x_auth_mode", "client_auth") + form.Set("x_auth_username", user) + form.Set("x_auth_password", password) + return c.requestCredentials(ctx, c.TokenRequestURI, + &request{credentials: temporaryCredentials, method: c.TokenCredentailsMethod, form: form}) +} + +// AuthorizationURL returns the URL for resource owner authorization. See +// http://tools.ietf.org/html/rfc5849#section-2.2 for information about +// resource owner authorization. +func (c *Client) AuthorizationURL(temporaryCredentials *Credentials, additionalParams url.Values) string { + params := make(url.Values) + for k, vs := range additionalParams { + params[k] = vs + } + params.Set("oauth_token", temporaryCredentials.Token) + return c.ResourceOwnerAuthorizationURI + "?" + params.Encode() +} + +// HTTPClient is the context key to use with context's +// WithValue function to associate an *http.Client value with a context. +var HTTPClient contextKey + +type contextKey struct{} + +func contextClient(ctx context.Context) *http.Client { + if ctx != nil { + if hc, ok := ctx.Value(HTTPClient).(*http.Client); ok && hc != nil { + return hc + } + } + return http.DefaultClient +} + +// RequestCredentialsError is an error containing +// response information when requesting credentials. +type RequestCredentialsError struct { + StatusCode int + Header http.Header + Body []byte + msg string +} + +func (e RequestCredentialsError) Error() string { + return e.msg +} diff --git a/oauth/oauth_test.go b/oauth/oauth_test.go new file mode 100644 index 0000000..ba595ff --- /dev/null +++ b/oauth/oauth_test.go @@ -0,0 +1,535 @@ +// Copyright 2010 Gary Burd +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package oauth + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/pem" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func parseURL(urlStr string) *url.URL { + u, err := url.Parse(urlStr) + if err != nil { + panic(err) + } + return u +} + +var oauthTests = []struct { + method string + url *url.URL + form url.Values + nonce string + timestamp string + + clientCredentials Credentials + credentials Credentials + + signatureMethod SignatureMethod + + base string + header string +}{ + { + // Simple example from Twitter OAuth tool + method: "GET", + url: parseURL("https://api.twitter.com/1/"), + form: url.Values{"page": {"10"}}, + nonce: "8067e8abc6bdca2006818132445c8f4c", + timestamp: "1355795903", + clientCredentials: Credentials{"kMViZR2MHk2mM7hUNVw9A", "56Fgl58yOfqXOhHXX0ybvOmSnPQFvR2miYmm30A"}, + credentials: Credentials{"10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", "yF75mvq4LZMHj9O0DXwoC3ZxUnN1ptvieThYuOAYM"}, + base: `GET&https%3A%2F%2Fapi.twitter.com%2F1%2F&oauth_consumer_key%3DkMViZR2MHk2mM7hUNVw9A%26oauth_nonce%3D8067e8abc6bdca2006818132445c8f4c%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1355795903%26oauth_token%3D10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU%26oauth_version%3D1.0%26page%3D10`, + header: `OAuth oauth_consumer_key="kMViZR2MHk2mM7hUNVw9A", oauth_nonce="8067e8abc6bdca2006818132445c8f4c", oauth_signature="o5cx1ggJrY9ognZuVVeUwglKV8U%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1355795903", oauth_token="10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", oauth_version="1.0"`, + }, + { + // Test case and port insensitivity. + method: "GeT", + url: parseURL("https://apI.twItter.com:443/1/"), + form: url.Values{"page": {"10"}}, + nonce: "8067e8abc6bdca2006818132445c8f4c", + timestamp: "1355795903", + clientCredentials: Credentials{"kMViZR2MHk2mM7hUNVw9A", "56Fgl58yOfqXOhHXX0ybvOmSnPQFvR2miYmm30A"}, + credentials: Credentials{"10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", "yF75mvq4LZMHj9O0DXwoC3ZxUnN1ptvieThYuOAYM"}, + base: `GET&https%3A%2F%2Fapi.twitter.com%2F1%2F&oauth_consumer_key%3DkMViZR2MHk2mM7hUNVw9A%26oauth_nonce%3D8067e8abc6bdca2006818132445c8f4c%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1355795903%26oauth_token%3D10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU%26oauth_version%3D1.0%26page%3D10`, + header: `OAuth oauth_consumer_key="kMViZR2MHk2mM7hUNVw9A", oauth_nonce="8067e8abc6bdca2006818132445c8f4c", oauth_signature="o5cx1ggJrY9ognZuVVeUwglKV8U%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1355795903", oauth_token="10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", oauth_version="1.0"`, + }, + { + // Example generated using the Netflix OAuth tool. + method: "GET", + url: parseURL("http://api-public.netflix.com/catalog/titles"), + form: url.Values{"term": {"Dark Knight"}, "count": {"2"}}, + nonce: "1234", + timestamp: "1355850443", + clientCredentials: Credentials{"apiKey001", "sharedSecret002"}, + credentials: Credentials{"accessToken003", "accessSecret004"}, + base: `GET&http%3A%2F%2Fapi-public.netflix.com%2Fcatalog%2Ftitles&count%3D2%26oauth_consumer_key%3DapiKey001%26oauth_nonce%3D1234%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1355850443%26oauth_token%3DaccessToken003%26oauth_version%3D1.0%26term%3DDark%2520Knight`, + header: `OAuth oauth_consumer_key="apiKey001", oauth_nonce="1234", oauth_signature="0JAoaqt6oz6TJx8N%2B06XmhPjcOs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1355850443", oauth_token="accessToken003", oauth_version="1.0"`, + }, + { + // Special characters in form values. + method: "GET", + url: parseURL("http://PHOTOS.example.net:8001/Photos"), + form: url.Values{"photo size": {"300%"}, "title": {"Back of $100 Dollars Bill"}}, + nonce: "kllo~9940~pd9333jh", + timestamp: "1191242096", + clientCredentials: Credentials{"dpf43f3++p+#2l4k3l03", "secret01"}, + credentials: Credentials{"nnch734d(0)0sl2jdk", "secret02"}, + base: "GET&http%3A%2F%2Fphotos.example.net%3A8001%2FPhotos&oauth_consumer_key%3Ddpf43f3%252B%252Bp%252B%25232l4k3l03%26oauth_nonce%3Dkllo~9940~pd9333jh%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1191242096%26oauth_token%3Dnnch734d%25280%25290sl2jdk%26oauth_version%3D1.0%26photo%2520size%3D300%2525%26title%3DBack%2520of%2520%2524100%2520Dollars%2520Bill", + header: `OAuth oauth_consumer_key="dpf43f3%2B%2Bp%2B%232l4k3l03", oauth_nonce="kllo~9940~pd9333jh", oauth_signature="n1UAoQy2PoIYizZUiWvkdCxM3P0%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1191242096", oauth_token="nnch734d%280%290sl2jdk", oauth_version="1.0"`, + }, + { + // Special characters in path, multiple values for same key in form. + method: "GET", + url: parseURL("http://EXAMPLE.COM:80/Space%20Craft"), + form: url.Values{"name": {"value", "value"}}, + nonce: "Ix4U1Ei3RFL", + timestamp: "1327384901", + clientCredentials: Credentials{"abcd", "efgh"}, + credentials: Credentials{"ijkl", "mnop"}, + base: "GET&http%3A%2F%2Fexample.com%2FSpace%2520Craft&name%3Dvalue%26name%3Dvalue%26oauth_consumer_key%3Dabcd%26oauth_nonce%3DIx4U1Ei3RFL%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1327384901%26oauth_token%3Dijkl%26oauth_version%3D1.0", + header: `OAuth oauth_consumer_key="abcd", oauth_nonce="Ix4U1Ei3RFL", oauth_signature="TZZ5u7qQorLnmKs%2Biqunb8gqkh4%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1327384901", oauth_token="ijkl", oauth_version="1.0"`, + }, + { + // Query string in URL. + method: "GET", + url: parseURL("http://EXAMPLE.COM:80/Space%20Craft?name=value"), + form: url.Values{"name": {"value"}}, + nonce: "Ix4U1Ei3RFL", + timestamp: "1327384901", + clientCredentials: Credentials{"abcd", "efgh"}, + credentials: Credentials{"ijkl", "mnop"}, + base: "GET&http%3A%2F%2Fexample.com%2FSpace%2520Craft&name%3Dvalue%26name%3Dvalue%26oauth_consumer_key%3Dabcd%26oauth_nonce%3DIx4U1Ei3RFL%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1327384901%26oauth_token%3Dijkl%26oauth_version%3D1.0", + header: `OAuth oauth_consumer_key="abcd", oauth_nonce="Ix4U1Ei3RFL", oauth_signature="TZZ5u7qQorLnmKs%2Biqunb8gqkh4%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1327384901", oauth_token="ijkl", oauth_version="1.0"`, + }, + { + // "/" in form value. + method: "POST", + url: parseURL("https://stream.twitter.com/1.1/statuses/filter.json"), + form: url.Values{"track": {"example.com/abcd"}}, + nonce: "bf2cb6d611e59f99103238fc9a3bb8d8", + timestamp: "1362434376", + clientCredentials: Credentials{"consumer_key", "consumer_secret"}, + credentials: Credentials{"token", "secret"}, + base: "POST&https%3A%2F%2Fstream.twitter.com%2F1.1%2Fstatuses%2Ffilter.json&oauth_consumer_key%3Dconsumer_key%26oauth_nonce%3Dbf2cb6d611e59f99103238fc9a3bb8d8%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1362434376%26oauth_token%3Dtoken%26oauth_version%3D1.0%26track%3Dexample.com%252Fabcd", + header: `OAuth oauth_consumer_key="consumer_key", oauth_nonce="bf2cb6d611e59f99103238fc9a3bb8d8", oauth_signature="LcxylEOnNdgoKSJi7jX07mxcvfM%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1362434376", oauth_token="token", oauth_version="1.0"`, + }, + { + // "/" in query string + method: "POST", + url: parseURL("https://stream.twitter.com/1.1/statuses/filter.json?track=example.com/query"), + form: url.Values{}, + nonce: "884275759fbab914654b50ae643c563a", + timestamp: "1362435218", + clientCredentials: Credentials{"consumer_key", "consumer_secret"}, + credentials: Credentials{"token", "secret"}, + base: "POST&https%3A%2F%2Fstream.twitter.com%2F1.1%2Fstatuses%2Ffilter.json&oauth_consumer_key%3Dconsumer_key%26oauth_nonce%3D884275759fbab914654b50ae643c563a%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1362435218%26oauth_token%3Dtoken%26oauth_version%3D1.0%26track%3Dexample.com%252Fquery", + header: `OAuth oauth_consumer_key="consumer_key", oauth_nonce="884275759fbab914654b50ae643c563a", oauth_signature="OAldqvRrKDXRGZ9BqSi2CqeVH0g%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1362435218", oauth_token="token", oauth_version="1.0"`, + }, + { + // QuickBooks query string + method: "GET", + url: parseURL("https://qb.sbfinance.intuit.com/v3/company/1273852765/query"), + form: url.Values{"query": {"select * from account"}}, + nonce: "12345678", + timestamp: "1409876517", + clientCredentials: Credentials{"consumer_key", "consumer_secret"}, + credentials: Credentials{"token", "secret"}, + base: "GET&https%3A%2F%2Fqb.sbfinance.intuit.com%2Fv3%2Fcompany%2F1273852765%2Fquery&oauth_consumer_key%3Dconsumer_key%26oauth_nonce%3D12345678%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1409876517%26oauth_token%3Dtoken%26oauth_version%3D1.0%26query%3Dselect%2520%252A%2520from%2520account", + header: `OAuth oauth_consumer_key="consumer_key", oauth_nonce="12345678", oauth_signature="7crYee%2BJLvg7dksQiHbarUHN3rY%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1409876517", oauth_token="token", oauth_version="1.0"`, + }, + { + // Plain text signature method + signatureMethod: PLAINTEXT, + method: "GET", + url: parseURL("http://example.com/"), + clientCredentials: Credentials{"key", "secret"}, + credentials: Credentials{"accesskey", "accesssecret"}, + header: `OAuth oauth_consumer_key="key", oauth_signature="secret%26accesssecret", oauth_signature_method="PLAINTEXT", oauth_token="accesskey", oauth_version="1.0"`, + }, + { + // RSA-SHA1 signature method + signatureMethod: RSASHA1, + method: "GET", + url: parseURL("http://term.ie/oauth/example/echo_api.php"), + form: url.Values{"method": {"foo%20bar"}, "bar": {"baz"}}, + nonce: "a7da4d14579d61886be9d596d1a6a720", + timestamp: "1420240290", + clientCredentials: Credentials{Token: "key"}, + credentials: Credentials{Token: "accesskey"}, + base: `GET&http%3A%2F%2Fterm.ie%2Foauth%2Fexample%2Fecho_api.php&bar%3Dbaz%26method%3Dfoo%252520bar%26oauth_consumer_key%3Dkey%26oauth_nonce%3Da7da4d14579d61886be9d596d1a6a720%26oauth_signature_method%3DRSA-SHA1%26oauth_timestamp%3D1420240290%26oauth_token%3Daccesskey%26oauth_version%3D1.0`, + header: `OAuth oauth_consumer_key="key", oauth_nonce="a7da4d14579d61886be9d596d1a6a720", oauth_signature="jPun728OkfFo7BjZiaQ5UBVChwk6tf0uKNFDmNKVb%2Bd6aWYEzsDVkqqjcgTrCRNabK8ubAnhyprafk0mk3zEJe%2BxGb9GKauqwUJ6ZZoGJNYYZg3BZUQvdxSKFs1M4MUMv3fxntmD%2BoyE8jPbrVM2zD1G1AAPm79sX%2B8XE25tBE8%3D", oauth_signature_method="RSA-SHA1", oauth_timestamp="1420240290", oauth_token="accesskey", oauth_version="1.0"`, + }, + { + // RSA-SHA256 signature method + signatureMethod: RSASHA256, + method: "GET", + url: parseURL("http://term.ie/oauth/example/echo_api.php"), + form: url.Values{"method": {"foo%20bar"}, "bar": {"baz"}}, + nonce: "a7da4d14579d61886be9d596d1a6a720", + timestamp: "1420240290", + clientCredentials: Credentials{Token: "key"}, + credentials: Credentials{Token: "accesskey"}, + base: `GET&http%3A%2F%2Fterm.ie%2Foauth%2Fexample%2Fecho_api.php&bar%3Dbaz%26method%3Dfoo%252520bar%26oauth_consumer_key%3Dkey%26oauth_nonce%3Da7da4d14579d61886be9d596d1a6a720%26oauth_signature_method%3DRSA-SHA256%26oauth_timestamp%3D1420240290%26oauth_token%3Daccesskey%26oauth_version%3D1.0`, + header: `OAuth oauth_consumer_key="key", oauth_nonce="a7da4d14579d61886be9d596d1a6a720", oauth_signature="i1hxcRO6QJcZE6BLifFKF2rfNNpNPIplUtGE74%2BUFgVE%2B9nGnHXmKC1xro8oK03lHM5pGLp9zlcQYpDmLUCMZ2wVLmND1dCQ033UzUZvvxslK7c5gp53QvL5bduoQc2hRoAJTpFd%2FpN9o%2FdhwNCxNY%2BC1GD%2FWeaPE9Hg5EuP03Y%3D", oauth_signature_method="RSA-SHA256", oauth_timestamp="1420240290", oauth_token="accesskey", oauth_version="1.0"`, + }, + { + // HMACSHA1 signature method + signatureMethod: HMACSHA1, + method: "GET", + url: parseURL("https://example.com/1/"), + form: url.Values{"page": {"10"}}, + nonce: "8067e8abc6bdca2006818132445c8f4c", + timestamp: "1355795903", + clientCredentials: Credentials{"kMViZR2MHk2mM7hUNVw9A", "56Fgl58yOfqXOhHXX0ybvOmSnPQFvR2miYmm30A"}, + credentials: Credentials{"10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", "yF75mvq4LZMHj9O0DXwoC3ZxUnN1ptvieThYuOAYM"}, + base: `GET&https%3A%2F%2Fexample.com%2F1%2F&oauth_consumer_key%3DkMViZR2MHk2mM7hUNVw9A%26oauth_nonce%3D8067e8abc6bdca2006818132445c8f4c%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1355795903%26oauth_token%3D10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU%26oauth_version%3D1.0%26page%3D10`, + header: `OAuth oauth_consumer_key="kMViZR2MHk2mM7hUNVw9A", oauth_nonce="8067e8abc6bdca2006818132445c8f4c", oauth_signature="bNV77q9f89pz4PVk5Vsc8T%2Fi1Hk%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1355795903", oauth_token="10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", oauth_version="1.0"`, + }, + { + // HMACSHA256 signature method + signatureMethod: HMACSHA256, + method: "GET", + url: parseURL("https://example.com/1/"), + form: url.Values{"page": {"10"}}, + nonce: "8067e8abc6bdca2006818132445c8f4c", + timestamp: "1355795903", + clientCredentials: Credentials{"kMViZR2MHk2mM7hUNVw9A", "56Fgl58yOfqXOhHXX0ybvOmSnPQFvR2miYmm30A"}, + credentials: Credentials{"10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", "yF75mvq4LZMHj9O0DXwoC3ZxUnN1ptvieThYuOAYM"}, + base: `GET&https%3A%2F%2Fexample.com%2F1%2F&oauth_consumer_key%3DkMViZR2MHk2mM7hUNVw9A%26oauth_nonce%3D8067e8abc6bdca2006818132445c8f4c%26oauth_signature_method%3DHMAC-SHA256%26oauth_timestamp%3D1355795903%26oauth_token%3D10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU%26oauth_version%3D1.0%26page%3D10`, + header: `OAuth oauth_consumer_key="kMViZR2MHk2mM7hUNVw9A", oauth_nonce="8067e8abc6bdca2006818132445c8f4c", oauth_signature="GFfMidiFmlO%2BsI3tkXVTIqxOwM7EhnMwpB4ULsnPLlQ%3D", oauth_signature_method="HMAC-SHA256", oauth_timestamp="1355795903", oauth_token="10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", oauth_version="1.0"`, + }, +} + +func TestBaseString(t *testing.T) { + for _, ot := range oauthTests { + if ot.signatureMethod == PLAINTEXT { + // PLAINTEXT signature does not use the base string. + continue + } + oauthParams := map[string]string{ + "oauth_consumer_key": ot.clientCredentials.Token, + "oauth_nonce": ot.nonce, + "oauth_signature_method": ot.signatureMethod.String(), + "oauth_timestamp": ot.timestamp, + "oauth_token": ot.credentials.Token, + "oauth_version": "1.0", + } + var buf bytes.Buffer + writeBaseString(&buf, ot.method, ot.url, ot.form, oauthParams) + base := buf.String() + if base != ot.base { + t.Errorf("base string for %s %s\n = %q,\n want %q", ot.method, ot.url, base, ot.base) + } + } +} + +var pemPrivateKey = `-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQC0YjCwIfYoprq/FQO6lb3asXrxLlJFuCvtinTF5p0GxvQGu5O3 +gYytUvtC2JlYzypSRjVxwxrsuRcP3e641SdASwfrmzyvIgP08N4S0IFzEURkV1wp +/IpH7kH41EtbmUmrXSwfNZsnQRE5SYSOhh+LcK2wyQkdgcMv11l4KoBkcwIDAQAB +AoGAWFlbZXlM2r5G6z48tE+RTKLvB1/btgAtq8vLw/5e3KnnbcDD6fZO07m4DRaP +jRryrJdsp8qazmUdcY0O1oK4FQfpprknDjP+R1XHhbhkQ4WEwjmxPstZMUZaDWF5 +8d3otc23mCzwh3YcUWFu09KnMpzZsK59OfyjtkS44EDWpbECQQDXgN0ODboKsuEA +VAhAtPUqspU9ivRa6yLai9kCnPb9GcztrsJZQm4NHcKVbmD2F2L4pDRx4Pmglhfl +V7G/a6T7AkEA1kfU0+DkXc6I/jXHJ6pDLA5s7dBHzWgDsBzplSdkVQbKT3MbeYje +ByOxzXhulOWLBQW/vxmW4HwU95KTRlj06QJASPoBYY3yb0cN/J94P/lHgJMDCNky +UEuJ/PoYndLrrN/8zow8kh91xwlJ6HJ9cTiQMmTgwaOOxPuu0eI1df4M2wJBAJJS +WrKUT1z/O+zbLDOZwGTFNPzvzRgmft4z4A1J6OlmyZ+XKpvDKloVtcRpCJoEZPn5 +AwaroquID4k/PfI7rIECQHeWa6+kPADv9IrK/92mujujS0MSEiynDw5NjTnHAH0v +8TrXzs+LCWDN/gbOCKPfnWRkgwgOeC8NN3h0zUIIUtA= +-----END RSA PRIVATE KEY----- +` + +func TestAuthorizationHeader(t *testing.T) { + originalTestHook := testHook + defer func() { + testHook = originalTestHook + }() + + block, _ := pem.Decode([]byte(pemPrivateKey)) + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + t.Fatal(err) + } + + for _, ot := range oauthTests { + testHook = func(p map[string]string) { + if _, ok := p["oauth_nonce"]; ok { + p["oauth_nonce"] = ot.nonce + } + if _, ok := p["oauth_timestamp"]; ok { + p["oauth_timestamp"] = ot.timestamp + } + } + c := Client{Credentials: ot.clientCredentials, SignatureMethod: ot.signatureMethod, PrivateKey: privateKey} + header, err := c.authorizationHeader(&request{credentials: &ot.credentials, method: ot.method, u: ot.url, form: ot.form}) + if err != nil { + t.Errorf("authorizationHeader(&cred, %q, %q, %v) returned error %v", ot.method, ot.url.String(), ot.form, err) + continue + } + if header != ot.header { + t.Errorf("authorizationHeader(&cred, %q, %q, %v) =\n %s\nwant: %s", ot.method, ot.url.String(), ot.form, header, ot.header) + } + } +} + +func TestNonce(t *testing.T) { + // This test is flaky, but failures should be very rare. + n := nonce() + if len(n) < 8 { + t.Fatalf("nonce is %s, exected something longer", n) + } +} + +func TestRequestToken(t *testing.T) { + var method string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedMethod := method + if method == "" { + expectedMethod = http.MethodPost + } + if r.Method != expectedMethod { + t.Errorf("got method %s, want %s", r.Method, expectedMethod) + } + expectedContenType := "" + if r.Method != http.MethodGet { + expectedContenType = "application/x-www-form-urlencoded" + } + if contentType := r.Header.Get("Content-Type"); contentType != expectedContenType { + t.Errorf("got content type %q, want %q", contentType, expectedContenType) + } + if auth := r.Header.Get("Authorization"); !strings.Contains(auth, `oauth_verifier="verifier"`) { + t.Errorf("verifier missing from auth header %q", auth) + } + v := url.Values{} + v.Set("oauth_token", "token") + v.Set("oauth_token_secret", "secret") + io.WriteString(w, v.Encode()) + })) + defer ts.Close() + + for _, method = range []string{"", "GET", "POST"} { + c := Client{TokenRequestURI: ts.URL, TokenCredentailsMethod: method} + cred, _, err := c.RequestToken(http.DefaultClient, &Credentials{}, "verifier") + if err != nil { + t.Errorf("returned error %v", err) + } + if cred.Token != "token" { + t.Errorf("token for %s want %s", cred.Token, "token") + } + if cred.Secret != "secret" { + t.Errorf("secret for %s want %s", cred.Secret, "secret") + } + } +} + +func TestRenewRequestCredentials(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a := r.Header.Get("Authorization") + if !strings.Contains(a, `oauth_token="token"`) { + t.Errorf("Authorization header %q should contains %q", a, `oauth_token="token"`) + } + if !strings.Contains(a, `oauth_session_handle="session-handle"`) { + t.Errorf("Authorization header %q should contains %q", a, `oauth_session_handle="session-handle"`) + } + v := url.Values{} + v.Set("oauth_token", "response-token") + v.Set("oauth_token_secret", "response-token-secret") + v.Set("oauth_session_handle", "response-session-handle") + io.WriteString(w, v.Encode()) + })) + defer ts.Close() + + c := Client{RenewCredentialRequestURI: ts.URL} + cred, rv, err := c.RenewRequestCredentials(http.DefaultClient, &Credentials{Token: "token"}, "session-handle") + if err != nil { + t.Errorf("returned error %v", err) + } + if cred.Token != "response-token" { + t.Errorf("token for %s want %s", cred.Token, "response-token") + } + if cred.Secret != "response-token-secret" { + t.Errorf("secret for %s want %s", cred.Secret, "response-token-secret") + } + if rv.Get("oauth_session_handle") != "response-session-handle" { + t.Errorf("session handle for %s want %s", rv.Get("oauth_session_handle"), "response-session-handle") + } +} + +func TestGet(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("got method %s, want %s", r.Method, http.MethodGet) + } + if err := r.ParseForm(); err != nil { + t.Errorf("returned error %v", err) + } + if form := r.Form.Get("form"); form != "foo" { + t.Errorf("form %s, want %s", form, "foo") + } + cookie, err := r.Cookie("client-cookie") + if err != nil { + t.Errorf("returned error %v", err) + } + if cookie.Value != "foobar" { + t.Errorf("client-cookie %s, want %s", cookie.Value, "foobar") + } + io.WriteString(w, "bar") + })) + defer ts.Close() + + u, err := url.Parse(ts.URL) + if err != nil { + t.Errorf("returned error %v", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("returned error %v", err) + } + jar.SetCookies(u, []*http.Cookie{{Name: "client-cookie", Value: "foobar"}}) + v := url.Values{} + v.Set("form", "foo") + c := Client{} + resp, err := c.Get(&http.Client{Jar: jar}, &Credentials{}, u.String(), v) + if err != nil { + t.Errorf("returned error %v", err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Errorf("returned error %v", err) + } + if string(b) != "bar" { + t.Errorf("body %s, want %s", string(b), "bar") + } +} + +func TestGet_ClientNil(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("got method %s, want %s", r.Method, http.MethodGet) + } + io.WriteString(w, "bar") + })) + defer ts.Close() + + c := Client{} + resp, err := c.Get(nil, &Credentials{}, ts.URL, nil) + if err != nil { + t.Errorf("returned error %v", err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Errorf("returned error %v", err) + } + if string(b) != "bar" { + t.Errorf("body %s, want %s", string(b), "bar") + } +} + +func TestGetContext(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("got method %s, want %s", r.Method, http.MethodGet) + } + cookie, err := r.Cookie("client-cookie") + if err != nil { + t.Errorf("returned error %v", err) + } + if cookie.Value != "foobar" { + t.Errorf("client-cookie %s, want %s", cookie.Value, "foobar") + } + io.WriteString(w, "bar") + })) + defer ts.Close() + + u, err := url.Parse(ts.URL) + if err != nil { + t.Errorf("returned error %v", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("returned error %v", err) + } + jar.SetCookies(u, []*http.Cookie{{Name: "client-cookie", Value: "foobar"}}) + ctx := context.WithValue(context.Background(), HTTPClient, &http.Client{Jar: jar}) + c := Client{} + resp, err := c.GetContext(ctx, &Credentials{}, u.String(), nil) + if err != nil { + t.Errorf("returned error %v", err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Errorf("returned error %v", err) + } + if string(b) != "bar" { + t.Errorf("body %s, want %s", string(b), "bar") + } +} + +func TestRequestCredentialsError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", "oauth_problem=token_rejected") + w.WriteHeader(http.StatusUnauthorized) + io.WriteString(w, "oauth_problem=token_rejected") + })) + defer ts.Close() + + c := Client{TokenRequestURI: ts.URL} + _, _, err := c.RequestToken(http.DefaultClient, &Credentials{}, "verifier") + if err == nil { + t.Error("error should not be nil") + } + if rce, ok := err.(RequestCredentialsError); ok { + if rce.StatusCode != http.StatusUnauthorized { + t.Errorf("status code %d, want %d", rce.StatusCode, http.StatusUnauthorized) + } + wa := rce.Header.Get("WWW-Authenticate") + if wa != "oauth_problem=token_rejected" { + t.Errorf("WWW-Authenticate header %s, want %s", wa, "oauth_problem=token_rejected") + } + if string(rce.Body) != "oauth_problem=token_rejected" { + t.Errorf("body %s,want %s", rce.Body, "oauth_problem=token_rejected") + } + } else { + t.Error("error should be assertable RequestCredentialsError") + } +} + +func TestGetContext_Cancel(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + })) + defer ts.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + c := Client{} + _, err := c.GetContext(ctx, &Credentials{}, ts.URL, nil) + if err == nil { + t.Error("error should not be nil") + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..4465da4 --- /dev/null +++ b/options.go @@ -0,0 +1,50 @@ +package goplurk + +import ( + "strconv" + "strings" +) + +type Options interface { + Get() map[string]string +} + +type baseOptions struct { + options map[string]string +} + +func (o *baseOptions) Get() map[string]string { + if o.options == nil { + o.options = map[string]string{} + } + return o.options +} + +type PlurkAddOptions struct { + baseOptions +} + +func NewPlurkAddOptions() *PlurkAddOptions { + return &PlurkAddOptions{} +} + +func (o *PlurkAddOptions) LimitedTo(ids ...int64) *PlurkAddOptions { + limitedToStrs := []string{} + for _, limited := range ids { + limitedToStrs = append(limitedToStrs, strconv.FormatInt(limited, 10)) + } + o.Get()["limited_to"] = "[" + strings.Join(limitedToStrs, ",") + "]" + return o +} +func (o *PlurkAddOptions) NoComments() *PlurkAddOptions { + o.Get()["no_comments"] = "1" + return o +} +func (o *PlurkAddOptions) FriendsOnlyComments() *PlurkAddOptions { + o.Get()["no_comments"] = "2" + return o +} +func (o *PlurkAddOptions) Lang(lang string) *PlurkAddOptions { + o.Get()["lang"] = lang + return o +}