├── tests ├── tests.go └── e2e_test.go ├── README.md ├── handlers ├── payloads.go ├── context.go ├── routes.go └── oauth.go ├── providers ├── cursor.go ├── facebook │ ├── register.go │ ├── mapper.go │ ├── errors.go │ ├── parser.go │ ├── oauth.go │ └── facebook.go ├── config.go ├── util.go ├── providers.go ├── twitter │ ├── errors.go │ ├── oauth.go │ ├── mapper.go │ └── twitter.go ├── errors.go ├── oauth.go └── query.go ├── post.go ├── permission.go ├── LICENSE ├── social.go └── _example └── main.go /tests/tests.go: -------------------------------------------------------------------------------- 1 | package tests 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Go-Social 2 | ========= 3 | -------------------------------------------------------------------------------- /handlers/payloads.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | -------------------------------------------------------------------------------- /tests/e2e_test.go: -------------------------------------------------------------------------------- 1 | package tests_test 2 | 3 | // TODO 4 | -------------------------------------------------------------------------------- /handlers/context.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | var ( 4 | ProviderIDCtxKey = &contextKey{"ProviderID"} 5 | ProviderOAuthCtxKey = &contextKey{"ProviderOAuth"} 6 | ) 7 | 8 | type contextKey struct { 9 | name string 10 | } 11 | 12 | func (k *contextKey) String() string { 13 | return "context value " + k.name 14 | } 15 | -------------------------------------------------------------------------------- /providers/cursor.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | // Query cursor 4 | type Cursor struct { 5 | Next *Query 6 | Prev *Query 7 | } 8 | 9 | func NewCursor(query Query, prevID string, nextID string) *Cursor { 10 | c := &Cursor{} 11 | p := query 12 | n := query 13 | 14 | c.Prev = &p 15 | c.Prev.SinceID = prevID 16 | c.Prev.UntilID = "" 17 | 18 | c.Next = &n 19 | c.Next.SinceID = "" 20 | c.Next.UntilID = nextID 21 | 22 | return c 23 | } 24 | -------------------------------------------------------------------------------- /providers/facebook/register.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import "github.com/go-social/social/providers" 4 | 5 | func Configure(appID string, appSecret string, oauthCallback string) { 6 | AppID = appID 7 | AppSecret = appSecret 8 | OAuthCallback = oauthCallback 9 | } 10 | 11 | func init() { 12 | providers.Register(ProviderID, &providers.Provider{ 13 | Configure: Configure, 14 | New: New, 15 | NewOAuth: NewOAuth, 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /providers/config.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "github.com/go-chi/jwtauth" 5 | ) 6 | 7 | var TokenAuth *jwtauth.JWTAuth 8 | 9 | type ProviderConfig struct { 10 | AppID string `toml:"app_id"` 11 | AppSecret string `toml:"app_secret"` 12 | OAuthCallback string `toml:"oauth_callback"` 13 | } 14 | 15 | type ProviderConfigs map[string]ProviderConfig 16 | 17 | func Configure(confs ProviderConfigs, tokenAuth *jwtauth.JWTAuth) { 18 | for id, conf := range confs { 19 | if p, ok := Registry[id]; ok { 20 | p.Configure(conf.AppID, conf.AppSecret, conf.OAuthCallback) 21 | } 22 | } 23 | TokenAuth = tokenAuth 24 | } 25 | -------------------------------------------------------------------------------- /post.go: -------------------------------------------------------------------------------- 1 | package social 2 | 3 | import "time" 4 | 5 | // Post is a normalized `post` object across multiple providers. As normalized 6 | // as possible. The original data is available in Raw 7 | type Post struct { 8 | ID string `json:"id"` 9 | Provider string `json:"provider"` 10 | URL string `json:"url"` 11 | 12 | Author User `json:"author"` 13 | Contents string `json:"contents"` 14 | NumShares int32 `json:"num_shares"` 15 | NumLikes int32 `json:"num_likes"` 16 | 17 | Tags []string `json:"tags"` 18 | Links []string `json:"links"` 19 | 20 | PublishedAt *time.Time `json:"published_at,omitempty"` 21 | UpdatedAt *time.Time `json:"updated_at,omitempty"` 22 | 23 | Raw interface{} 24 | } 25 | 26 | type Posts []*Post 27 | 28 | func (ps *Posts) Add(post ...*Post) { 29 | *ps = append(*ps, post...) 30 | } 31 | -------------------------------------------------------------------------------- /permission.go: -------------------------------------------------------------------------------- 1 | package social 2 | 3 | type Permission int 4 | 5 | const ( 6 | PermissionNone Permission = iota 7 | PermissionRead 8 | PermissionWrite 9 | PermissionReadWrite 10 | ) 11 | 12 | var permissions = []string{ 13 | "", "r", "w", "rw", 14 | } 15 | 16 | func (p Permission) String() string { 17 | return permissions[p] 18 | } 19 | 20 | func (p Permission) MarshalText() ([]byte, error) { 21 | return []byte(p.String()), nil 22 | } 23 | 24 | func (p *Permission) UnmarshalText(text []byte) error { 25 | *p = PermissionRead 26 | enum := string(text) 27 | for i, k := range permissions { 28 | if enum == k { 29 | *p = Permission(i) 30 | return nil 31 | } 32 | } 33 | return nil 34 | } 35 | 36 | func PermissionFromString(text string) Permission { 37 | for i, k := range permissions { 38 | if text == k { 39 | return Permission(i) 40 | } 41 | } 42 | return PermissionNone 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-present github.com/go-social authors. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /providers/util.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | var ( 11 | // timezoneMap is used to convert time zone to offset 12 | timezoneMap = map[string]string{ 13 | "GMT": "+0000", "EST": "-0500", "EDT": "-0400", 14 | "CST": "-0600", "CDT": "-0500", "MST": "-0700", "MDT": "-0600", 15 | "PST": "-0800", "PDT": "-0700", "AKDT": "-0800", "CHST": "+1000", 16 | "HST": "-1000", "AST": "-0400", "SST": "-1100", "AKST": "-0800", 17 | } 18 | ) 19 | 20 | // TODO: is there something in stdlib that does this...? 21 | 22 | func GetUTCTimeForLayout(timeStr string, layout string) (time.Time, error) { 23 | t, err := time.Parse(layout, timeStr) 24 | if err != nil { 25 | return t, errors.Wrapf(err, "Error parsing time string:%v", timeStr) 26 | } 27 | 28 | // replace the timezone with the offset, otherwise go won't convert to UTC correctly 29 | timezone := t.Location().String() 30 | if offset, ok := timezoneMap[timezone]; ok { 31 | layout = strings.Replace(layout, "MST", "-0700", 1) 32 | timeStr = strings.Replace(timeStr, timezone, offset, 1) 33 | t, err = time.Parse(layout, timeStr) 34 | if err != nil { 35 | return t, errors.Wrapf(err, "Error parsing time string:%v", timeStr) 36 | } 37 | } 38 | return t.UTC().Truncate(time.Second), nil 39 | } 40 | -------------------------------------------------------------------------------- /providers/facebook/mapper.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/go-social/social" 8 | "github.com/go-social/social/providers" 9 | ) 10 | 11 | type Mapper struct{} 12 | 13 | func (m *Mapper) BuildPosts(fbPosts []FbPost) social.Posts { 14 | var posts social.Posts 15 | for _, fbPost := range fbPosts { 16 | post := m.BuildPost(fbPost) 17 | if post != nil { 18 | posts.Add(post) 19 | } 20 | } 21 | return posts 22 | } 23 | 24 | func (m *Mapper) BuildPost(fbPost FbPost) *social.Post { 25 | var post *social.Post 26 | 27 | idSlice := strings.Split(fbPost.ID, "_") 28 | if len(idSlice) < 2 { 29 | return nil 30 | } 31 | userID, postID := idSlice[0], idSlice[1] 32 | 33 | post.URL = "https://facebook.com/" + userID + "/posts/" + postID // TODO: need this...? cuz there is fbPost.Link ..? 34 | post.ID = fbPost.ID 35 | post.Provider = ProviderID 36 | post.NumShares = int32(fbPost.Shares.Count) 37 | 38 | post.Author = social.User{ 39 | ID: fbPost.From.ID, 40 | Name: fbPost.From.Name, 41 | ProfileURL: "https://facebook.com/profile.php?id=" + fbPost.From.ID, 42 | AvatarURL: fmt.Sprintf("https://graph.facebook.com/%v/picture?type=large", fbPost.From.ID), 43 | } 44 | 45 | publishedAt, _ := providers.GetUTCTimeForLayout(fbPost.CreatedTime, timeLayout) 46 | updatedAt, _ := providers.GetUTCTimeForLayout(fbPost.UpdatedTime, timeLayout) 47 | 48 | post.PublishedAt = &publishedAt 49 | post.UpdatedAt = &updatedAt 50 | 51 | return post 52 | } 53 | -------------------------------------------------------------------------------- /providers/providers.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-social/social" 7 | ) 8 | 9 | type Provider struct { 10 | Configure func(appID string, appSecret string, oauthCallback string) 11 | New func(ctx context.Context, creds social.Credentials) (ProviderSession, error) 12 | NewOAuth func() social.OAuth 13 | } 14 | 15 | type ProviderSession interface { 16 | // ID of the Provider 17 | ID() string 18 | 19 | // Post a message to the provider and return the new Post object created. 20 | Post(ctx context.Context, msg string, link string) (*social.Post, error) 21 | 22 | // Search content on a provider network 23 | Search(query Query) (social.Posts, *Cursor, error) 24 | 25 | // Get a user's feed/wall 26 | GetFeed(query Query) (social.Posts, *Cursor, error) // Feed 27 | 28 | // Get a user's own posts 29 | GetPosts(query Query) (social.Posts, *Cursor, error) // Posts 30 | 31 | // Get the user social profile object 32 | GetUser(query Query) (*social.User, error) 33 | 34 | // Get a user's friends list (aka following) 35 | GetFriends(query Query) ([]*social.User, *Cursor, error) 36 | 37 | // Get a user's followers list 38 | GetFollowers(query Query) ([]*social.User, *Cursor, error) 39 | } 40 | 41 | func NewSession(ctx context.Context, providerID string, creds social.Credentials) (ProviderSession, error) { 42 | r, ok := Registry[providerID] 43 | if !ok { 44 | return nil, ErrUnknownProviderID 45 | } 46 | return r.New(ctx, creds) 47 | } 48 | 49 | var Registry = make(map[string]*Provider) 50 | 51 | func Register(providerID string, provider *Provider) { 52 | Registry[providerID] = provider 53 | } 54 | -------------------------------------------------------------------------------- /social.go: -------------------------------------------------------------------------------- 1 | package social 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type OAuth interface { 10 | ProviderID() string 11 | 12 | // Authorization URL to start OAuth process and retrieve a request token 13 | AuthCodeURL(r *http.Request, claims map[string]interface{}) (string, error) 14 | 15 | // Request an access token from a request token to complete authentication 16 | Exchange(ctx context.Context, r *http.Request) ([]Credentials, error) 17 | } 18 | 19 | type Credentials interface { 20 | ProviderID() string 21 | ProviderUserID() string 22 | AccessToken() string 23 | AccessTokenSecret() string 24 | RefreshToken() string 25 | ExpiresAt() *time.Time 26 | Permission() Permission 27 | SetPermission(string) 28 | } 29 | 30 | type User struct { 31 | Provider string `json:"provider" url:"provider"` 32 | ID string `json:"id" url:"id"` 33 | Username string `json:"username" url:"username"` 34 | Name string `json:"name" url:"name"` 35 | Email string `json:"email" url:"email"` 36 | ProfileURL string `json:"profile_url" url:"profile_url"` 37 | AvatarURL string `json:"avatar_url" url:"avatar_url"` 38 | NumPosts int32 `json:"num_posts" url:"num_posts"` 39 | NumFollowers int32 `json:"num_followers" url:"num_folowers"` 40 | NumFollowing int32 `json:"num_following" url:"num_following"` 41 | Lang string `json:"lang" url:"lang"` 42 | Location string `json:"location" url:"location"` 43 | Timezone string `json:"timezone" url:"timezone"` 44 | Private bool `json:"private" url:"private"` 45 | LastSyncAt *time.Time `json:"last_sync_at" url:"last_sync_at"` 46 | } 47 | -------------------------------------------------------------------------------- /providers/facebook/errors.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/go-social/social/providers" 9 | fb "github.com/huandu/facebook" 10 | ) 11 | 12 | var errNodeTypePage = errors.New("Tried accessing a nonexisting field on a node type Page") 13 | 14 | // Facebook error codes 15 | // https://developers.facebook.com/docs/graph-api/using-graph-api/v2.2#errors 16 | func providerError(err error) error { 17 | if err == nil { 18 | return nil 19 | } 20 | 21 | // Most probably oauth2 error. 22 | if e, ok := err.(*url.Error); ok { 23 | if strings.Contains(strings.ToLower(e.Error()), "unauthorized") { 24 | return providers.ErrAuthFailed 25 | } 26 | return providers.ErrUnknown.Err(err) 27 | } 28 | 29 | if e, ok := err.(*fb.Error); ok { 30 | switch e.Code { 31 | case 1, 2: 32 | return providers.ErrProviderDown 33 | 34 | case 4, 17, 341: 35 | return providers.ErrHitRateLimit 36 | 37 | case 10: 38 | return providers.ErrMustReauth 39 | 40 | case 100: 41 | // This happens when the user tries to get an e-mail from a page. 42 | // "(#100) Tried accessing nonexisting field (email) on node type (Page)" 43 | if strings.Contains(e.Error(), "Page") { 44 | return errNodeTypePage 45 | } 46 | case 102, 190: 47 | switch e.ErrorSubcode { 48 | case 458, 459, 460: 49 | return providers.ErrMustReauth 50 | case 463: 51 | return providers.ErrExpiredToken 52 | case 464: 53 | return providers.ErrBadAccount 54 | default: 55 | return providers.ErrInvalidToken 56 | } 57 | 58 | case 506: 59 | return providers.ErrDuplicatePost 60 | 61 | case 803: 62 | return providers.ErrUsernameSearch 63 | } 64 | } 65 | 66 | return providers.ErrUnknown.Err(err) 67 | } 68 | -------------------------------------------------------------------------------- /providers/twitter/errors.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "github.com/ChimeraCoder/anaconda" 5 | "github.com/go-social/social/providers" 6 | ) 7 | 8 | func providerError(err error) error { 9 | if err == nil { 10 | return nil 11 | } 12 | if e, ok := err.(*anaconda.ApiError); ok { 13 | // twitter errors: https://dev.twitter.com/docs/error-codes-responses 14 | if len(e.Decoded.Errors) > 0 { 15 | apiErr := e.Decoded.Errors[0] // oh anaconda.. 16 | 17 | switch apiErr.Code { 18 | case anaconda.TwitterErrorCouldNotAuthenticate: 19 | return providers.ErrAuthFailed 20 | case anaconda.TwitterErrorDoesNotExist: 21 | return providers.ErrInvalidQuery 22 | case anaconda.TwitterErrorAccountSuspended: 23 | return providers.ErrBadAccount 24 | case anaconda.TwitterErrorRateLimitExceeded: 25 | return providers.ErrHitRateLimit 26 | case anaconda.TwitterErrorInvalidToken: 27 | return providers.ErrInvalidToken 28 | case anaconda.TwitterErrorOverCapacity: 29 | return providers.ErrProviderDown 30 | case anaconda.TwitterErrorInternalError: 31 | return providers.ErrProviderDown 32 | case anaconda.TwitterErrorCouldNotAuthenticateYou: 33 | return providers.ErrAuthFailed 34 | case anaconda.TwitterErrorStatusIsADuplicate: 35 | return providers.ErrWritingPost 36 | case anaconda.TwitterErrorBadAuthenticationData: 37 | return providers.ErrAuthFailed 38 | case anaconda.TwitterErrorUserMustVerifyLogin: 39 | return providers.ErrMustReauth 40 | default: 41 | return providers.ErrUnknown.Err(e) 42 | } 43 | } else { 44 | // It seems that twitter doesn't return a "code" on the unauthorized error 45 | if e.StatusCode == 401 { 46 | return providers.ErrUnauthorizedQuery 47 | } else { 48 | return providers.ErrUnknown.Err(err) 49 | } 50 | } 51 | } else { 52 | return providers.ErrUnknown.Err(err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /handlers/routes.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi" 8 | "github.com/go-chi/jwtauth" 9 | "github.com/go-chi/render" 10 | "github.com/go-social/social" 11 | "github.com/go-social/social/providers" 12 | ) 13 | 14 | type ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) 15 | type CallbackHandlerFunc func(w http.ResponseWriter, r *http.Request, creds []social.Credentials, user *social.User, err error) 16 | 17 | func Routes(oauthErrorFn ErrorHandlerFunc, oauthCallbackFn CallbackHandlerFunc) http.Handler { 18 | r := chi.NewRouter() 19 | 20 | r.Get("/", ListProviders) 21 | 22 | r.Route("/{provider}", func(r chi.Router) { 23 | r.Use(ProviderCtx(oauthErrorFn)) 24 | 25 | r.Get("/", OAuth(oauthErrorFn)) // open 26 | 27 | r.Group(func(r chi.Router) { 28 | // secure, via jwt state token 29 | r.Use(jwtauth.Verify(providers.TokenAuth, tokenFromQuery("state"))) 30 | r.Use(jwtauth.Authenticator) 31 | r.Get("/callback", OAuthCallback(oauthCallbackFn)) 32 | }) 33 | }) 34 | 35 | // TODO: this needs to be secured as well, as 36 | // its a callback router 37 | r.Get("/loopback/{route}", Loopback) 38 | 39 | return r 40 | } 41 | 42 | func ListProviders(w http.ResponseWriter, r *http.Request) { 43 | plist := []string{} 44 | for id, _ := range providers.Registry { 45 | plist = append(plist, id) 46 | } 47 | 48 | // TODO: make payloads for all request / response objects, and render.Render() 49 | render.JSON(w, r, plist) 50 | 51 | } 52 | 53 | // Loopback redirects the client to another path on our router 54 | func Loopback(w http.ResponseWriter, r *http.Request) { 55 | ctx := r.Context() 56 | _, claims, _ := jwtauth.FromContext(ctx) 57 | 58 | route := chi.URLParam(r, "route") 59 | providerID, ok := claims["provider"] 60 | 61 | if !ok { 62 | render.Status(r, 403) // TODO: defined payload.. 63 | render.JSON(w, r, "invalid provider id") 64 | return 65 | } 66 | 67 | switch route { 68 | case "googleapi": 69 | redirectURL := fmt.Sprintf("/auth/%s/callback?%s", providerID, r.URL.Query().Encode()) 70 | http.Redirect(w, r, redirectURL, 302) 71 | return 72 | } 73 | w.WriteHeader(http.StatusNoContent) 74 | } 75 | 76 | func tokenFromQuery(param string) func(r *http.Request) string { 77 | // Get token from query param 78 | return func(r *http.Request) string { 79 | return r.URL.Query().Get(param) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /handlers/oauth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/go-chi/chi" 10 | "github.com/go-chi/jwtauth" 11 | "github.com/go-social/social" 12 | "github.com/go-social/social/providers" 13 | ) 14 | 15 | func ProviderCtx(oauthErrorFn ErrorHandlerFunc) func(next http.Handler) http.Handler { 16 | return func(next http.Handler) http.Handler { 17 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | providerID := strings.ToLower(chi.URLParam(r, "provider")) 19 | 20 | oauth, err := providers.NewOAuth(providerID) 21 | if err != nil { 22 | oauthErrorFn(w, r, err) 23 | return 24 | } 25 | 26 | ctx := context.WithValue(r.Context(), ProviderIDCtxKey, providerID) 27 | ctx = context.WithValue(ctx, ProviderOAuthCtxKey, oauth) 28 | 29 | next.ServeHTTP(w, r.WithContext(ctx)) 30 | }) 31 | } 32 | } 33 | 34 | func OAuth(oauthErrorFn ErrorHandlerFunc) func(w http.ResponseWriter, r *http.Request) { 35 | return func(w http.ResponseWriter, r *http.Request) { 36 | ctx := r.Context() 37 | oauth := ctx.Value(ProviderOAuthCtxKey).(social.OAuth) 38 | 39 | state := jwtauth.Claims{ 40 | "sub": "OAuthCallback", 41 | "provider": oauth.ProviderID(), 42 | "perm": social.PermissionFromString(r.URL.Query().Get("perm")).String(), 43 | } 44 | 45 | // Give users 15 minutes to authenticate 46 | state.SetIssuedNow() 47 | state.SetExpiryIn(15 * time.Minute) 48 | 49 | authURL, err := oauth.AuthCodeURL(r, state) 50 | if err != nil { 51 | oauthErrorFn(w, r, err) 52 | return 53 | } 54 | 55 | http.Redirect(w, r, authURL, 302) 56 | } 57 | } 58 | 59 | func OAuthCallback(oauthCallbackFn CallbackHandlerFunc) func(w http.ResponseWriter, r *http.Request) { 60 | return func(w http.ResponseWriter, r *http.Request) { 61 | var err error 62 | var mcreds []social.Credentials 63 | var providerUser *social.User 64 | 65 | ctx := r.Context() 66 | oauth := ctx.Value(ProviderOAuthCtxKey).(social.OAuth) 67 | 68 | defer func() { 69 | oauthCallbackFn(w, r, mcreds, providerUser, err) 70 | }() 71 | 72 | mcreds, err = oauth.Exchange(ctx, r) 73 | if err != nil { 74 | return 75 | } 76 | creds := mcreds[0] 77 | 78 | p, err := providers.NewSession(ctx, oauth.ProviderID(), creds) 79 | if err != nil { 80 | return 81 | } 82 | 83 | providerUser, err = p.GetUser(providers.NoQuery) 84 | if err != nil { 85 | return 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /providers/twitter/oauth.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/garyburd/go-oauth/oauth" 9 | "github.com/go-social/social" 10 | "github.com/go-social/social/providers" 11 | ) 12 | 13 | type OAuth struct { 14 | client oauth.Client 15 | } 16 | 17 | func NewOAuth() social.OAuth { 18 | client := oauth.Client{ 19 | TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token", 20 | ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authenticate", 21 | TokenRequestURI: "https://api.twitter.com/oauth/access_token", 22 | } 23 | client.Credentials.Token = AppID 24 | client.Credentials.Secret = AppSecret 25 | return &OAuth{client} 26 | } 27 | 28 | func (oa *OAuth) ProviderID() string { 29 | return ProviderID 30 | } 31 | 32 | func (oa *OAuth) AuthCodeURL(r *http.Request, claims map[string]interface{}) (string, error) { 33 | // NOTE: Twitter API permissions are set per application, not per token. 34 | // Default to read only permission 35 | if _, ok := claims["perm"]; !ok { 36 | claims["perm"] = social.PermissionRead.String() 37 | } 38 | 39 | _, stateToken, err := providers.TokenAuth.Encode(claims) 40 | if err != nil { 41 | return "", err 42 | } 43 | callbackURL := OAuthCallback + "?state=" + stateToken 44 | 45 | tempCred, err := oa.client.RequestTemporaryCredentials(http.DefaultClient, callbackURL, nil) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | v := url.Values{} 51 | if _, ok := claims["force_login"]; ok { 52 | v.Set("force_login", "true") 53 | v.Set("screen_name", "") 54 | } 55 | 56 | authURL := oa.client.AuthorizationURL(tempCred, v) 57 | return authURL, nil 58 | } 59 | 60 | func (oa *OAuth) Exchange(ctx context.Context, r *http.Request) ([]social.Credentials, error) { 61 | callbackArgs := r.URL.Query() 62 | reqToken := callbackArgs.Get("oauth_token") 63 | verifier := callbackArgs.Get("oauth_verifier") 64 | tempCred := &oauth.Credentials{reqToken, ""} 65 | 66 | access, _, err := oa.client.RequestToken(http.DefaultClient, tempCred, verifier) 67 | if err != nil { 68 | return nil, providerError(err) 69 | } 70 | 71 | // TODO: ensure we always set CredUserID for a social cred, 72 | // even if we have to make a request to get it 73 | 74 | // NOTE: twitter does not expire oauth tokens: 75 | // https://dev.twitter.com/oauth/overview/faq 76 | creds := []social.Credentials{ 77 | &providers.OAuth1Creds{ 78 | CredProviderID: ProviderID, 79 | CredAccessToken: access.Token, 80 | CredAccessTokenSecret: access.Secret, 81 | }, 82 | } 83 | return creds, nil 84 | } 85 | -------------------------------------------------------------------------------- /providers/twitter/mapper.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/ChimeraCoder/anaconda" 10 | "github.com/go-social/social" 11 | "github.com/go-social/social/providers" 12 | ) 13 | 14 | type Mapper struct{} 15 | 16 | func (m Mapper) BuildPosts(tweets []anaconda.Tweet) social.Posts { 17 | var posts social.Posts 18 | for _, tweet := range tweets { 19 | post := m.BuildPost(tweet) 20 | if post != nil { 21 | posts.Add(post) 22 | } 23 | } 24 | return posts 25 | } 26 | 27 | func (m Mapper) BuildPost(tweet anaconda.Tweet) *social.Post { 28 | postURL := fmt.Sprintf("https://twitter.com/%v/status/%v", tweet.User.ScreenName, tweet.IdStr) 29 | 30 | post := &social.Post{ 31 | Raw: tweet, 32 | ID: tweet.IdStr, 33 | Provider: ProviderID, 34 | URL: postURL, 35 | Author: social.User{ 36 | ID: tweet.User.IdStr, 37 | Name: tweet.User.Name, 38 | Username: tweet.User.ScreenName, 39 | ProfileURL: "https://twitter.com/" + tweet.User.ScreenName, 40 | AvatarURL: getLargeProfileImageURL(tweet.User.ProfileImageURL), 41 | NumFollowers: int32(tweet.User.FollowersCount), 42 | NumFollowing: int32(tweet.User.FriendsCount), 43 | }, 44 | Contents: tweet.Text, 45 | NumShares: int32(tweet.RetweetCount), 46 | NumLikes: int32(tweet.FavoriteCount), 47 | } 48 | 49 | publishedAt, _ := providers.GetUTCTimeForLayout(tweet.CreatedAt, TimeLayout) 50 | post.PublishedAt = &publishedAt 51 | 52 | return post 53 | } 54 | 55 | type UserMapper struct{} 56 | 57 | func (m UserMapper) BuildUsers(us []anaconda.User) []*social.User { 58 | var users []*social.User 59 | for _, u := range us { 60 | users = append(users, m.BuildUser(u)) 61 | } 62 | return users 63 | } 64 | 65 | func (m UserMapper) BuildUser(u anaconda.User) *social.User { 66 | return &social.User{ 67 | Provider: ProviderID, 68 | ID: u.IdStr, 69 | Username: u.ScreenName, 70 | Name: u.Name, 71 | ProfileURL: fmt.Sprintf("https://twitter.com/%s", u.ScreenName), 72 | AvatarURL: getLargeProfileImageURL(u.ProfileImageURL), 73 | NumPosts: int32(u.StatusesCount), 74 | NumFollowers: int32(u.FollowersCount), 75 | NumFollowing: int32(u.FriendsCount), 76 | Lang: u.Lang, 77 | Location: u.Location, 78 | Timezone: u.TimeZone, 79 | Private: u.Protected, 80 | } 81 | } 82 | 83 | func getLargeProfileImageURL(url string) string { 84 | parts := strings.Split(url, "?") 85 | ext := path.Ext(parts[0]) 86 | r, _ := regexp.Compile("(?i)_normal" + ext) 87 | return r.ReplaceAllString(url, ext) 88 | } 89 | -------------------------------------------------------------------------------- /providers/errors.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import "fmt" 4 | 5 | // TODO: redo the errors.. use pkg/errors 6 | // review box errors thing or other stuff in render.. 7 | 8 | // TODO: We need iota-style error codes, so we can catch'em in the below layers easily. 9 | var ( 10 | ErrUnknownProviderID = &Error{Code: 1, Msg: "unknown provider id"} 11 | 12 | // Authorization 13 | ErrNoCredentials = &Error{Code: 1000, Msg: "missing provider credentials, please connect your social account first"} 14 | ErrAuthFailed = &Error{Code: 1001, Msg: "provider authorization failed, please re-connect your social account"} 15 | ErrInvalidToken = &Error{Code: 1002, Msg: "invalid provider token, please re-connect your social account"} 16 | ErrExpiredToken = &Error{Code: 1003, Msg: "expired provider token, please re-connect your social account"} 17 | ErrHitRateLimit = &Error{Code: 1004, Msg: "hit token rate limit"} 18 | ErrBadAccount = &Error{Code: 1005, Msg: "disabled account"} 19 | ErrMustReauth = &Error{Code: 1006, Msg: "authentication error, please re-connect your social account"} 20 | ErrGetUser = &Error{Code: 1007, Msg: "unable to fetch user profile"} 21 | ErrEmptyCode = &Error{Code: 1008, Msg: "empty code in callback"} 22 | 23 | // Queries 24 | ErrInvalidQuery = &Error{Code: 2000, Msg: "invalid request query"} 25 | ErrNoQueryAccess = &Error{Code: 2001, Msg: "provider does not have access for this query"} 26 | ErrInvalidAsset = &Error{Code: 2002, Msg: "invalid asset"} 27 | ErrWritingPost = &Error{Code: 2003, Msg: "unable to post asset"} 28 | ErrDuplicatePost = &Error{Code: 2004, Msg: "duplicate post"} 29 | ErrUsernameSearch = &Error{Code: 2005, Msg: "provided doesn't allow @username searches, @page (brand) searches work"} 30 | ErrUnauthorizedQuery = &Error{Code: 2006, Msg: "user unauthorized to make this query"} 31 | 32 | // Everything else 33 | ErrUnknown = &Error{Code: 5000, Msg: "unknown provider error"} 34 | ErrProviderDown = &Error{Code: 5001, Msg: "provider is down"} 35 | ErrUnsupported = &Error{Code: 5002, Msg: "unsupported operation"} 36 | ErrNotImplemented = &Error{Code: 5003, Msg: "not implemented"} 37 | ErrInvalidContent = &Error{Code: 5004, Msg: "empty title and url provided"} 38 | ) 39 | 40 | // Provider-specific error 41 | type Error struct { 42 | err error // the original error 43 | Code int // provider error code 44 | Msg string // provider error string 45 | } 46 | 47 | func (e *Error) Error() string { 48 | s := fmt.Sprintf("%d - %s", e.Code, e.Msg) 49 | if e.err != nil { 50 | return s + ": " + e.err.Error() 51 | } 52 | return s 53 | } 54 | 55 | func (e *Error) Err(err error) error { 56 | return &Error{err: err, Code: e.Code, Msg: e.Msg} 57 | } 58 | -------------------------------------------------------------------------------- /providers/oauth.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-social/social" 7 | "golang.org/x/oauth2" 8 | ) 9 | 10 | func NewOAuth(providerID string) (social.OAuth, error) { 11 | r, ok := Registry[providerID] 12 | if !ok { 13 | return nil, ErrUnknownProviderID 14 | } 15 | return r.NewOAuth(), nil 16 | } 17 | 18 | // OAuth1Creds is a normalized social.Credentials implementation to 19 | // use with social providers using oauth1 (ie. twitter) 20 | type OAuth1Creds struct { 21 | CredProviderID string 22 | CredProviderUserID string 23 | CredAccessToken string 24 | CredAccessTokenSecret string 25 | CredRefreshToken string 26 | CredExpiresAt *time.Time 27 | CredPermission social.Permission 28 | } 29 | 30 | var _ social.Credentials = &OAuth1Creds{} 31 | 32 | func (c *OAuth1Creds) ProviderID() string { 33 | return c.CredProviderID 34 | } 35 | 36 | func (c *OAuth1Creds) ProviderUserID() string { 37 | return c.CredProviderUserID 38 | } 39 | 40 | func (c *OAuth1Creds) AccessToken() string { 41 | return c.CredAccessToken 42 | } 43 | 44 | func (c *OAuth1Creds) AccessTokenSecret() string { 45 | return c.CredAccessTokenSecret 46 | } 47 | 48 | func (c *OAuth1Creds) RefreshToken() string { 49 | return c.CredRefreshToken 50 | } 51 | 52 | func (c *OAuth1Creds) Permission() social.Permission { 53 | return c.CredPermission 54 | } 55 | 56 | func (c *OAuth1Creds) SetPermission(perm string) { 57 | c.CredPermission = social.PermissionFromString(perm) 58 | } 59 | 60 | func (c *OAuth1Creds) ExpiresAt() *time.Time { 61 | return c.CredExpiresAt 62 | } 63 | 64 | // OAuth2Creds is a normalized social.Credentials implementation to 65 | // use with social providers using oauth2 (ie. facebook, google, ..) 66 | type OAuth2Creds struct { 67 | *oauth2.Token 68 | CredProviderID string 69 | CredProviderUserID string 70 | CredPermission social.Permission 71 | } 72 | 73 | var _ social.Credentials = &OAuth2Creds{} 74 | 75 | func (c *OAuth2Creds) ProviderID() string { 76 | return c.CredProviderID 77 | } 78 | 79 | func (c *OAuth2Creds) ProviderUserID() string { 80 | return c.CredProviderUserID 81 | } 82 | 83 | func (c *OAuth2Creds) AccessToken() string { 84 | return c.Token.AccessToken 85 | } 86 | 87 | func (c *OAuth2Creds) AccessTokenSecret() string { 88 | return "" 89 | } 90 | 91 | func (c *OAuth2Creds) RefreshToken() string { 92 | return c.Token.RefreshToken 93 | } 94 | 95 | func (c *OAuth2Creds) Permission() social.Permission { 96 | return c.CredPermission 97 | } 98 | 99 | func (c *OAuth2Creds) SetPermission(perm string) { 100 | c.CredPermission = social.PermissionFromString(perm) 101 | } 102 | 103 | func (c *OAuth2Creds) ExpiresAt() *time.Time { 104 | return &c.Token.Expiry 105 | } 106 | -------------------------------------------------------------------------------- /_example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi" 9 | "github.com/go-chi/chi/middleware" 10 | "github.com/go-chi/jwtauth" 11 | "github.com/go-chi/render" 12 | "github.com/go-social/social" 13 | authHandlers "github.com/go-social/social/handlers" 14 | "github.com/go-social/social/providers" 15 | _ "github.com/go-social/social/providers/facebook" 16 | _ "github.com/go-social/social/providers/twitter" 17 | "github.com/pkg/errors" 18 | ) 19 | 20 | func main() { 21 | // JWT token authentication for service access 22 | tokenAuth := jwtauth.New("HS256", []byte("secret"), nil) 23 | 24 | // Configure social providers 25 | pcfg := providers.ProviderConfigs{ 26 | "twitter": { 27 | AppID: "x", 28 | AppSecret: "y", 29 | OAuthCallback: "http://localhost:1515/auth/twitter/callback", 30 | }, 31 | "facebook": { 32 | AppID: "x", 33 | AppSecret: "y", 34 | OAuthCallback: "http://localhost:1515/auth/facebook/callback", 35 | }, 36 | "google": { 37 | AppID: "x", 38 | AppSecret: "y", 39 | OAuthCallback: "http://localhost:1515/auth/google/callback", 40 | }, 41 | } 42 | providers.Configure(pcfg, tokenAuth) 43 | 44 | // HTTP service 45 | r := chi.NewRouter() 46 | r.Use(middleware.RequestID) 47 | r.Use(middleware.Logger) 48 | 49 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 50 | w.Write([]byte(".")) 51 | }) 52 | 53 | r.Mount("/auth", authHandlers.Routes(oauthErrorHandler, oauthLoginHandler)) 54 | 55 | // Start the server on port 0.0.0.0:1515 56 | http.ListenAndServe(":1515", r) 57 | } 58 | 59 | func oauthErrorHandler(w http.ResponseWriter, r *http.Request, err error) { 60 | if err == nil { 61 | err = errors.Errorf("unknown auth error") 62 | } 63 | render.Status(r, 401) 64 | render.JSON(w, r, err) 65 | } 66 | 67 | func oauthLoginHandler(w http.ResponseWriter, r *http.Request, creds []social.Credentials, user *social.User, err error) { 68 | fmt.Println("oauth login sequence complete") 69 | 70 | if err != nil { 71 | fmt.Println("error:", err) 72 | render.Status(r, 401) 73 | render.JSON(w, r, err) 74 | return 75 | } 76 | 77 | fmt.Println("success!") 78 | fmt.Println("creds:", creds) 79 | fmt.Println("user:", user) 80 | 81 | cred := creds[0] // pick first one in case there are multiple (ie. fb) 82 | 83 | provider, err := providers.NewSession(context.Background(), cred.ProviderID(), cred) 84 | if err != nil { 85 | fmt.Println("error:", err) 86 | render.Status(r, 401) 87 | render.JSON(w, r, err) 88 | return 89 | } 90 | 91 | profile, err := provider.GetUser(providers.NoQuery) 92 | if err != nil { 93 | fmt.Println("error:", err) 94 | render.Status(r, 401) 95 | render.JSON(w, r, err) 96 | return 97 | } 98 | fmt.Println("provider.GetUser():", profile) 99 | 100 | render.JSON(w, r, profile) 101 | } 102 | -------------------------------------------------------------------------------- /providers/facebook/parser.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | fb "github.com/huandu/facebook" 5 | ) 6 | 7 | type UserProfile struct { 8 | FirstName string `json:"first_name"` 9 | LastName string `json:"last_name"` 10 | Name string `json:"name"` 11 | ID string `json:"id"` 12 | Locale string `json:"locale"` 13 | Link string `json:"link"` 14 | 15 | Picture struct { 16 | Data struct { 17 | URL string `json:"url"` 18 | } `json:"data"` 19 | } `json:"picture"` 20 | 21 | Location struct { 22 | ID string `json:"id"` 23 | Name string `json:"name"` 24 | } `json:"location"` 25 | 26 | Timezone float32 `json:"timezone"` 27 | Friends struct { 28 | Summary struct { 29 | TotalCount int `json:"total_count"` 30 | } `json:"summary"` 31 | } `json:"friends"` 32 | 33 | Email string `json:"email"` 34 | } 35 | 36 | type FbResponse struct { 37 | Data []FbPost `json:"data"` 38 | Paging Paging `json:"paging"` 39 | FbError fb.Error `json:"error"` 40 | UserProfile 41 | } 42 | 43 | type Paging struct { 44 | Next string `json:"next"` 45 | Previous string `json:"previous"` 46 | } 47 | 48 | type FbPost struct { 49 | ID string `json:"id" facebook:"id"` 50 | 51 | From struct { 52 | ID string `json:"id" facebook:"id"` 53 | Name string `json:"name" facebook:"name"` 54 | Likes int `json:"likes" facebook:"likes"` 55 | } `json:"from" facebook:"from"` 56 | 57 | To struct { 58 | ID string `json:"id" facebook:"id"` 59 | Name string `json:"name" facebook:"name"` 60 | } `json:"to" facebook:"to"` 61 | 62 | Story string `json:"story" facebook:"story"` 63 | Name string `json:"name" facebook:"name"` 64 | Message string `json:"message" facebook:"message"` 65 | Description string `json:"description" facebook:"description"` 66 | Shares struct { 67 | Count int `json:"count"` 68 | } `json:"shares" facebook:"shares"` 69 | 70 | CreatedTime string `json:"created_time" facebook:"created_time"` 71 | UpdatedTime string `json:"updated_time" facebook:"updated_time"` 72 | 73 | Type string `json:"type" facebook:"type"` 74 | StatusType string `json:"status_type" facebook:"status_type"` 75 | 76 | Picture string `json:"full_picture" facebook:"full_picture"` 77 | Link string `json:"link" facebook:"link"` 78 | Source string `json:"source" facebook:"source"` 79 | Icon string `json:"icon" facebook:"icon"` 80 | 81 | Attachments struct { 82 | Data []struct { 83 | Description string `json:"description" facebook:"description"` 84 | Title string `json:"title" facebook:"title"` 85 | Type string `json:"type" facebook:"type"` 86 | URL string `json:"url" facebook:"url"` 87 | Media struct { 88 | Image struct { 89 | Width int `json:"width" facebook:"width"` 90 | Height int `json:"height" facebook:"height"` 91 | Src string `json:"src" facebook:"src"` 92 | } `json:"image" facebook:"image"` 93 | } `json:"media" facebook:"media"` 94 | SubAttachments struct { 95 | Data []struct { 96 | Description string `json:"description" facebook:"description"` 97 | Title string `json:"title" facebook:"title"` 98 | Type string `json:"type" facebook:"type"` 99 | URL string `json:"url" facebook:"url"` 100 | Media struct { 101 | Image struct { 102 | Width int `json:"width" facebook:"width"` 103 | Height int `json:"height" facebook:"height"` 104 | Src string `json:"src" facebook:"src"` 105 | } `json:"image" facebook:"image"` 106 | } `json:"media" facebook:"media"` 107 | } `json:"data" facebook:"data"` 108 | } `json:"subattachments" facebook:"subattachments"` 109 | } `json:"data" facebook:"data"` 110 | } `json:"attachments" facebook:"attachments"` 111 | } 112 | 113 | type FbResponseAccounts struct { 114 | Data []FbAccount `json:"data"` 115 | Paging Paging `json:"paging"` 116 | FbError fb.Error `json:"error"` 117 | } 118 | 119 | type FbAccount struct { 120 | ID string `json:"id" facebook:"id"` 121 | Name string `json:"name" facebook:"name"` 122 | AccessToken string `json:"access_token" facebook:"access_token"` 123 | Category string `json:"category" facebook:"category"` 124 | Perms []string `json:"perms" facebook:"perms"` 125 | } 126 | -------------------------------------------------------------------------------- /providers/facebook/oauth.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/go-social/social" 12 | "github.com/go-social/social/providers" 13 | fb "github.com/huandu/facebook" 14 | "golang.org/x/oauth2" 15 | "golang.org/x/oauth2/facebook" 16 | ) 17 | 18 | var ( 19 | // https://developers.facebook.com/docs/facebook-login/permissions/v2.11 20 | loginScope = []string{ 21 | "public_profile", 22 | "email", 23 | "user_location", 24 | } 25 | readScope = []string{ 26 | "user_friends", 27 | "user_posts", 28 | "user_status", 29 | "user_likes", 30 | "user_photos", 31 | "user_videos", 32 | } 33 | writeScope = []string{ 34 | "manage_pages", 35 | "publish_pages", 36 | "publish_actions", 37 | } 38 | ) 39 | 40 | type OAuth struct { 41 | *oauth2.Config 42 | } 43 | 44 | func NewOAuth() social.OAuth { 45 | config := &oauth2.Config{ 46 | ClientID: AppID, 47 | ClientSecret: AppSecret, 48 | RedirectURL: OAuthCallback, 49 | Endpoint: facebook.Endpoint, 50 | } 51 | 52 | return &OAuth{config} 53 | } 54 | 55 | func (oa *OAuth) ProviderID() string { 56 | return ProviderID 57 | } 58 | 59 | func (oa *OAuth) AuthCodeURL(r *http.Request, claims map[string]interface{}) (string, error) { 60 | scope := loginScope 61 | 62 | perm, _ := claims["perm"].(string) 63 | switch social.PermissionFromString(perm) { 64 | case social.PermissionRead: 65 | scope = append(scope, readScope...) 66 | case social.PermissionWrite: 67 | scope = append(scope, writeScope...) 68 | case social.PermissionReadWrite: 69 | scope = append(scope, append(readScope, writeScope...)...) 70 | } 71 | 72 | opts := []oauth2.AuthCodeOption{ 73 | oauth2.AccessTypeOffline, 74 | oauth2.ApprovalForce, 75 | oauth2.SetAuthURLParam("scope", strings.Join(scope, ",")), 76 | } 77 | if _, ok := claims["force_login"]; ok { 78 | opts = append(opts, oauth2.SetAuthURLParam("auth_type", "reauthenticate")) 79 | } 80 | 81 | _, stateToken, err := providers.TokenAuth.Encode(claims) 82 | if err != nil { 83 | return "", err 84 | } 85 | 86 | return oa.Config.AuthCodeURL(stateToken, opts...), nil 87 | } 88 | 89 | func (oa *OAuth) Exchange(ctx context.Context, r *http.Request) ([]social.Credentials, error) { 90 | callbackArgs := r.URL.Query() 91 | code := callbackArgs.Get("code") 92 | cbError := callbackArgs.Get("error") 93 | cbErrorMsg := callbackArgs.Get("error_reason") 94 | cbErrorDesc := callbackArgs.Get("error_description") 95 | 96 | if cbError != "" { 97 | msg := fmt.Sprintf("Error:%v, ErrorReason:%v, ErrorDescription:%v", cbError, cbErrorMsg, cbErrorDesc) 98 | // TODO should probably use a different error code 99 | return nil, providers.ErrAuthFailed.Err(errors.New(msg)) 100 | } 101 | if code == "" { 102 | msg := "empty code in facebook callback" 103 | return nil, providers.ErrAuthFailed.Err(errors.New(msg)) 104 | } 105 | 106 | userToken, err := oa.Config.Exchange(ctx, code) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | // TODO: ensure we always set CredUserID for a social cred, 112 | // even if we have to make a request to get it 113 | 114 | creds := []social.Credentials{ 115 | &providers.OAuth2Creds{ 116 | CredProviderID: ProviderID, 117 | Token: userToken, 118 | }, 119 | } 120 | 121 | client := oa.Config.Client(ctx, userToken) 122 | api := &fb.Session{ 123 | Version: FacebookApiVersion, 124 | HttpClient: client, 125 | } 126 | 127 | // Fetch tokens for FB pages. 128 | resp, err := api.Get("/me/accounts", getFbParams(url.Values{})) 129 | if err != nil { 130 | return creds, nil 131 | } 132 | 133 | var accounts FbResponseAccounts 134 | if err := resp.Decode(&accounts); err != nil { 135 | return creds, nil 136 | } 137 | 138 | for _, account := range accounts.Data { 139 | // Re-use user's TokenType, RefreshToken and Expiry. 140 | pageToken := &oauth2.Token{ 141 | AccessToken: account.AccessToken, 142 | TokenType: userToken.TokenType, 143 | RefreshToken: userToken.RefreshToken, 144 | Expiry: userToken.Expiry, 145 | } 146 | 147 | cred := &providers.OAuth2Creds{ 148 | CredProviderID: ProviderID, 149 | CredProviderUserID: account.ID, 150 | Token: pageToken, 151 | } 152 | 153 | creds = append(creds, cred) 154 | } 155 | 156 | return creds, nil 157 | } 158 | -------------------------------------------------------------------------------- /providers/query.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | DefaultNumResults = 20 11 | MaxNumResults = 50 12 | ) 13 | 14 | var ( 15 | NoQuery = Query{} 16 | ) 17 | 18 | // TODO: use https://github.com/google/go-querystring with 19 | // `url` struct tags to easier parse/build query strings 20 | type Query struct { 21 | Search SearchParts 22 | Filter string // Second-pass keywords filter 23 | Username string // Query by a specific username 24 | UserID string 25 | 26 | Limit int 27 | Sort string // recent,popular (default: recent) 28 | SinceID string // TODO: rename these to NextID and PrevID? 29 | UntilID string 30 | Perm string // read or write, default: read 31 | 32 | Params url.Values 33 | } 34 | 35 | func NewQuery(args url.Values) Query { 36 | q := Query{ 37 | Limit: DefaultNumResults, 38 | Sort: "recent", 39 | } 40 | 41 | if args == nil { 42 | return q 43 | } 44 | 45 | q.Search = NewSearchParts(args.Get("q")) 46 | q.Filter = args.Get("filter") 47 | q.Username = args.Get("username") 48 | q.UserID = args.Get("userid") 49 | 50 | if q.Username != "" { 51 | q.Search.Usernames = append(q.Search.Usernames, q.Username) 52 | } 53 | 54 | limit, err := strconv.Atoi(args.Get("limit")) 55 | if err != nil || limit < 1 || limit > MaxNumResults { 56 | q.Limit = DefaultNumResults 57 | } else { 58 | q.Limit = limit 59 | } 60 | 61 | sort := args.Get("sort") 62 | if sort == "popular" { 63 | q.Sort = sort 64 | } 65 | 66 | q.SinceID = args.Get("since_id") 67 | q.UntilID = args.Get("until_id") 68 | q.Perm = args.Get("perm") 69 | 70 | q.Params = args 71 | 72 | return q 73 | } 74 | 75 | func (q Query) ToURLArgs() url.Values { 76 | args := q.Params 77 | if q.Search.String() != "" { 78 | args.Set("q", q.Search.String()) 79 | } 80 | if q.Filter != "" { 81 | args.Set("filter", q.Filter) 82 | } 83 | if q.Username != "" { 84 | args.Set("username", q.Username) 85 | } 86 | if q.UserID != "" { 87 | args.Set("userid", q.UserID) 88 | } 89 | args.Set("limit", strconv.Itoa(q.Limit)) 90 | 91 | if q.Sort != "recent" { 92 | args.Set("sort", q.Sort) 93 | } 94 | 95 | if q.SinceID != "" { 96 | args.Set("since_id", q.SinceID) 97 | } 98 | if q.UntilID != "" { 99 | args.Set("until_id", q.UntilID) 100 | } 101 | if q.Perm != "" { 102 | args.Set("perm", q.Perm) 103 | } 104 | return args 105 | } 106 | 107 | // Search query parts 108 | type SearchParts struct { 109 | Usernames, Tags, Words []string 110 | } 111 | 112 | func NewSearchParts(q string) SearchParts { 113 | qs := strings.TrimSpace(q) 114 | qp := SearchParts{} 115 | parts := strings.Split(qs, " ") 116 | 117 | for _, k := range parts { 118 | if len(k) == 0 { 119 | continue 120 | } 121 | switch k[0:1] { 122 | case "@": 123 | qp.Usernames = append(qp.Usernames, k[1:]) 124 | case "#": 125 | qp.Tags = append(qp.Tags, k[1:]) 126 | default: 127 | qp.Words = append(qp.Words, k) 128 | } 129 | } 130 | return qp 131 | } 132 | 133 | func (sq SearchParts) String() (s string) { 134 | if len(sq.Usernames) > 0 { 135 | s += sq.buildPart(sq.Usernames, "@") + " " 136 | } 137 | if len(sq.Tags) > 0 { 138 | s += sq.buildPart(sq.Tags, "#") + " " 139 | } 140 | if len(sq.Words) > 0 { 141 | s += sq.buildPart(sq.Words, "") 142 | } 143 | return strings.TrimSpace(s) 144 | } 145 | 146 | // Return only the tags and words in the search query 147 | func (sq SearchParts) Keywords(prefix ...bool) (s string) { 148 | addPrefix := false 149 | if len(prefix) > 0 { 150 | addPrefix = prefix[0] 151 | } 152 | if len(sq.Tags) > 0 { 153 | p := "" 154 | if addPrefix { 155 | p = "#" 156 | } 157 | s += sq.buildPart(sq.Tags, p) + " " 158 | } 159 | if len(sq.Words) > 0 { 160 | s += sq.buildPart(sq.Words, "") 161 | } 162 | return strings.TrimSpace(s) 163 | } 164 | 165 | // Returns the first username found 166 | func (sq SearchParts) Username() (s string) { 167 | if len(sq.Usernames) > 0 { 168 | s = sq.Usernames[0] 169 | } 170 | return 171 | } 172 | 173 | func (sq SearchParts) buildPart(parts []string, prefix string) (s string) { 174 | if len(parts) == 0 { 175 | return 176 | } 177 | for i, k := range parts { 178 | if prefix != "" { 179 | s += prefix + k 180 | } else { 181 | s += k 182 | } 183 | if i < len(parts)-1 { 184 | s += " " 185 | } 186 | } 187 | return 188 | } 189 | -------------------------------------------------------------------------------- /providers/facebook/facebook.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/go-social/social" 11 | "github.com/go-social/social/providers" 12 | fb "github.com/huandu/facebook" 13 | "golang.org/x/oauth2" 14 | "golang.org/x/oauth2/facebook" 15 | ) 16 | 17 | const ( 18 | ProviderID = `facebook` 19 | ) 20 | 21 | var ( 22 | postFields = strings.Join([]string{ 23 | "actions", 24 | "application", 25 | "attachments", 26 | "caption", 27 | "created_time", 28 | "description", 29 | "from", 30 | "full_picture", 31 | "icon", 32 | "id", 33 | "likes", 34 | "link", 35 | "message", 36 | "message_tags", 37 | "name", 38 | "object_id", 39 | "place", 40 | "privacy", 41 | "properties", 42 | "shares", 43 | "source", 44 | "status_type", 45 | "type", 46 | "updated_time", 47 | }, ",") 48 | 49 | basicFields = strings.Join([]string{ 50 | "about", 51 | "id", 52 | "link", 53 | "location", 54 | "name", 55 | "picture.type(large)", 56 | }, ",") 57 | 58 | userFields = strings.Join([]string{ 59 | basicFields, 60 | "email", 61 | "timezone", 62 | }, ",") 63 | ) 64 | 65 | const timeLayout = `2006-01-02T15:04:05-0700` 66 | 67 | var ( 68 | AppID string 69 | AppSecret string 70 | OAuthCallback string 71 | 72 | // Facebook API version 73 | // See: https://developers.facebook.com/docs/apps/changelog for updates 74 | FacebookApiVersion = "v2.11" 75 | ) 76 | 77 | type Provider struct { 78 | creds social.Credentials 79 | api *fb.Session 80 | } 81 | 82 | func New(ctx context.Context, creds social.Credentials) (providers.ProviderSession, error) { 83 | conf := &oauth2.Config{ 84 | ClientID: AppID, 85 | ClientSecret: AppSecret, 86 | RedirectURL: OAuthCallback, 87 | Endpoint: facebook.Endpoint, 88 | } 89 | 90 | token := &oauth2.Token{ 91 | AccessToken: creds.AccessToken(), 92 | RefreshToken: creds.RefreshToken(), 93 | } 94 | if expiresAt := creds.ExpiresAt(); expiresAt != nil { 95 | token.Expiry = *expiresAt 96 | } 97 | 98 | api := &fb.Session{ 99 | Version: FacebookApiVersion, 100 | HttpClient: conf.Client(ctx, token), 101 | } 102 | 103 | if err := api.Validate(); err != nil { 104 | return nil, providerError(err) 105 | } 106 | 107 | return &Provider{api: api, creds: creds}, nil 108 | } 109 | 110 | func (p *Provider) ID() string { 111 | return ProviderID 112 | } 113 | 114 | func (p *Provider) Post(ctx context.Context, msg string, shareLink string) (*social.Post, error) { 115 | return nil, providers.ErrNotImplemented 116 | } 117 | 118 | func (p *Provider) Search(query providers.Query) (social.Posts, *providers.Cursor, error) { 119 | return nil, nil, providers.ErrUnsupported 120 | } 121 | 122 | func (p *Provider) GetFeed(query providers.Query) (social.Posts, *providers.Cursor, error) { 123 | return nil, nil, providers.ErrNotImplemented 124 | } 125 | 126 | func (p *Provider) GetPosts(query providers.Query) (social.Posts, *providers.Cursor, error) { 127 | return nil, nil, providers.ErrNotImplemented 128 | } 129 | 130 | func (p *Provider) GetUser(query providers.Query) (*social.User, error) { 131 | var resp fb.Result 132 | var err error 133 | 134 | username := query.Username 135 | if username == "" { 136 | username = "me" 137 | } 138 | 139 | args := url.Values{} 140 | args.Set("fields", userFields) 141 | resp, err = p.api.Get(username, getFbParams(args)) 142 | if err != nil && providerError(err) == errNodeTypePage { 143 | // Try again, this time this will query for basic information only. 144 | args := url.Values{} 145 | args.Set("fields", basicFields) 146 | resp, err = p.api.Get(username, getFbParams(args)) 147 | } 148 | if err != nil { 149 | return nil, providerError(err) 150 | } 151 | 152 | fbResponse, err := getActualResponseAndError(resp, err) 153 | if err != nil { 154 | return nil, providerError(err) 155 | } 156 | 157 | user := &social.User{ 158 | Provider: ProviderID, 159 | ID: fbResponse.ID, 160 | Name: fbResponse.Name, 161 | Username: fbResponse.Name, 162 | ProfileURL: fbResponse.Link, 163 | AvatarURL: fbResponse.Picture.Data.URL, 164 | Location: fbResponse.Location.Name, 165 | Email: fbResponse.Email, 166 | Timezone: strconv.FormatFloat(float64(fbResponse.Timezone), 'f', 2, 32), 167 | NumFollowers: int32(fbResponse.Friends.Summary.TotalCount), 168 | } 169 | 170 | return user, nil 171 | } 172 | 173 | func (p *Provider) GetFriends(query providers.Query) ([]*social.User, *providers.Cursor, error) { 174 | return nil, nil, providers.ErrNotImplemented 175 | } 176 | 177 | func (p *Provider) GetFollowers(query providers.Query) ([]*social.User, *providers.Cursor, error) { 178 | return nil, nil, providers.ErrNotImplemented 179 | } 180 | 181 | func getFbParams(args url.Values) fb.Params { 182 | params := fb.Params{} 183 | for key := range args { 184 | params[key] = args.Get(key) 185 | } 186 | return params 187 | } 188 | 189 | func getActualResponseAndError(res map[string]interface{}, err error) (FbResponse, error) { 190 | getFbResponse := func() FbResponse { 191 | var fbResponse FbResponse 192 | b, _ := json.Marshal(res) 193 | json.Unmarshal(b, &fbResponse) 194 | return fbResponse 195 | } 196 | 197 | if res != nil && len(res) > 0 { 198 | // check to see if error is present in the response 199 | fbResponse := getFbResponse() 200 | if fbResponse.FbError.Message != "" || fbResponse.FbError.Type != "" { 201 | // This means that fb returned an error code 202 | return FbResponse{}, &(fbResponse.FbError) 203 | } else { 204 | return fbResponse, nil 205 | } 206 | } 207 | if err != nil { 208 | // This means fb didnt return an error but some other error happened 209 | return FbResponse{}, err 210 | } 211 | fbResponse := getFbResponse() 212 | return fbResponse, nil 213 | } 214 | -------------------------------------------------------------------------------- /providers/twitter/twitter.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/ChimeraCoder/anaconda" 13 | "github.com/go-social/social" 14 | "github.com/go-social/social/providers" 15 | ) 16 | 17 | const ( 18 | ProviderID = `twitter` 19 | TimeLayout = `Mon Jan 02 15:04:05 -0700 2006` 20 | ) 21 | 22 | var ( 23 | AppID string 24 | AppSecret string 25 | OAuthCallback string 26 | ) 27 | 28 | type Provider struct { 29 | creds social.Credentials 30 | api *anaconda.TwitterApi 31 | } 32 | 33 | func New(ctx context.Context, creds social.Credentials) (providers.ProviderSession, error) { 34 | api := anaconda.NewTwitterApi(creds.AccessToken(), creds.AccessTokenSecret()) 35 | api.ReturnRateLimitError(true) 36 | api.DisableThrottling() 37 | api.HttpClient = http.DefaultClient 38 | return &Provider{creds: creds, api: api}, nil 39 | } 40 | 41 | func Configure(appID string, appSecret string, oauthCallback string) { 42 | AppID = appID 43 | AppSecret = appSecret 44 | OAuthCallback = oauthCallback 45 | 46 | anaconda.SetConsumerKey(appID) 47 | anaconda.SetConsumerSecret(appSecret) 48 | } 49 | 50 | func init() { 51 | providers.Register(ProviderID, &providers.Provider{ 52 | Configure: Configure, 53 | New: New, 54 | NewOAuth: NewOAuth, 55 | }) 56 | } 57 | 58 | func (p *Provider) ID() string { 59 | return ProviderID 60 | } 61 | 62 | // Post a tweet to twitter 63 | func (p *Provider) Post(ctx context.Context, msg string, shareLink string) (*social.Post, error) { 64 | // Append the share link to the message 65 | if shareLink != "" && strings.Index(msg, shareLink) < 0 { 66 | msg = fmt.Sprintf("%s %s", strings.TrimSpace(msg), shareLink) 67 | } 68 | 69 | // Send tweet 70 | tweet, err := p.api.PostTweet(msg, url.Values{}) 71 | if err != nil { 72 | perr := providerError(err) 73 | return nil, perr 74 | } 75 | 76 | newPost := &social.Post{ 77 | Provider: p.ID(), 78 | ID: tweet.IdStr, 79 | URL: fmt.Sprintf("https://twitter.com/statuses/%s", tweet.IdStr), 80 | } 81 | 82 | return newPost, nil 83 | } 84 | 85 | // Search Twitter via their REST API 86 | func (p *Provider) Search(query providers.Query) (social.Posts, *providers.Cursor, error) { 87 | var tweets []anaconda.Tweet 88 | var err error 89 | 90 | args := url.Values{} 91 | args.Add("count", strconv.Itoa(query.Limit)) 92 | args.Add("include_entities", "true") 93 | 94 | // remove retweets from a user's timeline 95 | args.Set("include_rts", "false") 96 | 97 | // remove replies from a user's timeline 98 | args.Set("exclude_replies", "true") 99 | 100 | if query.Sort == "popular" { 101 | args.Add("result_type", "mixed") 102 | } else { 103 | // note, twitter supports: mixed, recent, popular 104 | args.Add("result_type", "recent") 105 | } 106 | 107 | if query.UntilID != "" { 108 | args.Add("max_id", query.UntilID) 109 | } 110 | if query.SinceID != "" { 111 | args.Add("since_id", query.SinceID) 112 | } 113 | 114 | if query.Search.Username() != "" { 115 | // See: https://dev.twitter.com/rest/reference/get/statuses/user_timeline 116 | args.Set("screen_name", query.Search.Username()) 117 | 118 | // turn off trim_user, give us the entire object 119 | args.Set("trim_user", "false") 120 | 121 | // Query twitter's rest api 122 | tweets, err = p.api.GetUserTimeline(args) 123 | 124 | } else { 125 | q := query.Search.Keywords(true) 126 | 127 | var resp anaconda.SearchResponse 128 | resp, err = p.api.GetSearch(q, args) 129 | tweets = resp.Statuses 130 | } 131 | 132 | if err != nil { 133 | perr := providerError(err) 134 | return nil, nil, perr 135 | } 136 | 137 | posts := (Mapper{}).BuildPosts(tweets) 138 | prev, next := getCursorIDs(posts) 139 | cursor := providers.NewCursor(query, prev, next) 140 | 141 | return posts, cursor, nil 142 | } 143 | 144 | func (p *Provider) GetFeed(query providers.Query) (social.Posts, *providers.Cursor, error) { 145 | var tweets []anaconda.Tweet 146 | args := url.Values{} 147 | 148 | args.Add("count", strconv.Itoa(query.Limit)) 149 | if query.UntilID != "" { 150 | args.Add("max_id", query.UntilID) 151 | } 152 | if query.SinceID != "" { 153 | args.Add("since_id", query.SinceID) 154 | } 155 | 156 | tweets, err := p.api.GetHomeTimeline(args) 157 | if err != nil { 158 | perr := providerError(err) 159 | return nil, nil, perr 160 | } 161 | 162 | posts := (Mapper{}).BuildPosts(tweets) 163 | prev, next := getCursorIDs(posts) 164 | cursor := providers.NewCursor(query, prev, next) 165 | 166 | return posts, cursor, providerError(err) 167 | } 168 | 169 | func (p *Provider) GetPosts(query providers.Query) (social.Posts, *providers.Cursor, error) { 170 | var tweets []anaconda.Tweet 171 | args := url.Values{} 172 | 173 | args.Add("count", strconv.Itoa(query.Limit)) 174 | if query.UntilID != "" { 175 | args.Add("max_id", query.UntilID) 176 | } 177 | if query.SinceID != "" { 178 | args.Add("since_id", query.SinceID) 179 | } 180 | 181 | tweets, err := p.api.GetUserTimeline(args) 182 | if err != nil { 183 | perr := providerError(err) 184 | return nil, nil, perr 185 | } 186 | 187 | posts := (Mapper{}).BuildPosts(tweets) 188 | prev, next := getCursorIDs(posts) 189 | cursor := providers.NewCursor(query, prev, next) 190 | 191 | return posts, cursor, providerError(err) 192 | } 193 | 194 | func (p *Provider) GetUser(query providers.Query) (*social.User, error) { 195 | var u anaconda.User 196 | var err error 197 | 198 | if query.UserID != "" { 199 | userid, _ := strconv.Atoi(query.UserID) 200 | u, err = p.api.GetUsersShowById(int64(userid), nil) 201 | } else if query.Username == "" { 202 | u, err = p.api.GetSelf(nil) 203 | } else { 204 | u, err = p.api.GetUsersShow(query.Username, nil) 205 | // TODO: when twitter returns 404, then the user doesn't exist, 206 | // therefore we should respond with a user not found error 207 | } 208 | 209 | if err != nil { 210 | perr := providerError(err) 211 | return nil, perr 212 | } 213 | 214 | user := (UserMapper{}).BuildUser(u) 215 | return user, nil 216 | } 217 | 218 | // Get a user's friends (aka following) 219 | // Network docs: https://dev.twitter.com/rest/reference/get/friends/list 220 | func (p *Provider) GetFriends(query providers.Query) ([]*social.User, *providers.Cursor, error) { 221 | v := url.Values{} 222 | v.Add("count", strconv.Itoa(query.Limit)) 223 | v.Add("include_user_entities", "true") 224 | 225 | if query.UserID != "" { 226 | v.Add("user_id", query.UserID) 227 | } else if query.Username != "" { 228 | v.Add("screen_name", query.Username) 229 | } else { 230 | return nil, nil, errors.New("social: UserID or Username not specified in query") 231 | } 232 | if query.UntilID != "" { 233 | v.Set("cursor", query.UntilID) 234 | } 235 | if query.SinceID != "" { 236 | v.Set("cursor", query.SinceID) 237 | } 238 | 239 | q, err := p.api.GetFriendsList(v) 240 | if err != nil { 241 | perr := providerError(err) 242 | return nil, nil, perr 243 | } 244 | 245 | users := (UserMapper{}).BuildUsers(q.Users) 246 | cursor := providers.NewCursor(query, q.Previous_cursor_str, q.Next_cursor_str) 247 | 248 | return users, cursor, nil 249 | } 250 | 251 | // Get a user's followers 252 | // Network docs: https://dev.twitter.com/rest/reference/get/followers/list 253 | func (p *Provider) GetFollowers(query providers.Query) ([]*social.User, *providers.Cursor, error) { 254 | v := url.Values{} 255 | v.Add("count", strconv.Itoa(query.Limit)) 256 | v.Add("include_user_entities", "true") 257 | 258 | if query.UserID != "" { 259 | v.Add("user_id", query.UserID) 260 | } else if query.Username != "" { 261 | v.Add("screen_name", query.Username) 262 | } else { 263 | return nil, nil, errors.New("UserID or Username not specified in query") 264 | } 265 | if query.UntilID != "" { 266 | v.Set("cursor", query.UntilID) 267 | } 268 | if query.SinceID != "" { 269 | v.Set("cursor", query.SinceID) 270 | } 271 | 272 | q, err := p.api.GetFollowersList(v) 273 | if err != nil { 274 | perr := providerError(err) 275 | return nil, nil, perr 276 | } 277 | 278 | users := (UserMapper{}).BuildUsers(q.Users) 279 | cursor := providers.NewCursor(query, q.Previous_cursor_str, q.Next_cursor_str) 280 | 281 | return users, cursor, nil 282 | } 283 | 284 | func getCursorIDs(posts social.Posts) (prevID, nextID string) { 285 | if len(posts) == 0 { 286 | return 287 | } 288 | prevID = posts[0].ID 289 | nextID = posts[len(posts)-1].ID 290 | return 291 | } 292 | --------------------------------------------------------------------------------