From e85a245eba78a400d408cd818a30616ae3ea34bd Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Thu, 2 Nov 2023 21:50:12 -0700 Subject: [PATCH 1/4] Added is_featured to /mock/clips --- internal/database/videos.go | 1 + internal/mock_api/endpoints/clips/clips.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/internal/database/videos.go b/internal/database/videos.go index 4990244f..556f2021 100644 --- a/internal/database/videos.go +++ b/internal/database/videos.go @@ -50,6 +50,7 @@ type Clip struct { CreatedAt string `db:"created_at" json:"created_at"` Duration float64 `db:"duration" json:"duration"` VodOffset int `db:"vod_offset" json:"vod_offset"` + IsFeatured bool `json:"is_featured"` // calculated fields URL string `json:"url"` ThumbnailURL string `json:"thumbnail_url"` diff --git a/internal/mock_api/endpoints/clips/clips.go b/internal/mock_api/endpoints/clips/clips.go index 417d5791..fbe37d52 100644 --- a/internal/mock_api/endpoints/clips/clips.go +++ b/internal/mock_api/endpoints/clips/clips.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" + "strings" "time" "github.com/twitchdev/twitch-cli/internal/database" @@ -70,6 +72,8 @@ func getClips(w http.ResponseWriter, r *http.Request) { startedAt := r.URL.Query().Get("started_at") endedAt := r.URL.Query().Get("ended_at") + isFeatured := r.URL.Query().Get("is_featured") + if broadcasterID == "" && gameID == "" && id == "" { mock_errors.WriteBadRequest(w, "one of broadcaster_id, game_id, or id is required") return @@ -80,6 +84,11 @@ func getClips(w http.ResponseWriter, r *http.Request) { return } + if isFeatured != "" && (!strings.EqualFold(isFeatured, "false") && !strings.EqualFold(isFeatured, "true")) { + mock_errors.WriteBadRequest(w, "is_featured must be true or false") + return + } + if startedAt != "" && endedAt == "" { sa, _ := time.Parse(time.RFC3339, startedAt) endedAt = sa.Add(7 * 24 * time.Hour).Format(time.RFC3339) @@ -92,6 +101,18 @@ func getClips(w http.ResponseWriter, r *http.Request) { } clips := dbr.Data.([]database.Clip) + + if isFeatured != "" { + newClips := []database.Clip{} + for _, clip := range clips { + featured, _ := strconv.ParseBool(isFeatured) + if clip.IsFeatured == featured { + newClips = append(newClips, clip) + } + } + clips = newClips + } + apiResponse := models.APIResponse{ Data: clips, } From f7e353f2ad964b2a1fe94fbc636d8708d0b570d0 Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Thu, 2 Nov 2023 22:00:54 -0700 Subject: [PATCH 2/4] /mock/users/follows returns 410 Gone; Removed channel.follow v1 --- .../{follow_v2 => follow}/follow_event.go | 2 +- .../follow_event_test.go | 2 +- .../events/types/follow_v1/follow_event.go | 129 ------------------ .../types/follow_v2/follow_event_test.go | 78 ----------- internal/events/types/types.go | 6 +- internal/mock_api/endpoints/endpoints.go | 3 + 6 files changed, 7 insertions(+), 213 deletions(-) rename internal/events/types/{follow_v2 => follow}/follow_event.go (99%) rename internal/events/types/{follow_v1 => follow}/follow_event_test.go (99%) delete mode 100644 internal/events/types/follow_v1/follow_event.go delete mode 100644 internal/events/types/follow_v2/follow_event_test.go diff --git a/internal/events/types/follow_v2/follow_event.go b/internal/events/types/follow/follow_event.go similarity index 99% rename from internal/events/types/follow_v2/follow_event.go rename to internal/events/types/follow/follow_event.go index a2176517..2d7ea48a 100644 --- a/internal/events/types/follow_v2/follow_event.go +++ b/internal/events/types/follow/follow_event.go @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package follow_v2 +package follow import ( "encoding/json" diff --git a/internal/events/types/follow_v1/follow_event_test.go b/internal/events/types/follow/follow_event_test.go similarity index 99% rename from internal/events/types/follow_v1/follow_event_test.go rename to internal/events/types/follow/follow_event_test.go index 1172d763..bce21122 100644 --- a/internal/events/types/follow_v1/follow_event_test.go +++ b/internal/events/types/follow/follow_event_test.go @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package follow_v1 +package follow import ( "encoding/json" diff --git a/internal/events/types/follow_v1/follow_event.go b/internal/events/types/follow_v1/follow_event.go deleted file mode 100644 index 2827f16e..00000000 --- a/internal/events/types/follow_v1/follow_event.go +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package follow_v1 - -import ( - "encoding/json" - "strings" - - "github.com/twitchdev/twitch-cli/internal/events" - "github.com/twitchdev/twitch-cli/internal/models" -) - -var transportsSupported = map[string]bool{ - models.TransportWebhook: true, - models.TransportWebSocket: true, -} -var triggers = []string{"follow"} - -var triggerMapping = map[string]map[string]string{ - models.TransportWebhook: { - "follow": "channel.follow", - }, - models.TransportWebSocket: { - "follow": "channel.follow", - }, -} - -type Event struct{} - -func (e Event) GenerateEvent(params events.MockEventParameters) (events.MockEventResponse, error) { - var event []byte - var err error - - switch params.Transport { - case models.TransportWebhook, models.TransportWebSocket: - body := models.EventsubResponse{ - Subscription: models.EventsubSubscription{ - ID: params.ID, - Status: params.SubscriptionStatus, - Type: triggerMapping[params.Transport][params.Trigger], - Version: e.SubscriptionVersion(), - Condition: models.EventsubCondition{ - BroadcasterUserID: params.ToUserID, - }, - Transport: models.EventsubTransport{ - Method: "webhook", - Callback: "null", - }, - Cost: 0, - CreatedAt: params.Timestamp, - }, - Event: models.FollowEventSubEvent{ - UserID: params.FromUserID, - UserLogin: params.FromUserName, - UserName: params.FromUserName, - BroadcasterUserID: params.ToUserID, - BroadcasterUserLogin: params.ToUserID, - BroadcasterUserName: params.ToUserName, - FollowedAt: params.Timestamp, - }, - } - - event, err = json.Marshal(body) - if err != nil { - return events.MockEventResponse{}, err - } - - // Delete event info if Subscription.Status is not set to "enabled" - if !strings.EqualFold(params.SubscriptionStatus, "enabled") { - var i interface{} - if err := json.Unmarshal([]byte(event), &i); err != nil { - return events.MockEventResponse{}, err - } - if m, ok := i.(map[string]interface{}); ok { - delete(m, "event") // Matches JSON key defined in body variable above - } - - event, err = json.Marshal(i) - if err != nil { - return events.MockEventResponse{}, err - } - } - default: - return events.MockEventResponse{}, nil - } - - return events.MockEventResponse{ - ID: params.ID, - JSON: event, - FromUser: params.FromUserID, - ToUser: params.ToUserID, - }, nil -} - -func (e Event) ValidTransport(transport string) bool { - return transportsSupported[transport] -} - -func (e Event) ValidTrigger(trigger string) bool { - for _, t := range triggers { - if t == trigger { - return true - } - } - return false -} -func (e Event) GetTopic(transport string, trigger string) string { - return triggerMapping[transport][trigger] -} -func (e Event) GetAllTopicsByTransport(transport string) []string { - allTopics := []string{} - for _, topic := range triggerMapping[transport] { - allTopics = append(allTopics, topic) - } - return allTopics -} -func (e Event) GetEventSubAlias(t string) string { - // check for aliases - for trigger, topic := range triggerMapping[models.TransportWebhook] { - if topic == t { - return trigger - } - } - return "" -} - -func (e Event) SubscriptionVersion() string { - return "1" -} diff --git a/internal/events/types/follow_v2/follow_event_test.go b/internal/events/types/follow_v2/follow_event_test.go deleted file mode 100644 index f9c39a01..00000000 --- a/internal/events/types/follow_v2/follow_event_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package follow_v2 - -import ( - "encoding/json" - "testing" - - "github.com/twitchdev/twitch-cli/internal/events" - "github.com/twitchdev/twitch-cli/internal/models" - "github.com/twitchdev/twitch-cli/test_setup" -) - -var fromUser = "1234" -var toUser = "4567" - -func TestEventSub(t *testing.T) { - a := test_setup.SetupTestEnv(t) - - params := events.MockEventParameters{ - FromUserID: fromUser, - ToUserID: toUser, - Transport: models.TransportWebhook, - Trigger: "subscribe", - SubscriptionStatus: "enabled", - } - - r, err := Event{}.GenerateEvent(params) - a.Nil(err) - - var body models.SubEventSubResponse - err = json.Unmarshal(r.JSON, &body) - a.Nil(err) - - a.Equal(toUser, body.Event.BroadcasterUserID, "Expected to user %v, got %v", toUser, body.Event.BroadcasterUserID) - a.Equal(fromUser, body.Event.UserID, "Expected from user %v, got %v", r.ToUser, body.Event.UserID) -} - -func TestFakeTransport(t *testing.T) { - a := test_setup.SetupTestEnv(t) - - params := events.MockEventParameters{ - FromUserID: fromUser, - ToUserID: toUser, - Transport: "fake_transport", - Trigger: triggers[0], - SubscriptionStatus: "enabled", - } - - r, err := Event{}.GenerateEvent(params) - a.Nil(err) - a.Empty(r) -} -func TestValidTrigger(t *testing.T) { - a := test_setup.SetupTestEnv(t) - - r := Event{}.ValidTrigger("notreal") - a.Equal(false, r) - - r = Event{}.ValidTrigger("follow") - a.Equal(true, r) -} - -func TestValidTransport(t *testing.T) { - a := test_setup.SetupTestEnv(t) - - r := Event{}.ValidTransport(models.TransportWebhook) - a.Equal(true, r) - - r = Event{}.ValidTransport("noteventsub") - a.Equal(false, r) -} -func TestGetTopic(t *testing.T) { - a := test_setup.SetupTestEnv(t) - - r := Event{}.GetTopic(models.TransportWebhook, "follow") - a.NotNil(r) -} diff --git a/internal/events/types/types.go b/internal/events/types/types.go index 23b3e171..68545549 100644 --- a/internal/events/types/types.go +++ b/internal/events/types/types.go @@ -20,8 +20,7 @@ import ( "github.com/twitchdev/twitch-cli/internal/events/types/cheer" "github.com/twitchdev/twitch-cli/internal/events/types/drop" "github.com/twitchdev/twitch-cli/internal/events/types/extension_transaction" - "github.com/twitchdev/twitch-cli/internal/events/types/follow_v1" - "github.com/twitchdev/twitch-cli/internal/events/types/follow_v2" + "github.com/twitchdev/twitch-cli/internal/events/types/follow" "github.com/twitchdev/twitch-cli/internal/events/types/gift" "github.com/twitchdev/twitch-cli/internal/events/types/goal" "github.com/twitchdev/twitch-cli/internal/events/types/hype_train" @@ -51,8 +50,7 @@ func AllEvents() []events.MockEvent { cheer.Event{}, drop.Event{}, extension_transaction.Event{}, - follow_v1.Event{}, - follow_v2.Event{}, + follow.Event{}, gift.Event{}, goal.Event{}, hype_train.Event{}, diff --git a/internal/mock_api/endpoints/endpoints.go b/internal/mock_api/endpoints/endpoints.go index 4e0625c2..53b428b7 100644 --- a/internal/mock_api/endpoints/endpoints.go +++ b/internal/mock_api/endpoints/endpoints.go @@ -110,5 +110,8 @@ func Gone() map[string][]string { "/soundtrack/playlists": { "GET", }, + "/users/follows": { + "GET", + }, } } From 54f4271b71683ac384ba07997715577651e91e1e Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Thu, 2 Nov 2023 22:24:59 -0700 Subject: [PATCH 3/4] Added 410 Gone responses to EventSub WebSocket server when trying to subscribe to an event version that was removed --- internal/events/types/types.go | 8 ++++++++ .../events/websocket/mock_server/manager.go | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/events/types/types.go b/internal/events/types/types.go index 68545549..3cd7c05a 100644 --- a/internal/events/types/types.go +++ b/internal/events/types/types.go @@ -151,3 +151,11 @@ func GetByTriggerAndTransportAndVersion(trigger string, transport string, versio // Default error return nil, errors.New("Invalid event") } + +// These events were removed from production +// This does not include any "beta" events, just old production versions +func RemovedEvents() map[string]string { + return map[string]string{ + "channel.follow": "1", + } +} diff --git a/internal/events/websocket/mock_server/manager.go b/internal/events/websocket/mock_server/manager.go index 69c67733..3c1cb2e9 100644 --- a/internal/events/websocket/mock_server/manager.go +++ b/internal/events/websocket/mock_server/manager.go @@ -337,7 +337,14 @@ func subscriptionPageHandlerPost(w http.ResponseWriter, r *http.Request) { return } - // Check if the topic exists + // Check if the topic was deprecated/removed + for e, v := range types.RemovedEvents() { + if body.Type == e && body.Version == v { + handlerResponseErrorGone(w) + return + } + } + _, err = types.GetByTriggerAndTransportAndVersion(body.Type, body.Transport.Method, body.Version) if err != nil { handlerResponseErrorBadRequest(w, "The combination of values in the type and version fields is not valid") @@ -546,3 +553,13 @@ func handlerResponseErrorInternalServerError(w http.ResponseWriter, message stri }) w.Write(bytes) } + +func handlerResponseErrorGone(w http.ResponseWriter) { + w.WriteHeader(http.StatusGone) + bytes, _ := json.Marshal(&SubscriptionPostErrorResponse{ + Error: "Gone", + Message: "This subscription type is not available.", + Status: 410, + }) + w.Write(bytes) +} From d3599022a330a855df7486152fee27f681a1137d Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Tue, 21 Nov 2023 17:01:57 -0800 Subject: [PATCH 4/4] Removed /users/follows and added /channels/followers and channels/followed --- internal/api/resources.go | 21 ++- internal/database/database_test.go | 2 +- internal/database/user.go | 29 ++-- .../endpoints/channels/channels_test.go | 50 ++++++ .../mock_api/endpoints/channels/followed.go | 112 ++++++++++++++ .../mock_api/endpoints/channels/followers.go | 144 ++++++++++++++++++ internal/mock_api/endpoints/endpoints.go | 3 +- internal/mock_api/endpoints/users/follows.go | 95 ------------ .../mock_api/endpoints/users/users_test.go | 19 --- test_setup/test_server/test_server.go | 1 + 10 files changed, 344 insertions(+), 132 deletions(-) create mode 100644 internal/mock_api/endpoints/channels/followed.go create mode 100644 internal/mock_api/endpoints/channels/followers.go delete mode 100644 internal/mock_api/endpoints/users/follows.go diff --git a/internal/api/resources.go b/internal/api/resources.go index 3257df1e..ac1d56b1 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -171,33 +171,40 @@ var endpointMethodSupports = map[string]map[string]bool{ "PATCH": true, "DELETE": false, }, - "/subscriptions": { + "/channels/followed": { "GET": true, "POST": false, "PUT": false, "PATCH": false, "DELETE": false, }, - "/tags/streams": { + "/channels/followers": { "GET": true, "POST": false, "PUT": false, "PATCH": false, "DELETE": false, }, - "/streams/tags": { + "/subscriptions": { "GET": true, "POST": false, - "PUT": true, + "PUT": false, "PATCH": false, "DELETE": false, }, - "/users/follows": { + "/tags/streams": { "GET": true, - "POST": true, + "POST": false, "PUT": false, "PATCH": false, - "DELETE": true, + "DELETE": false, + }, + "/streams/tags": { + "GET": true, + "POST": false, + "PUT": true, + "PATCH": false, + "DELETE": false, }, "/users/extensions/list": { "GET": true, diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 35c1f1ee..fb151829 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -273,7 +273,7 @@ func TestUsers(t *testing.T) { err = q.AddFollow(urp) a.Nil(err) - dbr, err = q.GetFollows(urp) + dbr, err = q.GetFollows(urp, false) a.Nil(err) follows := dbr.Data.([]Follow) a.GreaterOrEqual(len(follows), 1) diff --git a/internal/database/user.go b/internal/database/user.go index c68951ac..ff56277f 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -38,13 +38,13 @@ type User struct { } type Follow struct { - BroadcasterID string `db:"to_id" json:"to_id"` - BroadcasterLogin string `db:"to_login" json:"to_login"` - BroadcasterName string `db:"to_name" json:"to_name"` - ViewerID string `db:"from_id" json:"from_id"` - ViewerLogin string `db:"from_login" json:"from_login"` - ViewerName string `db:"from_name" json:"from_name"` - FollowedAt string `db:"created_at" json:"followed_at"` + BroadcasterID string `db:"to_id"` + BroadcasterLogin string `db:"to_login"` + BroadcasterName string `db:"to_name"` + ViewerID string `db:"from_id"` + ViewerLogin string `db:"from_login"` + ViewerName string `db:"from_name"` + FollowedAt string `db:"created_at"` } type UserRequestParams struct { @@ -185,7 +185,10 @@ func (q *Query) AddFollow(p UserRequestParams) error { return err } -func (q *Query) GetFollows(p UserRequestParams) (*DBResponse, error) { +// "Total" returned depends on totalsFromUser bool. +// "true" will return the number of people the user from p.UserID currently follows. +// "false" will return the number of people the user from p.BroadcasterID currently follows. +func (q *Query) GetFollows(p UserRequestParams, totalsFromUser bool) (*DBResponse, error) { db := q.DB var r []Follow var f Follow @@ -203,8 +206,16 @@ func (q *Query) GetFollows(p UserRequestParams) (*DBResponse, error) { } r = append(r, f) } + + totalsP := UserRequestParams{} + if totalsFromUser { + totalsP.UserID = p.UserID + } else { + totalsP.BroadcasterID = p.BroadcasterID + } + var total int - rows, err = q.DB.NamedQuery(generateSQL("select count(*) from follows", p, SEP_AND), p) + rows, err = q.DB.NamedQuery(generateSQL("select count(*) from follows", totalsP, SEP_AND), totalsP) for rows.Next() { err := rows.Scan(&total) if err != nil { diff --git a/internal/mock_api/endpoints/channels/channels_test.go b/internal/mock_api/endpoints/channels/channels_test.go index f2351ae3..11e06910 100644 --- a/internal/mock_api/endpoints/channels/channels_test.go +++ b/internal/mock_api/endpoints/channels/channels_test.go @@ -189,3 +189,53 @@ func TestInformation(t *testing.T) { a.Nil(err) a.Equal(400, resp.StatusCode) } + +func TestFollowed(t *testing.T) { + a := test_setup.SetupTestEnv(t) + ts := test_server.SetupTestServer(FollowedEndpoint{}) + + // get + req, _ := http.NewRequest(http.MethodGet, ts.URL+FollowedEndpoint{}.Path(), nil) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + q.Set("user_id", "1") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) + + q.Set("broadcaster_id", "2") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) +} + +func TestFollowers(t *testing.T) { + a := test_setup.SetupTestEnv(t) + ts := test_server.SetupTestServer(FollowersEndpoint{}) + + // get + req, _ := http.NewRequest(http.MethodGet, ts.URL+FollowersEndpoint{}.Path(), nil) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + resp, err := http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(400, resp.StatusCode) + + q.Set("broadcaster_id", "1") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) + + q.Set("user_id", "2") + req.URL.RawQuery = q.Encode() + resp, err = http.DefaultClient.Do(req) + a.Nil(err) + a.Equal(200, resp.StatusCode) +} diff --git a/internal/mock_api/endpoints/channels/followed.go b/internal/mock_api/endpoints/channels/followed.go new file mode 100644 index 00000000..442b0822 --- /dev/null +++ b/internal/mock_api/endpoints/channels/followed.go @@ -0,0 +1,112 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package channels + +import ( + "encoding/json" + "net/http" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/mock_api/authentication" + "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" + "github.com/twitchdev/twitch-cli/internal/models" +) + +var followedMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var followedScopesByMethod = map[string][]string{ + http.MethodGet: {"user:read:follows"}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type FollowedEndpoint struct{} + +type GetFollowedEndpointResponseData struct { + BroadcasterID string `json:"broadcaster_id"` + BroadcasterLogin string `json:"broadcaster_login"` + BroadcasterName string `json:"broadcaster_name"` + FollowedAt string `json:"followed_at"` +} + +func (e FollowedEndpoint) Path() string { return "/channels/followed" } + +func (e FollowedEndpoint) GetRequiredScopes(method string) []string { + return followedScopesByMethod[method] +} + +func (e FollowedEndpoint) ValidMethod(method string) bool { + return followedMethodsSupported[method] +} + +func (e FollowedEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodGet: + getFollowed(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } +} + +func getFollowed(w http.ResponseWriter, r *http.Request) { + user_id := r.URL.Query().Get("user_id") + broadcaster_id := r.URL.Query().Get("broadcaster_id") + + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + + if user_id == "" { + mock_errors.WriteBadRequest(w, "The user_id query parameter is required") + return + } + + if user_id != userCtx.UserID { + mock_errors.WriteUnauthorized(w, "user_id does not match User ID in the access token") + return + } + + req := database.UserRequestParams{ + UserID: user_id, + BroadcasterID: broadcaster_id, + } + + dbr, err := db.NewQuery(r, 100).GetFollows(req, true) + if dbr == nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + + // Build list of who the user is following + follows := []GetFollowedEndpointResponseData{} + for _, f := range dbr.Data.([]database.Follow) { + follows = append(follows, GetFollowedEndpointResponseData{ + BroadcasterID: f.BroadcasterID, + BroadcasterLogin: f.BroadcasterLogin, + BroadcasterName: f.BroadcasterName, + FollowedAt: f.FollowedAt, + }) + } + + body := models.APIResponse{ + Data: follows, + Total: &dbr.Total, + } + if dbr != nil && dbr.Cursor != "" { + body.Pagination = &models.APIPagination{ + Cursor: dbr.Cursor, + } + } + + bytes, _ := json.Marshal(body) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/channels/followers.go b/internal/mock_api/endpoints/channels/followers.go new file mode 100644 index 00000000..4c677f5b --- /dev/null +++ b/internal/mock_api/endpoints/channels/followers.go @@ -0,0 +1,144 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package channels + +import ( + "encoding/json" + "net/http" + + "github.com/twitchdev/twitch-cli/internal/database" + "github.com/twitchdev/twitch-cli/internal/mock_api/authentication" + "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" + "github.com/twitchdev/twitch-cli/internal/models" +) + +var followersMethodsSupported = map[string]bool{ + http.MethodGet: true, + http.MethodPost: false, + http.MethodDelete: false, + http.MethodPatch: false, + http.MethodPut: false, +} + +var followersScopesByMethod = map[string][]string{ + http.MethodGet: {"moderator:read:followers"}, + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, +} + +type FollowersEndpoint struct{} + +type GetFollowersEndpointResponseData struct { + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + FollowedAt string `json:"followed_at"` +} + +func (e FollowersEndpoint) Path() string { return "/channels/followers" } + +func (e FollowersEndpoint) GetRequiredScopes(method string) []string { + return followersScopesByMethod[method] +} + +func (e FollowersEndpoint) ValidMethod(method string) bool { + return followersMethodsSupported[method] +} + +func (e FollowersEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { + db = r.Context().Value("db").(database.CLIDatabase) + + switch r.Method { + case http.MethodGet: + getFollowers(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } +} + +func getFollowers(w http.ResponseWriter, r *http.Request) { + user_id := r.URL.Query().Get("user_id") + broadcaster_id := r.URL.Query().Get("broadcaster_id") + + if broadcaster_id == "" { + mock_errors.WriteBadRequest(w, "The broadcaster_id query parameter is required") + return + } + + /// If user_id used: + /// - Check for moderator:read:followers scope is used and if broadcaster_id == access token user id + /// - If false on above, check if user_id is a moderator for broadcaster_id + + userCtx := r.Context().Value("auth").(authentication.UserAuthentication) + trustedUser := false + + if userCtx.MatchesBroadcasterIDParam(r) { + trustedUser = true + } else { + // Check if user a moderator instead + moderatorListDbr, err := db.NewQuery(r, 1000).GetModeratorsForBroadcaster(broadcaster_id) + if err != nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + modFound := false + for _, mod := range moderatorListDbr.Data.([]database.Moderator) { + if mod.UserID == user_id { + modFound = true // Moderator found + } + } + if modFound { + trustedUser = true + } + } + + if !userCtx.HasScope("moderator:read:followers") { + // Doesn't matter if they are broadcaster, moderator, or regular: if they don't have the scope they don't get the data. + trustedUser = false + } + + if user_id != "" && !trustedUser { + mock_errors.WriteUnauthorized(w, "When user_id param is provided, user_id must match the User ID in the access token, and the access token must have the scope moderator:read:followers") + return + } + + req := database.UserRequestParams{ + UserID: user_id, + BroadcasterID: broadcaster_id, + } + + dbr, err := db.NewQuery(r, 100).GetFollows(req, false) + if dbr == nil { + mock_errors.WriteServerError(w, err.Error()) + return + } + + // Build list of who the user is following + follows := []GetFollowersEndpointResponseData{} + if trustedUser { + for _, f := range dbr.Data.([]database.Follow) { + follows = append(follows, GetFollowersEndpointResponseData{ + UserID: f.ViewerID, + UserLogin: f.ViewerLogin, + UserName: f.ViewerName, + FollowedAt: f.FollowedAt, + }) + } + } + + body := models.APIResponse{ + Data: follows, + Total: &dbr.Total, + } + if dbr != nil && dbr.Cursor != "" { + body.Pagination = &models.APIPagination{ + Cursor: dbr.Cursor, + } + } + + bytes, _ := json.Marshal(body) + w.Write(bytes) +} diff --git a/internal/mock_api/endpoints/endpoints.go b/internal/mock_api/endpoints/endpoints.go index 53b428b7..4d0603c6 100644 --- a/internal/mock_api/endpoints/endpoints.go +++ b/internal/mock_api/endpoints/endpoints.go @@ -40,6 +40,8 @@ func All() []mock_api.MockEndpoint { channel_points.Reward{}, channels.CommercialEndpoint{}, channels.Editors{}, + channels.FollowedEndpoint{}, + channels.FollowersEndpoint{}, channels.InformationEndpoint{}, channels.Vips{}, charity.CharityCampaign{}, @@ -83,7 +85,6 @@ func All() []mock_api.MockEndpoint { teams.ChannelTeams{}, teams.Teams{}, users.Blocks{}, - users.FollowsEndpoint{}, users.UsersEndpoint{}, videos.Videos{}, whispers.Whispers{}, diff --git a/internal/mock_api/endpoints/users/follows.go b/internal/mock_api/endpoints/users/follows.go deleted file mode 100644 index 3efab7dc..00000000 --- a/internal/mock_api/endpoints/users/follows.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package users - -import ( - "encoding/json" - "log" - "net/http" - - "github.com/twitchdev/twitch-cli/internal/database" - "github.com/twitchdev/twitch-cli/internal/mock_api/mock_errors" - "github.com/twitchdev/twitch-cli/internal/models" -) - -var followMethodsSupported = map[string]bool{ - http.MethodGet: true, - http.MethodPost: false, - http.MethodDelete: false, - http.MethodPatch: false, - http.MethodPut: false, -} - -var followScopesByMethod = map[string][]string{ - http.MethodGet: {}, - http.MethodPost: {}, - http.MethodDelete: {}, - http.MethodPatch: {}, - http.MethodPut: {}, -} - -type FollowsEndpoint struct{} - -func (e FollowsEndpoint) Path() string { return "/users/follows" } - -func (e FollowsEndpoint) GetRequiredScopes(method string) []string { - return followScopesByMethod[method] -} - -func (e FollowsEndpoint) ValidMethod(method string) bool { - return followMethodsSupported[method] -} - -func (e FollowsEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { - db = r.Context().Value("db").(database.CLIDatabase) - - switch r.Method { - case http.MethodGet: - getFollows(w, r) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - return - } -} - -func getFollows(w http.ResponseWriter, r *http.Request) { - to := r.URL.Query().Get("to_id") - from := r.URL.Query().Get("from_id") - - if len(to) == 0 && len(from) == 0 { - mock_errors.WriteBadRequest(w, "one of to_id or from_id is required") - return - } - - req := database.UserRequestParams{ - UserID: from, - BroadcasterID: to, - } - - dbr, err := db.NewQuery(r, 100).GetFollows(req) - if dbr == nil { - w.Write([]byte(err.Error())) - w.WriteHeader(http.StatusInternalServerError) - return - } - - f := dbr.Data.([]database.Follow) - - if len(f) == 0 { - f = []database.Follow{} - } - - body := models.APIResponse{ - Data: f, - Total: &dbr.Total, - } - if dbr != nil && dbr.Cursor != "" { - log.Printf("%#v", &dbr) - body.Pagination = &models.APIPagination{ - Cursor: dbr.Cursor, - } - } - - json, _ := json.Marshal(body) - w.Write(json) -} diff --git a/internal/mock_api/endpoints/users/users_test.go b/internal/mock_api/endpoints/users/users_test.go index 68d9ac1a..25a79017 100644 --- a/internal/mock_api/endpoints/users/users_test.go +++ b/internal/mock_api/endpoints/users/users_test.go @@ -113,22 +113,3 @@ func TestBlocks(t *testing.T) { a.Nil(err) a.Equal(204, resp.StatusCode) } - -func TestFollows(t *testing.T) { - a := test_setup.SetupTestEnv(t) - ts := test_server.SetupTestServer(FollowsEndpoint{}) - - // get - req, _ := http.NewRequest(http.MethodGet, ts.URL+FollowsEndpoint{}.Path(), nil) - q := req.URL.Query() - req.URL.RawQuery = q.Encode() - resp, err := http.DefaultClient.Do(req) - a.Nil(err) - a.Equal(400, resp.StatusCode) - - q.Set("to_id", "1") - req.URL.RawQuery = q.Encode() - resp, err = http.DefaultClient.Do(req) - a.Nil(err) - a.Equal(200, resp.StatusCode) -} diff --git a/test_setup/test_server/test_server.go b/test_setup/test_server/test_server.go index 098cbcd0..4516da7e 100644 --- a/test_setup/test_server/test_server.go +++ b/test_setup/test_server/test_server.go @@ -47,6 +47,7 @@ func SetupTestServer(next mock_api.MockEndpoint) *httptest.Server { "channel:read:subscriptions", "clips:edit", "moderation:read", + "moderator:read:followers", "moderator:manage:automod", "user:edit", "user:edit:follows",