diff --git a/internal/database/database.go b/internal/database/database.go index 53fdb68c..732a7f93 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -65,7 +65,11 @@ func getDatabase() (sqlx.DB, error) { } } db.SetMaxOpenConns(1) - checkAndUpdate(*db) + err = checkAndUpdate(*db) + if err != nil { + os.Exit(99) + } + return *db, nil } return sqlx.DB{}, nil diff --git a/internal/database/database_test.go b/internal/database/database_test.go index e1a5cace..50cfb4b5 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -619,14 +619,6 @@ func TestStreams(t *testing.T) { tags := dbr.Data.([]Tag) a.GreaterOrEqual(len(tags), 1) - err = q.InsertStreamTag(StreamTag{TagID: tag.ID, UserID: TEST_USER_ID}) - a.Nil(err) - - dbr, err = q.GetStreamTags(TEST_USER_ID) - a.Nil(err) - tags = dbr.Data.([]Tag) - a.GreaterOrEqual(len(tags), 0) - dbr, err = q.GetFollowedStreams(s.UserID) a.Nil(err) streams := dbr.Data.([]Stream) @@ -647,9 +639,6 @@ func TestStreams(t *testing.T) { stream := streams[0] a.GreaterOrEqual(len(stream.TagIDs), 0) - err = q.DeleteAllStreamTags(s.UserID) - a.Nil(err) - v := Video{ ID: util.RandomGUID(), StreamID: &s.ID, diff --git a/internal/database/init.go b/internal/database/init.go index fbaef7c5..cdc46fcc 100644 --- a/internal/database/init.go +++ b/internal/database/init.go @@ -10,7 +10,7 @@ import ( "github.com/jmoiron/sqlx" ) -const currentVersion = 5 +const currentVersion = 6 type migrateMap struct { SQL string @@ -49,6 +49,10 @@ ALTER TABLE users ADD COLUMN branded_content boolean not null default false; ALTER TABLE users ADD COLUMN content_labels text not null default '';`, Message: `Updating database to include Content Classification Label field.`, }, + 6: { + SQL: `DROP TABLE IF EXISTS stream_tags;`, + Message: `Removing deprecated stream_tags from database.`, + }, } func checkAndUpdate(db sqlx.DB) error { @@ -70,6 +74,8 @@ func checkAndUpdate(db sqlx.DB) error { for i := v; i < len(migrateSQL); i++ { _, err = db.Exec(migrateSQL[i].SQL) if err != nil { + fmt.Printf("DB Upgrade Error - %v\n", err) + fmt.Println("Exiting program. Please report this bug, and delete your Twitch CLI db file to regenerate it.") return err } @@ -99,7 +105,6 @@ create table channel_points_rewards( id text not null primary key, broadcaster_i create table channel_points_redemptions( id text not null primary key, reward_id text not null, broadcaster_id text not null, user_id text not null, user_input text, redemption_status text not null, redeemed_at text, foreign key (reward_id) references channel_points_rewards(id), foreign key (broadcaster_id) references users(id), foreign key (user_id) references users(id) ); create table streams( id text not null primary key, broadcaster_id id text not null, stream_type text not null default 'live', viewer_count int not null, started_at text not null, is_mature boolean not null default false, foreign key (broadcaster_id) references users(id) ); create table tags( id text not null primary key, is_auto boolean not null default false, tag_name text not null ); -create table stream_tags( user_id text not null, tag_id text not null, primary key(user_id, tag_id), foreign key(user_id) references users(id), foreign key(tag_id) references tags(id) ); create table teams( id text not null primary key, background_image_url text, banner text, created_at text not null, updated_at text, info text, thumbnail_url text, team_name text, team_display_name text ); create table team_members( team_id text not null, user_id text not null, primary key (team_id, user_id) foreign key (team_id) references teams(id), foreign key (user_id) references users(id) ); create table videos( id text not null primary key, stream_id text, broadcaster_id text not null, title text not null, video_description text not null, created_at text not null, published_at text, viewable text not null, view_count int not null default 0, duration text not null, video_language text not null default 'en', category_id text, type text default 'archive', foreign key (stream_id) references streams(id), foreign key (broadcaster_id) references users(id), foreign key (category_id) references categories(id) ); diff --git a/internal/database/streams.go b/internal/database/streams.go index d168efd7..e7767f51 100644 --- a/internal/database/streams.go +++ b/internal/database/streams.go @@ -30,11 +30,6 @@ type Stream struct { ThumbnailURL string `json:"thumbnail_url"` } -type StreamTag struct { - TagID string `db:"tag_id" json:"tag_id"` - UserID string `db:"user_id" json:"-"` -} - type Tag struct { ID string `db:"id" json:"id"` IsAuto bool `db:"is_auto" dbi:"false" json:"is_auto"` @@ -145,45 +140,12 @@ func (q *Query) GetTags(t Tag) (*DBResponse, error) { return &dbr, err } -func (q *Query) GetStreamTags(id string) (*DBResponse, error) { - r := []Tag{} - err := q.DB.Select(&r, "select t.* from tags t join stream_tags st on st.tag_id = t.id where st.user_id=$1", id) - if err != nil { - return nil, err - } - - dbr := DBResponse{ - Data: r, - Limit: q.Limit, - Total: len(r), - } - - if len(r) != q.Limit { - q.PaginationCursor = "" - } - - dbr.Cursor = q.PaginationCursor - - return &dbr, err -} - func (q *Query) InsertTag(t Tag) error { stmt := generateInsertSQL("tags", "", t, false) _, err := q.DB.NamedExec(stmt, t) return err } -func (q *Query) InsertStreamTag(st StreamTag) error { - stmt := generateInsertSQL("stream_tags", "", st, false) - _, err := q.DB.NamedExec(stmt, st) - return err -} - -func (q *Query) DeleteAllStreamTags(userID string) error { - _, err := q.DB.Exec("delete from stream_tags where user_id = $1", userID) - return err -} - func (q *Query) GetFollowedStreams(userID string) (*DBResponse, error) { var r = []Stream{} sql := "select s.*, u1.user_login as broadcaster_login, u1.display_name as broadcaster_name, u1.category_id as category_id, c.category_name, u1.stream_language as stream_language, u1.title as title from streams s join users u1 on s.broadcaster_id = u1.id left join categories c on c.id = u1.category_id join follows f on f.broadcaster_id = s.broadcaster_id where f.user_id = $1" @@ -195,7 +157,6 @@ func (q *Query) GetFollowedStreams(userID string) (*DBResponse, error) { } for i, s := range r { - var st []string if err != nil { return nil, err } @@ -205,11 +166,6 @@ func (q *Query) GetFollowedStreams(userID string) (*DBResponse, error) { if s.CategoryName.Valid { r[i].RealCategoryName = s.CategoryName.String } - err = q.DB.Select(&st, "select tag_id from stream_tags where user_id=$1", s.UserID) - if err != nil { - return nil, err - } - r[i].TagIDs = st } dbr := DBResponse{ diff --git a/internal/database/user.go b/internal/database/user.go index 50af24d6..c68951ac 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -330,17 +330,10 @@ func (q *Query) SearchChannels(query string, live_only bool) (*DBResponse, error } for i, c := range r { - st := []string{} - err = q.DB.Select(&st, "select tag_id from stream_tags where user_id=$1", c.ID) - if err != nil { - return nil, err - } - emptyString := "" if c.StartedAt == nil { r[i].StartedAt = &emptyString } - r[i].TagIDs = st // // Needs to be removed from db when this is fully removed from API r[i].Tags = []string{"English", "CLI Tag"} r[i].ThumbNailURL = "https://static-cdn.jtvnw.net/jtv_user_pictures/3f13ab61-ec78-4fe6-8481-8682cb3b0ac2-channel_offline_image-300x300.png" } diff --git a/internal/events/types/channel_update_v2/channel_update.go b/internal/events/types/channel_update_v2/channel_update.go index 77cec03f..d0f6d3ac 100644 --- a/internal/events/types/channel_update_v2/channel_update.go +++ b/internal/events/types/channel_update_v2/channel_update.go @@ -143,5 +143,5 @@ func (e Event) GetEventSubAlias(t string) string { } func (e Event) SubscriptionVersion() string { - return "beta" + return "2" } diff --git a/internal/mock_api/endpoints/endpoints.go b/internal/mock_api/endpoints/endpoints.go index 9b479987..4e0625c2 100644 --- a/internal/mock_api/endpoints/endpoints.go +++ b/internal/mock_api/endpoints/endpoints.go @@ -74,12 +74,10 @@ func All() []mock_api.MockEndpoint { schedule.ScheduleSettings{}, search.SearchCategories{}, search.SearchChannels{}, - streams.AllTags{}, streams.FollowedStreams{}, streams.Markers{}, streams.StreamKey{}, streams.Streams{}, - streams.StreamTags{}, subscriptions.BroadcasterSubscriptions{}, subscriptions.UserSubscriptions{}, teams.ChannelTeams{}, @@ -91,3 +89,26 @@ func All() []mock_api.MockEndpoint { whispers.Whispers{}, } } + +// All these endpoints return 410 Gone +func Gone() map[string][]string { + return map[string][]string{ + "/tags/streams": { + "GET", + }, + "/streams/tags": { + "GET", + "POST", + "PUT", + }, + "/soundtrack/current_track": { + "GET", + }, + "/soundtrack/playlist": { + "GET", + }, + "/soundtrack/playlists": { + "GET", + }, + } +} diff --git a/internal/mock_api/endpoints/streams/all_tags.go b/internal/mock_api/endpoints/streams/all_tags.go deleted file mode 100644 index bbd962de..00000000 --- a/internal/mock_api/endpoints/streams/all_tags.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package streams - -import ( - "encoding/json" - "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 allTagsMethodsSupported = map[string]bool{ - http.MethodGet: true, - http.MethodPost: false, - http.MethodDelete: false, - http.MethodPatch: false, - http.MethodPut: false, -} - -var allTagsScopesByMethod = map[string][]string{ - http.MethodGet: {}, - http.MethodPost: {}, - http.MethodDelete: {}, - http.MethodPatch: {}, - http.MethodPut: {}, -} - -type AllTags struct{} - -func (e AllTags) Path() string { return "/tags/streams" } - -func (e AllTags) GetRequiredScopes(method string) []string { - return allTagsScopesByMethod[method] -} - -func (e AllTags) ValidMethod(method string) bool { - return allTagsMethodsSupported[method] -} - -func (e AllTags) ServeHTTP(w http.ResponseWriter, r *http.Request) { - db = r.Context().Value("db").(database.CLIDatabase) - - switch r.Method { - case http.MethodGet: - getAllTags(w, r) - break - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -func getAllTags(w http.ResponseWriter, r *http.Request) { - tagIDs := r.URL.Query()["tag_id"] - dbResponse := database.DBResponse{} - tags := []database.Tag{} - - if len(tagIDs) > 100 { - mock_errors.WriteBadRequest(w, "only 100 tag_ids can be provided at a time") - return - } - - if len(tagIDs) > 0 { - for _, id := range tagIDs { - t := database.Tag{ID: id} - dbr, err := db.NewQuery(r, 100).GetTags(t) - if err != nil { - mock_errors.WriteServerError(w, "error fetching tags") - return - } - - tagResponse := dbr.Data.([]database.Tag) - tags = append(tags, tagResponse...) - } - } else { - t := database.Tag{} - dbr, err := db.NewQuery(r, 100).GetTags(t) - if err != nil { - mock_errors.WriteServerError(w, "error fetching tags") - return - } - dbResponse = *dbr - tagResponse := dbr.Data.([]database.Tag) - tags = append(tags, tagResponse...) - } - - apiResponse := models.APIResponse{ - Data: convertTags(tags), - } - - if len(tagIDs) == 0 && len(tags) == dbResponse.Limit { - apiResponse.Pagination = &models.APIPagination{Cursor: dbResponse.Cursor} - } - - bytes, _ := json.Marshal(apiResponse) - w.Write(bytes) -} diff --git a/internal/mock_api/endpoints/streams/stream_tags.go b/internal/mock_api/endpoints/streams/stream_tags.go deleted file mode 100644 index d677937e..00000000 --- a/internal/mock_api/endpoints/streams/stream_tags.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package streams - -import ( - "encoding/json" - "log" - "net/http" - - "github.com/mattn/go-sqlite3" - "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 streamTagsMethodsSupported = map[string]bool{ - http.MethodGet: true, - http.MethodPost: false, - http.MethodDelete: false, - http.MethodPatch: false, - http.MethodPut: true, -} - -var streamTagsScopesByMethod = map[string][]string{ - http.MethodGet: {}, - http.MethodPost: {}, - http.MethodDelete: {}, - http.MethodPatch: {}, - http.MethodPut: {"channel:manage:broadcast"}, -} - -type StreamTags struct{} - -type PutBodyStreamTags struct { - TagIDs []string `json:"tag_ids"` -} - -func (e StreamTags) Path() string { return "/streams/tags" } - -func (e StreamTags) GetRequiredScopes(method string) []string { - return streamTagsScopesByMethod[method] -} - -func (e StreamTags) ValidMethod(method string) bool { - return streamTagsMethodsSupported[method] -} - -func (e StreamTags) ServeHTTP(w http.ResponseWriter, r *http.Request) { - db = r.Context().Value("db").(database.CLIDatabase) - - switch r.Method { - case http.MethodGet: - getStreamTags(w, r) - break - case http.MethodPut: - putStreamTags(w, r) - break - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -func getStreamTags(w http.ResponseWriter, r *http.Request) { - userCtx := r.Context().Value("auth").(authentication.UserAuthentication) - - if !userCtx.MatchesBroadcasterIDParam(r) { - mock_errors.WriteUnauthorized(w, "broadcaster_id does not match token") - return - } - - dbr, err := db.NewQuery(r, 100).GetStreamTags(userCtx.UserID) - if err != nil { - log.Print(err) - mock_errors.WriteServerError(w, "error fetching tags") - return - } - tagResponse := dbr.Data.([]database.Tag) - - apiResponse := models.APIResponse{ - Data: convertTags(tagResponse), - } - - if len(tagResponse) == dbr.Limit { - apiResponse.Pagination = &models.APIPagination{Cursor: dbr.Cursor} - } - - bytes, _ := json.Marshal(apiResponse) - w.Write(bytes) -} - -func putStreamTags(w http.ResponseWriter, r *http.Request) { - userCtx := r.Context().Value("auth").(authentication.UserAuthentication) - - if !userCtx.MatchesBroadcasterIDParam(r) { - mock_errors.WriteUnauthorized(w, "broadcaster_id does not match token") - return - } - - body := PutBodyStreamTags{} - - err := json.NewDecoder(r.Body).Decode(&body) - if err != nil { - mock_errors.WriteBadRequest(w, "error parsing body") - return - } - - err = db.NewQuery(r, 100).DeleteAllStreamTags(userCtx.UserID) - if err != nil { - log.Print(err) - mock_errors.WriteServerError(w, err.Error()) - return - } - for _, tag := range body.TagIDs { - err = db.NewQuery(r, 100).InsertStreamTag(database.StreamTag{UserID: userCtx.UserID, TagID: tag}) - if err != nil { - if database.DatabaseErrorIs(err, sqlite3.ErrConstraintForeignKey) { - mock_errors.WriteBadRequest(w, "invalid tag provided") - return - } - mock_errors.WriteServerError(w, err.Error()) - return - } - } - - w.WriteHeader(http.StatusNoContent) -} diff --git a/internal/mock_api/endpoints/streams/streams_test.go b/internal/mock_api/endpoints/streams/streams_test.go index 602465c0..a9ce8cf5 100644 --- a/internal/mock_api/endpoints/streams/streams_test.go +++ b/internal/mock_api/endpoints/streams/streams_test.go @@ -12,25 +12,6 @@ import ( "github.com/twitchdev/twitch-cli/test_setup/test_server" ) -func TestAllTags(t *testing.T) { - a := test_setup.SetupTestEnv(t) - ts := test_server.SetupTestServer(AllTags{}) - - // get - req, _ := http.NewRequest(http.MethodGet, ts.URL+AllTags{}.Path(), nil) - q := req.URL.Query() - req.URL.RawQuery = q.Encode() - resp, err := http.DefaultClient.Do(req) - a.Nil(err) - a.Equal(200, resp.StatusCode) - - q.Set("tag_id", "1234") - req.URL.RawQuery = q.Encode() - resp, err = http.DefaultClient.Do(req) - a.Nil(err) - a.Equal(200, resp.StatusCode) -} - func TestFollowedStreams(t *testing.T) { a := test_setup.SetupTestEnv(t) ts := test_server.SetupTestServer(FollowedStreams{}) @@ -99,45 +80,6 @@ func TestMarkers(t *testing.T) { a.Equal(400, resp.StatusCode) } -func TestStreamTags(t *testing.T) { - a := test_setup.SetupTestEnv(t) - ts := test_server.SetupTestServer(StreamTags{}) - - // get - req, _ := http.NewRequest(http.MethodGet, ts.URL+StreamTags{}.Path(), nil) - q := req.URL.Query() - req.URL.RawQuery = q.Encode() - resp, err := http.DefaultClient.Do(req) - a.Nil(err) - a.Equal(401, 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) - - // put - put := PutBodyStreamTags{ - TagIDs: []string{"1234"}, - } - b, _ := json.Marshal(put) - req, _ = http.NewRequest(http.MethodPut, ts.URL+StreamTags{}.Path(), bytes.NewBuffer(b)) - q.Del("broadcaster_id") - req.URL.RawQuery = q.Encode() - resp, err = http.DefaultClient.Do(req) - a.Nil(err) - a.Equal(401, resp.StatusCode) - - b, _ = json.Marshal(put) - req, _ = http.NewRequest(http.MethodPut, ts.URL+StreamTags{}.Path(), bytes.NewBuffer(b)) - q.Set("broadcaster_id", "1") - req.URL.RawQuery = q.Encode() - resp, err = http.DefaultClient.Do(req) - a.Nil(err) - a.Equal(400, resp.StatusCode) -} - func TestStreamKey(t *testing.T) { a := test_setup.SetupTestEnv(t) ts := test_server.SetupTestServer(StreamKey{}) diff --git a/internal/mock_api/generate/generate.go b/internal/mock_api/generate/generate.go index d890c159..eac2a1a9 100644 --- a/internal/mock_api/generate/generate.go +++ b/internal/mock_api/generate/generate.go @@ -463,25 +463,9 @@ func generateUsers(ctx context.Context, count int) error { tagIds = append(tagIds, tag.ID) } - // creates fake stream tags, videos, and markers - log.Printf("Creating stream tags, videos, clips, and stream markers...") + // creates fake videos, and markers + log.Printf("Creating videos, clips, and stream markers...") for _, s := range streams { - var prevTag string - for i := 0; i < int(util.RandomInt(5)); i++ { - st := database.StreamTag{ - UserID: s.Broacaster, - TagID: tagIds[util.RandomInt(int64(len(tagIds)-1))], - } - if prevTag == st.TagID { - continue - } - - err := db.NewQuery(nil, 100).InsertStreamTag(st) - if err != nil { - log.Print(err.Error()) - } - prevTag = st.TagID - } // markers // videos diff --git a/internal/mock_api/mock_server/server.go b/internal/mock_api/mock_server/server.go index b2525049..e26573bd 100644 --- a/internal/mock_api/mock_server/server.go +++ b/internal/mock_api/mock_server/server.go @@ -4,12 +4,14 @@ package mock_server import ( "context" + "encoding/json" "fmt" "log" "net" "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -19,6 +21,7 @@ import ( "github.com/twitchdev/twitch-cli/internal/mock_api/generate" "github.com/twitchdev/twitch-cli/internal/mock_auth" "github.com/twitchdev/twitch-cli/internal/mock_units" + "github.com/twitchdev/twitch-cli/internal/models" ) const MOCK_NAMESPACE = "/mock" @@ -105,6 +108,11 @@ func RegisterHandlers(m *http.ServeMux) { for _, e := range mock_auth.All() { m.Handle(AUTH_NAMESPACE+e.Path(), loggerMiddleware(e)) } + + // For removed endpoints we don't have to worry about an actual handler, since its just gonna return 410 Gone + for e := range endpoints.Gone() { + m.Handle(MOCK_NAMESPACE+e, loggerMiddleware(nil)) + } } func loggerMiddleware(next http.Handler) http.Handler { @@ -120,6 +128,39 @@ func loggerMiddleware(next http.Handler) http.Handler { return } + // Check for removed endpoints, which will return 410 Gone + for goneEndpoint, methods := range endpoints.Gone() { + if r.URL.Path == MOCK_NAMESPACE+goneEndpoint { + validRemovedEndpoint := false + for _, m := range methods { + if strings.EqualFold(m, r.Method) { + validRemovedEndpoint = true + } + } + + // In production, removed API URLs with no previously existing method return 404 + // e.g., "GET helix/tags/streams" returns 410, but "DELETE helix/tags/streams" returns 404 + if !validRemovedEndpoint { + bytes, _ := json.Marshal(models.APIResponse{ + Error: "Not Found", + Status: 404, + Message: "", + }) + w.WriteHeader(http.StatusNotFound) + w.Write(bytes) + } else { + bytes, _ := json.Marshal(models.APIResponse{ + Error: "Gone", + Status: 410, + Message: "The API is deprecated.", + }) + w.WriteHeader(410) + w.Write(bytes) + } + return + } + } + next.ServeHTTP(w, r) }) }