From d576697b0d76eea971eafb00829bbb5522b32a41 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Wed, 17 Aug 2022 11:31:18 +0200 Subject: [PATCH 1/9] feat: add user-by-connection query --- .../gql/v3/resolvers/query/query.users.go | 25 +++++++++++++++++++ internal/gql/v3/schema/users.gql | 1 + 2 files changed, 26 insertions(+) diff --git a/internal/gql/v3/resolvers/query/query.users.go b/internal/gql/v3/resolvers/query/query.users.go index d5a18eb7..493fdc57 100644 --- a/internal/gql/v3/resolvers/query/query.users.go +++ b/internal/gql/v3/resolvers/query/query.users.go @@ -2,6 +2,7 @@ package query import ( "context" + "strings" "github.com/seventv/api/internal/gql/v3/auth" "github.com/seventv/api/internal/gql/v3/gen/model" @@ -63,3 +64,27 @@ func (r *Resolver) Users(ctx context.Context, queryArg string, pageArg *int, lim return result, err } + +func (r *Resolver) UserByConnection(ctx context.Context, connectionId string) (*model.User, error) { + user, err := r.Ctx.Inst().Query.Users(ctx, bson.M{"connections.id": strings.ToLower(connectionId)}).First() + if err != nil { + return nil, err + } + + if user.ID.IsZero() || user.ID == structures.DeletedUser.ID { + return nil, errors.ErrUnknownUser() + } + + bans, err := r.Ctx.Inst().Query.Bans(ctx, query.BanQueryOptions{ // remove emotes made by users who own nothing and are happy + Filter: bson.M{"effects": bson.M{"$bitsAnySet": structures.BanEffectMemoryHole}}, + }) + if err != nil { + return nil, err + } + + if _, ok := bans.MemoryHole[user.ID]; ok { + return nil, errors.ErrUnknownUser() + } + + return helpers.UserStructureToModel(user, r.Ctx.Config().CdnURL), nil +} diff --git a/internal/gql/v3/schema/users.gql b/internal/gql/v3/schema/users.gql index 7c6e0250..b33e79cc 100644 --- a/internal/gql/v3/schema/users.gql +++ b/internal/gql/v3/schema/users.gql @@ -1,6 +1,7 @@ extend type Query { actor: User user(id: ObjectID!): User! + userByConnection(id: String!): User! users(query: String!, page: Int, limit: Int): [UserPartial!]! } From 6709779c91143a74ee52e68149459265aed3e920 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Wed, 17 Aug 2022 13:54:36 +0200 Subject: [PATCH 2/9] fix: use loaded for connection-id query --- internal/gql/v3/resolvers/query/query.users.go | 2 +- internal/loaders/loaders.go | 12 ++++++++++-- internal/loaders/user.loader.go | 7 +++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/gql/v3/resolvers/query/query.users.go b/internal/gql/v3/resolvers/query/query.users.go index 493fdc57..adbef169 100644 --- a/internal/gql/v3/resolvers/query/query.users.go +++ b/internal/gql/v3/resolvers/query/query.users.go @@ -66,7 +66,7 @@ func (r *Resolver) Users(ctx context.Context, queryArg string, pageArg *int, lim } func (r *Resolver) UserByConnection(ctx context.Context, connectionId string) (*model.User, error) { - user, err := r.Ctx.Inst().Query.Users(ctx, bson.M{"connections.id": strings.ToLower(connectionId)}).First() + user, err := r.Ctx.Inst().Loaders.UserByConnectionID().Load(strings.ToLower(connectionId)) if err != nil { return nil, err } diff --git a/internal/loaders/loaders.go b/internal/loaders/loaders.go index fd5f3159..511bc14b 100644 --- a/internal/loaders/loaders.go +++ b/internal/loaders/loaders.go @@ -17,6 +17,7 @@ const LoadersKey = utils.Key("dataloaders") type Instance interface { UserByID() UserLoaderByID UserByUsername() UserLoaderByUsername + UserByConnectionID() UserByConnectionID EmoteByID() EmoteLoaderByID EmoteByOwnerID() BatchEmoteLoaderByID EmoteSetByID() EmoteSetLoaderByID @@ -25,8 +26,9 @@ type Instance interface { type inst struct { // User Loaders - userByID UserLoaderByID - userByUsername UserLoaderByUsername + userByID UserLoaderByID + userByUsername UserLoaderByUsername + userByConnectionID UserByConnectionID // Emote Loaders emoteByID EmoteLoaderByID @@ -52,6 +54,7 @@ func New(ctx context.Context, mngo mongo.Instance, rdis redis.Instance, quer *qu l.userByID = userLoader[primitive.ObjectID](ctx, l, "_id") l.userByUsername = userLoader[string](ctx, l, "username") + l.userByConnectionID = userLoader[string](ctx, l, "connection.id") l.emoteByID = emoteLoader(ctx, l, "versions.id") l.emoteByOwnerID = batchEmoteLoader(ctx, l, "owner_id") l.emoteSetByID = emoteSetByID(ctx, l) @@ -68,6 +71,10 @@ func (l inst) UserByUsername() UserLoaderByUsername { return l.userByUsername } +func (l inst) UserByConnectionID() UserByConnectionID { + return l.userByConnectionID +} + func (l inst) EmoteByID() EmoteLoaderByID { return l.emoteByID } @@ -88,6 +95,7 @@ func (l *inst) EmoteByOwnerID() BatchEmoteLoaderByID { type ( UserLoaderByID = *dataloader.DataLoader[primitive.ObjectID, structures.User] UserLoaderByUsername = *dataloader.DataLoader[string, structures.User] + UserByConnectionID = *dataloader.DataLoader[string, structures.User] EmoteLoaderByID = *dataloader.DataLoader[primitive.ObjectID, structures.Emote] BatchEmoteLoaderByID = *dataloader.DataLoader[primitive.ObjectID, []structures.Emote] EmoteSetLoaderByID = *dataloader.DataLoader[primitive.ObjectID, structures.EmoteSet] diff --git a/internal/loaders/user.loader.go b/internal/loaders/user.loader.go index a0794e69..1e8b8514 100644 --- a/internal/loaders/user.loader.go +++ b/internal/loaders/user.loader.go @@ -42,6 +42,13 @@ func userLoader[T comparable](ctx context.Context, x inst, keyName string) *data case "username": v, _ := utils.ToAny(u.Username).(T) m[v] = u + case "connection.id": + for _, c := range u.Connections { + if c.ID != "" { + v, _ := utils.ToAny(c.ID).(T) + m[v] = u + } + } default: v, _ := utils.ToAny(u.ID).(T) m[v] = u From 60e9dc2fb1cdc21b47043f5bd246d5f7d7ce8d8f Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Thu, 18 Aug 2022 10:32:25 +0200 Subject: [PATCH 3/9] refactor: require users to specify a connection platform --- .../gql/v3/resolvers/query/query.users.go | 9 ++- internal/gql/v3/schema/users.gql | 2 +- internal/loaders/loaders.go | 15 +++-- internal/loaders/user.loader.go | 60 ++++++++++++++++--- 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/internal/gql/v3/resolvers/query/query.users.go b/internal/gql/v3/resolvers/query/query.users.go index adbef169..2c6487ea 100644 --- a/internal/gql/v3/resolvers/query/query.users.go +++ b/internal/gql/v3/resolvers/query/query.users.go @@ -2,7 +2,6 @@ package query import ( "context" - "strings" "github.com/seventv/api/internal/gql/v3/auth" "github.com/seventv/api/internal/gql/v3/gen/model" @@ -65,8 +64,12 @@ func (r *Resolver) Users(ctx context.Context, queryArg string, pageArg *int, lim return result, err } -func (r *Resolver) UserByConnection(ctx context.Context, connectionId string) (*model.User, error) { - user, err := r.Ctx.Inst().Loaders.UserByConnectionID().Load(strings.ToLower(connectionId)) +func (r *Resolver) UserByConnection(ctx context.Context, connectionPlatform model.ConnectionPlatform, id string) (*model.User, error) { + l, ok := r.Ctx.Inst().Loaders.UserByConnectionID(structures.UserConnectionPlatform(connectionPlatform)) + if !ok { + return nil, errors.ErrInvalidRequest().SetDetail("Unknown connection platform") + } + user, err := l.Load(id) if err != nil { return nil, err } diff --git a/internal/gql/v3/schema/users.gql b/internal/gql/v3/schema/users.gql index b33e79cc..d1045699 100644 --- a/internal/gql/v3/schema/users.gql +++ b/internal/gql/v3/schema/users.gql @@ -1,7 +1,7 @@ extend type Query { actor: User user(id: ObjectID!): User! - userByConnection(id: String!): User! + userByConnection(platform: ConnectionPlatform!, id: String!): User! users(query: String!, page: Int, limit: Int): [UserPartial!]! } diff --git a/internal/loaders/loaders.go b/internal/loaders/loaders.go index 511bc14b..fb2d3be8 100644 --- a/internal/loaders/loaders.go +++ b/internal/loaders/loaders.go @@ -17,7 +17,7 @@ const LoadersKey = utils.Key("dataloaders") type Instance interface { UserByID() UserLoaderByID UserByUsername() UserLoaderByUsername - UserByConnectionID() UserByConnectionID + UserByConnectionID(structures.UserConnectionPlatform) (UserByConnectionID, bool) EmoteByID() EmoteLoaderByID EmoteByOwnerID() BatchEmoteLoaderByID EmoteSetByID() EmoteSetLoaderByID @@ -28,7 +28,7 @@ type inst struct { // User Loaders userByID UserLoaderByID userByUsername UserLoaderByUsername - userByConnectionID UserByConnectionID + userByConnectionID map[structures.UserConnectionPlatform]UserByConnectionID // Emote Loaders emoteByID EmoteLoaderByID @@ -54,7 +54,11 @@ func New(ctx context.Context, mngo mongo.Instance, rdis redis.Instance, quer *qu l.userByID = userLoader[primitive.ObjectID](ctx, l, "_id") l.userByUsername = userLoader[string](ctx, l, "username") - l.userByConnectionID = userLoader[string](ctx, l, "connection.id") + l.userByConnectionID = map[structures.UserConnectionPlatform]*dataloader.DataLoader[string, structures.User]{ + structures.UserConnectionPlatformTwitch: userByConnectionLoader(ctx, l, structures.UserConnectionPlatformTwitch), + structures.UserConnectionPlatformYouTube: userByConnectionLoader(ctx, l, structures.UserConnectionPlatformYouTube), + structures.UserConnectionPlatformDiscord: userByConnectionLoader(ctx, l, structures.UserConnectionPlatformDiscord), + } l.emoteByID = emoteLoader(ctx, l, "versions.id") l.emoteByOwnerID = batchEmoteLoader(ctx, l, "owner_id") l.emoteSetByID = emoteSetByID(ctx, l) @@ -71,8 +75,9 @@ func (l inst) UserByUsername() UserLoaderByUsername { return l.userByUsername } -func (l inst) UserByConnectionID() UserByConnectionID { - return l.userByConnectionID +func (l inst) UserByConnectionID(platform structures.UserConnectionPlatform) (UserByConnectionID, bool) { + loader, ok := l.userByConnectionID[platform] + return loader, ok } func (l inst) EmoteByID() EmoteLoaderByID { diff --git a/internal/loaders/user.loader.go b/internal/loaders/user.loader.go index 1e8b8514..19789489 100644 --- a/internal/loaders/user.loader.go +++ b/internal/loaders/user.loader.go @@ -42,13 +42,6 @@ func userLoader[T comparable](ctx context.Context, x inst, keyName string) *data case "username": v, _ := utils.ToAny(u.Username).(T) m[v] = u - case "connection.id": - for _, c := range u.Connections { - if c.ID != "" { - v, _ := utils.ToAny(c.ID).(T) - m[v] = u - } - } default: v, _ := utils.ToAny(u.ID).(T) m[v] = u @@ -72,3 +65,56 @@ func userLoader[T comparable](ctx context.Context, x inst, keyName string) *data }, }) } + +func userByConnectionLoader(ctx context.Context, x inst, platform structures.UserConnectionPlatform) *dataloader.DataLoader[string, structures.User] { + return dataloader.New(dataloader.Config[string, structures.User]{ + Wait: time.Millisecond * 25, + Fetch: func(keys []string) ([]structures.User, []error) { + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + + items := make([]structures.User, len(keys)) + errs := make([]error, len(keys)) + + // Initially fill the response with deleted emotes in case some cannot be found + for i := 0; i < len(items); i++ { + items[i] = structures.DeletedUser + } + + // Fetch users + result := x.query.Users(ctx, bson.M{ + "connections.id": bson.M{"$in": keys}, + "connections.platform": platform, + }) + if result.Empty() { + return items, errs + } + users, err := result.Items() + + if err == nil { + m := make(map[string]structures.User) + for _, u := range users { + for _, c := range u.Connections { + if c.Platform == platform { + m[c.ID] = u + } + } + } + + for i, v := range keys { + if x, ok := m[v]; ok { + items[i] = x + } else { + errs[i] = errors.ErrUnknownUser() + } + } + } else { + for i := range errs { + errs[i] = err + } + } + + return items, errs + }, + }) +} From fe50f7ab90b9b6c91b89227136812aa495c77b12 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Thu, 18 Aug 2022 10:43:44 +0200 Subject: [PATCH 4/9] fix: ci --- internal/gql/v3/resolvers/query/query.users.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/gql/v3/resolvers/query/query.users.go b/internal/gql/v3/resolvers/query/query.users.go index 2c6487ea..63344f9f 100644 --- a/internal/gql/v3/resolvers/query/query.users.go +++ b/internal/gql/v3/resolvers/query/query.users.go @@ -69,6 +69,7 @@ func (r *Resolver) UserByConnection(ctx context.Context, connectionPlatform mode if !ok { return nil, errors.ErrInvalidRequest().SetDetail("Unknown connection platform") } + user, err := l.Load(id) if err != nil { return nil, err From ec9382ee870836b2a867c62ae56002f1295cf481 Mon Sep 17 00:00:00 2001 From: Anatole Date: Sun, 11 Sep 2022 05:28:12 +0200 Subject: [PATCH 5/9] Get User Connection --- data/model/emote-set.model.go | 10 +- data/model/model.go | 3 + data/model/user.model.go | 114 ++++++++++++++---- .../gql/v3/resolvers/query/query.users.go | 20 +-- internal/gql/v3/schema/users.gql | 9 +- internal/loaders/loaders.go | 10 +- internal/loaders/user.loader.go | 4 +- .../v3/routes/users/users.by-connection.go | 92 ++++++++++++++ internal/rest/v3/routes/users/users.root.go | 1 + 9 files changed, 212 insertions(+), 51 deletions(-) create mode 100644 internal/rest/v3/routes/users/users.by-connection.go diff --git a/data/model/emote-set.model.go b/data/model/emote-set.model.go index 9ede4b3e..fa74f071 100644 --- a/data/model/emote-set.model.go +++ b/data/model/emote-set.model.go @@ -16,7 +16,7 @@ type EmoteSetModel struct { Emotes []ActiveEmoteModel `json:"emotes"` Capacity int32 `json:"capacity"` ParentID *primitive.ObjectID `json:"parent_id,omitempty"` - Owner *UserModel `json:"owner" extensions:"x-nullable"` + Owner *UserPartialModel `json:"owner" extensions:"x-nullable"` } type ActiveEmoteModel struct { @@ -42,13 +42,17 @@ func (x *modelizer) EmoteSet(v structures.EmoteSet) EmoteSetModel { emotes[i] = x.ActiveEmote(e) } - var owner *UserModel + var owner *UserPartialModel if v.Owner != nil { - u := x.User(*v.Owner) + u := x.User(*v.Owner).ToPartial() owner = &u } + if v.Tags == nil { + v.Tags = []string{} + } + return EmoteSetModel{ ID: v.ID, Name: v.Name, diff --git a/data/model/model.go b/data/model/model.go index e71027b9..0bf4b523 100644 --- a/data/model/model.go +++ b/data/model/model.go @@ -4,10 +4,13 @@ import ( "fmt" "github.com/seventv/common/structures/v3" + "go.mongodb.org/mongo-driver/bson" ) type Modelizer interface { User(v structures.User) UserModel + UserEditor(v structures.UserEditor) UserEditorModel + UserConnection(v structures.UserConnection[bson.Raw]) UserConnectionModel EmoteSet(v structures.EmoteSet) EmoteSetModel } diff --git a/data/model/user.model.go b/data/model/user.model.go index 14ca9666..82f1b2fa 100644 --- a/data/model/user.model.go +++ b/data/model/user.model.go @@ -1,22 +1,37 @@ package model import ( + "fmt" + "regexp" + "github.com/seventv/common/structures/v3" "github.com/seventv/common/utils" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" ) +var twitchPictureSizeRegExp = regexp.MustCompile("([0-9]{2,3})x([0-9]{2,3})") + type UserModel struct { - ID primitive.ObjectID `json:"id"` - UserType UserTypeModel `json:"type,omitempty" enums:",BOT,SYSTEM"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - RoleIDs []primitive.ObjectID `json:"roles"` - Connections []UserConnectionModel `json:"connections"` + ID primitive.ObjectID `json:"id"` + UserType UserTypeModel `json:"type,omitempty" enums:",BOT,SYSTEM"` + Username string `json:"username"` + ProfilePictureURL string `json:"profile_picture_url,omitempty"` + DisplayName string `json:"display_name"` + Biography string `json:"biography,omitempty" extensions:"x-omitempty"` + Editors []UserEditorModel `json:"editors,omitempty"` + RoleIDs []primitive.ObjectID `json:"roles"` + Connections []UserConnectionModel `json:"connections"` +} + +type UserPartialModel struct { + ID primitive.ObjectID `json:"id"` + UserType UserTypeModel `json:"type,omitempty" enums:",BOT,SYSTEM"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + RoleIDs []primitive.ObjectID `json:"roles"` } -// swagger:type string type UserTypeModel string var ( @@ -31,23 +46,73 @@ func (x *modelizer) User(v structures.User) UserModel { connections[i] = x.UserConnection(c) } + editors := make([]UserEditorModel, len(v.Editors)) + for i, e := range v.Editors { + editors[i] = x.UserEditor(e) + } + + profilePictureURL := "" + if v.AvatarID != "" { + profilePictureURL = fmt.Sprintf("//%s/pp/%s/%s", x.cdnURL, v.ID.Hex(), v.AvatarID) + } else { + for _, con := range v.Connections { + if con.Platform == structures.UserConnectionPlatformTwitch { + if con, err := structures.ConvertUserConnection[structures.UserConnectionDataTwitch](con); err == nil { + profilePictureURL = twitchPictureSizeRegExp.ReplaceAllString(con.Data.ProfileImageURL[6:], "70x70") + } + } + } + } + return UserModel{ + ID: v.ID, + UserType: UserTypeModel(v.UserType), + Username: v.Username, + ProfilePictureURL: profilePictureURL, + Biography: v.Biography, + DisplayName: utils.Ternary(v.DisplayName != "", v.DisplayName, v.Username), + Editors: editors, + RoleIDs: v.RoleIDs, + Connections: connections, + } +} + +func (um UserModel) ToPartial() UserPartialModel { + return UserPartialModel{ + ID: um.ID, + UserType: um.UserType, + Username: um.Username, + DisplayName: um.DisplayName, + RoleIDs: um.RoleIDs, + } +} + +type UserEditorModel struct { + ID primitive.ObjectID `json:"id"` + Permissions int32 `json:"permissions"` + Visible bool `json:"visible"` + AddedAt int64 `json:"added_at"` +} + +func (x *modelizer) UserEditor(v structures.UserEditor) UserEditorModel { + return UserEditorModel{ ID: v.ID, - UserType: UserTypeModel(v.UserType), - Username: v.Username, - DisplayName: utils.Ternary(v.DisplayName != "", v.DisplayName, v.Username), - RoleIDs: v.RoleIDs, - Connections: connections, + Permissions: int32(v.Permissions), + Visible: v.Visible, + AddedAt: v.AddedAt.UnixMilli(), } } type UserConnectionModel struct { - ID string `json:"id"` - Platform UserConnectionPlatformModel `json:"platform" enums:"TWITCH,YOUTUBE,DISCORD"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - LinkedAt int64 `json:"linked_at"` - EmoteSet *EmoteSetModel `json:"emote_set,omitempty" extensions:"x-omitempty"` + ID string `json:"id"` + Platform UserConnectionPlatformModel `json:"platform" enums:"TWITCH,YOUTUBE,DISCORD"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + LinkedAt int64 `json:"linked_at"` + EmoteCapacity int32 `json:"emote_capacity"` + EmoteSet *EmoteSetModel `json:"emote_set,omitempty" extensions:"x-omitempty"` + + User *UserModel `json:"user,omitempty" extensions:"x-omitempty"` } type UserConnectionPlatformModel string @@ -90,11 +155,12 @@ func (x *modelizer) UserConnection(v structures.UserConnection[bson.Raw]) UserCo } return UserConnectionModel{ - ID: v.ID, - Platform: UserConnectionPlatformModel(v.Platform), - Username: username, - DisplayName: displayName, - LinkedAt: v.LinkedAt.UnixMilli(), - EmoteSet: set, + ID: v.ID, + Platform: UserConnectionPlatformModel(v.Platform), + Username: username, + DisplayName: displayName, + LinkedAt: v.LinkedAt.UnixMilli(), + EmoteCapacity: int32(v.EmoteSlots), + EmoteSet: set, } } diff --git a/internal/gql/v3/resolvers/query/query.users.go b/internal/gql/v3/resolvers/query/query.users.go index 6ea721be..cd6159cc 100644 --- a/internal/gql/v3/resolvers/query/query.users.go +++ b/internal/gql/v3/resolvers/query/query.users.go @@ -84,13 +84,8 @@ func (r *Resolver) Users(ctx context.Context, queryArg string, pageArg *int, lim return result, err } -func (r *Resolver) UserByConnection(ctx context.Context, connectionPlatform model.ConnectionPlatform, id string) (*model.User, error) { - l, ok := r.Ctx.Inst().Loaders.UserByConnectionID(structures.UserConnectionPlatform(connectionPlatform)) - if !ok { - return nil, errors.ErrInvalidRequest().SetDetail("Unknown connection platform") - } - - user, err := l.Load(id) +func (r *Resolver) UserByConnection(ctx context.Context, platform model.ConnectionPlatform, id string) (*model.User, error) { + user, err := r.Ctx.Inst().Loaders.UserByConnectionID(structures.UserConnectionPlatform(platform)).Load(id) if err != nil { return nil, err } @@ -99,16 +94,5 @@ func (r *Resolver) UserByConnection(ctx context.Context, connectionPlatform mode return nil, errors.ErrUnknownUser() } - bans, err := r.Ctx.Inst().Query.Bans(ctx, query.BanQueryOptions{ // remove emotes made by users who own nothing and are happy - Filter: bson.M{"effects": bson.M{"$bitsAnySet": structures.BanEffectMemoryHole}}, - }) - if err != nil { - return nil, err - } - - if _, ok := bans.MemoryHole[user.ID]; ok { - return nil, errors.ErrUnknownUser() - } - return helpers.UserStructureToModel(user, r.Ctx.Config().CdnURL), nil } diff --git a/internal/gql/v3/schema/users.gql b/internal/gql/v3/schema/users.gql index 686d5fbc..4fe5e0bd 100644 --- a/internal/gql/v3/schema/users.gql +++ b/internal/gql/v3/schema/users.gql @@ -1,9 +1,15 @@ extend type Query { actor: User + + # Fetch a single user by ID user(id: ObjectID!): User! - usersByID(list: [ObjectID!]!): [UserPartial!]! + # Fetch a single user by connection id userByConnection(platform: ConnectionPlatform!, id: String!): User! + + # Search users users(query: String!, page: Int, limit: Int): [UserPartial!]! + # Fetch many users by ID + usersByID(list: [ObjectID!]!): [UserPartial!]! } extend type Subscription { @@ -102,6 +108,7 @@ type UserCosmetic { enum ConnectionPlatform { TWITCH YOUTUBE + DISCORD } input UserConnectionUpdate { diff --git a/internal/loaders/loaders.go b/internal/loaders/loaders.go index fb2d3be8..72b5e4e4 100644 --- a/internal/loaders/loaders.go +++ b/internal/loaders/loaders.go @@ -17,7 +17,7 @@ const LoadersKey = utils.Key("dataloaders") type Instance interface { UserByID() UserLoaderByID UserByUsername() UserLoaderByUsername - UserByConnectionID(structures.UserConnectionPlatform) (UserByConnectionID, bool) + UserByConnectionID(structures.UserConnectionPlatform) UserByConnectionID EmoteByID() EmoteLoaderByID EmoteByOwnerID() BatchEmoteLoaderByID EmoteSetByID() EmoteSetLoaderByID @@ -75,9 +75,13 @@ func (l inst) UserByUsername() UserLoaderByUsername { return l.userByUsername } -func (l inst) UserByConnectionID(platform structures.UserConnectionPlatform) (UserByConnectionID, bool) { +func (l inst) UserByConnectionID(platform structures.UserConnectionPlatform) UserByConnectionID { loader, ok := l.userByConnectionID[platform] - return loader, ok + if !ok { + return l.userByConnectionID[structures.UserConnectionPlatformTwitch] + } + + return loader } func (l inst) EmoteByID() EmoteLoaderByID { diff --git a/internal/loaders/user.loader.go b/internal/loaders/user.loader.go index 19789489..10bb1543 100644 --- a/internal/loaders/user.loader.go +++ b/internal/loaders/user.loader.go @@ -21,7 +21,7 @@ func userLoader[T comparable](ctx context.Context, x inst, keyName string) *data items := make([]structures.User, len(keys)) errs := make([]error, len(keys)) - // Initially fill the response with deleted emotes in case some cannot be found + // Initially fill the response with deleted users in case some cannot be found for i := 0; i < len(items); i++ { items[i] = structures.DeletedUser } @@ -76,7 +76,7 @@ func userByConnectionLoader(ctx context.Context, x inst, platform structures.Use items := make([]structures.User, len(keys)) errs := make([]error, len(keys)) - // Initially fill the response with deleted emotes in case some cannot be found + // Initially fill the response with deleted users in case some cannot be found for i := 0; i < len(items); i++ { items[i] = structures.DeletedUser } diff --git a/internal/rest/v3/routes/users/users.by-connection.go b/internal/rest/v3/routes/users/users.by-connection.go new file mode 100644 index 00000000..396f424c --- /dev/null +++ b/internal/rest/v3/routes/users/users.by-connection.go @@ -0,0 +1,92 @@ +package users + +import ( + "strings" + + "github.com/seventv/api/data/model" + "github.com/seventv/api/internal/global" + "github.com/seventv/api/internal/rest/rest" + "github.com/seventv/common/errors" + "github.com/seventv/common/structures/v3" +) + +type userConnectionRoute struct { + Ctx global.Context +} + +func newUserConnection(gctx global.Context) rest.Route { + return &userConnectionRoute{gctx} +} + +func (r *userConnectionRoute) Config() rest.RouteConfig { + return rest.RouteConfig{ + URI: "/{connection.platform}/{connection.id}", + Method: rest.GET, + Children: []rest.Route{}, + } +} + +// @Summary Get User By Connection ID +// @Description Get user ID +// @Param {connection.id} path string true "ID of the user" +// @Tags users +// @Produce json +// @Success 200 {object} model.UserModel +// @Router /users/{connection.platform}/{connection.id} [get] +func (r *userConnectionRoute) Handler(ctx *rest.Ctx) rest.APIError { + // Retrieve the platform desired + platformArg, ok := ctx.UserValue("connection.platform").String() + if !ok { + return errors.ErrInvalidRequest().SetDetail("connection.platform must be specified") + } + + // Filter out unsupported platforms + platform := structures.UserConnectionPlatform(strings.ToUpper(platformArg)) + switch platform { + case structures.UserConnectionPlatformTwitch: + case structures.UserConnectionPlatformYouTube: + case structures.UserConnectionPlatformDiscord: + default: + return errors.ErrUnknownUserConnection().SetDetail("'%s' is not supported", platform) + } + + // Retrieve specified connection id + connID, ok := ctx.UserValue("connection.id").String() + if !ok { + return errors.ErrInvalidRequest().SetDetail("connection.id must be specified") + } + + // Fetch user data + user, err := r.Ctx.Inst().Loaders.UserByConnectionID(platform).Load(connID) + if err != nil { + return errors.From(err) + } + + uc, i := user.Connections.Get(connID) + if i == -1 { + return errors.ErrUnknownUserConnection() + } + + // Fetch Emote Set + var emoteSetModel model.EmoteSetModel + + if !uc.EmoteSetID.IsZero() { + set, err := r.Ctx.Inst().Loaders.EmoteSetByID().Load(uc.EmoteSetID) + if err != nil && !errors.Compare(err, errors.ErrUnknownEmoteSet()) { + return errors.From(err) + } + + emoteSetModel = r.Ctx.Inst().Modelizer.EmoteSet(set) + } + + // Construct the final response structure + userModel := r.Ctx.Inst().Modelizer.User(user) + userConnModel := r.Ctx.Inst().Modelizer.UserConnection(uc) + userConnModel.User = &userModel + + if !emoteSetModel.ID.IsZero() { + userConnModel.EmoteSet = &emoteSetModel + } + + return ctx.JSON(rest.OK, userConnModel) +} diff --git a/internal/rest/v3/routes/users/users.root.go b/internal/rest/v3/routes/users/users.root.go index f86b5b0d..e3ecbb5d 100644 --- a/internal/rest/v3/routes/users/users.root.go +++ b/internal/rest/v3/routes/users/users.root.go @@ -19,6 +19,7 @@ func (r *Route) Config() rest.RouteConfig { Method: rest.GET, Children: []rest.Route{ newUser(r.Ctx), + newUserConnection(r.Ctx), newPictureUpload(r.Ctx), }, Middleware: []rest.Middleware{}, From 0e0ade69dc91fb21e07860ff78af576eb2b4c08d Mon Sep 17 00:00:00 2001 From: Anatole Date: Mon, 12 Sep 2022 01:22:23 +0200 Subject: [PATCH 6/9] Get Emote --- data/model/emote-set.model.go | 11 ++- data/model/emote.model.go | 75 +++++++++++++++---- data/model/model.go | 50 ++++++++----- .../v2/routes/cosmetics/cosmetics.avatars.go | 2 +- .../rest/v3/routes/emotes/emotes.by-id.go | 48 ++++++++++++ internal/rest/v3/routes/emotes/emotes.go | 1 + .../v3/routes/users/users.by-connection.go | 8 +- 7 files changed, 156 insertions(+), 39 deletions(-) create mode 100644 internal/rest/v3/routes/emotes/emotes.by-id.go diff --git a/data/model/emote-set.model.go b/data/model/emote-set.model.go index fa74f071..e2f380f6 100644 --- a/data/model/emote-set.model.go +++ b/data/model/emote-set.model.go @@ -25,6 +25,7 @@ type ActiveEmoteModel struct { Flags ActiveEmoteFlagModel `json:"flags"` Timestamp time.Time ActorID primitive.ObjectID `json:"actor_id,omitempty"` + Data *EmotePartialModel `json:"data,omitempty" extensions:"x-nullable"` } type ActiveEmoteFlagModel int32 @@ -50,7 +51,7 @@ func (x *modelizer) EmoteSet(v structures.EmoteSet) EmoteSetModel { } if v.Tags == nil { - v.Tags = []string{} + v.Tags = make([]string, 0) } return EmoteSetModel{ @@ -67,11 +68,19 @@ func (x *modelizer) EmoteSet(v structures.EmoteSet) EmoteSetModel { } func (x *modelizer) ActiveEmote(v structures.ActiveEmote) ActiveEmoteModel { + var data *EmotePartialModel + + if v.Emote != nil { + e := x.Emote(*v.Emote).ToPartial() + data = &e + } + return ActiveEmoteModel{ ID: v.ID, Name: v.Name, Flags: ActiveEmoteFlagModel(v.Flags), Timestamp: v.Timestamp, ActorID: v.ActorID, + Data: data, } } diff --git a/data/model/emote.model.go b/data/model/emote.model.go index 1bf29547..5c50f350 100644 --- a/data/model/emote.model.go +++ b/data/model/emote.model.go @@ -1,6 +1,7 @@ package model import ( + "fmt" "sort" "github.com/seventv/common/structures/v3" @@ -15,11 +16,23 @@ type EmoteModel struct { Lifecycle EmoteLifecycleModel `json:"lifecycle"` Listed bool `json:"listed"` Animated bool `json:"animated"` - Owner *UserModel `json:"owner,omitempty" extensions:"x-omitempty"` - Images []Image `json:"images"` + Owner *UserPartialModel `json:"owner,omitempty" extensions:"x-omitempty"` + Host ImageHost `json:"host"` Versions []EmoteVersionModel `json:"versions"` } +type EmotePartialModel struct { + ID primitive.ObjectID `json:"id"` + Name string `json:"name"` + Flags EmoteFlagsModel `json:"flags"` + Tags []string `json:"tags"` + Lifecycle EmoteLifecycleModel `json:"lifecycle"` + Listed bool `json:"listed"` + Animated bool `json:"animated"` + Owner *UserPartialModel `json:"owner,omitempty" extensions:"x-omitempty"` + Host ImageHost `json:"host"` +} + type EmoteVersionModel struct { ID primitive.ObjectID `json:"id"` Name string `json:"name"` @@ -27,7 +40,7 @@ type EmoteVersionModel struct { Lifecycle EmoteLifecycleModel `json:"lifecycle"` Listed bool `json:"listed"` Animated bool `json:"animated"` - Images []Image `json:"images"` + Host ImageHost `json:"host"` } type EmoteLifecycleModel int32 @@ -57,7 +70,7 @@ const ( ) func (x *modelizer) Emote(v structures.Emote) EmoteModel { - images := make([]Image, 0) + images := make([]ImageFile, 0) lifecycle := EmoteLifecycleDisabled listed := false animated := false @@ -65,12 +78,12 @@ func (x *modelizer) Emote(v structures.Emote) EmoteModel { versions := make([]EmoteVersionModel, len(v.Versions)) for i, ver := range v.Versions { - files := ver.GetFiles("", true) + files := append(ver.GetFiles("image/avif", true), ver.GetFiles("image/webp", true)...) sort.Slice(files, func(i, j int) bool { - return files[i].Width > files[j].Width + return files[i].Width < files[j].Width }) - vimages := make([]Image, len(files)) + vimages := make([]ImageFile, len(files)) for i, fi := range files { vimages[i] = x.Image(fi) @@ -86,13 +99,21 @@ func (x *modelizer) Emote(v structures.Emote) EmoteModel { versions[i] = x.EmoteVersion(ver) } - var owner *UserModel + var owner *UserPartialModel if v.Owner != nil { - u := x.User(*v.Owner) + u := x.User(*v.Owner).ToPartial() owner = &u } + sort.Slice(versions, func(i, j int) bool { + return versions[i].ID == v.ID || versions[j].ID.Timestamp().After(versions[i].ID.Timestamp()) + }) + + if v.Tags == nil { + v.Tags = make([]string, 0) + } + return EmoteModel{ ID: v.ID, Name: v.Name, @@ -102,18 +123,39 @@ func (x *modelizer) Emote(v structures.Emote) EmoteModel { Listed: listed, Animated: animated, Owner: owner, - Images: images, - Versions: versions, + Host: ImageHost{ + URL: fmt.Sprintf("//%s/emote/%s", x.cdnURL, v.ID.Hex()), + Files: images, + }, + Versions: versions, + } +} + +func (em EmoteModel) ToPartial() EmotePartialModel { + return EmotePartialModel{ + ID: em.ID, + Name: em.Name, + Flags: em.Flags, + Tags: em.Tags, + Lifecycle: em.Lifecycle, + Listed: em.Listed, + Animated: em.Animated, + Owner: em.Owner, + Host: em.Host, } } func (x *modelizer) EmoteVersion(v structures.EmoteVersion) EmoteVersionModel { - var images []Image + var files []ImageFile - for _, fi := range v.GetFiles("", true) { - images = append(images, x.Image(fi)) + for _, fi := range append(v.GetFiles("image/avif", true), v.GetFiles("image/webp", true)...) { + files = append(files, x.Image(fi)) } + sort.Slice(files, func(i, j int) bool { + return files[i].Width < files[j].Width + }) + return EmoteVersionModel{ ID: v.ID, Name: v.Name, @@ -121,6 +163,9 @@ func (x *modelizer) EmoteVersion(v structures.EmoteVersion) EmoteVersionModel { Lifecycle: EmoteLifecycleModel(v.State.Lifecycle), Listed: v.State.Listed, Animated: v.Animated, - Images: images, + Host: ImageHost{ + URL: fmt.Sprintf("//%s/emote/%s", x.cdnURL, v.ID.Hex()), + Files: files, + }, } } diff --git a/data/model/model.go b/data/model/model.go index 0bf4b523..66661649 100644 --- a/data/model/model.go +++ b/data/model/model.go @@ -1,13 +1,14 @@ package model import ( - "fmt" + "strings" "github.com/seventv/common/structures/v3" "go.mongodb.org/mongo-driver/bson" ) type Modelizer interface { + Emote(v structures.Emote) EmoteModel User(v structures.User) UserModel UserEditor(v structures.UserEditor) UserEditorModel UserConnection(v structures.UserConnection[bson.Raw]) UserConnectionModel @@ -31,24 +32,37 @@ type ModelInstanceOptions struct { Website string } -type Image struct { - Name string `json:"name"` - Width int32 `json:"width"` - Height int32 `json:"height"` - FrameCount int32 `json:"frame_count"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - URL string `json:"url"` +type ImageHost struct { + URL string `json:"url"` + Files []ImageFile `json:"files"` } -func (x *modelizer) Image(v structures.EmoteFile) Image { - return Image{ - Name: v.Name, - Width: v.Width, - Height: v.Height, - FrameCount: v.FrameCount, - Size: v.Size, - ContentType: v.ContentType, - URL: fmt.Sprintf("//%s/%s", x.cdnURL, v.Key), +type ImageFile struct { + Name string `json:"name"` + Width int32 `json:"width"` + Height int32 `json:"height"` + FrameCount int32 `json:"frame_count"` + Size int64 `json:"size"` + Format ImageFormat `json:"format"` +} + +type ImageFormat string + +const ( + ImageFormatAVIF ImageFormat = "AVIF" + ImageFormatWEBP ImageFormat = "WEBP" +) + +func (x *modelizer) Image(v structures.EmoteFile) ImageFile { + format := strings.Split(v.ContentType, "/")[1] + format = strings.ToUpper(format) + + return ImageFile{ + Name: v.Name, + Format: ImageFormat(format), + Width: v.Width, + Height: v.Height, + FrameCount: v.FrameCount, + Size: v.Size, } } diff --git a/internal/rest/v2/routes/cosmetics/cosmetics.avatars.go b/internal/rest/v2/routes/cosmetics/cosmetics.avatars.go index 5da98699..ca7f10f2 100644 --- a/internal/rest/v2/routes/cosmetics/cosmetics.avatars.go +++ b/internal/rest/v2/routes/cosmetics/cosmetics.avatars.go @@ -174,7 +174,7 @@ func (r *avatars) Handler(ctx *rest.Ctx) errors.APIError { case "object_id": key = u.ID.Hex() case "login": - key = u.Username + key = tw.Data.Login default: continue } diff --git a/internal/rest/v3/routes/emotes/emotes.by-id.go b/internal/rest/v3/routes/emotes/emotes.by-id.go new file mode 100644 index 00000000..3600c8b7 --- /dev/null +++ b/internal/rest/v3/routes/emotes/emotes.by-id.go @@ -0,0 +1,48 @@ +package emotes + +import ( + "github.com/seventv/api/internal/global" + "github.com/seventv/api/internal/rest/middleware" + "github.com/seventv/api/internal/rest/rest" + "github.com/seventv/common/errors" +) + +type emoteRoute struct { + Ctx global.Context +} + +func newEmote(gctx global.Context) rest.Route { + return &emoteRoute{gctx} +} + +func (r *emoteRoute) Config() rest.RouteConfig { + return rest.RouteConfig{ + URI: "/{emote.id}", + Method: rest.GET, + Children: []rest.Route{}, + Middleware: []rest.Middleware{ + middleware.SetCacheControl(r.Ctx, 300, []string{"s-maxage=600"}), + }, + } +} + +// @Summary Get Emote +// @Description Get emote by ID +// @Param emoteID path string true "ID of the emote" +// @Tags emotes +// @Produce json +// @Success 200 {object} model.EmoteModel +// @Router /emotes/{emote.id} [get] +func (r *emoteRoute) Handler(ctx *rest.Ctx) rest.APIError { + emoteID, err := ctx.UserValue("emote.id").ObjectID() + if err != nil { + return errors.From(err) + } + + emote, err := r.Ctx.Inst().Loaders.EmoteByID().Load(emoteID) + if err != nil { + return errors.From(err) + } + + return ctx.JSON(rest.OK, r.Ctx.Inst().Modelizer.Emote(emote)) +} diff --git a/internal/rest/v3/routes/emotes/emotes.go b/internal/rest/v3/routes/emotes/emotes.go index be8e194a..8a89340c 100644 --- a/internal/rest/v3/routes/emotes/emotes.go +++ b/internal/rest/v3/routes/emotes/emotes.go @@ -21,6 +21,7 @@ func (r *Route) Config() rest.RouteConfig { Method: rest.GET, Children: []rest.Route{ newCreate(r.Ctx), + newEmote(r.Ctx), }, Middleware: []rest.Middleware{}, } diff --git a/internal/rest/v3/routes/users/users.by-connection.go b/internal/rest/v3/routes/users/users.by-connection.go index 396f424c..3850cdaa 100644 --- a/internal/rest/v3/routes/users/users.by-connection.go +++ b/internal/rest/v3/routes/users/users.by-connection.go @@ -26,12 +26,12 @@ func (r *userConnectionRoute) Config() rest.RouteConfig { } } -// @Summary Get User By Connection ID -// @Description Get user ID -// @Param {connection.id} path string true "ID of the user" +// @Summary Get User Connection +// @Description Query for a user's connected account and its attached emote set +// @Param {connection.id} path string true "twitch, youtube or discord user ID" // @Tags users // @Produce json -// @Success 200 {object} model.UserModel +// @Success 200 {object} model.UserConnectionModel // @Router /users/{connection.platform}/{connection.id} [get] func (r *userConnectionRoute) Handler(ctx *rest.Ctx) rest.APIError { // Retrieve the platform desired From b72c818b2a795ff7c80e5a1b7ff5180744057be3 Mon Sep 17 00:00:00 2001 From: Anatole Date: Mon, 12 Sep 2022 04:41:35 +0200 Subject: [PATCH 7/9] k8s: prod --- k8s/production.template.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/k8s/production.template.yaml b/k8s/production.template.yaml index 0a6833ac..72186123 100644 --- a/k8s/production.template.yaml +++ b/k8s/production.template.yaml @@ -27,6 +27,9 @@ spec: - name: rest containerPort: 3100 protocol: TCP + - name: portal + containerPort: 3200 + protocol: TCP - name: metrics containerPort: 9100 protocol: TCP @@ -94,6 +97,10 @@ spec: protocol: TCP port: 3100 targetPort: rest + - name: portal + protocol: TCP + port: 3200 + targetPort: portal - name: metrics protocol: TCP port: 9100 @@ -211,6 +218,14 @@ spec: rules: - host: 7tv.io http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: api + port: + name: portal paths: - pathType: Prefix path: /v3/gql From 049227f9eb77205deba8aefbf241436d139da390 Mon Sep 17 00:00:00 2001 From: Anatole Date: Mon, 12 Sep 2022 04:45:20 +0200 Subject: [PATCH 8/9] portal/nav: hide env if prod --- portal/src/components/Nav.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/portal/src/components/Nav.vue b/portal/src/components/Nav.vue index b3fcf485..99f57064 100644 --- a/portal/src/components/Nav.vue +++ b/portal/src/components/Nav.vue @@ -24,8 +24,8 @@ - - {{ version.toString().toUpperCase() }} + + {{ env.toString().toUpperCase() }} @@ -34,7 +34,7 @@ import { ref } from "vue"; import Logo from "@svg/Logo.vue"; -const version = import.meta.env.VITE_APP_ENV; +const env = import.meta.env.VITE_APP_ENV; const navOpen = ref(false); const toggleNav = () => { From 8fbfc702a3fe0ada59f4b9593c65748d36ac7c0b Mon Sep 17 00:00:00 2001 From: Anatole Date: Mon, 12 Sep 2022 04:49:25 +0200 Subject: [PATCH 9/9] k8s: fix error --- k8s/production.template.yaml | 1 - portal/src/views/Docs/Docs.vue | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/k8s/production.template.yaml b/k8s/production.template.yaml index 72186123..0c8a2567 100644 --- a/k8s/production.template.yaml +++ b/k8s/production.template.yaml @@ -226,7 +226,6 @@ spec: name: api port: name: portal - paths: - pathType: Prefix path: /v3/gql backend: diff --git a/portal/src/views/Docs/Docs.vue b/portal/src/views/Docs/Docs.vue index 63acaf0c..92c4030c 100644 --- a/portal/src/views/Docs/Docs.vue +++ b/portal/src/views/Docs/Docs.vue @@ -40,6 +40,10 @@ {{ route.path }} + +
+ {{ route.description }} +
@@ -155,6 +159,10 @@ main.docs { color: themed("warning"); } } + + .route-item-description { + margin: 0.5em; + } } .docs-sidebar {