From 8a02fc5297f3c94b38b7e74f43a1eeb55fb0d8ae Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Sat, 2 May 2015 17:59:11 -0700 Subject: [PATCH 01/15] Use OAuth2 to make reddit API requests --- README.md | 8 ++++- oauth_session.go | 89 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 oauth_session.go diff --git a/README.md b/README.md index e619353..86ace18 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,13 @@ Geddit is a convenient abstraction for the [reddit.com](http://reddit.com) API i This library is a WIP. It should have some API coverage, but does not yet include things like the new OAuth model. -## example +## examples + +See [godoc](http://godoc.org/github.com/jzelinskie/geddit) for OAuth examples. + +Here is an example usage of the old, cookie authentication method: + +(NOTE: You will be heavily rate-limited by reddit's API when using cookies. Consider switching to OAuth). ```Go package main diff --git a/oauth_session.go b/oauth_session.go new file mode 100644 index 0000000..2abe18e --- /dev/null +++ b/oauth_session.go @@ -0,0 +1,89 @@ +// Copyright 2012 Jimmy Zelinskie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package reddit implements an abstraction for the reddit.com API. +package geddit + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "golang.org/x/net/context" + "golang.org/x/oauth2" +) + +// OAuthSession represents an OAuth session with reddit.com -- +// all authenticated API calls are methods bound to this type. +type OAuthSession struct { + Client *http.Client + ClientID string + ClientSecret string + OAuthConfig *oauth2.Config + UserAgent string +} + +// NewLoginSession creates a new session for those who want to log into a +// reddit account via OAuth. +func NewOAuthSession(clientID, clientSecret, useragent string, limit bool) (*OAuthSession, error) { + s := &OAuthSession{} + + if useragent != "" { + s.UserAgent = useragent + } else { + s.UserAgent = "Geddit Reddit Bot https://github.com/jzelinskie/geddit" + } + + // Set OAuth config + // TODO Set user-defined scopes + s.OAuthConfig = &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: []string{}, //"identity", "read"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth.reddit.com", + TokenURL: "https://www.reddit.com/api/v1/access_token", + }, + } + + ctx := context.Background() + + // TODO offer auth code version as well as personal scripts + t, err := s.OAuthConfig.PasswordCredentialsToken(ctx, s.Username, s.Password) + if err != nil { + return nil, err + } + + s.Client = s.OAuthConfig.Client(ctx, t) + return s, nil +} + +func (s OAuthSession) getBody(url string, d interface{}) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + // This is needed to avoid rate limits + req.Header.Set("User-Agent", s.UserAgent) + + resp, err := s.Client.Do(req) + if err != nil { + return err + } + fmt.Println(req.Header.Get("User-Agent")) + defer resp.Body.Close() + + // DEBUG + body, err := ioutil.ReadAll(resp.Body) + fmt.Printf("***DEBUG***\nRequest Body: %s\n***DEBUG***\n\n", body) + + err = json.Unmarshal(body, d) + if err != nil { + return err + } + + return nil +} From b7ac640e618b21355e45e4b23dcd4918296a5dbd Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Sat, 2 May 2015 17:59:42 -0700 Subject: [PATCH 02/15] Add basic coverage for OAuth API endpoints "Me" Endpoints: Me() GET api/v1/me MyKarama() GET api/v1/me/karma MyPreferences() GET api/v1/me/prefs MyTrophies() GET api/v1/me/trophies MyFriends() GET api/v1/me/friends User Endpoints: AboutRedditor() GET user//about UserTrophies() GET api/v1/user//trophies Subreddit Endpoints: AboutSubreddit() GET r//about Comments() GET comments/ Submit() POST api/submit Delete() POST api/del SubredditSubmissions() GET r/ FrontPage() GET --- oauth_session.go | 268 ++++++++++++++++++++++++++++++++++++++++++++++- redditor.go | 58 ++++++++-- 2 files changed, 312 insertions(+), 14 deletions(-) diff --git a/oauth_session.go b/oauth_session.go index 2abe18e..7259a45 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -10,7 +10,11 @@ import ( "fmt" "io/ioutil" "net/http" + "net/url" + "strconv" + "strings" + "github.com/google/go-querystring/query" "golang.org/x/net/context" "golang.org/x/oauth2" ) @@ -60,20 +64,19 @@ func NewOAuthSession(clientID, clientSecret, useragent string, limit bool) (*OAu return s, nil } -func (s OAuthSession) getBody(url string, d interface{}) error { - req, err := http.NewRequest("GET", url, nil) +func (o *OAuthSession) getBody(link string, d interface{}) error { + req, err := http.NewRequest("GET", link, nil) if err != nil { return err } // This is needed to avoid rate limits - req.Header.Set("User-Agent", s.UserAgent) + req.Header.Set("User-Agent", o.UserAgent) - resp, err := s.Client.Do(req) + resp, err := o.Client.Do(req) if err != nil { return err } - fmt.Println(req.Header.Get("User-Agent")) defer resp.Body.Close() // DEBUG @@ -87,3 +90,258 @@ func (s OAuthSession) getBody(url string, d interface{}) error { return nil } + +func (o *OAuthSession) Me() (*Redditor, error) { + r := &Redditor{} + err := o.getBody("https://oauth.reddit.com/api/v1/me", r) + if err != nil { + return nil, err + } + return r, nil +} + +func (o *OAuthSession) MyKarma() ([]Karma, error) { + type karma struct { + Data []Karma + } + k := &karma{} + err := o.getBody("https://oauth.reddit.com/api/v1/me/karma", k) + if err != nil { + return nil, err + } + return k.Data, nil +} + +func (o *OAuthSession) MyPreferences() (*Preferences, error) { + p := &Preferences{} + err := o.getBody("https://oauth.reddit.com/api/v1/me/prefs", p) + if err != nil { + return nil, err + } + return p, nil +} + +func (o *OAuthSession) MyFriends() ([]Friend, error) { + type friends struct { + Data struct { + Children []Friend + } + } + f := &friends{} + err := o.getBody("https://oauth.reddit.com/api/v1/me/friends", f) + if err != nil { + return nil, err + } + return f.Data.Children, nil +} + +func (o *OAuthSession) MyTrophies() ([]*Trophy, error) { + type trophyData struct { + Data struct { + Trophies []struct { + Data Trophy + } + } + } + + t := &trophyData{} + err := o.getBody("https://oauth.reddit.com/api/v1/me/trophies", t) + if err != nil { + return nil, err + } + + var myTrophies []*Trophy + for _, trophy := range t.Data.Trophies { + myTrophies = append(myTrophies, &trophy.Data) + } + return myTrophies, nil +} + +// AboutRedditor returns a Redditor for the given username using OAuth. +func (o *OAuthSession) AboutRedditor(user string) (*Redditor, error) { + type redditor struct { + Data Redditor + } + r := &redditor{} + link := fmt.Sprintf("https://oauth.reddit.com/user/%s/about", user) + + err := o.getBody(link, r) + if err != nil { + return nil, err + } + return &r.Data, nil +} + +func (o *OAuthSession) UserTrophies(user string) ([]*Trophy, error) { + type trophyData struct { + Data struct { + Trophies []struct { + Data Trophy + } + } + } + + t := &trophyData{} + url := fmt.Sprintf("https://oauth.reddit.com/api/v1/user/%s/trophies", user) + err := o.getBody(url, t) + if err != nil { + return nil, err + } + + var trophies []*Trophy + for _, trophy := range t.Data.Trophies { + trophies = append(trophies, &trophy.Data) + } + return trophies, nil +} + +// AboutSubreddit returns a subreddit for the given subreddit name using OAuth. +func (o *OAuthSession) AboutSubreddit(name string) (*Subreddit, error) { + type subreddit struct { + Data Subreddit + } + sr := &subreddit{} + link := fmt.Sprintf("https://oauth.reddit.com/r/%s/about", name) + + err := o.getBody(link, sr) + if err != nil { + return nil, err + } + return &sr.Data, nil +} + +// Comments returns the comments for a given Submission using OAuth. +func (o *OAuthSession) Comments(h *Submission) ([]*Comment, error) { + var c interface{} + link := fmt.Sprintf("https://oauth.reddit.com/comments/%s", h.ID) + err := o.getBody(link, &c) + if err != nil { + return nil, err + } + helper := new(helper) + helper.buildComments(c) + return helper.comments, nil +} + +func (o *OAuthSession) postBody(link string, form url.Values, d interface{}) error { + req, err := http.NewRequest("POST", link, strings.NewReader(form.Encode())) + if err != nil { + return err + } + + // This is needed to avoid rate limits + req.Header.Set("User-Agent", o.UserAgent) + + // POST form provided + req.PostForm = form + + resp, err := o.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + // DEBUG + body, err := ioutil.ReadAll(resp.Body) + fmt.Printf("***DEBUG***\nRequest Body: %s\n***DEBUG***\n\n", body) + + // The caller may want JSON decoded, or this could just be an update/delete request. + if d != nil { + err = json.Unmarshal(body, d) + if err != nil { + return err + } + } + + return nil +} + +// SubmitLink accepts a NewSubmission type and submits a new link using OAuth. +// Returns a Submission type. +func (o *OAuthSession) Submit(ns *NewSubmission) (*Submission, error) { + + // Build form for POST request. + v := url.Values{ + "title": {ns.Title}, + "url": {ns.Content}, + "text": {ns.Content}, + "sr": {ns.Subreddit}, + "sendreplies": {strconv.FormatBool(ns.SendReplies)}, + "resubmit": {strconv.FormatBool(ns.Resubmit)}, + "api_type": {"json"}, + // TODO implement captchas for OAuth types + //"captcha": {ns.Captcha.Response}, + //"iden": {ns.Captcha.Iden}, + } + if ns.Self { + v.Add("kind", "self") + } else { + v.Add("kind", "link") + } + + type submission struct { + Json struct { + Errors [][]string + Data Submission + } + } + submit := &submission{} + + err := o.postBody("https://oauth.reddit.com/api/submit", v, submit) + if err != nil { + return nil, err + } + // TODO check s.Errors and do something useful? + return &submit.Json.Data, nil +} + +// Delete deletes a link or comment using the given full name ID. +func (o *OAuthSession) Delete(d Deleter) error { + // Build form for POST request. + v := url.Values{} + v.Add("id", d.deleteID()) + + return o.postBody("https://oauth.reddit.com/api/del", v, nil) +} + +// SubredditSubmissions returns the submissions on the given subreddit using OAuth. +func (o *OAuthSession) SubredditSubmissions(subreddit string, sort popularitySort, params ListingOptions) ([]*Submission, error) { + v, err := query.Values(params) + if err != nil { + return nil, err + } + + baseUrl := "https://oauth.reddit.com" + + // If subbreddit given, add to URL + if subreddit != "" { + baseUrl += "/r/" + subreddit + } + + redditURL := fmt.Sprintf(baseUrl+"/%s.json?%s", sort, v.Encode()) + + type Response struct { + Data struct { + Children []struct { + Data *Submission + } + } + } + + r := new(Response) + err = o.getBody(redditURL, r) + if err != nil { + return nil, err + } + + submissions := make([]*Submission, len(r.Data.Children)) + for i, child := range r.Data.Children { + submissions[i] = child.Data + } + + return submissions, nil +} + +// Frontpage returns the submissions on the default reddit frontpage using OAuth. +func (o *OAuthSession) Frontpage(sort popularitySort, params ListingOptions) ([]*Submission, error) { + return o.SubredditSubmissions("", sort, params) +} diff --git a/redditor.go b/redditor.go index 0fcfc53..649b39e 100644 --- a/redditor.go +++ b/redditor.go @@ -9,15 +9,55 @@ import ( ) type Redditor struct { - ID string `json:"id"` - Name string `json:"name"` - LinkKarma int `json:"link_karma"` - CommentKarma int `json:"comment_karma"` - Created float32 `json:"created_utc"` - Gold bool `json:"is_gold"` - Mod bool `json:"is_mod"` - Mail *bool `json:"has_mail"` - ModMail *bool `json:"has_mod_mail"` + ID string `json:"id"` + Name string `json:"name"` + Created float32 `json:"created_utc"` + Gold bool `json:"is_gold"` + Mod bool `json:"is_mod"` + Mail *bool `json:"has_mail"` + ModMail *bool `json:"has_mod_mail"` + Karma +} + +type Preferences struct { + Research bool `json:"research"` + ShowStylesheets bool `json:"show_stylesheets"` + ShowLinkFlair bool `json:"show_link_flair"` + ShowTrending bool `json:"show_trending"` + PrivateFeeds bool `json:"private_feeds"` + IgnoreSuggestedSort bool `json:"ignore_suggested_sort"` + Media string `json:"media"` + ClickGadget bool `json:"clickgadget"` + LabelNSFW bool `json:"label_nsfw"` + Over18 bool `json:"over_18"` + EmailMessages bool `json:"email_messages"` + HighlightControversial bool `json:"highlight_controversial"` + ForceHTTPS bool `json:"force_https"` + Language string `json:"lang"` + HideFromRobots bool `json:"hide_from_robots"` + PublicVotes bool `json:"public_votes"` + ShowFlair bool `json:"show_flair"` + HideAds bool `json:"hide_ads"` + Beta bool `json:"beta"` + NewWindow bool `json:"newwindow"` + LegacySearch bool `json:"legacy_search"` +} + +type Friend struct { + Date float32 `json:"date"` + Name string `json:"name"` + ID string `json:"id"` +} + +type Karma struct { + CommentKarma int `json:"comment_karma"` + LinkKarma int `json:"link_karma"` +} + +type Trophy struct { + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon_70"` } // String returns the string representation of a reddit user. From 017e3d73fd1c0e5fdd2e7da98f0e7c296c226c8a Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Sat, 19 Dec 2015 18:59:01 -0500 Subject: [PATCH 03/15] Add basic test for OAuthSession type --- oauth_session_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 oauth_session_test.go diff --git a/oauth_session_test.go b/oauth_session_test.go new file mode 100644 index 0000000..fb0224c --- /dev/null +++ b/oauth_session_test.go @@ -0,0 +1,80 @@ +package geddit + +import ( + "errors" + "fmt" + "log" + "net/http" + "net/http/httptest" + "net/url" + "path" + "testing" +) + +type RewriteTransport struct { + Transport http.RoundTripper + URL *url.URL +} + +func (t RewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.URL.Scheme = t.URL.Scheme + req.URL.Host = t.URL.Host + req.URL.Path = path.Join(t.URL.Path, req.URL.Path) + rt := t.Transport + if rt == nil { + rt = http.DefaultTransport + } + return rt.RoundTrip(req) +} + +func testTools(code int, body string) (*httptest.Server, *OAuthSession) { + // Dummy server to write JSON body provided + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, body) + })) + + u, err := url.Parse(server.URL) + if err != nil { + log.Fatalf("Failed to parse local server URL: %v", err) + } + o := &OAuthSession{Client: http.DefaultClient, UserAgent: "Geddit Test"} + o.Client.Transport = RewriteTransport{URL: u} + + return server, o +} + +// Test defaults o fresh OAuthSession type. +func TestNewOAuthSession(t *testing.T) { + o, err := NewOAuthSession("user", "pw", "agent", "http://") + if err != nil { + t.Fatal(err) + } + + if o.Client != nil { + t.Fatal(errors.New("HTTP client created before auth token!")) + } +} + +func TestMe(t *testing.T) { + server, oauth := testTools(200, `{"has_mail": false, "name": "aggrolite", "is_friend": false, "created": 1278447313.0, "suspension_expiration_utc": null, "hide_from_robots": true, "is_suspended": false, "modhash": "XXX", "created_utc": 1278418513.0, "link_karma": 2327, "comment_karma": 1233, "over_18": true, "is_gold": false, "is_mod": true, "id": "45xiz", "gold_expiration": null, "inbox_count": 0, "has_verified_email": true, "gold_creddits": 0, "has_mod_mail": false}`) + defer server.Close() + + me, err := oauth.Me() + if err != nil { + t.Errorf("Me() Test failed: %v", err) + } + // Sanity check just a few fields? + if me.Name != "aggrolite" { + t.Fatalf("Me() returned unexpected name: %s", me.Name) + } + if me.ID != "45xiz" { + t.Fatalf("Me() returned unexpected ID: %s", me.ID) + } + if me.String() != "aggrolite (2327-1233)" { + t.Fatalf("Me.String() returns unexpected result: %s", me.String()) + } + fmt.Println(me) + +} From 2db4fb4e3291c8649bace1603bf960b248d2a7d1 Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Mon, 21 Dec 2015 17:24:26 -0500 Subject: [PATCH 04/15] Use LoginAuth() to exchange a username/password for auth token After a new OAuthSession type is created with NewOAuthSession(), the caller is given the choice of how the auth token should be created. The auth token is required to define the HTTP client used for API requests. Currently, LoginAuth() is the only method of token exchange. Other methods should be implemented in the future, which is why LoginAuth() does not happen by default within the constructor of OAuthSession. --- oauth_session.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/oauth_session.go b/oauth_session.go index 7259a45..a6079e9 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -7,6 +7,7 @@ package geddit import ( "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" @@ -27,6 +28,7 @@ type OAuthSession struct { ClientSecret string OAuthConfig *oauth2.Config UserAgent string + ctx context.Context } // NewLoginSession creates a new session for those who want to log into a @@ -54,11 +56,16 @@ func NewOAuthSession(clientID, clientSecret, useragent string, limit bool) (*OAu ctx := context.Background() - // TODO offer auth code version as well as personal scripts - t, err := s.OAuthConfig.PasswordCredentialsToken(ctx, s.Username, s.Password) +// LoginAuth creates the required HTTP client with a new token. +func (o *OAuthSession) LoginAuth(username, password string) error { + // Fetch OAuth token. + t, err := o.OAuthConfig.PasswordCredentialsToken(o.ctx, username, password) if err != nil { - return nil, err + return err } + o.Client = o.OAuthConfig.Client(o.ctx, t) + return nil +} s.Client = s.OAuthConfig.Client(ctx, t) return s, nil @@ -73,6 +80,9 @@ func (o *OAuthSession) getBody(link string, d interface{}) error { // This is needed to avoid rate limits req.Header.Set("User-Agent", o.UserAgent) + if o.Client == nil { + return errors.New("OAuth Session lacks HTTP client! Use func (o OAuthSession) LoginAuthentication() to make one.") + } resp, err := o.Client.Do(req) if err != nil { return err @@ -235,6 +245,9 @@ func (o *OAuthSession) postBody(link string, form url.Values, d interface{}) err // POST form provided req.PostForm = form + if o.Client == nil { + return errors.New("OAuth Session lacks HTTP client! Use func (o OAuthSession) LoginAuthentication() to make one.") + } resp, err := o.Client.Do(req) if err != nil { return err From c7e4f2111fbfd0304b91353021af58e782cb46cd Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Mon, 21 Dec 2015 17:25:10 -0500 Subject: [PATCH 05/15] Add optional client-side throttling with Throttle() Disabled by default. Accepts a time.Duration and each HTTP request must wait on interval to elapse. User can disable any existing throttling by passing 0. --- oauth_session.go | 110 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/oauth_session.go b/oauth_session.go index a6079e9..407c0cd 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -14,7 +14,9 @@ import ( "net/url" "strconv" "strings" + "time" + "github.com/beefsack/go-rate" "github.com/google/go-querystring/query" "golang.org/x/net/context" "golang.org/x/oauth2" @@ -29,11 +31,12 @@ type OAuthSession struct { OAuthConfig *oauth2.Config UserAgent string ctx context.Context + throttle *rate.RateLimiter } // NewLoginSession creates a new session for those who want to log into a // reddit account via OAuth. -func NewOAuthSession(clientID, clientSecret, useragent string, limit bool) (*OAuthSession, error) { +func NewOAuthSession(clientID, clientSecret, useragent string) (*OAuthSession, error) { s := &OAuthSession{} if useragent != "" { @@ -53,8 +56,20 @@ func NewOAuthSession(clientID, clientSecret, useragent string, limit bool) (*OAu TokenURL: "https://www.reddit.com/api/v1/access_token", }, } + s.ctx = context.Background() + return s, nil +} - ctx := context.Background() +// Throttle sets the interval of each HTTP request. +// Disable by setting interval to 0. Disabled by default. +// Throttling is applied to invidual OAuthSession types. +func (o *OAuthSession) Throttle(interval time.Duration) { + if interval == 0 { + o.throttle = nil + return + } + o.throttle = rate.New(1, interval) +} // LoginAuth creates the required HTTP client with a new token. func (o *OAuthSession) LoginAuth(username, password string) error { @@ -67,8 +82,20 @@ func (o *OAuthSession) LoginAuth(username, password string) error { return nil } - s.Client = s.OAuthConfig.Client(ctx, t) - return s, nil +// AuthCodeURL creates and returns an auth URL which contains an auth code. +func (o *OAuthSession) AuthCodeURL(state string, scopes []string) string { + o.OAuthConfig.Scopes = scopes + return o.OAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOnline) +} + +// CodeAuth creates and sets a token using an authentication code returned from AuthCodeURL. +func (o *OAuthSession) CodeAuth(code string) error { + t, err := o.OAuthConfig.Exchange(o.ctx, code) + if err != nil { + return err + } + o.Client = o.OAuthConfig.Client(context.Background(), t) //o.ctx, t) + return nil } func (o *OAuthSession) getBody(link string, d interface{}) error { @@ -81,8 +108,14 @@ func (o *OAuthSession) getBody(link string, d interface{}) error { req.Header.Set("User-Agent", o.UserAgent) if o.Client == nil { - return errors.New("OAuth Session lacks HTTP client! Use func (o OAuthSession) LoginAuthentication() to make one.") + return errors.New("OAuth Session lacks HTTP client! Use func (o OAuthSession) LoginAuth() to make one.") + } + + // Throttle request + if o.throttle != nil { + o.throttle.Wait() } + resp, err := o.Client.Do(req) if err != nil { return err @@ -167,6 +200,47 @@ func (o *OAuthSession) MyTrophies() ([]*Trophy, error) { return myTrophies, nil } +// Listing returns a slice of Submission pointers. +// See https://www.reddit.com/dev/api#listings for documentation. +func (o *OAuthSession) Listing(username, listing string, sort popularitySort, after string) ([]*Submission, error) { + values := &url.Values{} + if sort != "" { + values.Set("sort", string(sort)) + } + if after != "" { + values.Set("after", after) + } + + type resp struct { + Data struct { + Children []struct { + Data *Submission + } + } + } + r := &resp{} + url := fmt.Sprintf("https://oauth.reddit.com/user/%s/%s?%s", username, listing, values.Encode()) + err := o.getBody(url, r) + if err != nil { + return nil, err + } + + submissions := make([]*Submission, len(r.Data.Children)) + for i, child := range r.Data.Children { + submissions[i] = child.Data + } + + return submissions, nil +} + +func (o *OAuthSession) MyUpvoted(sort popularitySort, after string) ([]*Submission, error) { + me, err := o.Me() + if err != nil { + return nil, err + } + return o.Listing(me.Name, "upvoted", sort, after) +} + // AboutRedditor returns a Redditor for the given username using OAuth. func (o *OAuthSession) AboutRedditor(user string) (*Redditor, error) { type redditor struct { @@ -246,8 +320,14 @@ func (o *OAuthSession) postBody(link string, form url.Values, d interface{}) err req.PostForm = form if o.Client == nil { - return errors.New("OAuth Session lacks HTTP client! Use func (o OAuthSession) LoginAuthentication() to make one.") + return errors.New("OAuth Session lacks HTTP client! Use func (o OAuthSession) LoginAuth() to make one.") + } + + // Throttle request + if o.throttle != nil { + o.throttle.Wait() } + resp, err := o.Client.Do(req) if err != nil { return err @@ -268,7 +348,7 @@ func (o *OAuthSession) postBody(link string, form url.Values, d interface{}) err return nil } -// SubmitLink accepts a NewSubmission type and submits a new link using OAuth. +// Submit accepts a NewSubmission type and submits a new link using OAuth. // Returns a Submission type. func (o *OAuthSession) Submit(ns *NewSubmission) (*Submission, error) { @@ -358,3 +438,19 @@ func (o *OAuthSession) SubredditSubmissions(subreddit string, sort popularitySor func (o *OAuthSession) Frontpage(sort popularitySort, params ListingOptions) ([]*Submission, error) { return o.SubredditSubmissions("", sort, params) } + +// Vote either votes or rescinds a vote for a Submission or Comment using OAuth. +func (o *OAuthSession) Vote(v Voter, dir vote) error { + // Build form for POST request. + form := url.Values{ + "id": {v.voteID()}, + "dir": {string(dir)}, + } + var foo interface{} + + err := o.postBody("https://oauth.reddit.com/api/vote", form, foo) + if err != nil { + return err + } + return nil +} From 2e31cff594a74f539acce3802efcdc1cc9f20c87 Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Mon, 21 Dec 2015 19:11:19 -0500 Subject: [PATCH 06/15] Add example usage of NewOAuthSession --- example_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 example_test.go diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..1d917e4 --- /dev/null +++ b/example_test.go @@ -0,0 +1,56 @@ +package geddit_test + +import ( + "fmt" + "log" + + "github.com/jzelinskie/geddit" +) + +func ExampleNewOAuthSession_login() { + o, err := geddit.NewOAuthSession( + "client_id", + "client_secret", + "Testing OAuth Bot by u/my_user v0.1 see source https://github.com/jzelinskie/geddit", + ) + if err != nil { + log.Fatal(err) + } + + // Create new auth token for confidential clients (personal scripts/apps). + err = o.LoginAuth("my_user", "my_password") + if err != nil { + log.Fatal(err) + } + + // Ready to make API calls! +} + +func ExampleNewOAuthSession_url() { + o, err := geddit.NewOAuthSession( + "client_id", + "client_secret", + "Testing OAuth Bot by u/my_user v0.1 see source https://github.com/jzelinskie/geddit", + "http://redirect.url", + ) + if err != nil { + log.Fatal(err) + } + + // Pass a random/unique state string which will be returned to the + // redirect URL. Ideally, you should verify that it matches to + // avoid CSRF attack. + url := o.AuthCodeURL("random string", []string{"indentity", "read"}) + fmt.Printf("Visit %s to obtain auth code", url) + + var code string + fmt.Scanln(&code) + + // Create and set token using given auth code. + err = o.CodeAuth(code) + if err != nil { + log.Fatal(err) + } + + // Ready to make API calls! +} From 3a5c87a74d432b4042c78c977530164cc9b5caa8 Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Tue, 22 Dec 2015 02:24:26 -0500 Subject: [PATCH 07/15] Support implicit grant authorization When creating a new OAuthSession type, the caller can now choose between authenticating for personal use or on behalf of a user. --- example_test.go | 1 + oauth_session.go | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/example_test.go b/example_test.go index 1d917e4..f9c95ef 100644 --- a/example_test.go +++ b/example_test.go @@ -12,6 +12,7 @@ func ExampleNewOAuthSession_login() { "client_id", "client_secret", "Testing OAuth Bot by u/my_user v0.1 see source https://github.com/jzelinskie/geddit", + "http://redirect.url", ) if err != nil { log.Fatal(err) diff --git a/oauth_session.go b/oauth_session.go index 407c0cd..97ded60 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -36,7 +36,7 @@ type OAuthSession struct { // NewLoginSession creates a new session for those who want to log into a // reddit account via OAuth. -func NewOAuthSession(clientID, clientSecret, useragent string) (*OAuthSession, error) { +func NewOAuthSession(clientID, clientSecret, useragent, redirectURL string) (*OAuthSession, error) { s := &OAuthSession{} if useragent != "" { @@ -46,15 +46,14 @@ func NewOAuthSession(clientID, clientSecret, useragent string) (*OAuthSession, e } // Set OAuth config - // TODO Set user-defined scopes s.OAuthConfig = &oauth2.Config{ ClientID: clientID, ClientSecret: clientSecret, - Scopes: []string{}, //"identity", "read"}, Endpoint: oauth2.Endpoint{ - AuthURL: "https://oauth.reddit.com", + AuthURL: "https://www.reddit.com/api/v1/authorize", TokenURL: "https://www.reddit.com/api/v1/access_token", }, + RedirectURL: redirectURL, } s.ctx = context.Background() return s, nil @@ -94,7 +93,7 @@ func (o *OAuthSession) CodeAuth(code string) error { if err != nil { return err } - o.Client = o.OAuthConfig.Client(context.Background(), t) //o.ctx, t) + o.Client = o.OAuthConfig.Client(context.Background(), t) return nil } From 5ae77127c24b0cff07caba18bfb0f92462f6ae5d Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Thu, 24 Dec 2015 03:01:08 -0500 Subject: [PATCH 08/15] Support CAPTCHA endpoints --- oauth_session.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/oauth_session.go b/oauth_session.go index 97ded60..3927b47 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -97,6 +97,40 @@ func (o *OAuthSession) CodeAuth(code string) error { return nil } +// NeedsCaptcha check whether CAPTCHAs are needed for the Submit function. +func (o *OAuthSession) NeedsCaptcha() (bool, error) { + var b bool + err := o.getBody("https://oauth.reddit.com/api/needs_captcha", &b) + if err != nil { + return false, err + } + return b, nil +} + +// NewCaptcha returns a string used to create CAPTCHA links for users. +func (o *OAuthSession) NewCaptcha() (string, error) { + // Build form for POST request. + v := url.Values{ + "api_type": {"json"}, + } + + type captcha struct { + Json struct { + Errors [][]string + Data struct { + Iden string + } + } + } + c := &captcha{} + + err := o.postBody("https://oauth.reddit.com/api/new_captcha", v, c) + if err != nil { + return "", err + } + return c.Json.Data.Iden, nil +} + func (o *OAuthSession) getBody(link string, d interface{}) error { req, err := http.NewRequest("GET", link, nil) if err != nil { From 821ef4badc8f02846a348c9a5b8e4a77872883ea Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Tue, 29 Dec 2015 13:50:35 -0800 Subject: [PATCH 09/15] Apply popularity sort and listing opts for Comments() --- oauth_session.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/oauth_session.go b/oauth_session.go index 3927b47..08fca0e 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -328,10 +328,14 @@ func (o *OAuthSession) AboutSubreddit(name string) (*Subreddit, error) { } // Comments returns the comments for a given Submission using OAuth. -func (o *OAuthSession) Comments(h *Submission) ([]*Comment, error) { +func (o *OAuthSession) Comments(h *Submission, sort popularitySort, params ListingOptions) ([]*Comment, error) { + p, err := query.Values(params) + if err != nil { + return nil, err + } var c interface{} - link := fmt.Sprintf("https://oauth.reddit.com/comments/%s", h.ID) - err := o.getBody(link, &c) + link := fmt.Sprintf("https://oauth.reddit.com/comments/%s?%s", h.ID, p.Encode()) + err = o.getBody(link, &c) if err != nil { return nil, err } From b19885333aef4320dc026da6725b56ba9a652c6a Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Tue, 29 Dec 2015 13:51:02 -0800 Subject: [PATCH 10/15] Save, Unsave, and fetch Saved comments --- oauth_session.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/oauth_session.go b/oauth_session.go index 08fca0e..4d26688 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -483,11 +483,85 @@ func (o *OAuthSession) Vote(v Voter, dir vote) error { "id": {v.voteID()}, "dir": {string(dir)}, } - var foo interface{} + var vo interface{} - err := o.postBody("https://oauth.reddit.com/api/vote", form, foo) + err := o.postBody("https://oauth.reddit.com/api/vote", form, vo) if err != nil { return err } return nil } + +// Save saves a link or comment using OAuth. +func (o *OAuthSession) Save(v Voter, category string) error { + // Build form for POST request. + form := url.Values{ + "id": {v.voteID()}, + "category": {category}, + } + var s interface{} + + err := o.postBody("https://oauth.reddit.com/api/save", form, s) + if err != nil { + return err + } + return nil +} + +// Unsave saves a link or comment using OAuth. +func (o *OAuthSession) Unsave(v Voter, category string) error { + // Build form for POST request. + form := url.Values{ + "id": {v.voteID()}, + "category": {category}, + } + var u interface{} + + err := o.postBody("https://oauth.reddit.com/api/unsave", form, u) + if err != nil { + return err + } + return nil +} + +// SavedLinks fetches links saved by given username using OAuth. +func (o *OAuthSession) SavedLinks(user string, params ListingOptions) ([]*Submission, error) { + type saved struct { + Data struct { + Children []struct { + Kind string + Data *Submission + } + } + } + s := &saved{} + url := fmt.Sprintf("https://oauth.reddit.com/user/%s/saved", user) + err := o.getBody(url, s) + if err != nil { + return nil, err + } + + var links []*Submission + for _, c := range s.Data.Children { + if c.Kind == "t1" { + continue + } + links = append(links, c.Data) + } + return links, nil + +} + +// SavedComments fetches comments saved by given username using OAuth. +func (o *OAuthSession) SavedComments(user string, params ListingOptions) ([]*Comment, error) { + var s interface{} + url := fmt.Sprintf("https://oauth.reddit.com/user/%s/saved", user) + err := o.getBody(url, &s) + if err != nil { + return nil, err + } + + helper := new(helper) + helper.buildComments(s) + return helper.comments, nil +} From 7b6acfe0347ad4d42ba8b542d2bdc4dbf72b9158 Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Tue, 29 Dec 2015 19:23:44 -0800 Subject: [PATCH 11/15] Allow Listing() to accept full options, support more My functions --- example_test.go | 4 +++ oauth_session.go | 63 +++++++++++++++++++++---------------------- oauth_session_test.go | 4 +++ 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/example_test.go b/example_test.go index f9c95ef..80024df 100644 --- a/example_test.go +++ b/example_test.go @@ -1,3 +1,7 @@ +// Copyright 2012 Jimmy Zelinskie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + package geddit_test import ( diff --git a/oauth_session.go b/oauth_session.go index 4d26688..58509e1 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -235,13 +235,13 @@ func (o *OAuthSession) MyTrophies() ([]*Trophy, error) { // Listing returns a slice of Submission pointers. // See https://www.reddit.com/dev/api#listings for documentation. -func (o *OAuthSession) Listing(username, listing string, sort popularitySort, after string) ([]*Submission, error) { - values := &url.Values{} - if sort != "" { - values.Set("sort", string(sort)) +func (o *OAuthSession) Listing(username, listing string, sort popularitySort, params ListingOptions) ([]*Submission, error) { + p, err := query.Values(params) + if err != nil { + return nil, err } - if after != "" { - values.Set("after", after) + if sort != "" { + p.Set("sort", string(sort)) } type resp struct { @@ -252,8 +252,8 @@ func (o *OAuthSession) Listing(username, listing string, sort popularitySort, af } } r := &resp{} - url := fmt.Sprintf("https://oauth.reddit.com/user/%s/%s?%s", username, listing, values.Encode()) - err := o.getBody(url, r) + url := fmt.Sprintf("https://oauth.reddit.com/user/%s/%s?%s", username, listing, p.Encode()) + err = o.getBody(url, r) if err != nil { return nil, err } @@ -266,12 +266,16 @@ func (o *OAuthSession) Listing(username, listing string, sort popularitySort, af return submissions, nil } -func (o *OAuthSession) MyUpvoted(sort popularitySort, after string) ([]*Submission, error) { +func (o *OAuthSession) Upvoted(username string, sort popularitySort, params ListingOptions) ([]*Submission, error) { + return o.Listing(username, "upvoted", sort, params) +} + +func (o *OAuthSession) MyUpvoted(sort popularitySort, params ListingOptions) ([]*Submission, error) { me, err := o.Me() if err != nil { return nil, err } - return o.Listing(me.Name, "upvoted", sort, after) + return o.Listing(me.Name, "upvoted", sort, params) } // AboutRedditor returns a Redditor for the given username using OAuth. @@ -525,31 +529,17 @@ func (o *OAuthSession) Unsave(v Voter, category string) error { } // SavedLinks fetches links saved by given username using OAuth. -func (o *OAuthSession) SavedLinks(user string, params ListingOptions) ([]*Submission, error) { - type saved struct { - Data struct { - Children []struct { - Kind string - Data *Submission - } - } - } - s := &saved{} - url := fmt.Sprintf("https://oauth.reddit.com/user/%s/saved", user) - err := o.getBody(url, s) +func (o *OAuthSession) SavedLinks(username string, params ListingOptions) ([]*Submission, error) { + return o.Listing(username, "saved", "", params) +} + +// MySavedLinks fetches links saved by current user using OAuth. +func (o *OAuthSession) MySavedLinks(params ListingOptions) ([]*Submission, error) { + me, err := o.Me() if err != nil { return nil, err } - - var links []*Submission - for _, c := range s.Data.Children { - if c.Kind == "t1" { - continue - } - links = append(links, c.Data) - } - return links, nil - + return o.Listing(me.Name, "saved", "", params) } // SavedComments fetches comments saved by given username using OAuth. @@ -565,3 +555,12 @@ func (o *OAuthSession) SavedComments(user string, params ListingOptions) ([]*Com helper.buildComments(s) return helper.comments, nil } + +// MySavedComments fetches comments saved by current user using OAuth. +func (o *OAuthSession) MySavedComments(params ListingOptions) ([]*Comment, error) { + me, err := o.Me() + if err != nil { + return nil, err + } + return o.SavedComments(me.Name, params) +} diff --git a/oauth_session_test.go b/oauth_session_test.go index fb0224c..83f5f37 100644 --- a/oauth_session_test.go +++ b/oauth_session_test.go @@ -1,3 +1,7 @@ +// Copyright 2012 Jimmy Zelinskie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + package geddit import ( From c453d9b1074bae3bfd9345ab9fa96ae6acf10913 Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Sat, 2 Jan 2016 20:34:42 -0800 Subject: [PATCH 12/15] Export TokenExpiry for personal script authentication Personal script authentication (password credential exchange) is not issued a refresh token by Reddit's API. The caller must request a new access token after each one expires. The TokenExpiry field indicates the lifetime of a token and will help the user determine when a new access token must be created. --- oauth_session.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/oauth_session.go b/oauth_session.go index 58509e1..797be89 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -29,6 +29,7 @@ type OAuthSession struct { ClientID string ClientSecret string OAuthConfig *oauth2.Config + TokenExpiry time.Time UserAgent string ctx context.Context throttle *rate.RateLimiter @@ -77,6 +78,7 @@ func (o *OAuthSession) LoginAuth(username, password string) error { if err != nil { return err } + o.TokenExpiry = t.Expiry o.Client = o.OAuthConfig.Client(o.ctx, t) return nil } From 065e9ccbca9412c3a2d4c64d9234577e0e41e9d9 Mon Sep 17 00:00:00 2001 From: Curtis Brandt Date: Thu, 7 Jan 2016 23:08:46 -0800 Subject: [PATCH 13/15] fixup! Add basic coverage for OAuth API endpoints --- oauth_session.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth_session.go b/oauth_session.go index 797be89..a28ae83 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -483,7 +483,7 @@ func (o *OAuthSession) Frontpage(sort popularitySort, params ListingOptions) ([] } // Vote either votes or rescinds a vote for a Submission or Comment using OAuth. -func (o *OAuthSession) Vote(v Voter, dir vote) error { +func (o *OAuthSession) Vote(v Voter, dir Vote) error { // Build form for POST request. form := url.Values{ "id": {v.voteID()}, From 146b7445ee070cd97b2a741f9cc9068bf146230a Mon Sep 17 00:00:00 2001 From: Jussi Timperi Date: Fri, 22 Jan 2016 01:52:36 +0200 Subject: [PATCH 14/15] Add Reply --- oauth_session.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/oauth_session.go b/oauth_session.go index a28ae83..d19f90c 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -498,6 +498,46 @@ func (o *OAuthSession) Vote(v Voter, dir Vote) error { return nil } +// Reply posts a comment as a response to a Submission or Comment using OAuth. +func (o OAuthSession) Reply(r Replier, comment string) (*Comment, error) { + // Build form for POST request. + form := url.Values{ + "api_type": {"json"}, + "thing_id": {r.replyID()}, + "text": {comment}, + } + + type response struct { + JSON struct { + Errors [][]string + Data struct { + Things []struct { + Data map[string]interface{} + } + } + } + } + + res := &response{} + + err := o.postBody("https://oauth.reddit.com/api/comment", form, res) + if err != nil { + return nil, err + } + + if len(res.JSON.Errors) != 0 { + var msg []string + for _, k := range res.JSON.Errors { + msg = append(msg, k[1]) + } + return nil, errors.New(strings.Join(msg, ", ")) + } + + c := makeComment(res.JSON.Data.Things[0].Data) + + return c, nil +} + // Save saves a link or comment using OAuth. func (o *OAuthSession) Save(v Voter, category string) error { // Build form for POST request. From 92992831934bc4cf3577400b4f85b7bba1cc479c Mon Sep 17 00:00:00 2001 From: Samir Bhatt Date: Sat, 30 Jan 2016 18:44:43 -0800 Subject: [PATCH 15/15] Added support for Application Only OAuth flow. Added support for duration parameter. For easier merging --- apponlyoauth_session.go | 267 +++++++++++++++++++++++++++++++++++ apponlyoauth_session_test.go | 35 +++++ oauth_session.go | 37 +++-- types.go | 27 ++++ 4 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 apponlyoauth_session.go create mode 100644 apponlyoauth_session_test.go diff --git a/apponlyoauth_session.go b/apponlyoauth_session.go new file mode 100644 index 0000000..ae1e7c9 --- /dev/null +++ b/apponlyoauth_session.go @@ -0,0 +1,267 @@ +// Copyright 2012 Jimmy Zelinskie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Copyright 2016 Samir Bhatt. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package geddit implements an abstraction for the reddit.com API. +package geddit + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/google/go-querystring/query" + "golang.org/x/net/context" + "golang.org/x/oauth2/clientcredentials" +) + +// AppOnlyOAuthSession represents an OAuth session with reddit.com -- +// all authenticated API calls are methods bound to this type. +type AppOnlyOAuthSession struct { + Client *http.Client + ClientID string + ClientSecret string + OAuthConfig *clientcredentials.Config + TokenExpiry time.Time + UserAgent string + ctx context.Context + Debug bool +} + +// NewAppOnlyOAuthSession creates a new session for those who want to log into a +// reddit account via Application Only OAuth. +// See https://github.com/reddit/reddit/wiki/OAuth2#application-only-oauth +func NewAppOnlyOAuthSession(clientID, clientSecret, useragent string, debug bool) (*AppOnlyOAuthSession, error) { + s := &AppOnlyOAuthSession{} + + if useragent != "" { + s.UserAgent = useragent + } else { + s.UserAgent = "Geddit API Client https://github.com/imheresamir/geddit" + } + + s.ClientID = clientID + s.ClientSecret = clientSecret + + // Set OAuth config + s.OAuthConfig = &clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: "https://www.reddit.com/api/v1/access_token", + } + + s.ctx = context.Background() + + return s, nil +} + +// refreshToken should be called internally before each API call +func (a *AppOnlyOAuthSession) refreshToken() error { + // Check if token needs to be refreshed + if time.Now().Before(a.TokenExpiry) { + return nil + } + + // Fetch OAuth token + t, err := a.OAuthConfig.Token(a.ctx) + if err != nil { + return err + } + a.TokenExpiry = t.Expiry + + a.Client = a.OAuthConfig.Client(a.ctx) + return nil +} + +func (a *AppOnlyOAuthSession) getBody(link string, d interface{}) error { + a.refreshToken() + + req, err := http.NewRequest("GET", link, nil) + if err != nil { + return err + } + + // This is needed to avoid rate limits + req.Header.Set("User-Agent", a.UserAgent) + + if a.Client == nil { + return errors.New("OAuth Session lacks HTTP client! Error getting token") + } + + resp, err := a.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + + // DEBUG + if a.Debug { + fmt.Printf("***DEBUG***\nRequest Body: %s\n***DEBUG***\n\n", body) + } + + err = json.Unmarshal(body, d) + if err != nil { + return err + } + + return nil +} + +// Listing returns a slice of Submission pointers. +// See https://www.reddit.com/dev/api#listings for documentation. +func (a *AppOnlyOAuthSession) Listing(username, listing string, sort popularitySort, params ListingOptions) ([]*Submission, error) { + p, err := query.Values(params) + if err != nil { + return nil, err + } + if sort != "" { + p.Set("sort", string(sort)) + } + + type resp struct { + Data struct { + Children []struct { + Data *Submission + } + } + } + r := &resp{} + url := fmt.Sprintf("https://oauth.reddit.com/user/%s/%s?%s", username, listing, p.Encode()) + err = a.getBody(url, r) + if err != nil { + return nil, err + } + + submissions := make([]*Submission, len(r.Data.Children)) + for i, child := range r.Data.Children { + submissions[i] = child.Data + } + + return submissions, nil +} + +func (a *AppOnlyOAuthSession) Upvoted(username string, sort popularitySort, params ListingOptions) ([]*Submission, error) { + return a.Listing(username, "upvoted", sort, params) +} + +// AboutRedditor returns a Redditor for the given username using OAuth. +func (a *AppOnlyOAuthSession) AboutRedditor(user string) (*Redditor, error) { + type redditor struct { + Data Redditor + } + r := &redditor{} + link := fmt.Sprintf("https://oauth.reddit.com/user/%s/about", user) + + err := a.getBody(link, r) + if err != nil { + return nil, err + } + return &r.Data, nil +} + +func (a *AppOnlyOAuthSession) UserTrophies(user string) ([]*Trophy, error) { + type trophyData struct { + Data struct { + Trophies []struct { + Data Trophy + } + } + } + + t := &trophyData{} + url := fmt.Sprintf("https://oauth.reddit.com/api/v1/user/%s/trophies", user) + err := a.getBody(url, t) + if err != nil { + return nil, err + } + + var trophies []*Trophy + for _, trophy := range t.Data.Trophies { + trophies = append(trophies, &trophy.Data) + } + return trophies, nil +} + +// AboutSubreddit returns a subreddit for the given subreddit name using OAuth. +func (a *AppOnlyOAuthSession) AboutSubreddit(name string) (*Subreddit, error) { + type subreddit struct { + Data Subreddit + } + sr := &subreddit{} + link := fmt.Sprintf("https://oauth.reddit.com/r/%s/about", name) + + err := a.getBody(link, sr) + if err != nil { + return nil, err + } + return &sr.Data, nil +} + +// Comments returns the comments for a given Submission using OAuth. +func (a *AppOnlyOAuthSession) Comments(h *Submission, sort popularitySort, params ListingOptions) ([]*Comment, error) { + p, err := query.Values(params) + if err != nil { + return nil, err + } + var c interface{} + link := fmt.Sprintf("https://oauth.reddit.com/comments/%s?%s", h.ID, p.Encode()) + err = a.getBody(link, &c) + if err != nil { + return nil, err + } + helper := new(helper) + helper.buildComments(c) + return helper.comments, nil +} + +// SubredditSubmissions returns the submissions on the given subreddit using OAuth. +func (a *AppOnlyOAuthSession) SubredditSubmissions(subreddit string, sort popularitySort, params ListingOptions) ([]*Submission, error) { + v, err := query.Values(params) + if err != nil { + return nil, err + } + + baseUrl := "https://oauth.reddit.com" + + // If subbreddit given, add to URL + if subreddit != "" { + baseUrl += "/r/" + subreddit + } + + redditURL := fmt.Sprintf(baseUrl+"/%s.json?%s", sort, v.Encode()) + + type Response struct { + Data struct { + Children []struct { + Data *Submission + } + } + } + + r := new(Response) + err = a.getBody(redditURL, r) + if err != nil { + return nil, err + } + + submissions := make([]*Submission, len(r.Data.Children)) + for i, child := range r.Data.Children { + submissions[i] = child.Data + } + + return submissions, nil +} + +// Frontpage returns the submissions on the default reddit frontpage using OAuth. +func (a *AppOnlyOAuthSession) Frontpage(sort popularitySort, params ListingOptions) ([]*Submission, error) { + return a.SubredditSubmissions("", sort, params) +} diff --git a/apponlyoauth_session_test.go b/apponlyoauth_session_test.go new file mode 100644 index 0000000..e82866c --- /dev/null +++ b/apponlyoauth_session_test.go @@ -0,0 +1,35 @@ +// Copyright 2012 Jimmy Zelinskie. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Copyright 2016 Samir Bhatt. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package geddit + +import ( + "testing" +) + +// TODO: Write better test functions + +func TestMain(t *testing.T) { + a, err := NewAppOnlyOAuthSession( + "client_id", + "client_secret", + "Testing OAuth Bot by u/imheresamir v0.1 see source https://github.com/imheresamir/geddit", + false, + ) + if err != nil { + t.Fatal(err) + } + + // Ready to make API calls! + _, err = a.SubredditSubmissions("hiphopheads", "hot", ListingOptions{}) + + if err != nil { + t.Fatal(err) + } + +} diff --git a/oauth_session.go b/oauth_session.go index d19f90c..fd9d264 100644 --- a/oauth_session.go +++ b/oauth_session.go @@ -33,12 +33,17 @@ type OAuthSession struct { UserAgent string ctx context.Context throttle *rate.RateLimiter + debug bool } // NewLoginSession creates a new session for those who want to log into a // reddit account via OAuth. -func NewOAuthSession(clientID, clientSecret, useragent, redirectURL string) (*OAuthSession, error) { - s := &OAuthSession{} +func NewOAuthSession(clientID, clientSecret, useragent, redirectURL string, debug bool) (*OAuthSession, error) { + s := &OAuthSession{ + ClientID: clientID, + ClientSecret: clientSecret, + debug: debug, + } if useragent != "" { s.UserAgent = useragent @@ -84,9 +89,9 @@ func (o *OAuthSession) LoginAuth(username, password string) error { } // AuthCodeURL creates and returns an auth URL which contains an auth code. -func (o *OAuthSession) AuthCodeURL(state string, scopes []string) string { +func (o *OAuthSession) AuthCodeURL(state string, scopes []string, duration string) string { o.OAuthConfig.Scopes = scopes - return o.OAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOnline) + return o.OAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOnline, oauth2.SetAuthURLParam("duration", duration)) } // CodeAuth creates and sets a token using an authentication code returned from AuthCodeURL. @@ -95,6 +100,7 @@ func (o *OAuthSession) CodeAuth(code string) error { if err != nil { return err } + o.TokenExpiry = t.Expiry o.Client = o.OAuthConfig.Client(context.Background(), t) return nil } @@ -157,9 +163,12 @@ func (o *OAuthSession) getBody(link string, d interface{}) error { } defer resp.Body.Close() - // DEBUG body, err := ioutil.ReadAll(resp.Body) - fmt.Printf("***DEBUG***\nRequest Body: %s\n***DEBUG***\n\n", body) + + // DEBUG + if o.debug { + fmt.Printf("***DEBUG***\nRequest Body: %s\n***DEBUG***\n\n", body) + } err = json.Unmarshal(body, d) if err != nil { @@ -376,9 +385,13 @@ func (o *OAuthSession) postBody(link string, form url.Values, d interface{}) err return err } defer resp.Body.Close() - // DEBUG + body, err := ioutil.ReadAll(resp.Body) - fmt.Printf("***DEBUG***\nRequest Body: %s\n***DEBUG***\n\n", body) + + // DEBUG + if o.debug { + fmt.Printf("***DEBUG***\nRequest Body: %s\n***DEBUG***\n\n", body) + } // The caller may want JSON decoded, or this could just be an update/delete request. if d != nil { @@ -502,15 +515,15 @@ func (o *OAuthSession) Vote(v Voter, dir Vote) error { func (o OAuthSession) Reply(r Replier, comment string) (*Comment, error) { // Build form for POST request. form := url.Values{ - "api_type": {"json"}, - "thing_id": {r.replyID()}, - "text": {comment}, + "api_type": {"json"}, + "thing_id": {r.replyID()}, + "text": {comment}, } type response struct { JSON struct { Errors [][]string - Data struct { + Data struct { Things []struct { Data map[string]interface{} } diff --git a/types.go b/types.go index 638861f..61076b8 100644 --- a/types.go +++ b/types.go @@ -88,3 +88,30 @@ type Deleter interface { type Replier interface { replyID() string } + +// OAuth constants + +const ( + OAuthScope_Identity string = "identity" + OAuthScope_Edit string = "edit" + OAuthScope_Flair string = "flair" + OAuthScope_History string = "history" + OAuthScope_Modconfig string = "modconfig" + OAuthScope_Modflair string = "modflair" + OAuthScope_Modlog string = "modlog" + OAuthScope_Modposts string = "modposts" + OAuthScope_Modwiki string = "modwiki" + OAuthScope_Mysubreddits string = "mysubreddits" + OAuthScope_Privatemessages string = "privatemessages" + OAuthScope_Read string = "read" + OAuthScope_Report string = "report" + OAuthScope_Save string = "save" + OAuthScope_Submit string = "submit" + OAuthScope_Subscribe string = "subscribe" + OAuthScope_Vote string = "vote" + OAuthScope_Wikiedit string = "wikiedit" + OAuthScope_Wikiread string = "wikiread" + + OAuthDuration_Temporary string = "temporary" + OAuthDuration_Permanent string = "permanent" +)