├── .dockerignore ├── .github ├── dependabot.yaml └── workflows │ └── ci.yaml ├── .gitignore ├── .golangci.yaml ├── .prettierignore ├── LICENSE.md ├── Makefile ├── README.md ├── cmd └── main.go ├── data ├── data.go ├── events │ ├── events.go │ ├── instance.go │ ├── payload.go │ ├── payload_commands.go │ └── types.go ├── model │ ├── cosmetic.model.go │ ├── emote-set.model.go │ ├── emote.model.go │ ├── entitlement.model.go │ ├── message.model.go │ ├── model.go │ ├── modelgql │ │ ├── cosmetic-gql.model.go │ │ ├── emote-set.gql.model.go │ │ ├── emote.gql.model.go │ │ ├── gql.model.go │ │ ├── message.gql.model.go │ │ ├── role.gql.model.go │ │ └── user.gql.model.go │ ├── role.model.go │ ├── user-connection.model.go │ ├── user-presence.model.go │ └── user.model.go ├── mutate │ ├── ban.mutation.go │ ├── emote.delete.go │ ├── emote.merge.go │ ├── emote.mutation.go │ ├── emote_set.active_emote.mutation.go │ ├── emote_set.create.mutation.go │ ├── emote_set.delete.mutation.go │ ├── emote_set.edit.mutation.go │ ├── message.inbox.go │ ├── message.mod_request.go │ ├── message.mutation.go │ ├── mutations.go │ ├── role.mutation.go │ ├── user.active_emote_set.mutation.go │ ├── user.delete.mutation.go │ ├── user.editors.mutation.go │ ├── user.set_role.mutation.go │ └── user.transfer-user-connection.mutation.go └── query │ ├── query.ban.go │ ├── query.bind_objects.go │ ├── query.cosmetics.go │ ├── query.emote-channels.go │ ├── query.emote-set.go │ ├── query.emote.go │ ├── query.entitlements.go │ ├── query.go │ ├── query.messages.go │ ├── query.roles.go │ ├── query.search-emotes.go │ ├── query.search-users.go │ ├── query.system.go │ └── query.users.go ├── docker ├── full.Dockerfile └── partial.Dockerfile ├── example.config.yaml ├── go.mod ├── go.sum ├── gqlgen.v3.yml ├── internal ├── api │ ├── eventbridge │ │ ├── eventbridge.go │ │ └── eventbridge_cosmetics.go │ ├── gql │ │ ├── gql.go │ │ ├── scalar │ │ │ ├── arbitrarymap.go │ │ │ ├── event.go │ │ │ ├── objectid.go │ │ │ └── stringmap.go │ │ └── v3 │ │ │ ├── auth │ │ │ └── auth.go │ │ │ ├── cache │ │ │ └── cache.go │ │ │ ├── complexity │ │ │ └── complexity.go │ │ │ ├── gen │ │ │ ├── generated │ │ │ │ └── gen.go │ │ │ └── model │ │ │ │ └── model.go │ │ │ ├── helpers │ │ │ ├── errors.go │ │ │ ├── fields.go │ │ │ ├── filter_images.go │ │ │ ├── keys.go │ │ │ └── transform.go │ │ │ ├── middleware │ │ │ ├── has-permission.go │ │ │ ├── internal.go │ │ │ └── middleware.go │ │ │ ├── resolvers │ │ │ ├── ban │ │ │ │ └── ban.go │ │ │ ├── cosmetics │ │ │ │ └── cosmetics.ops.go │ │ │ ├── emote │ │ │ │ ├── emote.channels.go │ │ │ │ ├── emote.go │ │ │ │ ├── emote.merge.ops.go │ │ │ │ ├── emote.ops.go │ │ │ │ ├── emote.partial.go │ │ │ │ └── emote.rerun.ops.go │ │ │ ├── emoteset │ │ │ │ ├── active-emote │ │ │ │ │ └── active-emote.go │ │ │ │ ├── emoteset.go │ │ │ │ └── emoteset.ops.go │ │ │ ├── image-host │ │ │ │ └── image-host.go │ │ │ ├── mutation │ │ │ │ ├── mutation.ban.go │ │ │ │ ├── mutation.cosmetics.go │ │ │ │ ├── mutation.emote.go │ │ │ │ ├── mutation.emoteset.go │ │ │ │ ├── mutation.go │ │ │ │ ├── mutation.messages.go │ │ │ │ ├── mutation.reports.go │ │ │ │ ├── mutation.role.go │ │ │ │ └── mutation.user.go │ │ │ ├── query │ │ │ │ ├── query.cosmetics.go │ │ │ │ ├── query.emotes.go │ │ │ │ ├── query.emoteset.go │ │ │ │ ├── query.go │ │ │ │ ├── query.messages.inbox.go │ │ │ │ ├── query.mod_requests.go │ │ │ │ ├── query.reports.go │ │ │ │ ├── query.trending-emotes.go │ │ │ │ └── query.users.go │ │ │ ├── report │ │ │ │ └── report.go │ │ │ ├── role │ │ │ │ └── role.go │ │ │ ├── root.go │ │ │ ├── user-editor │ │ │ │ └── user-editor.go │ │ │ └── user │ │ │ │ ├── user.cosmetics.go │ │ │ │ ├── user.cosmetics.ops.go │ │ │ │ ├── user.editors.ops.go │ │ │ │ ├── user.go │ │ │ │ ├── user.ops.go │ │ │ │ └── user.partial.go │ │ │ ├── schema │ │ │ ├── _schema.gql │ │ │ ├── audit.gql │ │ │ ├── bans.gql │ │ │ ├── cosmetics.gql │ │ │ ├── emotes.gql │ │ │ ├── emoteset.gql │ │ │ ├── files.gql │ │ │ ├── messages.gql │ │ │ ├── permissions.gql │ │ │ ├── reports.gql │ │ │ ├── roles.gql │ │ │ └── users.gql │ │ │ ├── types │ │ │ └── resolver.go │ │ │ └── v3.go │ └── rest │ │ ├── middleware │ │ ├── auth.go │ │ ├── cache.go │ │ └── ratelimit.go │ │ ├── portal │ │ └── serve_portal.go │ │ ├── rest.go │ │ ├── rest │ │ ├── context.go │ │ ├── parse.go │ │ └── route.go │ │ ├── v2 │ │ ├── docs │ │ │ ├── docs.go │ │ │ └── swagger.json │ │ ├── model │ │ │ ├── cosmetic.go │ │ │ ├── emote.go │ │ │ ├── role.go │ │ │ └── user.go │ │ ├── routes │ │ │ ├── auth │ │ │ │ ├── auth.go │ │ │ │ ├── youtube.go │ │ │ │ └── youtube.verify.go │ │ │ ├── chatterino │ │ │ │ └── chatterino.go │ │ │ ├── cosmetics │ │ │ │ ├── cosmetics.avatars.go │ │ │ │ └── cosmetics.go │ │ │ ├── downloads │ │ │ │ └── downloads.go │ │ │ ├── emotes │ │ │ │ ├── emote.go │ │ │ │ ├── emotes.emote.go │ │ │ │ └── emotes.global.go │ │ │ ├── root.go │ │ │ └── user │ │ │ │ ├── user.emotes.go │ │ │ │ └── user.go │ │ └── v2.go │ │ ├── v3 │ │ ├── docs │ │ │ ├── docs.go │ │ │ └── swagger.json │ │ ├── routes │ │ │ ├── auth │ │ │ │ ├── auth.route.go │ │ │ │ ├── logout.auth.route.go │ │ │ │ └── manual.route.go │ │ │ ├── config │ │ │ │ └── config.root.go │ │ │ ├── docs │ │ │ │ └── docs.go │ │ │ ├── emote-sets │ │ │ │ ├── emote-sets.by-id.go │ │ │ │ └── emote-sets.root.go │ │ │ ├── emotes │ │ │ │ ├── emotes.by-id.go │ │ │ │ ├── emotes.create.go │ │ │ │ ├── emotes.go │ │ │ │ └── emotes.process.go │ │ │ ├── entitlements │ │ │ │ ├── entitlements.create.go │ │ │ │ └── entitlements.go │ │ │ ├── root.go │ │ │ └── users │ │ │ │ ├── users.by-connection.go │ │ │ │ ├── users.by-id.go │ │ │ │ ├── users.delete.go │ │ │ │ ├── users.pictures.go │ │ │ │ ├── users.pictures.process.go │ │ │ │ ├── users.presence.write.go │ │ │ │ ├── users.root.go │ │ │ │ └── users.update-connection.go │ │ └── v3.go │ │ └── version.go ├── configure │ ├── config.go │ └── logging.go ├── constant │ └── keys.go ├── externalapis │ ├── discord.go │ ├── externalapis.go │ └── twitch.go ├── global │ └── context.go ├── instance │ └── instances.go ├── loaders │ ├── emote.loader.go │ ├── emoteset.loader.go │ ├── loaders.go │ ├── presence.loader.go │ └── user.loader.go ├── middleware │ ├── auth.middleware.go │ ├── cors.middleware.go │ ├── middleware.go │ └── ratelimit.middleware.go ├── search │ ├── emote.go │ └── meilisearch.go ├── svc │ ├── auth │ │ ├── auth.go │ │ ├── discord.auth.go │ │ ├── geoip.auth.go │ │ ├── jwt.auth.go │ │ ├── kick.auth.go │ │ ├── twitch.auth.go │ │ └── userdata.auth.go │ ├── health │ │ └── health.go │ ├── limiter │ │ └── limiter.go │ ├── monitoring │ │ └── monitoring.go │ ├── pprof │ │ └── pprof.go │ ├── presences │ │ ├── presences.fanout.go │ │ ├── presences.go │ │ └── presences.manager.go │ ├── prometheus │ │ └── prometheus.go │ └── youtube │ │ └── youtube.go └── testutil │ └── testutil.go ├── k8s ├── .gitignore ├── production.template.yaml └── staging.template.yaml ├── package.json ├── portal ├── .editorconfig ├── .env ├── .env.dev ├── .env.stage ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .stylelintrc.js ├── README.md ├── index.html ├── package.json ├── public │ └── ico.svg ├── src │ ├── App.vue │ ├── assets │ │ ├── style │ │ │ └── themes.scss │ │ └── svg │ │ │ └── Logo.vue │ ├── components │ │ ├── Docs │ │ │ ├── DocsRouteItem.vue │ │ │ └── DocsSideBar.vue │ │ ├── Nav.vue │ │ └── util │ │ │ └── Icon.vue │ ├── main.ts │ ├── router │ │ └── router.ts │ ├── store │ │ └── main.ts │ ├── style.scss │ ├── views │ │ ├── Apps │ │ │ └── Apps.vue │ │ ├── Docs │ │ │ └── Docs.vue │ │ └── Intro │ │ │ └── Intro.vue │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock ├── terraform ├── .terraform.lock.hcl ├── config.template.yaml ├── deployment.tf ├── main.tf ├── providers.tf └── variables.tf ├── tools.go └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.gif 3 | *.webp 4 | *.avif 5 | tmp/ 6 | *.yaml 7 | !example.config.yaml 8 | !docker-compose.yaml 9 | !.github/**/*.yaml 10 | *-gqlgen.go 11 | *_gen.go 12 | node_modules/ 13 | internal/rest/v*/docs 14 | .vscode/ 15 | go.work 16 | go.work.sum 17 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | open-pull-requests-limit: 0 5 | schedule: 6 | interval: daily 7 | time: "00:00" 8 | directory: "/" 9 | target-branch: dev 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | target-branch: dev 13 | schedule: 14 | interval: daily 15 | time: "00:00" 16 | open-pull-requests-limit: 0 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.gif 3 | *.webp 4 | *.avif 5 | out/ 6 | tmp/ 7 | *.yaml 8 | !example.config.yaml 9 | !terraform/config.template.yaml 10 | !docker-compose.yaml 11 | !.github/**/*.yaml 12 | *-gqlgen.go 13 | *_gen.go 14 | node_modules/ 15 | internal/rest/v*/docs 16 | .vscode/ 17 | go.work 18 | go.work.sum 19 | !.golangci.yaml 20 | 21 | # Terraform local state files 22 | **/.terraform/* 23 | *.tfstate 24 | *.tfstate.* 25 | *.tfplan 26 | crash.log 27 | *.tfvars 28 | override.tf 29 | override.tf.json 30 | *_override.tf 31 | *_override.tf.json 32 | .terraformrc 33 | terraform.rc 34 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | 4 | linters: 5 | enable: 6 | - errcheck 7 | - gosimple 8 | - govet 9 | - ineffassign 10 | - typecheck 11 | - unused 12 | - asciicheck 13 | - bidichk 14 | - exportloopref 15 | - forbidigo 16 | - forcetypeassert 17 | - goconst 18 | - wsl 19 | - whitespace 20 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore docs 2 | internal/api/rest/v*/docs/* 3 | .vscode/ 4 | 5 | # Ignore build outputs 6 | portal/dist -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build lint deps dev_deps generate portal clean work dev 2 | 3 | BUILDER := "unknown" 4 | VERSION := "unknown" 5 | 6 | ifeq ($(origin API_BUILDER),undefined) 7 | BUILDER = $(shell git config --get user.name); 8 | else 9 | BUILDER = ${API_BUILDER}; 10 | endif 11 | 12 | ifeq ($(origin API_VERSION),undefined) 13 | VERSION = $(shell git rev-parse HEAD); 14 | else 15 | VERSION = ${API_VERSION}; 16 | endif 17 | 18 | build: 19 | GOOS=linux GOARCH=amd64 go build -v -ldflags "-X 'main.Version=${VERSION}' -X 'main.Unix=$(shell date +%s)' -X 'main.User=${BUILDER}'" -o out/api cmd/*.go 20 | 21 | lint: 22 | # golangci-lint run --go=1.18 23 | # yarn prettier --check . 24 | 25 | format: 26 | gofmt -s -w . 27 | # yarn prettier --write . 28 | 29 | deps: 30 | go install github.com/swaggo/swag/cmd/swag@v1.8.10 31 | go install github.com/99designs/gqlgen@v0.17.24 32 | go mod download 33 | 34 | dev_deps: 35 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50.0 36 | yarn 37 | 38 | generate: 39 | echo ${DOCROOT} 40 | 41 | swag init --dir internal/api/rest/v3,data -g v3.go -o internal/api/rest/v3/docs & swag init --dir internal/api/rest/v2 -g v2.go -o internal/api/rest/v2/docs 42 | gqlgen --config ./gqlgen.v3.yml 43 | make format 44 | 45 | portal: 46 | yarn --cwd ./portal 47 | yarn --cwd ./portal build 48 | 49 | portal_stage: 50 | yarn --cwd ./portal 51 | yarn --cwd ./portal build --mode=stage 52 | 53 | test: 54 | go test -count=1 -cover -parallel $$(nproc) -race ./... 55 | 56 | clean: 57 | rm -rf \ 58 | out \ 59 | internal/api/gql/v2/gen/generated/generated-gqlgen.go \ 60 | internal/api/gql/v2/gen/model/models-gqlgen.go \ 61 | internal/api/gql/v3/gen/generated/generated-gqlgen.go \ 62 | internal/api/gql/v3/gen/model/models-gqlgen.go \ 63 | internal/api/rest/v2/docs \ 64 | internal/api/rest/v3/docs \ 65 | node_modules 66 | 67 | work: 68 | echo -e "go 1.18\n\nuse (\n\t.\n\t../Common\n\t../message-queue/go\n\t../image-processor/go\n\t../CompactDisc\n)" > go.work 69 | go mod tidy 70 | 71 | dev: 72 | go run cmd/main.go 73 | 74 | terraform: 75 | terraform -chdir=./terraform init 76 | 77 | deploy: 78 | terraform -chdir=./terraform apply -auto-approve 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | This is the old API and is being rewritten in our monorepo. 4 | 5 | Please see https://github.com/seventv/seventv for more info. 6 | 7 | No new features will be implemented here and if you are looking to contribute please go to the monorepo. 8 | -------------------------------------------------------------------------------- /data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | -------------------------------------------------------------------------------- /data/events/payload_commands.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/seventv/common/structures/v3" 5 | ) 6 | 7 | type BridgedCommandBody struct { 8 | Platform structures.UserConnectionPlatform `json:"platform"` 9 | Identifiers []string `json:"identifiers"` 10 | Kinds []structures.CosmeticKind `json:"kinds"` 11 | } 12 | -------------------------------------------------------------------------------- /data/model/entitlement.model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/seventv/common/structures/v3" 5 | "go.mongodb.org/mongo-driver/bson" 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type EntitlementModel struct { 10 | ID primitive.ObjectID `json:"id"` 11 | Kind EntitlementKind `json:"kind"` 12 | User UserPartialModel `json:"user"` 13 | RefID primitive.ObjectID `json:"ref_id"` 14 | } 15 | 16 | type EntitlementKind string 17 | 18 | const ( 19 | EntitlementKindBadge EntitlementKind = "BADGE" 20 | EntitlementKindPaint EntitlementKind = "PAINT" 21 | EntitlementKindEmoteSet EntitlementKind = "EMOTE_SET" 22 | ) 23 | 24 | func (m *modelizer) Entitlement(v structures.Entitlement[bson.Raw], user structures.User) EntitlementModel { 25 | e, _ := structures.ConvertEntitlement[structures.EntitlementDataBase](v) 26 | 27 | return EntitlementModel{ 28 | ID: e.ID, 29 | RefID: e.Data.RefID, 30 | User: m.User(user).ToPartial(), 31 | Kind: EntitlementKind(e.Kind), 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /data/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/seventv/common/structures/v3" 9 | "go.mongodb.org/mongo-driver/bson" 10 | ) 11 | 12 | type Modelizer interface { 13 | Emote(v structures.Emote) EmoteModel 14 | User(v structures.User) UserModel 15 | UserEditor(v structures.UserEditor) UserEditorModel 16 | UserConnection(v structures.UserConnection[bson.Raw]) UserConnectionModel 17 | Presence(v structures.UserPresence[bson.Raw]) PresenceModel 18 | Entitlement(v structures.Entitlement[bson.Raw], user structures.User) EntitlementModel 19 | Cosmetic(v structures.Cosmetic[bson.Raw]) CosmeticModel[json.RawMessage] 20 | Paint(v structures.Cosmetic[structures.CosmeticDataPaint]) CosmeticPaintModel 21 | Badge(v structures.Cosmetic[structures.CosmeticDataBadge]) CosmeticBadgeModel 22 | Avatar(v structures.User) CosmeticModel[CosmeticAvatarModel] 23 | EmoteSet(v structures.EmoteSet) EmoteSetModel 24 | ActiveEmote(v structures.ActiveEmote) ActiveEmoteModel 25 | Role(v structures.Role) RoleModel 26 | InboxMessage(v structures.Message[structures.MessageDataInbox]) InboxMessageModel 27 | ModRequestMessage(v structures.Message[structures.MessageDataModRequest]) ModRequestMessageModel 28 | } 29 | 30 | type modelizer struct { 31 | cdnURL string 32 | websiteURL string 33 | } 34 | 35 | func NewInstance(opt ModelInstanceOptions) Modelizer { 36 | return &modelizer{ 37 | cdnURL: opt.CDN, 38 | websiteURL: opt.Website, 39 | } 40 | } 41 | 42 | type ModelInstanceOptions struct { 43 | CDN string 44 | Website string 45 | } 46 | 47 | type ImageHost struct { 48 | URL string `json:"url"` 49 | Files []ImageFile `json:"files"` 50 | } 51 | 52 | type ImageFile struct { 53 | Name string `json:"name"` 54 | // deprecated 55 | StaticName string `json:"static_name"` 56 | Width int32 `json:"width"` 57 | Height int32 `json:"height"` 58 | FrameCount int32 `json:"frame_count,omitempty"` 59 | Size int64 `json:"size,omitempty"` 60 | Format ImageFormat `json:"format"` 61 | } 62 | 63 | type ImageFormat string 64 | 65 | const ( 66 | ImageFormatAVIF ImageFormat = "AVIF" 67 | ImageFormatWEBP ImageFormat = "WEBP" 68 | ) 69 | 70 | func (x *modelizer) Image(v structures.ImageFile) ImageFile { 71 | var ext string 72 | 73 | mime := strings.Split(v.ContentType, "/") 74 | if len(mime) == 2 { 75 | ext = mime[1] 76 | } 77 | 78 | format := strings.ToUpper(ext) 79 | 80 | return ImageFile{ 81 | Name: fmt.Sprintf("%s.%s", v.Name, ext), 82 | StaticName: fmt.Sprintf("%s_static.%s", v.Name, ext), 83 | Format: ImageFormat(format), 84 | Width: v.Width, 85 | Height: v.Height, 86 | FrameCount: v.FrameCount, 87 | Size: v.Size, 88 | } 89 | } 90 | 91 | type MutationResponse struct { 92 | OK bool `json:"ok"` 93 | } 94 | -------------------------------------------------------------------------------- /data/model/modelgql/emote-set.gql.model.go: -------------------------------------------------------------------------------- 1 | package modelgql 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/seventv/api/data/model" 7 | gql_model "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/common/utils" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | func EmoteSetModel(xm model.EmoteSetModel) *gql_model.EmoteSet { 13 | var ( 14 | emotes = make([]*gql_model.ActiveEmote, len(xm.Emotes)) 15 | ownerID *primitive.ObjectID 16 | ) 17 | 18 | for i, ae := range xm.Emotes { 19 | emotes[i] = ActiveEmoteModel(ae) 20 | } 21 | 22 | if xm.Owner != nil { 23 | ownerID = &xm.Owner.ID 24 | } 25 | 26 | return &gql_model.EmoteSet{ 27 | ID: xm.ID, 28 | Name: xm.Name, 29 | Flags: int(xm.Flags), 30 | Tags: xm.Tags, 31 | Emotes: emotes, 32 | EmoteCount: int(xm.EmoteCount), 33 | Capacity: int(xm.Capacity), 34 | Origins: utils.Map(xm.Origins, func(v model.EmoteSetOrigin) *gql_model.EmoteSetOrigin { 35 | return EmoteSetOrigin(v) 36 | }), 37 | OwnerID: ownerID, 38 | } 39 | } 40 | 41 | func ActiveEmoteModel(xm model.ActiveEmoteModel) *gql_model.ActiveEmote { 42 | var actorID primitive.ObjectID 43 | if xm.ActorID != nil { 44 | actorID = *xm.ActorID 45 | } 46 | 47 | return &gql_model.ActiveEmote{ 48 | ID: xm.ID, 49 | Name: xm.Name, 50 | Flags: int(xm.Flags), 51 | Timestamp: time.UnixMilli(xm.Timestamp), 52 | Actor: &gql_model.UserPartial{ID: actorID}, 53 | OriginID: xm.OriginID, 54 | } 55 | } 56 | 57 | func EmoteSetOrigin(xm model.EmoteSetOrigin) *gql_model.EmoteSetOrigin { 58 | return &gql_model.EmoteSetOrigin{ 59 | ID: xm.ID, 60 | Weight: int(xm.Weight), 61 | Slices: utils.Map(xm.Slices, func(v uint32) int { 62 | return int(v) 63 | }), 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /data/model/modelgql/emote.gql.model.go: -------------------------------------------------------------------------------- 1 | package modelgql 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/seventv/api/data/model" 7 | gql_model "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/common/utils" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | func EmoteModel(xm model.EmoteModel) *gql_model.Emote { 13 | var ( 14 | versions = make([]*gql_model.EmoteVersion, len(xm.Versions)) 15 | owner *gql_model.UserPartial 16 | ) 17 | 18 | for i, v := range xm.Versions { 19 | versions[i] = EmoteVersionModel(v) 20 | 21 | if v.ID == xm.ID { 22 | versions[i].Host = ImageHost(xm.Host) 23 | } 24 | } 25 | 26 | if xm.Owner != nil { 27 | u := *xm.Owner 28 | 29 | owner = UserPartialModel(u) 30 | } 31 | 32 | return &gql_model.Emote{ 33 | ID: xm.ID, 34 | Name: xm.Name, 35 | Flags: int(xm.Flags), 36 | State: utils.Map(xm.State, func(x model.EmoteVersionState) gql_model.EmoteVersionState { 37 | return gql_model.EmoteVersionState(x) 38 | }), 39 | Listed: xm.Listed, 40 | Lifecycle: int(xm.Lifecycle), 41 | Tags: xm.Tags, 42 | Animated: xm.Animated, 43 | CreatedAt: xm.ID.Timestamp(), 44 | OwnerID: xm.OwnerID, 45 | Owner: owner, 46 | Host: ImageHost(xm.Host), 47 | Versions: versions, 48 | } 49 | } 50 | 51 | func EmotePartialModel(xm model.EmotePartialModel) *gql_model.EmotePartial { 52 | var ( 53 | ownerID primitive.ObjectID 54 | owner *gql_model.UserPartial 55 | ) 56 | 57 | if xm.Owner != nil { 58 | u := *xm.Owner 59 | 60 | ownerID = u.ID 61 | owner = UserPartialModel(u) 62 | } 63 | 64 | return &gql_model.EmotePartial{ 65 | ID: xm.ID, 66 | Name: xm.Name, 67 | Flags: int(xm.Flags), 68 | State: utils.Map(xm.State, func(x model.EmoteVersionState) gql_model.EmoteVersionState { 69 | return gql_model.EmoteVersionState(x) 70 | }), 71 | Listed: xm.Listed, 72 | Lifecycle: int(xm.Lifecycle), 73 | Tags: xm.Tags, 74 | Animated: xm.Animated, 75 | OwnerID: ownerID, 76 | Owner: owner, 77 | Host: ImageHost(xm.Host), 78 | } 79 | } 80 | 81 | func EmoteVersionModel(xm model.EmoteVersionModel) *gql_model.EmoteVersion { 82 | host := &gql_model.ImageHost{ 83 | URL: "", 84 | Files: []*gql_model.Image{}, 85 | } 86 | if xm.Host != nil { 87 | host = ImageHost(*xm.Host) 88 | } 89 | 90 | return &gql_model.EmoteVersion{ 91 | ID: xm.ID, 92 | Name: xm.Name, 93 | Description: xm.Description, 94 | CreatedAt: time.UnixMilli(xm.CreatedAt), 95 | Host: host, 96 | Lifecycle: int(xm.Lifecycle), 97 | State: utils.Map(xm.State, func(x model.EmoteVersionState) gql_model.EmoteVersionState { 98 | return gql_model.EmoteVersionState(x) 99 | }), 100 | Listed: xm.Listed, 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /data/model/modelgql/gql.model.go: -------------------------------------------------------------------------------- 1 | package modelgql 2 | 3 | import ( 4 | "github.com/seventv/api/data/model" 5 | gql_model "github.com/seventv/api/internal/api/gql/v3/gen/model" 6 | ) 7 | 8 | func ImageFile(xm model.ImageFile) *gql_model.Image { 9 | return &gql_model.Image{ 10 | Name: xm.Name, 11 | Format: gql_model.ImageFormat(xm.Format), 12 | Width: int(xm.Width), 13 | Height: int(xm.Height), 14 | FrameCount: int(xm.FrameCount), 15 | Size: int(xm.Size), 16 | } 17 | } 18 | 19 | func ImageHost(xm model.ImageHost) *gql_model.ImageHost { 20 | var files = make([]*gql_model.Image, len(xm.Files)) 21 | 22 | for i, f := range xm.Files { 23 | files[i] = ImageFile(f) 24 | } 25 | 26 | return &gql_model.ImageHost{ 27 | URL: xm.URL, 28 | Files: files, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /data/model/modelgql/message.gql.model.go: -------------------------------------------------------------------------------- 1 | package modelgql 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/seventv/api/data/model" 7 | gql_model "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | ) 9 | 10 | func InboxMessageModel(xm model.InboxMessageModel) *gql_model.InboxMessage { 11 | return &gql_model.InboxMessage{ 12 | ID: xm.ID, 13 | Kind: gql_model.MessageKind(xm.Kind), 14 | CreatedAt: time.UnixMilli(xm.CreatedAt), 15 | AuthorID: xm.AuthorID, 16 | Read: xm.Read, 17 | Subject: xm.Subject, 18 | Content: xm.Content, 19 | Important: xm.Important, 20 | Starred: xm.Starred, 21 | Pinned: xm.Pinned, 22 | Placeholders: xm.Placeholders, 23 | } 24 | } 25 | 26 | func ModRequestMessageModel(xm model.ModRequestMessageModel) *gql_model.ModRequestMessage { 27 | return &gql_model.ModRequestMessage{ 28 | ID: xm.ID, 29 | Kind: gql_model.MessageKind(xm.Kind), 30 | CreatedAt: time.UnixMilli(xm.CreatedAt), 31 | AuthorID: xm.AuthorID, 32 | TargetKind: int(xm.TargetKind), 33 | TargetID: xm.TargetID, 34 | Read: xm.Read, 35 | Wish: xm.Wish, 36 | ActorCountryName: xm.ActorCountryName, 37 | ActorCountryCode: xm.ActorCountryCode, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /data/model/modelgql/role.gql.model.go: -------------------------------------------------------------------------------- 1 | package modelgql 2 | 3 | import ( 4 | "github.com/seventv/api/data/model" 5 | gql_model "github.com/seventv/api/internal/api/gql/v3/gen/model" 6 | ) 7 | 8 | func RoleModel(xm model.RoleModel) *gql_model.Role { 9 | return &gql_model.Role{ 10 | ID: xm.ID, 11 | Name: xm.Name, 12 | Position: int(xm.Position), 13 | Color: int(xm.Color), 14 | Allowed: xm.Allowed, 15 | Denied: xm.Denied, 16 | Invisible: xm.Invisible, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /data/model/role.model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/seventv/common/structures/v3" 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | ) 9 | 10 | type RoleModel struct { 11 | ID primitive.ObjectID `json:"id"` 12 | Name string `json:"name"` 13 | Position int32 `json:"position"` 14 | Color int32 `json:"color"` 15 | Allowed string `json:"allowed"` 16 | Denied string `json:"denied"` 17 | Invisible bool `json:"invisible,omitempty" extensions:"x-omitempty"` 18 | } 19 | 20 | func (x *modelizer) Role(v structures.Role) RoleModel { 21 | return RoleModel{ 22 | ID: v.ID, 23 | Name: v.Name, 24 | Position: v.Position, 25 | Color: int32(v.Color), 26 | Allowed: strconv.Itoa(int(v.Allowed)), 27 | Denied: strconv.Itoa(int(v.Denied)), 28 | Invisible: v.Invisible, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /data/model/user-presence.model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/seventv/common/structures/v3" 5 | "go.mongodb.org/mongo-driver/bson" 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type PresenceModel struct { 10 | ID primitive.ObjectID `json:"id"` 11 | UserID primitive.ObjectID `json:"user_id"` 12 | Timestamp int64 `json:"timestamp"` 13 | TTL int64 `json:"ttl"` 14 | Kind PresenceKind `json:"kind"` 15 | } 16 | 17 | type PresenceKind uint8 18 | 19 | const ( 20 | UserPresenceKindUnknown PresenceKind = iota 21 | UserPresenceKindChannel 22 | UserPresenceKindWebPage 23 | ) 24 | 25 | func (m *modelizer) Presence(v structures.UserPresence[bson.Raw]) PresenceModel { 26 | return PresenceModel{ 27 | ID: v.ID, 28 | UserID: v.UserID, 29 | Timestamp: v.Timestamp.UnixMilli(), 30 | TTL: v.TTL.UnixMilli(), 31 | Kind: PresenceKind(v.Kind), 32 | } 33 | } 34 | 35 | type UserPresenceWriteResponse struct { 36 | OK bool `json:"ok"` 37 | Presence PresenceModel `json:"presence"` 38 | } 39 | -------------------------------------------------------------------------------- /data/mutate/emote_set.delete.mutation.go: -------------------------------------------------------------------------------- 1 | package mutate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/common/errors" 7 | "github.com/seventv/common/mongo" 8 | "github.com/seventv/common/structures/v3" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.uber.org/zap" 11 | 12 | "github.com/seventv/api/data/events" 13 | ) 14 | 15 | func (m *Mutate) DeleteEmoteSet(ctx context.Context, esb *structures.EmoteSetBuilder, opt EmoteSetMutationOptions) error { 16 | if esb == nil || esb.EmoteSet.ID.IsZero() { 17 | return errors.ErrInternalIncompleteMutation() 18 | } 19 | 20 | // Check actor's permissions 21 | actor := opt.Actor 22 | if !opt.SkipValidation { 23 | if actor.ID.IsZero() { 24 | return errors.ErrUnauthorized() 25 | } 26 | 27 | if !actor.HasPermission(structures.RolePermissionEditEmoteSet) { 28 | return errors.ErrInsufficientPrivilege().SetFields(errors.Fields{"MISSING_PERMISSION": "EDIT_EMOTE_SET"}) 29 | } 30 | 31 | // Check if actor can delete this set 32 | if actor.ID != esb.EmoteSet.OwnerID && !actor.HasPermission(structures.RolePermissionEditAnyEmoteSet) { 33 | noPrivilege := errors.ErrInsufficientPrivilege().SetDetail("You are not allowed to modify this Emote Set") 34 | 35 | if err := m.mongo.Collection(mongo.CollectionNameUsers).FindOne(ctx, bson.M{ 36 | "_id": esb.EmoteSet.OwnerID, 37 | }).Decode(&esb.EmoteSet.Owner); err != nil { 38 | return errors.ErrUnknownUser() 39 | } 40 | 41 | ed, ok, _ := esb.EmoteSet.Owner.GetEditor(actor.ID) 42 | if !ok || !ed.HasPermission(structures.UserEditorPermissionManageEmoteSets) { 43 | return noPrivilege 44 | } 45 | } 46 | } 47 | 48 | if _, err := m.mongo.Collection(mongo.CollectionNameEmoteSets).DeleteOne(ctx, bson.M{ 49 | "_id": esb.EmoteSet.ID, 50 | }); err != nil { 51 | if err == mongo.ErrNoDocuments { 52 | return errors.ErrUnknownEmoteSet() 53 | } 54 | 55 | return errors.ErrInternalServerError() 56 | } 57 | 58 | // Emit event 59 | m.events.Dispatch(events.EventTypeDeleteEmoteSet, events.ChangeMap{ 60 | ID: esb.EmoteSet.OwnerID, 61 | Kind: structures.ObjectKindEmoteSet, 62 | Actor: m.modelizer.User(actor).ToPartial(), 63 | }, events.EventCondition{ 64 | "object_id": esb.EmoteSet.ID.Hex(), 65 | }) 66 | 67 | // Write audit log 68 | alb := structures.NewAuditLogBuilder(structures.AuditLog{ 69 | Changes: []*structures.AuditLogChange{}, 70 | }). 71 | SetKind(structures.AuditLogKindDeleteEmoteSet). 72 | SetActor(actor.ID). 73 | SetTargetKind(structures.ObjectKindEmoteSet). 74 | SetTargetID(esb.EmoteSet.ID) 75 | 76 | if _, err := m.mongo.Collection(mongo.CollectionNameAuditLogs).InsertOne(ctx, alb.AuditLog); err != nil { 77 | zap.S().Errorw("failed to write audit log", "error", err) 78 | } 79 | 80 | esb.MarkAsTainted() 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /data/mutate/message.inbox.go: -------------------------------------------------------------------------------- 1 | package mutate 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/common/errors" 8 | "github.com/seventv/common/mongo" 9 | "github.com/seventv/common/structures/v3" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | func (m *Mutate) SendInboxMessage(ctx context.Context, mb *structures.MessageBuilder[structures.MessageDataInbox], opt SendInboxMessageOptions) error { 15 | if mb == nil { 16 | return errors.ErrInternalIncompleteMutation() 17 | } else if mb.IsTainted() { 18 | return errors.ErrMutateTaintedObject() 19 | } 20 | 21 | // Check actor permissions 22 | actor := opt.Actor 23 | if actor == nil || actor.ID.IsZero() || !actor.HasPermission(structures.RolePermissionSendMessages) { 24 | return errors.ErrInsufficientPrivilege() 25 | } 26 | 27 | // Find recipients 28 | recipients := []*structures.User{} 29 | cur, err := m.mongo.Collection(mongo.CollectionNameUsers).Find(ctx, bson.M{ 30 | "$and": func() bson.A { 31 | a := bson.A{bson.M{"_id": bson.M{"$in": opt.Recipients}}} 32 | if opt.ConsiderBlockedUsers { // omit blocked users from recipients? 33 | a = append(a, bson.M{"blocked_user_ids": bson.M{"$not": bson.M{"$eq": actor.ID}}}) 34 | } 35 | 36 | return a 37 | }(), 38 | }) 39 | 40 | if err != nil { 41 | return err 42 | } 43 | 44 | if err = cur.All(ctx, &recipients); err != nil { 45 | return err 46 | } 47 | 48 | // Write message to DB 49 | result, err := m.mongo.Collection(mongo.CollectionNameMessages).InsertOne(ctx, mb.Message) 50 | 51 | if err != nil { 52 | return err 53 | } 54 | 55 | var msgID primitive.ObjectID 56 | switch t := result.InsertedID.(type) { 57 | case primitive.ObjectID: 58 | msgID = t 59 | } 60 | 61 | // Create read states for the recipients 62 | w := make([]mongo.WriteModel, len(recipients)) 63 | for i, u := range recipients { 64 | w[i] = &mongo.InsertOneModel{ 65 | Document: &structures.MessageRead{ 66 | MessageID: msgID, 67 | Kind: structures.MessageKindInbox, 68 | Timestamp: time.Now(), 69 | RecipientID: u.ID, 70 | Read: false, 71 | }, 72 | } 73 | } 74 | 75 | if _, err = m.mongo.Collection(mongo.CollectionNameMessagesRead).BulkWrite(ctx, w); err != nil { 76 | return err 77 | } 78 | 79 | mb.Message.ID = msgID 80 | mb.MarkAsTainted() 81 | 82 | return nil 83 | } 84 | 85 | type SendInboxMessageOptions struct { 86 | Actor *structures.User 87 | Recipients []primitive.ObjectID 88 | ConsiderBlockedUsers bool 89 | } 90 | -------------------------------------------------------------------------------- /data/mutate/message.mod_request.go: -------------------------------------------------------------------------------- 1 | package mutate 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/common/errors" 8 | "github.com/seventv/common/mongo" 9 | "github.com/seventv/common/structures/v3" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | func (m *Mutate) SendModRequestMessage(ctx context.Context, mb *structures.MessageBuilder[structures.MessageDataModRequest], weight int32) error { 15 | if mb == nil { 16 | return errors.ErrInternalIncompleteMutation() 17 | } else if mb.IsTainted() { 18 | return errors.ErrMutateTaintedObject() 19 | } 20 | 21 | // Get the message 22 | req := mb.Message.Data 23 | 24 | // Verify that the target item exists 25 | var target interface{} 26 | 27 | filter := bson.M{"_id": req.TargetID} 28 | 29 | switch req.TargetKind { 30 | case structures.ObjectKindEmote: 31 | filter = bson.M{"versions.id": req.TargetID} 32 | } 33 | 34 | coll := mongo.CollectionName(req.TargetKind.CollectionName()) 35 | 36 | if err := m.mongo.Collection(coll).FindOne(ctx, filter).Decode(&target); err != nil { 37 | if err == mongo.ErrNoDocuments { 38 | return errors.ErrInvalidRequest().SetDetail("Target item doesn't exist") 39 | } 40 | 41 | return errors.ErrInternalServerError().SetDetail(err.Error()) 42 | } 43 | 44 | // Create the message 45 | result, err := m.mongo.Collection(mongo.CollectionNameMessages).InsertOne(ctx, mb.Message) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | var msgID primitive.ObjectID 51 | switch t := result.InsertedID.(type) { 52 | case primitive.ObjectID: 53 | msgID = t 54 | } 55 | 56 | mb.Message.ID = msgID 57 | 58 | // Create a read state 59 | _, err = m.mongo.Collection(mongo.CollectionNameMessagesRead).InsertOne(ctx, &structures.MessageRead{ 60 | MessageID: msgID, 61 | Kind: structures.MessageKindModRequest, 62 | Timestamp: time.Now(), 63 | Weight: weight, 64 | }) 65 | 66 | mb.MarkAsTainted() 67 | 68 | return err 69 | } 70 | -------------------------------------------------------------------------------- /data/mutate/mutations.go: -------------------------------------------------------------------------------- 1 | package mutate 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/seventv/api/data/events" 7 | "github.com/seventv/api/data/model" 8 | "github.com/seventv/api/internal/loaders" 9 | "github.com/seventv/common/mongo" 10 | "github.com/seventv/common/redis" 11 | "github.com/seventv/common/svc" 12 | "github.com/seventv/common/svc/s3" 13 | "github.com/seventv/compactdisc" 14 | ) 15 | 16 | type Mutate struct { 17 | id svc.AppIdentity 18 | mongo mongo.Instance 19 | loaders loaders.Instance 20 | redis redis.Instance 21 | s3 s3.Instance 22 | modelizer model.Modelizer 23 | events events.Instance 24 | cd compactdisc.Instance 25 | mx map[string]*sync.Mutex 26 | } 27 | 28 | func New(opt InstanceOptions) *Mutate { 29 | return &Mutate{ 30 | id: opt.ID, 31 | mongo: opt.Mongo, 32 | loaders: opt.Loaders, 33 | redis: opt.Redis, 34 | s3: opt.S3, 35 | modelizer: opt.Modelizer, 36 | events: opt.Events, 37 | cd: opt.CD, 38 | mx: map[string]*sync.Mutex{}, 39 | } 40 | } 41 | 42 | type InstanceOptions struct { 43 | ID svc.AppIdentity 44 | Mongo mongo.Instance 45 | Loaders loaders.Instance 46 | Redis redis.Instance 47 | S3 s3.Instance 48 | Modelizer model.Modelizer 49 | Events events.Instance 50 | CD compactdisc.Instance 51 | } 52 | -------------------------------------------------------------------------------- /data/mutate/user.delete.mutation.go: -------------------------------------------------------------------------------- 1 | package mutate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/common/errors" 7 | "github.com/seventv/common/mongo" 8 | "github.com/seventv/common/structures/v3" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.mongodb.org/mongo-driver/bson/primitive" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | func (m *Mutate) DeleteUser(ctx context.Context, opt DeleteUserOptions) (int, error) { 15 | docsDeletedCount := 0 16 | 17 | if opt.Victim.ID.IsZero() || opt.Actor.ID.IsZero() { 18 | return 0, errors.ErrInternalIncompleteMutation() 19 | } 20 | 21 | if opt.Actor.GetHighestRole().Position <= opt.Victim.GetHighestRole().Position { 22 | return 0, errors.ErrInsufficientPrivilege() 23 | } 24 | 25 | // Delete all EUD 26 | for _, query := range userDeleteQueries(opt.Victim.ID) { 27 | res, err := m.mongo.Collection(query.collection).DeleteMany(ctx, query.filter) 28 | if err != nil { 29 | zap.S().Errorw("mutate, DeleteUser()", "error", err) 30 | 31 | return 0, err 32 | } 33 | 34 | docsDeletedCount += int(res.DeletedCount) 35 | } 36 | 37 | // Delete editor references 38 | if _, err := m.mongo.Collection(mongo.CollectionNameUsers).UpdateMany(ctx, bson.M{ 39 | "editors.id": opt.Victim.ID, 40 | }, bson.M{ 41 | "$pull": bson.M{"editors": bson.M{"id": opt.Victim.ID}}, 42 | }); err != nil { 43 | zap.S().Errorw("mutate, DeleteUser(), failed to remove editor references", "error", err) 44 | } 45 | 46 | return docsDeletedCount, nil 47 | } 48 | 49 | func userDeleteQueries(userID primitive.ObjectID) []userDeleteQuery { 50 | return []userDeleteQuery{ 51 | {mongo.CollectionNameEmoteSets, bson.M{"owner_id": userID}}, 52 | {mongo.CollectionNameMessages, bson.M{"author_id": userID}}, 53 | {mongo.CollectionNameMessagesRead, bson.M{"author_id": userID}}, 54 | {mongo.CollectionNameUserPresences, bson.M{"user_id": userID}}, 55 | {mongo.CollectionNameUsers, bson.M{"_id": userID}}, 56 | // {mongo.CollectionNameEntitlements, bson.M{"user_id": user}}, 57 | } 58 | } 59 | 60 | type userDeleteQuery struct { 61 | collection mongo.CollectionName 62 | filter bson.M 63 | } 64 | 65 | type DeleteUserOptions struct { 66 | Actor structures.User 67 | Victim structures.User 68 | } 69 | -------------------------------------------------------------------------------- /data/mutate/user.transfer-user-connection.mutation.go: -------------------------------------------------------------------------------- 1 | package mutate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/common/errors" 7 | "github.com/seventv/common/structures/v3" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func (m *Mutate) TransferUserConnection(ctx context.Context, actor structures.User, transferer, transferee structures.User, connectionID string) error { 13 | // Check permissions 14 | if (!actor.ID.IsZero() && actor.ID != transferer.ID) && actor.GetHighestRole().Position <= transferer.GetHighestRole().Position { 15 | return errors.ErrInsufficientPrivilege().SetDetail("Lower than victim") 16 | } 17 | 18 | // Get connection from the outgoing user 19 | connection, i := transferer.Connections.Get(connectionID) 20 | if i == -1 { 21 | return errors.ErrUnknownUserConnection() 22 | } 23 | 24 | // push connection to recipient 25 | if _, err := m.mongo.Collection("users").UpdateOne(ctx, primitive.M{"_id": transferee.ID}, primitive.M{"$push": primitive.M{"connections": connection}}); err != nil { 26 | zap.S().Errorw("mutate, TransferUserConnection(), couldn't push connection to transferee") 27 | 28 | return errors.ErrInternalServerError() 29 | } 30 | 31 | // delete connection from donor 32 | if _, err := m.mongo.Collection("users").UpdateOne(ctx, primitive.M{"_id": transferer.ID}, primitive.M{"$pull": primitive.M{"connections": connection}}); err != nil { 33 | zap.S().Errorw("mutate, TransferUserConnection(), couldn't pull connection from transferer") 34 | 35 | return errors.ErrInternalServerError() 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /data/query/query.bind_objects.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/common/structures/v3" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | ) 10 | 11 | type QueryBinder struct { 12 | ctx context.Context 13 | q *Query 14 | } 15 | 16 | func (q *Query) NewBinder(ctx context.Context) *QueryBinder { 17 | return &QueryBinder{ctx, q} 18 | } 19 | 20 | func (qb *QueryBinder) MapUsers(users []structures.User, roleEnts ...structures.Entitlement[bson.Raw]) (map[primitive.ObjectID]structures.User, error) { 21 | m := make(map[primitive.ObjectID]structures.User) 22 | entOW := len(roleEnts) > 0 23 | 24 | for _, v := range users { 25 | m[v.ID] = v 26 | 27 | if !entOW { 28 | roleEnts = append(roleEnts, v.Entitlements...) 29 | } 30 | } 31 | 32 | m2 := make(map[primitive.ObjectID][]primitive.ObjectID) 33 | 34 | for _, ent := range roleEnts { 35 | ent, err := structures.ConvertEntitlement[structures.EntitlementDataRole](ent) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | m2[ent.UserID] = append(m2[ent.UserID], ent.Data.RefID) 41 | } 42 | 43 | roles, _ := qb.q.Roles(qb.ctx, bson.M{}) 44 | if len(roles) > 0 { 45 | roleMap := make(map[primitive.ObjectID]structures.Role) 46 | 47 | var defaultRole structures.Role 48 | 49 | for _, r := range roles { 50 | if r.Default { 51 | defaultRole = r 52 | } 53 | 54 | roleMap[r.ID] = r 55 | } 56 | 57 | for key, u := range m { 58 | roleIDs := make([]primitive.ObjectID, len(m2[u.ID])+len(u.RoleIDs)+1) 59 | if defaultRole.ID.IsZero() { 60 | roleIDs[0] = structures.NilRole.ID 61 | } else { 62 | roleIDs[0] = defaultRole.ID 63 | } 64 | 65 | roleIDs[0] = defaultRole.ID 66 | copy(roleIDs[1:], u.RoleIDs) 67 | copy(roleIDs[len(u.RoleIDs)+1:], m2[u.ID]) 68 | 69 | u.Roles = make([]structures.Role, len(roleIDs)) // allocate space on the user's roles slice 70 | 71 | for i, roleID := range roleIDs { 72 | if role, ok := roleMap[roleID]; ok { // add role if exists 73 | u.Roles[i] = role 74 | } else { 75 | u.Roles[i] = structures.NilRole // set nil role if role wasn't found 76 | } 77 | } 78 | 79 | m[key] = u 80 | } 81 | } 82 | 83 | return m, nil 84 | } 85 | -------------------------------------------------------------------------------- /data/query/query.cosmetics.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/common/mongo" 8 | "github.com/seventv/common/structures/v3" 9 | "github.com/seventv/common/utils" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | mongod "go.mongodb.org/mongo-driver/mongo" 13 | "go.mongodb.org/mongo-driver/mongo/options" 14 | ) 15 | 16 | func (q *Query) Cosmetics(ctx context.Context, ids utils.Set[primitive.ObjectID]) ([]structures.Cosmetic[bson.Raw], error) { 17 | mtx := q.mtx("ManyCosmetics") 18 | mtx.Lock() 19 | defer mtx.Unlock() 20 | 21 | k := q.key("cosmetics") 22 | 23 | var ( 24 | result = []structures.Cosmetic[bson.Raw]{} 25 | err error 26 | cur *mongod.Cursor 27 | ) 28 | 29 | // Get cached 30 | if ok := q.getFromMemCache(ctx, k, &result); ok { 31 | goto end 32 | } 33 | 34 | // Query 35 | cur, err = q.mongo.Collection(mongo.CollectionNameCosmetics).Find(ctx, bson.M{}, options.Find().SetNoCursorTimeout(true)) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | if err = cur.All(ctx, &result); err != nil { 41 | return nil, err 42 | } 43 | 44 | // Set cache 45 | if err = q.setInMemCache(ctx, k, &result, time.Second*30); err != nil { 46 | return nil, err 47 | } 48 | 49 | end: 50 | return utils.Filter(result, func(x structures.Cosmetic[bson.Raw]) bool { 51 | return ids.Has(x.ID) 52 | }), nil 53 | } 54 | -------------------------------------------------------------------------------- /data/query/query.roles.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/seventv/common/mongo" 12 | "github.com/seventv/common/structures/v3" 13 | "go.mongodb.org/mongo-driver/bson" 14 | "go.mongodb.org/mongo-driver/mongo/options" 15 | ) 16 | 17 | func (q *Query) Roles(ctx context.Context, filter bson.M) ([]structures.Role, error) { 18 | mtx := q.mtx("ManyRoles") 19 | mtx.Lock() 20 | defer mtx.Unlock() 21 | 22 | hs := "all" 23 | 24 | if len(filter) > 0 { 25 | f, _ := json.Marshal(filter) 26 | h := sha256.New() 27 | h.Write(f) 28 | hs = hex.EncodeToString(h.Sum((nil))) 29 | } 30 | 31 | k := q.key(fmt.Sprintf("roles:%s", hs)) 32 | result := []structures.Role{} 33 | 34 | // Get cached 35 | if ok := q.getFromMemCache(ctx, k, &result); ok { 36 | return result, nil 37 | } 38 | 39 | // Query 40 | cur, err := q.mongo.Collection(mongo.CollectionNameRoles).Find(ctx, filter, options.Find().SetSort(bson.M{"position": -1})) 41 | if err == nil { 42 | if err = cur.All(ctx, &result); err != nil { 43 | return nil, err 44 | } 45 | } 46 | 47 | // Set cache 48 | if err = q.setInMemCache(ctx, k, &result, time.Second*10); err != nil { 49 | return nil, err 50 | } 51 | 52 | return result, nil 53 | } 54 | 55 | type ManyRolesOptions struct { 56 | DefaultOnly bool 57 | } 58 | -------------------------------------------------------------------------------- /data/query/query.system.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/common/structures/v3" 8 | "go.mongodb.org/mongo-driver/bson" 9 | ) 10 | 11 | func (q *Query) GlobalEmoteSet(ctx context.Context) (structures.EmoteSet, error) { 12 | mtx := q.mtx("GlobalEmoteSet") 13 | mtx.Lock() 14 | defer mtx.Unlock() 15 | 16 | k := q.key("global_emote_set") 17 | 18 | set := structures.EmoteSet{} 19 | 20 | // Get cached 21 | if ok := q.getFromMemCache(ctx, k, &set); ok { 22 | return set, nil 23 | } 24 | 25 | sys, err := q.mongo.System(ctx) 26 | if err != nil { 27 | return set, err 28 | } 29 | 30 | set, err = q.EmoteSets(ctx, bson.M{"_id": sys.EmoteSetID}, QueryEmoteSetsOptions{FetchOrigins: true}).First() 31 | if err != nil { 32 | return set, err 33 | } 34 | 35 | // Set cache 36 | if err := q.setInMemCache(ctx, k, set, time.Second*30); err != nil { 37 | return set, err 38 | } 39 | 40 | return set, nil 41 | } 42 | -------------------------------------------------------------------------------- /docker/full.Dockerfile: -------------------------------------------------------------------------------- 1 | # The base image used to build all other images 2 | ARG BASE_IMG=ubuntu:22.04 3 | # The tag to use for golang image 4 | ARG GOLANG_TAG=1.18.3 5 | 6 | # 7 | # Download and install all deps required to run tests and build the go application 8 | # 9 | FROM golang:$GOLANG_TAG as go 10 | 11 | FROM $BASE_IMG as go-builder 12 | WORKDIR /tmp/build 13 | 14 | # update the apt repo and install any deps we might need. 15 | RUN apt-get update && \ 16 | apt-get install -y \ 17 | build-essential \ 18 | make \ 19 | git && \ 20 | apt-get autoremove -y && \ 21 | apt-get clean -y && \ 22 | rm -rf /var/cache/apt/archives /var/lib/apt/lists/* 23 | 24 | ENV PATH /usr/local/go/bin:$PATH 25 | ENV GOPATH /go 26 | ENV PATH $GOPATH/bin:$PATH 27 | COPY --from=go /usr/local /usr/local 28 | COPY --from=go /go /go 29 | 30 | COPY go.mod . 31 | COPY go.sum . 32 | COPY Makefile . 33 | 34 | RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" && chmod -R 777 "$GOPATH" && \ 35 | make deps 36 | 37 | COPY . . 38 | 39 | RUN make generate 40 | 41 | ARG BUILDER 42 | ARG VERSION 43 | 44 | ENV IMAGES_BUILDER=${BUILDER} 45 | ENV IMAGES_VERSION=${VERSION} 46 | 47 | RUN make 48 | 49 | # 50 | # final squashed image 51 | # 52 | FROM $BASE_IMG as final 53 | WORKDIR /app 54 | 55 | RUN apt-get update && \ 56 | apt-get install -y \ 57 | ca-certificates && \ 58 | apt-get autoremove -y && \ 59 | apt-get clean -y && \ 60 | rm -rf /var/cache/apt/archives /var/lib/apt/lists/* 61 | 62 | COPY --from=go-builder /tmp/build/out . 63 | 64 | CMD ./api 65 | -------------------------------------------------------------------------------- /docker/partial.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMG=ubuntu:22.04 2 | 3 | FROM $BASE_IMG 4 | WORKDIR /app 5 | 6 | RUN apt-get update && \ 7 | apt-get install -y \ 8 | ca-certificates && \ 9 | apt-get autoremove -y && \ 10 | apt-get clean -y && \ 11 | rm -rf /var/cache/apt/archives /var/lib/apt/lists/* 12 | 13 | COPY out/api api 14 | COPY portal portal 15 | 16 | CMD ./api 17 | -------------------------------------------------------------------------------- /example.config.yaml: -------------------------------------------------------------------------------- 1 | # Log Level 2 | level: info 3 | 4 | # Temporary Folder (for emote uploads) 5 | temp_folder: "tmp" 6 | 7 | # URL to the web-app and cdn 8 | website_url: https://example.com/ 9 | website_old_url: https://old.example.com/ 10 | cdn_url: cdn.7tv.app 11 | 12 | # Redis Settings 13 | redis: 14 | addresses: 15 | - redis://foo.bar 16 | sentinel: false 17 | master_name: "" 18 | username: "" 19 | password: "" 20 | database: 0 21 | 22 | # MongoDB Settings 23 | mongo: 24 | uri: mongodb://username:password@url:port/?authSource=db 25 | db: db 26 | direct: false 27 | 28 | # HTTP Server Settings 29 | http: 30 | addr: "0.0.0.0" 31 | ports: 32 | gql: 3000 33 | rest: 3100 34 | type: tcp 35 | version_suffix: "" 36 | 37 | # The default amount of quota points granted on a new query 38 | quota_default_limit: 1000 39 | # The maximum amount of queries exceeding quota before a client's IP is temporarily blocked 40 | quota_max_bad_queries: 5 41 | 42 | # Cookie settings 43 | cookie_domain: "localhost" 44 | cookie_secure: false 45 | 46 | # RabbitMQ Settings 47 | rmq: 48 | uri: "" 49 | job_queue_name: "" 50 | result_queue_name: "" 51 | update_queue_name: "" 52 | 53 | aws: 54 | session_token: "" 55 | secret_key: "" 56 | region: "" 57 | internal_bucket: "" 58 | public_bucket: "" 59 | 60 | # Configure platforms 61 | platforms: 62 | twitch: 63 | enabled: true 64 | client_id: "" 65 | client_secret: "" 66 | redirect_uri: "" 67 | 68 | credentials: 69 | jwt_secret: "" 70 | -------------------------------------------------------------------------------- /gqlgen.v3.yml: -------------------------------------------------------------------------------- 1 | # Where are all the schema files located? globs are supported eg src/**/*.graphqls 2 | schema: 3 | - internal/api/gql/v3/schema/*.gql 4 | 5 | # Where should the generated server code go? 6 | exec: 7 | filename: internal/api/gql/v3/gen/generated/generated-gqlgen.go 8 | package: generated 9 | 10 | # Uncomment to enable federation 11 | # federation: 12 | # filename: graph/generated/federation-gqlgen.go 13 | # package: generated 14 | 15 | # Where should any generated models go? 16 | model: 17 | filename: internal/api/gql/v3/gen/model/models-gqlgen.go 18 | package: model 19 | 20 | # Where should the resolver implementations go? 21 | # resolver: 22 | # layout: follow-schema 23 | # dir: graph 24 | # package: graph 25 | 26 | # Optional: turn on use `gqlgen:"fieldName"` tags in your models 27 | # struct_tag: json 28 | 29 | # Optional: turn on to use []Thing instead of []*Thing 30 | # omit_slice_element_pointers: false 31 | 32 | # Optional: set to speed up generation time by not performing a final validation pass. 33 | # skip_validation: true 34 | 35 | # gqlgen will search for any type names in the schema in these go packages 36 | # if they match it will use them, otherwise it will generate them. 37 | # autobind: 38 | # - "github.com/seventv/api/internal/api/gql/v3/gen/model" 39 | 40 | # This section declares type mapping between the GraphQL and go type systems 41 | # 42 | # The first line in each type will be used as defaults for resolver arguments and 43 | # modelgen, the others will be allowed when binding to fields. Configure them to 44 | # your liking 45 | models: 46 | ID: 47 | model: 48 | - github.com/99designs/gqlgen/graphql.ID 49 | - github.com/99designs/gqlgen/graphql.Int 50 | - github.com/99designs/gqlgen/graphql.Int64 51 | - github.com/99designs/gqlgen/graphql.Int32 52 | Int: 53 | model: 54 | - github.com/99designs/gqlgen/graphql.Int 55 | - github.com/99designs/gqlgen/graphql.Int64 56 | - github.com/99designs/gqlgen/graphql.Int32 57 | 58 | ObjectID: 59 | model: github.com/seventv/api/internal/api/gql/scalar.ObjectID 60 | 61 | StringMap: 62 | model: github.com/seventv/api/internal/api/gql/scalar.StringMap 63 | 64 | ArbitraryMap: 65 | model: github.com/seventv/api/internal/api/gql/scalar.ArbitraryMap 66 | -------------------------------------------------------------------------------- /internal/api/eventbridge/eventbridge.go: -------------------------------------------------------------------------------- 1 | package eventbridge 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | 9 | "github.com/seventv/api/data/events" 10 | "github.com/seventv/api/internal/global" 11 | "github.com/seventv/common/utils" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | const SESSION_ID_KEY = utils.Key("session_id") 16 | 17 | func handle(gctx global.Context, body []byte) ([]events.Message[json.RawMessage], error) { 18 | ctx, cancel := context.WithCancel(gctx) 19 | defer cancel() 20 | 21 | return handleUserState(gctx, ctx, getCommandBody(body)) 22 | } 23 | 24 | // The EventAPI Bridge allows passing commands from the eventapi via the websocket 25 | func New(gctx global.Context) <-chan struct{} { 26 | done := make(chan struct{}) 27 | 28 | createUserStateLoader(gctx) 29 | 30 | go func() { 31 | err := http.ListenAndServe(gctx.Config().EventBridge.Bind, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | var err error 33 | 34 | // read body into byte slice 35 | if r.Body == nil { 36 | zap.S().Errorw("invalid eventapi bridge message", "err", "empty body") 37 | } 38 | 39 | defer r.Body.Close() 40 | 41 | var buf bytes.Buffer 42 | if _, err = buf.ReadFrom(r.Body); err != nil { 43 | zap.S().Errorw("invalid eventapi bridge message", "err", err) 44 | 45 | return 46 | } 47 | 48 | result, err := handle(gctx, buf.Bytes()) 49 | if err != nil { 50 | zap.S().Errorw("eventapi bridge command failed", "error", err) 51 | } 52 | 53 | if result == nil { 54 | result = []events.Message[json.RawMessage]{} 55 | } 56 | 57 | if err := json.NewEncoder(w).Encode(result); err != nil { 58 | zap.S().Errorw("eventapi bridge command failed", "error", err) 59 | } 60 | 61 | w.WriteHeader(200) 62 | })) 63 | 64 | if err != nil { 65 | zap.S().Errorw("eventapi bridge failed", "error", err) 66 | 67 | close(done) 68 | } 69 | }() 70 | 71 | go func() { 72 | <-gctx.Done() 73 | close(done) 74 | }() 75 | 76 | return done 77 | } 78 | 79 | func getCommandBody(body []byte) events.BridgedCommandBody { 80 | var result events.BridgedCommandBody 81 | 82 | if err := json.Unmarshal(body, &result); err != nil { 83 | zap.S().Errorw("invalid eventapi bridge message", "err", err) 84 | } 85 | 86 | return result 87 | } 88 | -------------------------------------------------------------------------------- /internal/api/gql/scalar/arbitrarymap.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/99designs/gqlgen/graphql" 9 | ) 10 | 11 | func MarshalArbitraryMap(m map[string]any) graphql.Marshaler { 12 | return graphql.WriterFunc(func(w io.Writer) { 13 | j, _ := json.Marshal(m) 14 | _, _ = w.Write(j) 15 | }) 16 | } 17 | 18 | func UnmarshalArbitraryMap(m any) (map[string]any, error) { 19 | switch v := m.(type) { 20 | case map[string]any: 21 | return v, nil 22 | default: 23 | return nil, fmt.Errorf("%T is not map[string]any", v) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/api/gql/scalar/event.go: -------------------------------------------------------------------------------- 1 | package models 2 | -------------------------------------------------------------------------------- /internal/api/gql/scalar/objectid.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/99designs/gqlgen/graphql" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | ) 10 | 11 | func MarshalObjectID(id primitive.ObjectID) graphql.Marshaler { 12 | return graphql.WriterFunc(func(w io.Writer) { 13 | _, _ = w.Write([]byte(fmt.Sprintf(`"%s"`, id.Hex()))) 14 | }) 15 | } 16 | 17 | func UnmarshalObjectID(v interface{}) (primitive.ObjectID, error) { 18 | switch v := v.(type) { 19 | case string: 20 | return primitive.ObjectIDFromHex(v) 21 | default: 22 | return primitive.NilObjectID, fmt.Errorf("%T is not an ObjectID", v) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/api/gql/scalar/stringmap.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/99designs/gqlgen/graphql" 9 | ) 10 | 11 | func MarshalStringMap(m map[string]string) graphql.Marshaler { 12 | return graphql.WriterFunc(func(w io.Writer) { 13 | j, _ := json.Marshal(m) 14 | _, _ = w.Write(j) 15 | }) 16 | } 17 | 18 | func UnmarshalStringMap(m interface{}) (map[string]string, error) { 19 | switch v := m.(type) { 20 | case map[string]string: 21 | return v, nil 22 | default: 23 | return nil, fmt.Errorf("%T is not map[string]string", v) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/api/gql/v3/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/internal/constant" 7 | "github.com/seventv/common/structures/v3" 8 | ) 9 | 10 | func For(ctx context.Context) structures.User { 11 | raw, _ := ctx.Value(constant.UserKey).(structures.User) 12 | return raw 13 | } 14 | -------------------------------------------------------------------------------- /internal/api/gql/v3/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/99designs/gqlgen/graphql" 8 | "github.com/seventv/api/internal/global" 9 | "github.com/seventv/common/redis" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | type redisCache struct { 14 | gCtx global.Context 15 | prefix string 16 | ttl time.Duration 17 | } 18 | 19 | func NewRedisCache(ctx global.Context, prefix string, ttl time.Duration) graphql.Cache { 20 | return &redisCache{ 21 | gCtx: ctx, 22 | prefix: prefix, 23 | ttl: ttl, 24 | } 25 | } 26 | 27 | func (c *redisCache) Get(ctx context.Context, key string) (value interface{}, ok bool) { 28 | v, err := c.gCtx.Inst().Redis.Get(ctx, redis.Key(c.prefix+key)) 29 | if err == nil { 30 | return v, true 31 | } 32 | 33 | if err != redis.Nil { 34 | zap.S().Errorw("failed to query redis", 35 | "err", err, 36 | ) 37 | } 38 | 39 | return nil, false 40 | } 41 | 42 | func (c *redisCache) Add(ctx context.Context, key string, value interface{}) { 43 | err := c.gCtx.Inst().Redis.SetEX(ctx, redis.Key(c.prefix+key), value, c.ttl) 44 | if err != nil { 45 | zap.S().Errorw("failed to query redis", 46 | "err", err, 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/api/gql/v3/complexity/complexity.go: -------------------------------------------------------------------------------- 1 | // TODO 2 | package complexity 3 | 4 | import ( 5 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 6 | "github.com/seventv/api/internal/global" 7 | ) 8 | 9 | func New(ctx global.Context) generated.ComplexityRoot { 10 | return generated.ComplexityRoot{} 11 | } 12 | 13 | func NewOps(ctx global.Context) generated.ComplexityRoot { 14 | return generated.ComplexityRoot{} 15 | } 16 | -------------------------------------------------------------------------------- /internal/api/gql/v3/gen/generated/gen.go: -------------------------------------------------------------------------------- 1 | package generated 2 | -------------------------------------------------------------------------------- /internal/api/gql/v3/gen/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | -------------------------------------------------------------------------------- /internal/api/gql/v3/helpers/errors.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | type ErrorGQL string 4 | 5 | func (e ErrorGQL) Error() string { 6 | return string(e) 7 | } 8 | 9 | const ( 10 | ErrUnauthorized ErrorGQL = "unauthorized" 11 | ErrAccessDenied ErrorGQL = "access denied" 12 | ErrUnknownEmote ErrorGQL = "unknown emote" 13 | ErrUnknownUser ErrorGQL = "unknown user" 14 | ErrUnknownRole ErrorGQL = "unknown role" 15 | ErrUnknownReport ErrorGQL = "unknown report" 16 | ErrBadObjectID ErrorGQL = "bad object id" 17 | ErrInternalServerError ErrorGQL = "internal server error" 18 | ErrBadInt ErrorGQL = "bad int" 19 | ErrDontBeSilly ErrorGQL = "don't be silly" 20 | ) 21 | -------------------------------------------------------------------------------- /internal/api/gql/v3/helpers/fields.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/99designs/gqlgen/graphql" 7 | ) 8 | 9 | type Field struct { 10 | Name string 11 | Children map[string]Field 12 | } 13 | 14 | func GetFields(ctx context.Context) map[string]Field { 15 | return GetNestedPreloads( 16 | graphql.GetOperationContext(ctx), 17 | graphql.CollectFieldsCtx(ctx, nil), 18 | ) 19 | } 20 | 21 | func GetNestedPreloads(ctx *graphql.OperationContext, fields []graphql.CollectedField) map[string]Field { 22 | f := map[string]Field{} 23 | for _, column := range fields { 24 | f[column.Name] = Field{ 25 | Name: column.Name, 26 | Children: GetNestedPreloads(ctx, graphql.CollectFields(ctx, column.Selections, nil)), 27 | } 28 | } 29 | 30 | return f 31 | } 32 | -------------------------------------------------------------------------------- /internal/api/gql/v3/helpers/filter_images.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 5 | "github.com/seventv/common/utils" 6 | ) 7 | 8 | func FilterImages(images []*model.Image, formats []model.ImageFormat) []*model.Image { 9 | if len(formats) == 0 { // default to all images 10 | return images 11 | } 12 | 13 | result := []*model.Image{} 14 | 15 | for _, im := range images { 16 | if !utils.Contains(formats, im.Format) { 17 | continue 18 | } 19 | 20 | result = append(result, im) 21 | } 22 | 23 | return result 24 | } 25 | -------------------------------------------------------------------------------- /internal/api/gql/v3/helpers/keys.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import "github.com/seventv/common/utils" 4 | 5 | const ( 6 | SessionToken utils.Key = "session-token" 7 | RateLimitFunc utils.Key = "rate-limit-fn" 8 | PipelineKey utils.Key = "pipeline" 9 | ) 10 | -------------------------------------------------------------------------------- /internal/api/gql/v3/helpers/transform.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 5 | "github.com/seventv/common/structures/v3" 6 | ) 7 | 8 | func ReportStructureToModel(s structures.Report) *model.Report { 9 | assignees := make([]*model.User, len(s.AssigneeIDs)) 10 | for i, oid := range s.AssigneeIDs { 11 | assignees[i] = &model.User{ID: oid} 12 | } 13 | 14 | return &model.Report{ 15 | ID: s.ID, 16 | TargetKind: int(s.TargetKind), 17 | TargetID: s.TargetID, 18 | ActorID: s.ActorID, 19 | Subject: s.Subject, 20 | Body: s.Body, 21 | Priority: int(s.Priority), 22 | Status: model.ReportStatus(s.Status), 23 | CreatedAt: s.CreatedAt, 24 | Notes: []string{}, 25 | Assignees: assignees, 26 | } 27 | } 28 | 29 | func BanStructureToModel(s structures.Ban) *model.Ban { 30 | return &model.Ban{ 31 | ID: s.ID, 32 | Reason: s.Reason, 33 | Effects: int(s.Effects), 34 | ExpireAt: s.ExpireAt, 35 | CreatedAt: s.ID.Timestamp(), 36 | ActorID: s.ActorID, 37 | VictimID: s.VictimID, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/api/gql/v3/middleware/has-permission.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/99designs/gqlgen/graphql" 7 | "github.com/seventv/api/internal/api/gql/v3/auth" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 9 | "github.com/seventv/api/internal/global" 10 | "github.com/seventv/common/errors" 11 | "github.com/seventv/common/structures/v3" 12 | ) 13 | 14 | func hasPermission(gCtx global.Context) func(ctx context.Context, obj interface{}, next graphql.Resolver, role []model.Permission) (res interface{}, err error) { 15 | return func(ctx context.Context, obj interface{}, next graphql.Resolver, role []model.Permission) (res interface{}, err error) { 16 | user := auth.For(ctx) 17 | if user.ID.IsZero() { 18 | return nil, errors.ErrUnauthorized() 19 | } 20 | 21 | var perms structures.RolePermission 22 | 23 | for _, v := range role { 24 | switch v { 25 | case model.PermissionBypassPrivacy: 26 | perms |= structures.RolePermissionBypassPrivacy 27 | case model.PermissionCreateEmoteSet: 28 | perms |= structures.RolePermissionCreateEmoteSet 29 | case model.PermissionEditEmote: 30 | perms |= structures.RolePermissionEditEmoteSet 31 | case model.PermissionCreateEmote: 32 | perms |= structures.RolePermissionCreateEmote 33 | case model.PermissionEditAnyEmote: 34 | perms |= structures.RolePermissionEditAnyEmote 35 | case model.PermissionEditAnyEmoteSet: 36 | perms |= structures.RolePermissionEditAnyEmoteSet 37 | case model.PermissionFeatureProfilePictureAnimation: 38 | perms |= structures.RolePermissionFeatureProfilePictureAnimation 39 | case model.PermissionFeatureZerowidthEmoteType: 40 | perms |= structures.RolePermissionFeatureZeroWidthEmoteType 41 | case model.PermissionManageBans: 42 | perms |= structures.RolePermissionManageBans 43 | case model.PermissionManageCosmetics: 44 | perms |= structures.RolePermissionManageCosmetics 45 | case model.PermissionManageContent: 46 | perms |= structures.RolePermissionManageContent 47 | case model.PermissionManageReports: 48 | perms |= structures.RolePermissionManageReports 49 | case model.PermissionManageRoles: 50 | perms |= structures.RolePermissionManageRoles 51 | case model.PermissionManageStack: 52 | perms |= structures.RolePermissionManageStack 53 | case model.PermissionManageUsers: 54 | perms |= structures.RolePermissionManageUsers 55 | case model.PermissionCreateReport: 56 | perms |= structures.RolePermissionCreateReport 57 | case model.PermissionSendMessages: 58 | perms |= structures.RolePermissionSendMessages 59 | case model.PermissionSuperAdministrator: 60 | perms |= structures.RolePermissionSuperAdministrator 61 | } 62 | } 63 | 64 | if !user.HasPermission(perms) { 65 | return nil, errors.ErrUnauthorized() 66 | } 67 | 68 | return next(ctx) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/api/gql/v3/middleware/internal.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/99designs/gqlgen/graphql" 7 | "github.com/seventv/api/internal/global" 8 | "github.com/seventv/common/errors" 9 | ) 10 | 11 | func internal(gCtx global.Context) func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) { 12 | return func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) { 13 | return nil, errors.ErrInternalField() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/api/gql/v3/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 5 | "github.com/seventv/api/internal/global" 6 | ) 7 | 8 | func New(ctx global.Context) generated.DirectiveRoot { 9 | return generated.DirectiveRoot{ 10 | HasPermissions: hasPermission(ctx), 11 | Internal: internal(ctx), 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/ban/ban.go: -------------------------------------------------------------------------------- 1 | package ban 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 9 | "github.com/seventv/api/internal/api/gql/v3/types" 10 | ) 11 | 12 | type Resolver struct { 13 | types.Resolver 14 | } 15 | 16 | func New(r types.Resolver) generated.BanResolver { 17 | return &Resolver{r} 18 | } 19 | 20 | func (r *Resolver) Victim(ctx context.Context, obj *model.Ban) (*model.User, error) { 21 | user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.VictimID) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return modelgql.UserModel(r.Ctx.Inst().Modelizer.User(user)), nil 27 | } 28 | 29 | func (r *Resolver) Actor(ctx context.Context, obj *model.Ban) (*model.User, error) { 30 | user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.ActorID) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return modelgql.UserModel(r.Ctx.Inst().Modelizer.User(user)), nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/emote/emote.channels.go: -------------------------------------------------------------------------------- 1 | package emote 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/seventv/api/data/model/modelgql" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 9 | "github.com/seventv/common/errors" 10 | "github.com/seventv/common/structures/v3" 11 | ) 12 | 13 | const EMOTE_CHANNEL_QUERY_SIZE_MOST = 50 14 | const EMOTE_CHANNEL_QUERY_PAGE_CAP = 500 15 | 16 | func (r *Resolver) Channels(ctx context.Context, obj *model.Emote, pageArg *int, limitArg *int) (*model.UserSearchResult, error) { 17 | limit := EMOTE_CHANNEL_QUERY_SIZE_MOST 18 | if limitArg != nil { 19 | limit = *limitArg 20 | } 21 | 22 | if limit > EMOTE_CHANNEL_QUERY_SIZE_MOST { 23 | limit = EMOTE_CHANNEL_QUERY_SIZE_MOST 24 | } else if limit < 1 { 25 | return nil, errors.ErrInvalidRequest().SetDetail("limit cannot be less than 1") 26 | } 27 | 28 | page := 1 29 | if pageArg != nil { 30 | page = *pageArg 31 | } 32 | 33 | if page < 1 { 34 | page = 1 35 | } 36 | 37 | if page > EMOTE_CHANNEL_QUERY_PAGE_CAP { 38 | return nil, errors.ErrInvalidRequest().SetFields(errors.Fields{ 39 | "PAGE": strconv.Itoa(page), 40 | "LIMIT": strconv.Itoa(EMOTE_CHANNEL_QUERY_PAGE_CAP), 41 | }).SetDetail("No further pagination is allowed") 42 | } 43 | 44 | users, count, err := r.Ctx.Inst().Query.EmoteChannels(ctx, obj.ID, page, limit) 45 | if err != nil && !errors.Compare(err, errors.ErrNoItems()) { 46 | return nil, err 47 | } 48 | 49 | models := make([]*model.UserPartial, len(users)) 50 | 51 | for i, u := range users { 52 | if u.ID.IsZero() { 53 | u = structures.DeletedUser 54 | } 55 | 56 | models[i] = modelgql.UserPartialModel(r.Ctx.Inst().Modelizer.User(u).ToPartial()) 57 | } 58 | 59 | results := model.UserSearchResult{ 60 | Total: int(count), 61 | Items: models, 62 | } 63 | 64 | return &results, nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/emote/emote.merge.ops.go: -------------------------------------------------------------------------------- 1 | package emote 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/data/mutate" 8 | "github.com/seventv/api/internal/api/gql/v3/auth" 9 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 10 | "github.com/seventv/common/errors" 11 | "github.com/seventv/common/structures/v3" 12 | "go.mongodb.org/mongo-driver/bson" 13 | "go.mongodb.org/mongo-driver/bson/primitive" 14 | ) 15 | 16 | // Merge implements generated.EmoteOpsResolver 17 | func (r *ResolverOps) Merge(ctx context.Context, obj *model.EmoteOps, targetID primitive.ObjectID, reasonArg *string) (*model.Emote, error) { 18 | actor := auth.For(ctx) 19 | if actor.ID.IsZero() { 20 | return nil, errors.ErrUnauthorized() 21 | } 22 | 23 | if !actor.HasPermission(structures.RolePermissionEditAnyEmote) { 24 | return nil, errors.ErrInsufficientPrivilege() 25 | } 26 | 27 | emote, err := r.Ctx.Inst().Query.Emotes(ctx, bson.M{"versions.id": obj.ID}).First() 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | ver, ind := emote.GetVersion(obj.ID) 33 | if ind < 0 { 34 | return nil, errors.ErrUnknownEmote() 35 | } 36 | 37 | eb := structures.NewEmoteBuilder(emote) 38 | 39 | targetEmote, err := r.Ctx.Inst().Query.Emotes(ctx, bson.M{"versions.id": targetID}).First() 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | reason := "" 45 | if reasonArg != nil { 46 | reason = *reasonArg 47 | } 48 | 49 | if err := r.Ctx.Inst().Mutate.MergeEmote(ctx, eb, mutate.MergeEmoteOptions{ 50 | Actor: actor, 51 | NewEmote: targetEmote, 52 | VersionID: ver.ID, 53 | Reason: reason, 54 | SkipValidation: false, 55 | }); err != nil { 56 | return nil, err 57 | } 58 | 59 | returnEmote, err := r.Ctx.Inst().Loaders.EmoteByID().Load(targetEmote.ID) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | return modelgql.EmoteModel(r.Ctx.Inst().Modelizer.Emote(returnEmote)), nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/emote/emote.partial.go: -------------------------------------------------------------------------------- 1 | package emote 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 9 | "github.com/seventv/api/internal/api/gql/v3/types" 10 | "github.com/seventv/common/errors" 11 | "github.com/seventv/common/structures/v3" 12 | ) 13 | 14 | type ResolverPartial struct { 15 | types.Resolver 16 | } 17 | 18 | func NewPartial(r types.Resolver) generated.EmotePartialResolver { 19 | return &ResolverPartial{r} 20 | } 21 | 22 | func (r *ResolverPartial) Owner(ctx context.Context, obj *model.EmotePartial) (*model.UserPartial, error) { 23 | if obj.Owner != nil && obj.Owner.ID != structures.DeletedUser.ID { 24 | return obj.Owner, nil 25 | } 26 | 27 | user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.OwnerID) 28 | if err != nil { 29 | if errors.Compare(err, errors.ErrUnknownUser()) { 30 | return nil, nil 31 | } 32 | 33 | return nil, err 34 | } 35 | 36 | return modelgql.UserPartialModel(r.Ctx.Inst().Modelizer.User(user).ToPartial()), nil 37 | } 38 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/emoteset/active-emote/active-emote.go: -------------------------------------------------------------------------------- 1 | package activeemote 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 9 | "github.com/seventv/api/internal/api/gql/v3/types" 10 | "github.com/seventv/common/errors" 11 | ) 12 | 13 | type Resolver struct { 14 | types.Resolver 15 | } 16 | 17 | func New(r types.Resolver) generated.ActiveEmoteResolver { 18 | return &Resolver{r} 19 | } 20 | 21 | func (r *Resolver) Actor(ctx context.Context, obj *model.ActiveEmote) (*model.UserPartial, error) { 22 | if obj.Actor == nil { 23 | return nil, nil 24 | } 25 | 26 | user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.Actor.ID) 27 | if err != nil { 28 | if errors.Compare(err, errors.ErrUnknownUser()) { 29 | return nil, nil 30 | } 31 | 32 | return nil, err 33 | } 34 | 35 | return modelgql.UserPartialModel(r.Ctx.Inst().Modelizer.User(user).ToPartial()), nil 36 | } 37 | 38 | func (r *Resolver) Data(ctx context.Context, obj *model.ActiveEmote) (*model.EmotePartial, error) { 39 | emote, err := r.Ctx.Inst().Loaders.EmoteByID().Load(obj.ID) 40 | if err != nil { 41 | if errors.Compare(err, errors.ErrUnknownEmote()) { 42 | return nil, nil 43 | } 44 | 45 | return nil, err 46 | } 47 | 48 | return modelgql.EmotePartialModel(r.Ctx.Inst().Modelizer.Emote(emote).ToPartial()), nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/emoteset/emoteset.go: -------------------------------------------------------------------------------- 1 | package emoteset 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 9 | "github.com/seventv/api/internal/api/gql/v3/types" 10 | ) 11 | 12 | type Resolver struct { 13 | types.Resolver 14 | } 15 | 16 | func New(r types.Resolver) generated.EmoteSetResolver { 17 | return &Resolver{r} 18 | } 19 | 20 | func (r *Resolver) Owner(ctx context.Context, obj *model.EmoteSet) (*model.UserPartial, error) { 21 | if obj.OwnerID == nil { 22 | return nil, nil 23 | } 24 | 25 | user, err := r.Ctx.Inst().Loaders.UserByID().Load(*obj.OwnerID) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return modelgql.UserPartialModel(r.Ctx.Inst().Modelizer.User(user).ToPartial()), nil 31 | } 32 | 33 | func (*Resolver) Emotes(ctx context.Context, obj *model.EmoteSet, limit *int, origins *bool) ([]*model.ActiveEmote, error) { 34 | // remove foreign emotes? 35 | cut := len(obj.Emotes) 36 | 37 | if origins != nil && !*origins { 38 | for i, e := range obj.Emotes { 39 | if !e.OriginID.IsZero() { 40 | cut = i 41 | } 42 | } 43 | } 44 | 45 | emotes := make([]*model.ActiveEmote, cut) 46 | copy(emotes, obj.Emotes[:cut]) 47 | 48 | if limit != nil && *limit < len(emotes) { 49 | emotes = emotes[:*limit] 50 | } 51 | 52 | return emotes, nil 53 | } 54 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/image-host/image-host.go: -------------------------------------------------------------------------------- 1 | package imagehost 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/api/internal/api/gql/v3/types" 9 | "github.com/seventv/common/utils" 10 | ) 11 | 12 | type Resolver struct { 13 | types.Resolver 14 | } 15 | 16 | func New(r types.Resolver) generated.ImageHostResolver { 17 | return &Resolver{r} 18 | } 19 | 20 | func (*Resolver) Files(ctx context.Context, obj *model.ImageHost, formats []model.ImageFormat) ([]*model.Image, error) { 21 | if len(formats) == 0 { 22 | return obj.Files, nil 23 | } 24 | 25 | for i := 0; i < len(obj.Files); i++ { 26 | f := obj.Files[i] 27 | if utils.Contains(formats, f.Format) { 28 | continue 29 | } 30 | 31 | obj.Files = utils.SliceRemove(obj.Files, i) 32 | i-- 33 | } 34 | 35 | return obj.Files, nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/mutation/mutation.ban.go: -------------------------------------------------------------------------------- 1 | package mutation 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/api/data/mutate" 8 | "github.com/seventv/api/internal/api/gql/v3/auth" 9 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 10 | "github.com/seventv/api/internal/api/gql/v3/helpers" 11 | "github.com/seventv/common/errors" 12 | "github.com/seventv/common/mongo" 13 | "github.com/seventv/common/structures/v3" 14 | "go.mongodb.org/mongo-driver/bson" 15 | "go.mongodb.org/mongo-driver/bson/primitive" 16 | ) 17 | 18 | func (r *Resolver) CreateBan(ctx context.Context, victimID primitive.ObjectID, reason string, effects int, expireAtArg *time.Time, anonymousArg *bool) (*model.Ban, error) { 19 | // Get the actor uszer² 20 | actor := auth.For(ctx) 21 | if actor.ID.IsZero() { 22 | return nil, errors.ErrUnauthorized() 23 | } 24 | 25 | // When the ban expires 26 | expireAt := time.Now().AddDate(0, 0, 3) 27 | if expireAtArg != nil { 28 | expireAt = *expireAtArg 29 | } 30 | 31 | // Fetch the victim user 32 | victim, err := r.Ctx.Inst().Query.Users(ctx, bson.M{"_id": victimID}).First() 33 | if err != nil { 34 | if victim.ID.IsZero() { 35 | return nil, errors.ErrUnknownUser() 36 | } 37 | 38 | return nil, err 39 | } 40 | 41 | // Create the ban 42 | bb := structures.NewBanBuilder(structures.Ban{}). 43 | SetActorID(actor.ID). 44 | SetVictimID(victim.ID). 45 | SetReason(reason). 46 | SetExpireAt(expireAt). 47 | SetEffects(structures.BanEffect(effects)) 48 | if err := r.Ctx.Inst().Mutate.CreateBan(ctx, bb, mutate.CreateBanOptions{ 49 | Actor: &actor, 50 | Victim: &victim, 51 | }); err != nil { 52 | return nil, err 53 | } 54 | 55 | return helpers.BanStructureToModel(bb.Ban), nil 56 | } 57 | 58 | func (r *Resolver) EditBan(ctx context.Context, banID primitive.ObjectID, reason *string, effects *int, expireAt *string) (*model.Ban, error) { 59 | actor := auth.For(ctx) 60 | 61 | ban := structures.Ban{} 62 | if err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameBans).FindOne(ctx, bson.M{ 63 | "_id": banID, 64 | }).Decode(&ban); err != nil { 65 | return nil, err 66 | } 67 | 68 | bb := structures.NewBanBuilder(ban) 69 | 70 | if reason != nil { 71 | bb.SetReason(*reason) 72 | } 73 | 74 | if effects != nil { 75 | bb.SetEffects(structures.BanEffect(*effects)) 76 | } 77 | 78 | if expireAt != nil { 79 | at, err := time.Parse(time.RFC3339, *expireAt) 80 | if err != nil { 81 | return nil, errors.ErrInvalidRequest().SetDetail("Unable to parse time: %s", err.Error()) 82 | } 83 | 84 | bb.SetExpireAt(at) 85 | } 86 | 87 | if err := r.Ctx.Inst().Mutate.EditBan(ctx, bb, mutate.EditBanOptions{ 88 | Actor: &actor, 89 | }); err != nil { 90 | return nil, err 91 | } 92 | 93 | return nil, nil 94 | } 95 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/mutation/mutation.cosmetics.go: -------------------------------------------------------------------------------- 1 | package mutation 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/common/errors" 9 | "github.com/seventv/common/mongo" 10 | "github.com/seventv/common/structures/v3" 11 | "github.com/seventv/common/utils" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *Resolver) CreateCosmeticPaint(ctx context.Context, def model.CosmeticPaintInput) (primitive.ObjectID, error) { 17 | mainColor := 0 18 | if def.Color != nil { 19 | mainColor = *def.Color 20 | } 21 | 22 | angle := 90 23 | if def.Angle != nil { 24 | angle = *def.Angle 25 | } 26 | 27 | shape := "" 28 | if def.Shape != nil { 29 | shape = *def.Shape 30 | } 31 | 32 | imgURL := "" 33 | if def.ImageURL != nil { 34 | imgURL = *def.ImageURL 35 | } 36 | 37 | stops := make([]structures.CosmeticPaintGradientStop, len(def.Stops)) 38 | for i, st := range def.Stops { 39 | stops[i] = structures.CosmeticPaintGradientStop{ 40 | At: st.At, 41 | Color: utils.Color(st.Color), 42 | } 43 | } 44 | 45 | shadows := make([]structures.CosmeticPaintDropShadow, len(def.Shadows)) 46 | for i, sh := range def.Shadows { 47 | shadows[i] = structures.CosmeticPaintDropShadow{ 48 | OffsetX: sh.XOffset, 49 | OffsetY: sh.YOffset, 50 | Radius: sh.Radius, 51 | Color: utils.Color(sh.Color), 52 | } 53 | } 54 | 55 | cos := structures.Cosmetic[structures.CosmeticDataPaint]{ 56 | ID: primitive.NewObjectIDFromTimestamp(time.Now()), 57 | Kind: structures.CosmeticKindNametagPaint, 58 | Priority: 0, 59 | Name: def.Name, 60 | Data: structures.CosmeticDataPaint{ 61 | Function: structures.CosmeticPaintGradientFunction(def.Function), 62 | Color: utils.PointerOf(utils.Color(mainColor)), 63 | Stops: stops, 64 | Repeat: def.Repeat, 65 | Angle: int32(angle), 66 | Shape: shape, 67 | ImageURL: imgURL, 68 | DropShadows: shadows, 69 | }, 70 | } 71 | 72 | result, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameCosmetics).InsertOne(ctx, cos) 73 | if err != nil { 74 | zap.S().Errorw("failed to create new paint cosmetic", 75 | "error", err, 76 | ) 77 | 78 | return primitive.NilObjectID, errors.ErrInternalServerError().SetDetail(err.Error()) 79 | } 80 | 81 | id, _ := result.InsertedID.(primitive.ObjectID) 82 | 83 | return id, nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/mutation/mutation.emote.go: -------------------------------------------------------------------------------- 1 | package mutation 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | ) 9 | 10 | func (*Resolver) Emote(ctx context.Context, id primitive.ObjectID) (*model.EmoteOps, error) { 11 | return &model.EmoteOps{ 12 | ID: id, 13 | }, nil 14 | } 15 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/mutation/mutation.emoteset.go: -------------------------------------------------------------------------------- 1 | package mutation 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/data/mutate" 8 | "github.com/seventv/api/internal/api/gql/v3/auth" 9 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 10 | "github.com/seventv/common/structures/v3" 11 | "go.mongodb.org/mongo-driver/bson" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | ) 14 | 15 | func (r *Resolver) EmoteSet(ctx context.Context, id primitive.ObjectID) (*model.EmoteSetOps, error) { 16 | return &model.EmoteSetOps{ 17 | ID: id, 18 | }, nil 19 | } 20 | 21 | // CreateEmoteSet: create a new emote set 22 | func (r *Resolver) CreateEmoteSet(ctx context.Context, userID primitive.ObjectID, input model.CreateEmoteSetInput) (*model.EmoteSet, error) { 23 | actor := auth.For(ctx) 24 | 25 | // Set up emote set builder 26 | isPrivileged := false 27 | if input.Privileged != nil && *input.Privileged { 28 | isPrivileged = true 29 | } 30 | 31 | b := structures.NewEmoteSetBuilder(structures.EmoteSet{Emotes: []structures.ActiveEmote{}}). 32 | SetName(input.Name). 33 | SetPrivileged(isPrivileged). 34 | SetOwnerID(userID). 35 | SetCapacity(300) 36 | 37 | // Execute mutation 38 | if err := r.Ctx.Inst().Mutate.CreateEmoteSet(ctx, b, mutate.EmoteSetMutationOptions{ 39 | Actor: actor, 40 | }); err != nil { 41 | return nil, err 42 | } 43 | 44 | emoteSet, err := r.Ctx.Inst().Query.EmoteSets(ctx, bson.M{"_id": b.EmoteSet.ID}).First() 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return modelgql.EmoteSetModel(r.Ctx.Inst().Modelizer.EmoteSet(emoteSet)), nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/mutation/mutation.go: -------------------------------------------------------------------------------- 1 | package mutation 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/api/internal/api/gql/v3/types" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | type Resolver struct { 14 | types.Resolver 15 | } 16 | 17 | func New(r types.Resolver) generated.MutationResolver { 18 | return &Resolver{r} 19 | } 20 | 21 | func (r *Resolver) Z() *zap.SugaredLogger { 22 | return zap.S().Named("mutation") 23 | } 24 | 25 | func (r *Resolver) SetUserRole(ctx context.Context, userID primitive.ObjectID, roleID primitive.ObjectID, action model.ListItemAction) (*model.User, error) { 26 | // TODO 27 | return nil, nil 28 | } 29 | 30 | // Cosmetics implements generated.MutationResolver 31 | func (*Resolver) Cosmetics(ctx context.Context, id primitive.ObjectID) (*model.CosmeticOps, error) { 32 | return &model.CosmeticOps{ 33 | ID: id, 34 | }, nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/mutation/mutation.role.go: -------------------------------------------------------------------------------- 1 | package mutation 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/seventv/api/data/model/modelgql" 8 | "github.com/seventv/api/data/mutate" 9 | "github.com/seventv/api/internal/api/gql/v3/auth" 10 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 11 | "github.com/seventv/common/errors" 12 | "github.com/seventv/common/structures/v3" 13 | "github.com/seventv/common/utils" 14 | "go.mongodb.org/mongo-driver/bson" 15 | "go.mongodb.org/mongo-driver/bson/primitive" 16 | ) 17 | 18 | func (r *Resolver) CreateRole(ctx context.Context, data model.CreateRoleInput) (*model.Role, error) { 19 | actor := auth.For(ctx) 20 | 21 | rb := structures.NewRoleBuilder(structures.Role{}). 22 | SetName(data.Name) 23 | 24 | if err := r.Ctx.Inst().Mutate.CreateRole(ctx, rb, mutate.RoleMutationOptions{ 25 | Actor: &actor, 26 | }); err != nil { 27 | return nil, err 28 | } 29 | 30 | return modelgql.RoleModel(r.Ctx.Inst().Modelizer.Role(rb.Role)), nil 31 | } 32 | 33 | func (r *Resolver) EditRole(ctx context.Context, roleID primitive.ObjectID, data model.EditRoleInput) (*model.Role, error) { 34 | actor := auth.For(ctx) 35 | 36 | roles, err := r.Ctx.Inst().Query.Roles(ctx, bson.M{"_id": roleID}) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | if len(roles) == 0 { 42 | return nil, errors.ErrUnknownRole() 43 | } 44 | 45 | rb := structures.NewRoleBuilder(roles[0]) 46 | 47 | if data.Name != nil { 48 | rb.SetName(*data.Name) 49 | } 50 | 51 | if data.Color != nil { 52 | c := *data.Color 53 | 54 | rb.SetColor(utils.Color(c)) 55 | } 56 | 57 | if data.Allowed != nil { 58 | a, _ := strconv.Atoi(*data.Allowed) 59 | 60 | rb.SetAllowed(structures.RolePermission(a)) 61 | } 62 | 63 | if data.Denied != nil { 64 | d, _ := strconv.Atoi(*data.Denied) 65 | 66 | rb.SetDenied(structures.RolePermission(d)) 67 | } 68 | 69 | if err := r.Ctx.Inst().Mutate.EditRole(ctx, rb, mutate.RoleEditOptions{ 70 | Actor: &actor, 71 | OriginalPosition: roles[0].Position, 72 | }); err != nil { 73 | return nil, err 74 | } 75 | 76 | return modelgql.RoleModel(r.Ctx.Inst().Modelizer.Role(rb.Role)), nil 77 | } 78 | 79 | func (r *Resolver) DeleteRole(ctx context.Context, roleID primitive.ObjectID) (string, error) { 80 | actor := auth.For(ctx) 81 | 82 | roles, err := r.Ctx.Inst().Query.Roles(ctx, bson.M{"_id": roleID}) 83 | if err != nil { 84 | return "", err 85 | } 86 | 87 | if len(roles) == 0 { 88 | return "", errors.ErrUnknownRole() 89 | } 90 | 91 | if err := r.Ctx.Inst().Mutate.DeleteRole(ctx, structures.NewRoleBuilder(roles[0]), mutate.RoleMutationOptions{ 92 | Actor: &actor, 93 | }); err != nil { 94 | return "", err 95 | } 96 | 97 | return "", nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/mutation/mutation.user.go: -------------------------------------------------------------------------------- 1 | package mutation 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | ) 10 | 11 | func (r *Resolver) User(ctx context.Context, id primitive.ObjectID) (*model.UserOps, error) { 12 | user, err := r.Ctx.Inst().Loaders.UserByID().Load(id) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | m := modelgql.UserModel(r.Ctx.Inst().Modelizer.User(user)) 18 | 19 | return &model.UserOps{ 20 | ID: m.ID, 21 | Connections: m.Connections, 22 | }, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/query/query.cosmetics.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/common/errors" 9 | "github.com/seventv/common/mongo" 10 | "github.com/seventv/common/structures/v3" 11 | "go.mongodb.org/mongo-driver/bson" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | ) 14 | 15 | // Cosmetics implements generated.QueryResolver 16 | func (r *Resolver) Cosmetics(ctx context.Context, list []primitive.ObjectID) (*model.CosmeticsQuery, error) { 17 | result := model.CosmeticsQuery{} 18 | 19 | filter := bson.M{} 20 | if len(list) > 0 { 21 | filter["_id"] = bson.M{"$in": list} 22 | } 23 | 24 | cur, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameCosmetics).Find(ctx, filter) 25 | if err != nil { 26 | return &result, errors.ErrInternalServerError() 27 | } 28 | 29 | cosmetics := []structures.Cosmetic[bson.Raw]{} 30 | if err := cur.All(ctx, &cosmetics); err != nil { 31 | return nil, errors.ErrInternalServerError() 32 | } 33 | 34 | paints := []*model.CosmeticPaint{} 35 | badges := []*model.CosmeticBadge{} 36 | 37 | for _, cosmetic := range cosmetics { 38 | switch cosmetic.Kind { 39 | case structures.CosmeticKindNametagPaint: 40 | c, err := structures.ConvertCosmetic[structures.CosmeticDataPaint](cosmetic) 41 | if err == nil { 42 | paints = append(paints, modelgql.CosmeticPaint(r.Ctx.Inst().Modelizer.Paint(c))) 43 | } 44 | case structures.CosmeticKindBadge: 45 | c, err := structures.ConvertCosmetic[structures.CosmeticDataBadge](cosmetic) 46 | if err == nil { 47 | badges = append(badges, modelgql.CosmeticBadge(r.Ctx.Inst().Modelizer.Badge(c))) 48 | } 49 | } 50 | } 51 | 52 | return &model.CosmeticsQuery{ 53 | Paints: paints, 54 | Badges: badges, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/query/query.emoteset.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hashicorp/go-multierror" 7 | "github.com/seventv/api/data/model/modelgql" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 9 | "github.com/seventv/common/errors" 10 | "github.com/seventv/common/mongo" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | func (r *Resolver) EmoteSet(ctx context.Context, id primitive.ObjectID) (*model.EmoteSet, error) { 15 | set, err := r.Ctx.Inst().Loaders.EmoteSetByID().Load(id) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return modelgql.EmoteSetModel(r.Ctx.Inst().Modelizer.EmoteSet(set)), nil 21 | } 22 | 23 | func (r *Resolver) EmoteSetsByID(ctx context.Context, ids []primitive.ObjectID) ([]*model.EmoteSet, error) { 24 | sets, errs := r.Ctx.Inst().Loaders.EmoteSetByID().LoadAll(ids) 25 | if err := multierror.Append(nil, errs...).ErrorOrNil(); err != nil { 26 | return nil, err 27 | } 28 | 29 | result := make([]*model.EmoteSet, len(sets)) 30 | for i, v := range sets { 31 | result[i] = modelgql.EmoteSetModel(r.Ctx.Inst().Modelizer.EmoteSet(v)) 32 | } 33 | 34 | return result, nil 35 | } 36 | 37 | func (r *Resolver) NamedEmoteSet(ctx context.Context, name model.EmoteSetName) (*model.EmoteSet, error) { 38 | var setID primitive.ObjectID 39 | 40 | switch name { 41 | case model.EmoteSetNameGlobal: 42 | sys, err := r.Ctx.Inst().Mongo.System(ctx) 43 | if err != nil { 44 | if err == mongo.ErrNoDocuments { 45 | return nil, errors.ErrUnknownEmoteSet() 46 | } 47 | 48 | return nil, errors.ErrInternalServerError() 49 | } 50 | 51 | setID = sys.EmoteSetID 52 | } 53 | 54 | if setID.IsZero() { 55 | return nil, errors.ErrUnknownEmoteSet() 56 | } 57 | 58 | set, err := r.Ctx.Inst().Loaders.EmoteSetByID().Load(setID) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | return modelgql.EmoteSetModel(r.Ctx.Inst().Modelizer.EmoteSet(set)), nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/query/query.messages.inbox.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/data/query" 8 | "github.com/seventv/api/internal/api/gql/v3/auth" 9 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 10 | "github.com/seventv/common/errors" 11 | "github.com/seventv/common/structures/v3" 12 | "go.mongodb.org/mongo-driver/bson" 13 | "go.mongodb.org/mongo-driver/bson/primitive" 14 | ) 15 | 16 | const INBOX_QUERY_LIMIT_MOST = 1000 17 | 18 | func (r *Resolver) Inbox(ctx context.Context, userID primitive.ObjectID, afterIDArg *primitive.ObjectID, limitArg *int) ([]*model.InboxMessage, error) { 19 | actor := auth.For(ctx) 20 | if actor.ID.IsZero() { 21 | return nil, errors.ErrUnauthorized() 22 | } 23 | 24 | // Pagination 25 | afterID := primitive.NilObjectID 26 | if afterIDArg != nil { 27 | afterID = *afterIDArg 28 | } 29 | 30 | limit := 100 31 | if limitArg != nil { 32 | limit = *limitArg 33 | if limit > INBOX_QUERY_LIMIT_MOST { 34 | limit = INBOX_QUERY_LIMIT_MOST 35 | } 36 | } 37 | 38 | // Fetch target user 39 | user, err := r.Ctx.Inst().Query.Users(ctx, bson.M{"_id": userID}).First() 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | messages, err := r.Ctx.Inst().Query.InboxMessages(ctx, query.InboxMessagesQueryOptions{ 45 | Actor: &actor, 46 | User: &user, 47 | Limit: limit, 48 | AfterID: afterID, 49 | SkipPermissionCheck: false, 50 | }).Items() 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | result := make([]*model.InboxMessage, len(messages)) 56 | 57 | for i, msg := range messages { 58 | if msg, err := structures.ConvertMessage[structures.MessageDataInbox](msg); err == nil { 59 | result[i] = modelgql.InboxMessageModel(r.Ctx.Inst().Modelizer.InboxMessage(msg)) 60 | } 61 | } 62 | 63 | return result, nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/query/query.mod_requests.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/seventv/api/data/model/modelgql" 8 | "github.com/seventv/api/data/query" 9 | "github.com/seventv/api/internal/api/gql/v3/auth" 10 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 11 | "github.com/seventv/common/errors" 12 | "github.com/seventv/common/structures/v3" 13 | "go.mongodb.org/mongo-driver/bson" 14 | "go.mongodb.org/mongo-driver/bson/primitive" 15 | ) 16 | 17 | // ModRequests implements generated.QueryResolver 18 | func (r *Resolver) ModRequests(ctx context.Context, afterIDArg *primitive.ObjectID, limitArg *int, wish *string, country *string) (*model.ModRequestMessageList, error) { 19 | actor := auth.For(ctx) 20 | if actor.ID.IsZero() { 21 | return nil, errors.ErrUnauthorized() 22 | } 23 | 24 | afterID := primitive.NilObjectID 25 | if afterIDArg != nil { 26 | afterID = *afterIDArg 27 | } 28 | 29 | match := bson.M{} 30 | if !afterID.IsZero() { 31 | match["_id"] = bson.M{"$lt": afterID} 32 | } 33 | 34 | if wish != nil { 35 | match["data.wish"] = *wish 36 | } 37 | 38 | if country != nil { 39 | match["data.actor_country_code"] = strings.ToUpper(*country) 40 | } 41 | 42 | limit := 50 43 | if limitArg != nil { 44 | limit = *limitArg 45 | } 46 | 47 | msgQuery := r.Ctx.Inst().Query.ModRequestMessages(ctx, query.ModRequestMessagesQueryOptions{ 48 | Actor: &actor, 49 | Filter: match, 50 | Limit: limit, 51 | Sort: bson.D{{Key: "weight", Value: -1}, {Key: "_id", Value: 1}}, // bson.M{"_id": 1, "weight": -1}, 52 | Targets: map[structures.ObjectKind]bool{ 53 | structures.ObjectKindEmote: true, 54 | }, 55 | }) 56 | 57 | messages, err := msgQuery.Items() 58 | if err != nil { 59 | errCode, _ := err.(errors.APIError) 60 | if errCode.Code() == errors.ErrNoItems().Code() { 61 | return &model.ModRequestMessageList{}, nil 62 | } 63 | 64 | return nil, err 65 | } 66 | 67 | result := make([]*model.ModRequestMessage, len(messages)) 68 | 69 | for i, msg := range messages { 70 | if msg, err := structures.ConvertMessage[structures.MessageDataModRequest](msg); err == nil { 71 | result[i] = modelgql.ModRequestMessageModel(r.Ctx.Inst().Modelizer.ModRequestMessage(msg)) 72 | } 73 | } 74 | 75 | return &model.ModRequestMessageList{ 76 | Messages: result, 77 | Total: int(msgQuery.Total()), 78 | }, nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/query/query.reports.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/internal/api/gql/v3/auth" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/api/internal/api/gql/v3/helpers" 9 | "github.com/seventv/common/errors" 10 | "github.com/seventv/common/mongo" 11 | "github.com/seventv/common/structures/v3" 12 | "go.mongodb.org/mongo-driver/bson" 13 | "go.mongodb.org/mongo-driver/bson/primitive" 14 | "go.mongodb.org/mongo-driver/mongo/options" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | func (r *Resolver) Reports(ctx context.Context, statusArg *model.ReportStatus, limitArg *int, afterIDArg *primitive.ObjectID, beforeIDArg *primitive.ObjectID) ([]*model.Report, error) { 19 | actor := auth.For(ctx) 20 | if actor.ID.IsZero() { 21 | return nil, errors.ErrUnauthorized() 22 | } 23 | 24 | // Define limit 25 | limit := int64(12) 26 | if limitArg != nil { 27 | limit = int64(*limitArg) 28 | } 29 | 30 | if limit > 100 { 31 | limit = 100 32 | } 33 | 34 | // Paginate 35 | pagination := bson.M{} 36 | filter := bson.M{} 37 | 38 | if statusArg != nil { 39 | filter["status"] = *statusArg 40 | } 41 | 42 | if afterIDArg != nil { 43 | pagination["$gt"] = *afterIDArg 44 | } 45 | 46 | if beforeIDArg != nil { 47 | pagination["$lt"] = *beforeIDArg 48 | } 49 | 50 | if len(pagination) > 0 { 51 | filter["_id"] = pagination 52 | } 53 | 54 | opt := options.Find().SetLimit(limit).SetSort(bson.M{"created_at": 1}) 55 | 56 | cur, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameReports).Find(ctx, filter, opt) 57 | if err != nil { 58 | zap.S().Errorw("mongo, failed to create reports query", "error", err) 59 | 60 | return nil, errors.ErrInternalServerError() 61 | } 62 | 63 | reports := []structures.Report{} 64 | if err := cur.All(ctx, &reports); err != nil { 65 | zap.S().Errorw("mongo, failed to query reports") 66 | 67 | return nil, errors.ErrInternalServerError() 68 | } 69 | 70 | result := make([]*model.Report, len(reports)) 71 | for i, report := range reports { 72 | result[i] = helpers.ReportStructureToModel(report) 73 | } 74 | 75 | return result, nil 76 | } 77 | 78 | func (r *Resolver) Report(ctx context.Context, id primitive.ObjectID) (*model.Report, error) { 79 | report := structures.Report{} 80 | if err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameReports).FindOne(ctx, bson.M{"_id": id}).Decode(&report); err != nil { 81 | if err == mongo.ErrNoDocuments { 82 | return nil, errors.ErrUnknownReport() 83 | } 84 | 85 | return nil, errors.ErrInternalServerError() 86 | } 87 | 88 | return helpers.ReportStructureToModel(report), nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/report/report.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hashicorp/go-multierror" 7 | "github.com/seventv/api/data/model/modelgql" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 9 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 10 | "github.com/seventv/api/internal/api/gql/v3/types" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | type Resolver struct { 15 | types.Resolver 16 | } 17 | 18 | // Actor implements generated.ReportResolver 19 | func (r *Resolver) Actor(ctx context.Context, obj *model.Report) (*model.User, error) { 20 | user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.ActorID) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return modelgql.UserModel(r.Ctx.Inst().Modelizer.User(user)), nil 26 | } 27 | 28 | func New(r types.Resolver) generated.ReportResolver { 29 | return &Resolver{r} 30 | } 31 | 32 | func (r *Resolver) Reporter(ctx context.Context, obj *model.Report) (*model.User, error) { 33 | user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.Actor.ID) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return modelgql.UserModel(r.Ctx.Inst().Modelizer.User(user)), nil 39 | } 40 | 41 | func (r *Resolver) Assignees(ctx context.Context, obj *model.Report) ([]*model.User, error) { 42 | ids := make([]primitive.ObjectID, len(obj.Assignees)) 43 | for i, v := range obj.Assignees { 44 | ids[i] = v.ID 45 | } 46 | 47 | users, errs := r.Ctx.Inst().Loaders.UserByID().LoadAll(ids) 48 | 49 | err := multierror.Append(nil, errs...).ErrorOrNil() 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | result := make([]*model.User, len(users)) 55 | for i, v := range users { 56 | result[i] = modelgql.UserModel(r.Ctx.Inst().Modelizer.User(v)) 57 | } 58 | 59 | return result, nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/role/role.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/api/internal/api/gql/v3/types" 9 | ) 10 | 11 | type Resolver struct { 12 | types.Resolver 13 | } 14 | 15 | func New(r types.Resolver) generated.RoleResolver { 16 | return &Resolver{r} 17 | } 18 | 19 | func (r *Resolver) Members(ctx context.Context, obj *model.Role, page *int, limit *int) ([]*model.User, error) { 20 | // TODO 21 | return nil, nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/user-editor/user-editor.go: -------------------------------------------------------------------------------- 1 | package user_editor 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/model/modelgql" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 8 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 9 | "github.com/seventv/api/internal/api/gql/v3/types" 10 | "github.com/seventv/common/structures/v3" 11 | ) 12 | 13 | type Resolver struct { 14 | types.Resolver 15 | } 16 | 17 | func New(r types.Resolver) generated.UserEditorResolver { 18 | return &Resolver{r} 19 | } 20 | 21 | func (r *Resolver) User(ctx context.Context, obj *model.UserEditor) (*model.UserPartial, error) { 22 | if obj.User != nil && obj.User.ID != structures.DeletedEmote.ID { 23 | return obj.User, nil 24 | } 25 | 26 | u, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.ID) 27 | if err != nil { 28 | return modelgql.UserPartialModel(r.Ctx.Inst().Modelizer.User(structures.DeletedUser).ToPartial()), nil 29 | } 30 | 31 | return modelgql.UserPartialModel(r.Ctx.Inst().Modelizer.User(u).ToPartial()), nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/user/user.cosmetics.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 7 | "github.com/seventv/common/errors" 8 | "github.com/seventv/common/mongo" 9 | "github.com/seventv/common/structures/v3" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/mongo/options" 12 | ) 13 | 14 | // Cosmetics implements generated.UserResolver 15 | func (r *Resolver) Cosmetics(ctx context.Context, obj *model.User) ([]*model.UserCosmetic, error) { 16 | cur, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameEntitlements).Find(ctx, bson.M{ 17 | "user_id": obj.ID, 18 | "kind": bson.M{"$in": bson.A{structures.CosmeticKindBadge, structures.CosmeticKindNametagPaint}}, 19 | }, options.Find().SetProjection(bson.M{"data.ref": 1, "data.selected": 1, "kind": 1})) 20 | if err != nil { 21 | return nil, errors.ErrInternalServerError() 22 | } 23 | 24 | ents := []structures.Entitlement[structures.EntitlementDataBaseSelectable]{} 25 | 26 | if err = cur.All(ctx, &ents); err != nil { 27 | return nil, errors.ErrInternalServerError() 28 | } 29 | 30 | result := make([]*model.UserCosmetic, len(ents)) 31 | for i, ent := range ents { 32 | result[i] = &model.UserCosmetic{ 33 | ID: ent.Data.RefID, 34 | Selected: ent.Data.Selected, 35 | Kind: model.CosmeticKind(ent.Kind), 36 | } 37 | } 38 | 39 | return result, nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/api/gql/v3/resolvers/user/user.partial.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/internal/api/gql/v3/gen/generated" 7 | "github.com/seventv/api/internal/api/gql/v3/gen/model" 8 | "github.com/seventv/api/internal/api/gql/v3/types" 9 | "github.com/seventv/common/mongo" 10 | "github.com/seventv/common/structures/v3" 11 | "github.com/seventv/common/utils" 12 | "go.mongodb.org/mongo-driver/bson" 13 | "go.mongodb.org/mongo-driver/mongo/options" 14 | ) 15 | 16 | type ResolverPartial struct { 17 | types.Resolver 18 | } 19 | 20 | func NewPartial(r types.Resolver) generated.UserPartialResolver { 21 | return &ResolverPartial{r} 22 | } 23 | 24 | func (r *ResolverPartial) EmoteSets(ctx context.Context, obj *model.UserPartial) ([]*model.EmoteSetPartial, error) { 25 | cur, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameEmoteSets).Find(ctx, bson.M{ 26 | "owner_id": obj.ID, 27 | }, options.Find().SetProjection(bson.M{ 28 | "_id": 1, 29 | "name": 1, 30 | "capacity": 1, 31 | })) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | result := []*model.EmoteSetPartial{} 37 | 38 | for cur.Next(ctx) { 39 | set := structures.EmoteSet{} 40 | 41 | if err := cur.Decode(&set); err != nil { 42 | continue 43 | } 44 | 45 | result = append(result, &model.EmoteSetPartial{ 46 | ID: set.ID, 47 | Name: set.Name, 48 | Capacity: int(set.Capacity), 49 | }) 50 | } 51 | 52 | return result, nil 53 | } 54 | 55 | func (r *ResolverPartial) Style(ctx context.Context, obj *model.UserPartial) (*model.UserStyle, error) { 56 | badge, paint := userEntitlements(r.Ctx, obj.ID) 57 | 58 | return &model.UserStyle{ 59 | Color: obj.Style.Color, 60 | PaintID: utils.Ternary(paint.ID.IsZero(), nil, &paint.ID), 61 | BadgeID: utils.Ternary(badge.ID.IsZero(), nil, &badge.ID), 62 | Paint: utils.Ternary(paint.ID.IsZero(), nil, paint), 63 | Badge: utils.Ternary(badge.ID.IsZero(), nil, badge), 64 | }, nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/_schema.gql: -------------------------------------------------------------------------------- 1 | scalar Time 2 | scalar ObjectID 3 | scalar StringMap 4 | scalar ArbitraryMap 5 | 6 | schema { 7 | query: Query 8 | mutation: Mutation 9 | } 10 | 11 | directive @goField( 12 | forceResolver: Boolean 13 | name: String 14 | ) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION 15 | 16 | directive @internal on FIELD_DEFINITION 17 | 18 | # Sorting cursor, binding a specific order to a value 19 | input Sort { 20 | value: String! 21 | order: SortOrder! 22 | } 23 | 24 | # The order with which sorting should occur, either ASCENDING or DESCENDING 25 | enum SortOrder { 26 | ASCENDING 27 | DESCENDING 28 | } 29 | 30 | enum ListItemAction { 31 | ADD 32 | UPDATE 33 | REMOVE 34 | } 35 | 36 | enum ObjectKind { 37 | USER 38 | EMOTE 39 | EMOTE_SET 40 | ROLE 41 | ENTITLEMENT 42 | BAN 43 | MESSAGE 44 | REPORT 45 | } 46 | 47 | type ChangeMap { 48 | id: ObjectID! 49 | kind: ObjectKind! 50 | actor: User 51 | added: [ChangeField!]! 52 | updated: [ChangeField!]! 53 | removed: [ChangeField!]! 54 | pushed: [ChangeField!]! 55 | pulled: [ChangeField!]! 56 | } 57 | 58 | type ChangeField { 59 | key: String! 60 | index: Int 61 | nested: Boolean! 62 | type: String! 63 | old_value: String 64 | value: String 65 | } 66 | 67 | extend type Query { 68 | proxied_endpoint(id: Int!, user_id: ObjectID): String! 69 | } 70 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/audit.gql: -------------------------------------------------------------------------------- 1 | type AuditLog { 2 | id: ObjectID! 3 | actor: UserPartial! 4 | actor_id: ObjectID! 5 | kind: Int! 6 | target_id: ObjectID! 7 | target_kind: Int! 8 | created_at: Time! 9 | changes: [AuditLogChange!]! 10 | reason: String! 11 | } 12 | 13 | type AuditLogChange { 14 | format: Int! 15 | key: String! 16 | value: ArbitraryMap 17 | array_value: AuditLogChangeArray 18 | } 19 | 20 | type AuditLogChangeArray { 21 | added: [ArbitraryMap]! 22 | removed: [ArbitraryMap]! 23 | updated: [ArbitraryMap]! 24 | } 25 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/bans.gql: -------------------------------------------------------------------------------- 1 | extend type Mutation { 2 | createBan( 3 | victim_id: ObjectID! 4 | reason: String! 5 | effects: Int! 6 | expire_at: Time 7 | anonymous: Boolean 8 | ): Ban @hasPermissions(role: [MANAGE_BANS]) 9 | editBan( 10 | ban_id: ObjectID! 11 | reason: String 12 | effects: Int 13 | expire_at: String 14 | ): Ban @hasPermissions(role: [MANAGE_BANS]) 15 | } 16 | 17 | type Ban { 18 | id: ObjectID! 19 | reason: String! 20 | effects: Int! 21 | expire_at: Time! 22 | created_at: Time! 23 | 24 | victim_id: ObjectID! 25 | victim: User @goField(forceResolver: true) 26 | actor_id: ObjectID! 27 | actor: User @goField(forceResolver: true) 28 | } 29 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/emoteset.gql: -------------------------------------------------------------------------------- 1 | extend type Query { 2 | emoteSet(id: ObjectID!): EmoteSet! 3 | emoteSetsByID(list: [ObjectID!]!): [EmoteSet!]! 4 | namedEmoteSet(name: EmoteSetName!): EmoteSet! 5 | } 6 | 7 | extend type Mutation { 8 | emoteSet(id: ObjectID!): EmoteSetOps 9 | createEmoteSet(user_id: ObjectID!, data: CreateEmoteSetInput!): EmoteSet 10 | @hasPermissions(role: [CREATE_EMOTE_SET]) 11 | } 12 | 13 | type EmoteSetOps { 14 | id: ObjectID! 15 | emotes(id: ObjectID!, action: ListItemAction!, name: String): [ActiveEmote!]! 16 | @goField(forceResolver: true) 17 | update(data: UpdateEmoteSetInput!): EmoteSet! @goField(forceResolver: true) 18 | delete: Boolean! @goField(forceResolver: true) 19 | } 20 | 21 | type EmoteSet { 22 | id: ObjectID! 23 | name: String! 24 | flags: Int! 25 | tags: [String!]! 26 | emotes(limit: Int, origins: Boolean): [ActiveEmote!]! 27 | @goField(forceResolver: true) 28 | emote_count: Int! 29 | capacity: Int! 30 | origins: [EmoteSetOrigin!]! 31 | owner_id: ObjectID 32 | owner: UserPartial @goField(forceResolver: true) 33 | } 34 | 35 | type EmoteSetPartial { 36 | id: ObjectID! 37 | name: String! 38 | capacity: Int! 39 | } 40 | 41 | type ActiveEmote { 42 | id: ObjectID! 43 | name: String! 44 | flags: Int! 45 | timestamp: Time! 46 | data: EmotePartial! @goField(forceResolver: true) 47 | actor: UserPartial @goField(forceResolver: true) 48 | origin_id: ObjectID 49 | } 50 | 51 | type EmoteSetOrigin { 52 | id: ObjectID! 53 | weight: Int! 54 | slices: [Int!] 55 | } 56 | 57 | input CreateEmoteSetInput { 58 | name: String! 59 | privileged: Boolean @hasPermissions(role: [SUPER_ADMINISTRATOR]) 60 | } 61 | 62 | input UpdateEmoteSetInput { 63 | name: String 64 | capacity: Int 65 | origins: [EmoteSetOriginInput!] 66 | } 67 | 68 | input EmoteSetOriginInput { 69 | id: ObjectID! 70 | weight: Int! 71 | slices: [Int!] 72 | } 73 | 74 | enum EmoteSetName { 75 | GLOBAL 76 | } 77 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/files.gql: -------------------------------------------------------------------------------- 1 | type Image { 2 | name: String! 3 | format: ImageFormat! 4 | width: Int! 5 | height: Int! 6 | frame_count: Int! 7 | size: Int! 8 | } 9 | 10 | enum ImageFormat { 11 | AVIF 12 | WEBP 13 | GIF 14 | PNG 15 | } 16 | 17 | type ImageHost { 18 | url: String! 19 | files(formats: [ImageFormat!]): [Image!]! @goField(forceResolver: true) 20 | } 21 | 22 | type Archive { 23 | name: String! 24 | content_type: String! 25 | url: String! 26 | size: Int! 27 | } 28 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/messages.gql: -------------------------------------------------------------------------------- 1 | extend type Query { 2 | announcement: String! 3 | inbox(user_id: ObjectID!, after_id: ObjectID, limit: Int): [InboxMessage!]! 4 | @hasPermissions 5 | modRequests( 6 | after_id: ObjectID 7 | limit: Int 8 | wish: String 9 | country: String 10 | ): ModRequestMessageList! 11 | } 12 | 13 | extend type Mutation { 14 | readMessages(message_ids: [ObjectID!]!, read: Boolean!): Int! @hasPermissions 15 | sendInboxMessage( 16 | recipients: [ObjectID!]! 17 | subject: String! 18 | content: String! 19 | important: Boolean 20 | anonymous: Boolean 21 | ): InboxMessage @hasPermissions(role: [SEND_MESSAGES]) 22 | 23 | dismissVoidTargetModRequests(object: Int!): Int! 24 | @hasPermissions(role: [MANAGE_STACK]) 25 | } 26 | 27 | interface Message { 28 | id: ObjectID! 29 | kind: MessageKind! 30 | created_at: Time! 31 | author_id: ObjectID 32 | read: Boolean! 33 | read_at: Time 34 | } 35 | 36 | enum MessageKind { 37 | EMOTE_COMMENT 38 | MOD_REQUEST 39 | INBOX 40 | NEWS 41 | } 42 | 43 | type InboxMessage implements Message { 44 | id: ObjectID! 45 | kind: MessageKind! 46 | created_at: Time! 47 | author_id: ObjectID 48 | read: Boolean! 49 | read_at: Time 50 | 51 | subject: String! 52 | content: String! 53 | important: Boolean! 54 | starred: Boolean! 55 | pinned: Boolean! 56 | placeholders: StringMap! 57 | } 58 | 59 | type ModRequestMessage implements Message { 60 | id: ObjectID! 61 | kind: MessageKind! 62 | created_at: Time! 63 | author_id: ObjectID 64 | read: Boolean! 65 | read_at: Time 66 | 67 | target_kind: Int! 68 | target_id: ObjectID! 69 | wish: String! 70 | actor_country_name: String! 71 | actor_country_code: String! 72 | } 73 | 74 | type ModRequestMessageList { 75 | messages: [ModRequestMessage!]! 76 | total: Int! 77 | } 78 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/permissions.gql: -------------------------------------------------------------------------------- 1 | directive @hasPermissions( 2 | role: [Permission!] 3 | ) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION 4 | 5 | enum Permission { 6 | CREATE_EMOTE 7 | EDIT_EMOTE 8 | CREATE_EMOTE_SET 9 | EDIT_EMOTE_SET 10 | 11 | CREATE_REPORT 12 | SEND_MESSAGES 13 | 14 | FEATURE_ZEROWIDTH_EMOTE_TYPE 15 | FEATURE_PROFILE_PICTURE_ANIMATION 16 | 17 | MANAGE_BANS 18 | MANAGE_ROLES 19 | MANAGE_REPORTS 20 | MANAGE_USERS 21 | EDIT_ANY_EMOTE 22 | EDIT_ANY_EMOTE_SET 23 | BYPASS_PRIVACY 24 | 25 | SUPER_ADMINISTRATOR 26 | MANAGE_CONTENT 27 | MANAGE_STACK 28 | MANAGE_COSMETICS 29 | } 30 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/reports.gql: -------------------------------------------------------------------------------- 1 | extend type Query { 2 | reports( 3 | status: ReportStatus 4 | limit: Int 5 | after_id: ObjectID 6 | before_id: ObjectID 7 | ): [Report]! @hasPermissions(role: [MANAGE_REPORTS]) 8 | report(id: ObjectID!): Report @hasPermissions(role: [MANAGE_REPORTS]) 9 | } 10 | 11 | extend type Mutation { 12 | createReport(data: CreateReportInput!): Report 13 | @hasPermissions(role: [CREATE_REPORT]) 14 | editReport(report_id: ObjectID!, data: EditReportInput!): Report 15 | @hasPermissions(role: [MANAGE_REPORTS]) 16 | } 17 | 18 | type Report { 19 | id: ObjectID! 20 | target_kind: Int! 21 | target_id: ObjectID! 22 | actor_id: ObjectID! 23 | actor: User! @goField(forceResolver: true) 24 | subject: String! 25 | body: String! 26 | priority: Int! 27 | status: ReportStatus! 28 | created_at: Time! 29 | notes: [String!]! 30 | assignees: [User!]! @goField(forceResolver: true) 31 | } 32 | 33 | enum ReportStatus { 34 | OPEN 35 | ASSIGNED 36 | CLOSED 37 | } 38 | 39 | input CreateReportInput { 40 | target_kind: Int! 41 | target_id: ObjectID! 42 | subject: String! 43 | body: String! 44 | } 45 | 46 | input EditReportInput { 47 | priority: Int 48 | status: ReportStatus 49 | assignee: String 50 | note: EditReportNoteInput 51 | } 52 | 53 | input EditReportNoteInput { 54 | timestamp: String 55 | content: String 56 | internal: Boolean 57 | reply: String 58 | } 59 | -------------------------------------------------------------------------------- /internal/api/gql/v3/schema/roles.gql: -------------------------------------------------------------------------------- 1 | extend type Query { 2 | roles: [Role]! 3 | role(id: ObjectID!): Role 4 | } 5 | 6 | extend type Mutation { 7 | createRole(data: CreateRoleInput!): Role @hasPermissions(role: [MANAGE_ROLES]) 8 | editRole(role_id: ObjectID!, data: EditRoleInput!): Role 9 | @hasPermissions(role: [MANAGE_ROLES]) 10 | deleteRole(role_id: ObjectID!): String! @hasPermissions(role: [MANAGE_ROLES]) 11 | } 12 | 13 | type Role { 14 | id: ObjectID! 15 | name: String! 16 | color: Int! 17 | allowed: String! 18 | denied: String! 19 | position: Int! 20 | created_at: Time! 21 | invisible: Boolean! 22 | 23 | members(page: Int, limit: Int): [User!]! @goField(forceResolver: true) 24 | } 25 | 26 | input CreateRoleInput { 27 | name: String! 28 | color: Int! 29 | allowed: String! 30 | denied: String! 31 | } 32 | 33 | input EditRoleInput { 34 | name: String 35 | color: Int 36 | allowed: String 37 | denied: String 38 | position: Int 39 | } 40 | -------------------------------------------------------------------------------- /internal/api/gql/v3/types/resolver.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/seventv/api/internal/global" 4 | 5 | type Resolver struct { 6 | Ctx global.Context 7 | } 8 | -------------------------------------------------------------------------------- /internal/api/rest/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/global" 6 | "github.com/seventv/common/errors" 7 | ) 8 | 9 | func Auth(gCtx global.Context, required bool) rest.Middleware { 10 | return func(ctx *rest.Ctx) rest.APIError { 11 | if _, ok := ctx.GetActor(); !ok && required { 12 | return errors.ErrUnauthorized().SetDetail("Sign-In Required") 13 | } 14 | 15 | return nil 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /internal/api/rest/middleware/cache.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/seventv/api/internal/api/rest/rest" 8 | "github.com/seventv/api/internal/global" 9 | "github.com/seventv/common/utils" 10 | ) 11 | 12 | func SetCacheControl(gCtx global.Context, maxAge int, args []string) rest.Middleware { 13 | return func(ctx *rest.Ctx) rest.APIError { 14 | ctx.Response.Header.Set("Cache-Control", fmt.Sprintf( 15 | "max-age=%d%s %s", 16 | maxAge, 17 | utils.Ternary(len(args) > 0, ",", ""), 18 | strings.Join(args, ", "), 19 | )) 20 | 21 | return nil 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/api/rest/middleware/ratelimit.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/seventv/api/internal/api/rest/rest" 8 | "github.com/seventv/api/internal/constant" 9 | "github.com/seventv/api/internal/global" 10 | "github.com/seventv/api/internal/middleware" 11 | "github.com/seventv/common/errors" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func RateLimit(gctx global.Context, bucket string, rate [2]int64) rest.Middleware { 16 | return func(ctx *rest.Ctx) rest.APIError { 17 | identifier, _ := ctx.UserValue(constant.ClientIP).String() 18 | 19 | actor, ok := ctx.GetActor() 20 | if ok { 21 | identifier = actor.ID.Hex() 22 | } 23 | 24 | if identifier == "" { 25 | return nil 26 | } 27 | 28 | limit, remaining, ttl, err := middleware.DoRateLimit(gctx, ctx, bucket, rate[0], identifier, time.Second*time.Duration(rate[1])) 29 | if err != nil { 30 | switch e := err.(type) { 31 | case errors.APIError: 32 | return e 33 | } 34 | 35 | zap.S().Errorw("Error while rate limiting a request", "error", err) 36 | } 37 | 38 | // Apply headers 39 | ctx.Response.Header.Set("X-RateLimit-Limit", strconv.Itoa(int(limit))) 40 | ctx.Response.Header.Set("X-RateLimit-Remaining", strconv.Itoa(int(remaining))) 41 | ctx.Response.Header.Set("X-RateLimit-Reset", strconv.Itoa(int(ttl))) 42 | 43 | if remaining < 1 { 44 | return errors.ErrRateLimited() 45 | } 46 | 47 | return nil 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/api/rest/portal/serve_portal.go: -------------------------------------------------------------------------------- 1 | package portal 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "path" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/valyala/fasthttp" 13 | "github.com/valyala/fasttemplate" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | func Serve(ctx context.Context) { 18 | wd, _ := os.Getwd() 19 | root := path.Join(wd, "portal", "dist") 20 | 21 | if root == "" { 22 | root = "/portal/dist" 23 | } 24 | 25 | // Setup FS handler 26 | fs := &fasthttp.FS{ 27 | Root: root, 28 | } 29 | 30 | index, err := os.ReadFile(path.Join(root, "index.html")) 31 | if err != nil { 32 | zap.S().Warnw("couldn't begin serving dev portal", "error", err) 33 | 34 | return 35 | } 36 | 37 | favicon, err := os.ReadFile(path.Join(root, "ico.svg")) 38 | if err != nil { 39 | zap.S().Warnw("couldn't begin serving dev portal", "error", err) 40 | 41 | return 42 | } 43 | 44 | //replace-me 45 | template := fasttemplate.New(string(index), "") 46 | 47 | addr := os.Getenv("DEV_PORTAL_BIND") 48 | if addr == "" { 49 | addr = "0.0.0.0:3200" 50 | } 51 | 52 | // Start HTTP server. 53 | zap.S().Infow("Starting Portal Frontend", "addr", addr) 54 | 55 | go func() { 56 | handler := fs.NewRequestHandler() 57 | 58 | if err := fasthttp.ListenAndServe(addr, func(ctx *fasthttp.RequestCtx) { 59 | pth := string(ctx.Path()) 60 | if strings.HasPrefix(pth, "/assets/") { 61 | handler(ctx) 62 | } else { 63 | if pth == "/ico.svg" { 64 | ctx.Response.Header.Set("Content-Type", "image/svg+xml") 65 | ctx.Response.Header.Set("Cache-Control", "max-age=3600") 66 | ctx.SetBody(favicon) 67 | return 68 | } 69 | 70 | ctx.Response.Header.Set("Content-Type", "text/html; charset=utf-8") 71 | ctx.Response.Header.Set("Cache-Control", "no-cache") 72 | ctx.SetBodyString(template.ExecuteString(map[string]interface{}{ 73 | "META": "", 74 | })) 75 | } 76 | }); err != nil { 77 | log.Fatalf("error in ListenAndServe: %s", err) 78 | } 79 | }() 80 | 81 | log.Printf("Serving files from directory %s\n", root) 82 | 83 | sig := make(chan os.Signal, 1) 84 | signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) 85 | 86 | // Wait forever. 87 | select { 88 | case <-ctx.Done(): 89 | case <-sig: 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /internal/api/rest/rest/context.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/seventv/api/internal/constant" 7 | "github.com/seventv/common/errors" 8 | "github.com/seventv/common/structures/v3" 9 | "github.com/valyala/fasthttp" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | type Ctx struct { 14 | *fasthttp.RequestCtx 15 | } 16 | 17 | type APIError = errors.APIError 18 | 19 | func (c *Ctx) JSON(status HttpStatusCode, v interface{}) APIError { 20 | b, err := json.Marshal(v) 21 | if err != nil { 22 | c.SetStatusCode(InternalServerError) 23 | 24 | return errors.ErrInternalServerError(). 25 | SetDetail("JSON Parsing Failed"). 26 | SetFields(errors.Fields{"JSON_ERROR": err.Error()}) 27 | } 28 | 29 | c.SetStatusCode(status) 30 | c.SetContentType("application/json") 31 | c.SetBody(b) 32 | 33 | return nil 34 | } 35 | 36 | func (c *Ctx) SetStatusCode(code HttpStatusCode) { 37 | c.RequestCtx.SetStatusCode(int(code)) 38 | } 39 | 40 | func (c *Ctx) StatusCode() HttpStatusCode { 41 | return HttpStatusCode(c.RequestCtx.Response.StatusCode()) 42 | } 43 | 44 | // Set the current authenticated user 45 | func (c *Ctx) SetActor(u structures.User) { 46 | c.SetUserValue(string(constant.UserKey), u) 47 | } 48 | 49 | // Get the current authenticated user 50 | func (c *Ctx) GetActor() (structures.User, bool) { 51 | v := c.RequestCtx.UserValue(constant.UserKey) 52 | switch v := v.(type) { 53 | case structures.User: 54 | return v, true 55 | default: 56 | return structures.DeletedUser, false 57 | } 58 | } 59 | 60 | func (c *Ctx) Log() *zap.SugaredLogger { 61 | z := zap.S().Named("api/rest").With( 62 | "request_id", c.ID(), 63 | "route", c.Path(), 64 | ) 65 | 66 | actor, ok := c.GetActor() 67 | if ok { 68 | z = z.With("actor_id", actor.ID) 69 | } 70 | 71 | return z 72 | } 73 | 74 | func (c *Ctx) ClientIP() string { 75 | switch v := c.RequestCtx.UserValue(constant.ClientIP).(type) { 76 | case string: 77 | return v 78 | default: 79 | return "" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /internal/api/rest/rest/parse.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/seventv/api/internal/constant" 7 | "github.com/seventv/common/errors" 8 | "github.com/seventv/common/structures/v3" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | type Param struct { 13 | v interface{} 14 | } 15 | 16 | func (c *Ctx) UserValue(key constant.Key) *Param { 17 | return &Param{c.RequestCtx.UserValue(string(key))} 18 | } 19 | 20 | // String returns a string value of the param 21 | func (p *Param) String() (string, bool) { 22 | if p.v == nil { 23 | return "", false 24 | } 25 | 26 | s, ok := p.v.(string) 27 | 28 | return s, ok 29 | } 30 | 31 | // Int32 parses the param into an int32 32 | func (p *Param) Int32() (int32, error) { 33 | s, ok := p.String() 34 | if !ok { 35 | return 0, errors.ErrEmptyField() 36 | } 37 | 38 | i, err := strconv.ParseInt(s, 10, 32) 39 | if err != nil { 40 | return 0, errors.ErrBadInt().SetDetail(err.Error()) 41 | } 42 | 43 | return int32(i), nil 44 | } 45 | 46 | // Int64 parses the param into an int64 47 | func (p *Param) Int64() (int64, error) { 48 | s, ok := p.String() 49 | if !ok { 50 | return 0, errors.ErrEmptyField() 51 | } 52 | 53 | i, err := strconv.ParseInt(s, 10, 64) 54 | if err != nil { 55 | return 0, errors.ErrBadInt().SetDetail(err.Error()) 56 | } 57 | 58 | return int64(i), nil 59 | } 60 | 61 | // ObjectID parses the param into an Object ID 62 | func (p *Param) ObjectID() (primitive.ObjectID, error) { 63 | s, _ := p.String() 64 | if s == "" || !primitive.IsValidObjectID(s) { 65 | return primitive.NilObjectID, errors.ErrBadObjectID() 66 | } 67 | 68 | oid, err := primitive.ObjectIDFromHex(s) 69 | if err != nil { 70 | return primitive.NilObjectID, errors.ErrBadObjectID().SetDetail(err.Error()) 71 | } 72 | 73 | return oid, nil 74 | } 75 | 76 | func (p *Param) User() structures.User { 77 | var u structures.User 78 | switch t := p.v.(type) { 79 | case structures.User: 80 | u = t 81 | default: 82 | return structures.DeletedUser 83 | } 84 | 85 | return u 86 | } 87 | -------------------------------------------------------------------------------- /internal/api/rest/v2/model/cosmetic.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type CosmeticsMap struct { 4 | Timestamp int64 `json:"t"` 5 | Badges []*CosmeticBadge `json:"badges"` 6 | Paints []*CosmeticPaint `json:"paints"` 7 | } 8 | 9 | type CosmeticBadge struct { 10 | ID string `json:"id"` 11 | Name string `json:"name"` 12 | Tooltip string `json:"tooltip"` 13 | URLs [][2]string `json:"urls"` 14 | Users []string `json:"users"` 15 | Misc bool `json:"misc,omitempty"` 16 | } 17 | 18 | type CosmeticPaint struct { 19 | ID string `json:"id"` 20 | Name string `json:"name"` 21 | Users []string `json:"users"` 22 | Function string `json:"function"` 23 | Color *int32 `json:"color"` 24 | Stops []CosmeticPaintGradientStop `json:"stops"` 25 | Repeat bool `json:"repeat"` 26 | Angle int32 `json:"angle"` 27 | Shape string `json:"shape,omitempty"` 28 | ImageURL string `json:"image_url,omitempty"` 29 | DropShadows []CosmeticPaintDropShadow `json:"drop_shadows,omitempty"` 30 | } 31 | 32 | type CosmeticPaintGradientStop struct { 33 | At float64 `json:"at" bson:"at"` 34 | Color int32 `json:"color" bson:"color"` 35 | } 36 | 37 | type CosmeticPaintDropShadow struct { 38 | OffsetX float64 `json:"x_offset" bson:"x_offset"` 39 | OffsetY float64 `json:"y_offset" bson:"y_offset"` 40 | Radius float64 `json:"radius" bson:"radius"` 41 | Color int32 `json:"color" bson:"color"` 42 | } 43 | -------------------------------------------------------------------------------- /internal/api/rest/v2/model/emote.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strconv" 7 | 8 | v2structures "github.com/seventv/common/structures/v2" 9 | "github.com/seventv/common/structures/v3" 10 | "github.com/seventv/common/utils" 11 | ) 12 | 13 | type Emote struct { 14 | ID string `json:"id"` 15 | Name string `json:"name"` 16 | Owner *User `json:"owner"` 17 | Visibility int32 `json:"visibility"` 18 | VisibilitySimple []string `json:"visibility_simple"` 19 | Mime string `json:"mime"` 20 | Status int8 `json:"status"` 21 | Tags []string `json:"tags"` 22 | Width []int32 `json:"width"` 23 | Height []int32 `json:"height"` 24 | URLs [][2]string `json:"urls"` 25 | } 26 | 27 | const webpMime = "image/webp" 28 | 29 | func NewEmote(s structures.Emote, cdnURL string) *Emote { 30 | version, _ := s.GetVersion(s.ID) 31 | files := []structures.ImageFile{} 32 | status := structures.EmoteLifecycle(0) 33 | 34 | if !version.ID.IsZero() { 35 | files = version.GetFiles(webpMime, true) 36 | status = version.State.Lifecycle 37 | } 38 | 39 | vis := 0 40 | if !version.State.Listed { 41 | vis |= int(v2structures.EmoteVisibilityUnlisted) 42 | } 43 | 44 | if utils.BitField.HasBits(int64(s.Flags), int64(structures.EmoteFlagsZeroWidth)) { 45 | vis |= int(v2structures.EmoteVisibilityZeroWidth) 46 | } 47 | 48 | if utils.BitField.HasBits(int64(s.Flags), int64(structures.EmoteFlagsPrivate)) { 49 | vis |= int(v2structures.EmoteVisibilityPrivate) 50 | } 51 | 52 | simpleVis := []string{} 53 | 54 | for v, s := range v2structures.EmoteVisibilitySimpleMap { 55 | if !utils.BitField.HasBits(int64(vis), int64(v)) { 56 | continue 57 | } 58 | 59 | simpleVis = append(simpleVis, s) 60 | } 61 | 62 | owner := structures.DeletedUser 63 | if s.Owner != nil { 64 | owner = *s.Owner 65 | } 66 | 67 | width := make([]int32, len(files)) 68 | height := make([]int32, len(files)) 69 | urls := make([][2]string, 4) 70 | 71 | sort.Slice(files, func(i, j int) bool { 72 | return files[i].Width < files[j].Width 73 | }) 74 | 75 | for i, file := range files { 76 | if i > 3 { 77 | break 78 | } 79 | 80 | width[i] = file.Width 81 | height[i] = file.Height 82 | urls[i] = [2]string{ 83 | strconv.Itoa(i + 1), 84 | fmt.Sprintf("https://%s/%s", cdnURL, file.Key), 85 | } 86 | } 87 | 88 | return &Emote{ 89 | ID: s.ID.Hex(), 90 | Name: s.Name, 91 | Owner: NewUser(owner), 92 | Visibility: int32(vis), 93 | VisibilitySimple: simpleVis, 94 | Mime: webpMime, 95 | Status: int8(status), 96 | Tags: s.Tags, 97 | Width: width, 98 | Height: height, 99 | URLs: urls, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /internal/api/rest/v2/model/role.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | v2structures "github.com/seventv/common/structures/v2" 5 | "github.com/seventv/common/structures/v3" 6 | ) 7 | 8 | type Role struct { 9 | ID string `json:"id"` 10 | Name string `json:"name"` 11 | Position int32 `json:"position"` 12 | Color int32 `json:"color"` 13 | Allowed int64 `json:"allowed"` 14 | Denied int64 `json:"denied"` 15 | } 16 | 17 | func NewRole(s structures.Role) *Role { 18 | var p int64 19 | 20 | switch s.Allowed { 21 | case structures.RolePermissionCreateEmote: 22 | p |= v2structures.RolePermissionEmoteCreate 23 | case structures.RolePermissionEditEmote: 24 | p |= v2structures.RolePermissionEmoteEditOwned 25 | case structures.RolePermissionEditAnyEmote: 26 | p |= v2structures.RolePermissionEmoteEditAll 27 | case structures.RolePermissionCreateReport: 28 | p |= v2structures.RolePermissionCreateReports 29 | case structures.RolePermissionManageBans: 30 | p |= v2structures.RolePermissionBanUsers 31 | case structures.RolePermissionSuperAdministrator: 32 | p |= v2structures.RolePermissionAdministrator 33 | case structures.RolePermissionManageRoles: 34 | p |= v2structures.RolePermissionManageRoles 35 | case structures.RolePermissionManageUsers: 36 | p |= v2structures.RolePermissionManageUsers 37 | case structures.RolePermissionManageStack: 38 | p |= v2structures.RolePermissionEditApplicationMeta 39 | case structures.RolePermissionManageCosmetics: 40 | p |= v2structures.RolePermissionManageEntitlements 41 | case structures.RolePermissionFeatureZeroWidthEmoteType: 42 | p |= v2structures.RolePermissionUseZeroWidthEmote 43 | case structures.RolePermissionFeatureProfilePictureAnimation: 44 | p |= v2structures.RolePermissionUseCustomAvatars 45 | } 46 | 47 | return &Role{ 48 | ID: s.ID.Hex(), 49 | Name: s.Name, 50 | Position: s.Position, 51 | Color: int32(s.Color), 52 | Allowed: int64(p), 53 | Denied: int64(s.Denied), 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/api/rest/v2/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/seventv/common/structures/v3" 5 | "github.com/seventv/common/utils" 6 | ) 7 | 8 | type User struct { 9 | ID string `json:"id"` 10 | TwitchID string `json:"twitch_id"` 11 | Login string `json:"login"` 12 | DisplayName string `json:"display_name"` 13 | Role *Role `json:"role"` 14 | ProfilePictureID string `json:"profile_picture_id,omitempty"` 15 | } 16 | 17 | func NewUser(s structures.User) *User { 18 | tw, _, _ := s.Connections.Twitch() 19 | 20 | u := User{ 21 | ID: s.ID.Hex(), 22 | Login: s.Username, 23 | DisplayName: utils.Ternary(s.DisplayName != "", s.DisplayName, s.Username), 24 | Role: NewRole(s.GetHighestRole()), 25 | TwitchID: tw.ID, 26 | ProfilePictureID: utils.Ternary(s.HasPermission(structures.RolePermissionFeatureProfilePictureAnimation), s.AvatarID, ""), 27 | } 28 | 29 | return &u 30 | } 31 | -------------------------------------------------------------------------------- /internal/api/rest/v2/routes/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/seventv/api/internal/api/rest/rest" 7 | "github.com/seventv/api/internal/global" 8 | ) 9 | 10 | type Route struct { 11 | Ctx global.Context 12 | } 13 | 14 | func New(gCtx global.Context) rest.Route { 15 | return &Route{gCtx} 16 | } 17 | 18 | func (r *Route) Config() rest.RouteConfig { 19 | return rest.RouteConfig{ 20 | URI: "/auth", 21 | Method: rest.GET, 22 | Children: []rest.Route{ 23 | newYouTube(r.Ctx), 24 | }, 25 | } 26 | } 27 | 28 | func (r *Route) Handler(ctx *rest.Ctx) rest.APIError { 29 | ctx.Redirect(fmt.Sprintf("/v3%s/auth/twitch?old=true", r.Ctx.Config().Http.VersionSuffix), int(rest.Found)) 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/api/rest/v2/routes/cosmetics/cosmetics.avatars.go: -------------------------------------------------------------------------------- 1 | package cosmetics 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/middleware" 5 | "github.com/seventv/api/internal/api/rest/rest" 6 | "github.com/seventv/api/internal/global" 7 | "github.com/seventv/common/errors" 8 | ) 9 | 10 | type avatars struct { 11 | Ctx global.Context 12 | } 13 | 14 | func newAvatars(gCtx global.Context) rest.Route { 15 | return &avatars{gCtx} 16 | } 17 | 18 | // Config implements rest.Route 19 | func (r *avatars) Config() rest.RouteConfig { 20 | return rest.RouteConfig{ 21 | URI: "/avatars", 22 | Method: rest.GET, 23 | Children: []rest.Route{}, 24 | Middleware: []rest.Middleware{ 25 | middleware.SetCacheControl(r.Ctx, 86400, []string{"public"}), 26 | }, 27 | } 28 | } 29 | 30 | // Handler implements rest.Route 31 | func (r *avatars) Handler(ctx *rest.Ctx) errors.APIError { 32 | return errors.ErrEndOfLife().SetDetail("This endpoint is no longer available. Please use the EventAPI or the Get User endpoint instead.") 33 | } 34 | -------------------------------------------------------------------------------- /internal/api/rest/v2/routes/downloads/downloads.go: -------------------------------------------------------------------------------- 1 | package downloads 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/middleware" 5 | "github.com/seventv/api/internal/api/rest/rest" 6 | "github.com/seventv/api/internal/global" 7 | "github.com/seventv/common/errors" 8 | ) 9 | 10 | type Route struct { 11 | Ctx global.Context 12 | } 13 | 14 | func New(gctx global.Context) rest.Route { 15 | return &Route{gctx} 16 | } 17 | 18 | func (r *Route) Config() rest.RouteConfig { 19 | return rest.RouteConfig{ 20 | URI: "/webext", 21 | Method: rest.GET, 22 | Middleware: []rest.Middleware{ 23 | middleware.SetCacheControl(r.Ctx, 600, nil), 24 | }, 25 | } 26 | } 27 | 28 | // Downloads 29 | // @Summary Get Downloads 30 | // @Description Lists downloadable extensions and apps 31 | // @Produce json 32 | // @Success 200 {object} DownloadsResult 33 | // @Router /webext [get] 34 | func (r *Route) Handler(ctx *rest.Ctx) errors.APIError { 35 | platforms := []Platform{ // TODO: infer this data from a config 36 | { 37 | ID: "chrome", 38 | VersionTag: "2.2.2", 39 | New: false, 40 | }, 41 | { 42 | ID: "firefox", 43 | VersionTag: "2.2.2", 44 | New: false, 45 | }, 46 | { 47 | ID: "chatterino", 48 | VersionTag: "7.3.5", 49 | New: false, 50 | }, 51 | { 52 | ID: "mobile", 53 | VersionTag: "MOBILE", 54 | New: false, 55 | Variants: []PlatformVariant{ 56 | { 57 | Name: "Chatsen", 58 | ID: "chatsen", 59 | Author: "OrangeCat", 60 | Description: "Twitch chat client for iOS & Android with 7TV support", 61 | URL: "https://chatsen.app", 62 | }, 63 | { 64 | Name: "DankChat", 65 | ID: "dankchat", 66 | Author: "flex3rs", 67 | Description: "Android Twitch Chat Client with 7TV support", 68 | URL: "https://play.google.com/store/apps/details?id=com.flxrs.dankchat", 69 | }, 70 | }, 71 | }, 72 | } 73 | 74 | return ctx.JSON(rest.OK, platforms) 75 | } 76 | 77 | type DownloadsResult struct { 78 | Platforms []Platform `json:"platforms"` 79 | } 80 | 81 | type Platform struct { 82 | ID string `mapstructure:"id" json:"id"` 83 | VersionTag string `mapstructure:"version_tag" json:"version_tag"` 84 | New bool `mapstructure:"new" json:"new"` 85 | URL string `mapstructure:"url" json:"url,omitempty"` 86 | Variants []PlatformVariant `mapstructure:"variants" json:"variants,omitempty"` 87 | } 88 | 89 | type PlatformVariant struct { 90 | Name string `json:"name" mapstructure:"name"` 91 | ID string `json:"id" mapstructure:"id"` 92 | Author string `json:"author" mapstructure:"author"` 93 | Version string `json:"version" mapstructure:"version"` 94 | Description string `json:"description" mapstructure:"description"` 95 | URL string `json:"url" mapstructure:"url"` 96 | } 97 | -------------------------------------------------------------------------------- /internal/api/rest/v2/routes/emotes/emote.go: -------------------------------------------------------------------------------- 1 | package emotes 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/middleware" 5 | "github.com/seventv/api/internal/api/rest/rest" 6 | "github.com/seventv/api/internal/global" 7 | "github.com/seventv/common/errors" 8 | ) 9 | 10 | type Route struct { 11 | Ctx global.Context 12 | } 13 | 14 | func New(gCtx global.Context) rest.Route { 15 | return &Route{gCtx} 16 | } 17 | 18 | func (r *Route) Config() rest.RouteConfig { 19 | return rest.RouteConfig{ 20 | URI: "/emotes", 21 | Method: rest.GET, 22 | Children: []rest.Route{ 23 | newEmote(r.Ctx), 24 | newGlobals(r.Ctx), 25 | }, 26 | Middleware: []rest.Middleware{ 27 | middleware.SetCacheControl(r.Ctx, 86400, nil), 28 | }, 29 | } 30 | } 31 | 32 | func (r *Route) Handler(ctx *rest.Ctx) errors.APIError { 33 | return ctx.JSON(rest.SeeOther, []string{ 34 | "/emotes/{emote}", 35 | "/emotes/global", 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /internal/api/rest/v2/routes/emotes/emotes.emote.go: -------------------------------------------------------------------------------- 1 | package emotes 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/middleware" 5 | "github.com/seventv/api/internal/api/rest/rest" 6 | "github.com/seventv/api/internal/api/rest/v2/model" 7 | "github.com/seventv/api/internal/global" 8 | "github.com/seventv/common/errors" 9 | ) 10 | 11 | type emote struct { 12 | Ctx global.Context 13 | } 14 | 15 | func newEmote(gCtx global.Context) rest.Route { 16 | return &emote{gCtx} 17 | } 18 | 19 | func (r *emote) Config() rest.RouteConfig { 20 | return rest.RouteConfig{ 21 | URI: "/{emote}", 22 | Method: rest.GET, 23 | Children: []rest.Route{}, 24 | Middleware: []rest.Middleware{ 25 | middleware.SetCacheControl(r.Ctx, 604800, []string{"public"}), 26 | }, 27 | } 28 | } 29 | 30 | // Get Emote 31 | // @Summary Get Emote 32 | // @Description Find an emote by its ID 33 | // @Tags emotes 34 | // @Param emote path string false "Emote ID" 35 | // @Produce json 36 | // @Success 200 {object} model.Emote 37 | // @Router /emotes/{emote} [get] 38 | func (r *emote) Handler(ctx *rest.Ctx) errors.APIError { 39 | emoteID, err := ctx.UserValue("emote").ObjectID() 40 | if err != nil { 41 | return errors.From(err) 42 | } 43 | 44 | emote, err := r.Ctx.Inst().Loaders.EmoteByID().Load(emoteID) 45 | if err != nil { 46 | return errors.From(err) 47 | } 48 | 49 | if emote.ID.IsZero() { 50 | return errors.ErrUnknownEmote() 51 | } 52 | 53 | return ctx.JSON(rest.OK, model.NewEmote(emote, r.Ctx.Config().CdnURL)) 54 | } 55 | -------------------------------------------------------------------------------- /internal/api/rest/v2/routes/emotes/emotes.global.go: -------------------------------------------------------------------------------- 1 | package emotes 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/middleware" 5 | "github.com/seventv/api/internal/api/rest/rest" 6 | "github.com/seventv/api/internal/api/rest/v2/model" 7 | "github.com/seventv/api/internal/global" 8 | "github.com/seventv/common/errors" 9 | v2structures "github.com/seventv/common/structures/v2" 10 | "github.com/seventv/common/structures/v3" 11 | "github.com/seventv/common/utils" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | ) 14 | 15 | type globals struct { 16 | Ctx global.Context 17 | } 18 | 19 | func newGlobals(gCtx global.Context) rest.Route { 20 | return &globals{gCtx} 21 | } 22 | 23 | func (r *globals) Config() rest.RouteConfig { 24 | return rest.RouteConfig{ 25 | URI: "/global", 26 | Method: rest.GET, 27 | Children: []rest.Route{}, 28 | Middleware: []rest.Middleware{ 29 | middleware.SetCacheControl(r.Ctx, 1800, nil), 30 | }, 31 | } 32 | } 33 | 34 | // Get Global Emotes 35 | // @Summary Get Globla Emotes 36 | // @Description Lists active global emotes 37 | // @Tags emotes 38 | // @Produce json 39 | // @Success 200 {array} model.Emote 40 | // @Router /emotes/global [get] 41 | func (r *globals) Handler(ctx *rest.Ctx) errors.APIError { 42 | es, err := r.Ctx.Inst().Query.GlobalEmoteSet(ctx) 43 | if err != nil { 44 | return errors.From(err) 45 | } 46 | 47 | result := make([]model.Emote, len(es.Emotes)) 48 | 49 | emoteIDs := utils.Map(es.Emotes, func(a structures.ActiveEmote) primitive.ObjectID { 50 | return a.ID 51 | }) 52 | 53 | emotes, _ := r.Ctx.Inst().Loaders.EmoteByID().LoadAll(emoteIDs) 54 | 55 | emoteMap := map[primitive.ObjectID]structures.Emote{} 56 | for _, emote := range emotes { 57 | emoteMap[emote.ID] = emote 58 | } 59 | 60 | for i, ae := range es.Emotes { 61 | e := utils.PointerOf(emoteMap[ae.ID]) 62 | ae.Emote = e 63 | 64 | if ae.Emote == nil { 65 | continue 66 | } 67 | 68 | ae.Emote.Name = ae.Name 69 | 70 | result[i] = *model.NewEmote(*ae.Emote, r.Ctx.Config().CdnURL) 71 | result[i].Visibility |= v2structures.EmoteVisibilityGlobal 72 | } 73 | 74 | return ctx.JSON(rest.OK, result) 75 | } 76 | -------------------------------------------------------------------------------- /internal/api/rest/v2/routes/root.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/api/rest/v2/routes/auth" 6 | "github.com/seventv/api/internal/api/rest/v2/routes/chatterino" 7 | "github.com/seventv/api/internal/api/rest/v2/routes/cosmetics" 8 | "github.com/seventv/api/internal/api/rest/v2/routes/downloads" 9 | "github.com/seventv/api/internal/api/rest/v2/routes/emotes" 10 | "github.com/seventv/api/internal/api/rest/v2/routes/user" 11 | "github.com/seventv/api/internal/global" 12 | "github.com/seventv/common/errors" 13 | ) 14 | 15 | type Route struct { 16 | Ctx global.Context 17 | } 18 | 19 | func New(gCtx global.Context) rest.Route { 20 | return &Route{gCtx} 21 | } 22 | 23 | func (r *Route) Config() rest.RouteConfig { 24 | return rest.RouteConfig{ 25 | URI: "/v2" + r.Ctx.Config().Http.VersionSuffix, 26 | Method: rest.GET, 27 | Children: []rest.Route{ 28 | auth.New(r.Ctx), 29 | user.New(r.Ctx), 30 | emotes.New(r.Ctx), 31 | cosmetics.New(r.Ctx), 32 | downloads.New(r.Ctx), 33 | chatterino.New(r.Ctx), 34 | }, 35 | } 36 | } 37 | 38 | func (r *Route) Handler(ctx *rest.Ctx) rest.APIError { 39 | return errors.ErrUnknownRoute() 40 | } 41 | -------------------------------------------------------------------------------- /internal/api/rest/v2/routes/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/seventv/api/data/query" 5 | "github.com/seventv/api/internal/api/rest/middleware" 6 | "github.com/seventv/api/internal/api/rest/rest" 7 | "github.com/seventv/api/internal/api/rest/v2/model" 8 | "github.com/seventv/api/internal/global" 9 | "github.com/seventv/common/errors" 10 | "github.com/seventv/common/utils" 11 | "go.mongodb.org/mongo-driver/bson" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | ) 14 | 15 | type Route struct { 16 | Ctx global.Context 17 | } 18 | 19 | func New(gCtx global.Context) rest.Route { 20 | return &Route{gCtx} 21 | } 22 | 23 | func (r *Route) Config() rest.RouteConfig { 24 | return rest.RouteConfig{ 25 | URI: "/users/{user}", 26 | Method: rest.GET, 27 | Children: []rest.Route{ 28 | newEmotes(r.Ctx), 29 | }, 30 | Middleware: []rest.Middleware{ 31 | middleware.SetCacheControl(r.Ctx, 604800, []string{"public"}), 32 | }, 33 | } 34 | } 35 | 36 | // Get User 37 | // @Summary Get User 38 | // @Description Finds a user by its ID, Username or Twitch ID 39 | // @Tags users 40 | // @Param user path string false "User ID, Username or Twitch ID" 41 | // @Produce json 42 | // @Success 200 {object} model.User 43 | // @Router /users/{user} [get] 44 | func (r *Route) Handler(ctx *rest.Ctx) errors.APIError { 45 | key, _ := ctx.UserValue("user").String() 46 | 47 | var id primitive.ObjectID 48 | if primitive.IsValidObjectID(key) { 49 | id, _ = primitive.ObjectIDFromHex(key) 50 | } 51 | 52 | filter := utils.Ternary(id.IsZero(), bson.M{"$or": bson.A{ 53 | bson.M{"connections.id": key}, 54 | }}, bson.M{ 55 | "_id": id, 56 | }) 57 | 58 | user, err := r.Ctx.Inst().Query.Users(ctx, filter).First() 59 | if err != nil { 60 | return errors.From(err) 61 | } 62 | 63 | // Check ban 64 | bans, err := r.Ctx.Inst().Query.Bans(ctx, query.BanQueryOptions{ 65 | Filter: bson.M{"victim_id": user.ID}, 66 | }) 67 | if err == nil && bans.MemoryHole.Has(user.ID) { 68 | return errors.ErrUnknownUser() 69 | } 70 | 71 | if user.ID.IsZero() { 72 | return errors.ErrUnknownUser() 73 | } 74 | 75 | return ctx.JSON(rest.OK, model.NewUser(user)) 76 | } 77 | -------------------------------------------------------------------------------- /internal/api/rest/v2/v2.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/api/rest/v2/routes" 6 | "github.com/seventv/api/internal/global" 7 | ) 8 | 9 | // @title 7TV REST API 10 | // @version 2.0 11 | // @description This is the former v2 REST API for 7TV (deprecated) 12 | // @termsOfService TODO 13 | 14 | // @contact.name 7TV Developers 15 | // @contact.url https://discord.gg/7tv 16 | // @contact.email dev@7tv.io 17 | 18 | // @license.name Apache 2.0 + Commons Clause 19 | // @license.url https://github.com/SevenTV/REST/blob/dev/LICENSE.md 20 | 21 | // @host api.7tv.app 22 | // @BasePath /v2 23 | // @schemes https 24 | // @query.collection.format multi 25 | func API(gCtx global.Context, router *rest.Router) rest.Route { 26 | return routes.New(gCtx) 27 | } 28 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/auth/logout.auth.route.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/middleware" 5 | "github.com/seventv/api/internal/api/rest/rest" 6 | "github.com/seventv/api/internal/global" 7 | "github.com/seventv/api/internal/svc/auth" 8 | "github.com/seventv/common/errors" 9 | ) 10 | 11 | type logoutRoute struct { 12 | gctx global.Context 13 | } 14 | 15 | func newLogout(gctx global.Context) rest.Route { 16 | return &logoutRoute{gctx} 17 | } 18 | 19 | func (r *logoutRoute) Config() rest.RouteConfig { 20 | return rest.RouteConfig{ 21 | URI: "/logout", 22 | Method: rest.POST, 23 | Middleware: []rest.Middleware{ 24 | middleware.Auth(r.gctx, true), 25 | }, 26 | } 27 | } 28 | 29 | func (r *logoutRoute) Handler(ctx *rest.Ctx) errors.APIError { 30 | cookie := r.gctx.Inst().Auth.Cookie(auth.COOKIE_AUTH, "", 0) 31 | 32 | ctx.Response.Header.SetCookie(cookie) 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/config/config.root.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/middleware" 5 | "github.com/seventv/api/internal/api/rest/rest" 6 | "github.com/seventv/api/internal/global" 7 | "github.com/seventv/common/errors" 8 | ) 9 | 10 | type Route struct { 11 | Ctx global.Context 12 | } 13 | 14 | func New(gctx global.Context) rest.Route { 15 | return &Route{gctx} 16 | } 17 | 18 | func (r *Route) Config() rest.RouteConfig { 19 | return rest.RouteConfig{ 20 | URI: "/config/{name}", 21 | Method: rest.GET, 22 | Children: []rest.Route{}, 23 | Middleware: []rest.Middleware{ 24 | middleware.SetCacheControl(r.Ctx, 60, nil), 25 | }, 26 | } 27 | } 28 | 29 | func (r *Route) Handler(ctx *rest.Ctx) rest.APIError { 30 | // get path 31 | name, ok := ctx.UserValue("name").String() 32 | if !ok { 33 | return errors.ErrInvalidRequest().SetDetail("Missing config name") 34 | } 35 | 36 | sys, err := r.Ctx.Inst().Mongo.System(ctx) 37 | if err != nil { 38 | ctx.Log().Errorw("failed to get system config", 39 | "err", err, 40 | ) 41 | 42 | return errors.ErrInternalServerError() 43 | } 44 | 45 | var t any 46 | 47 | switch name { 48 | case "extension": 49 | t = sys.Config.Extension 50 | case "extension-nightly", "extension-beta": 51 | t = sys.Config.ExtensionNightly 52 | default: 53 | return errors.ErrInvalidRequest().SetDetail("Invalid config name") 54 | } 55 | 56 | return ctx.JSON(rest.OK, t) 57 | } 58 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/docs/docs.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/api/rest/v3/docs" 6 | "github.com/seventv/api/internal/global" 7 | ) 8 | 9 | type Route struct { 10 | Ctx global.Context 11 | } 12 | 13 | func New(gCtx global.Context) rest.Route { 14 | return &Route{gCtx} 15 | } 16 | 17 | func (r *Route) Config() rest.RouteConfig { 18 | return rest.RouteConfig{ 19 | URI: "/docs", 20 | Method: rest.GET, 21 | } 22 | } 23 | 24 | func (r *Route) Handler(ctx *rest.Ctx) rest.APIError { 25 | ctx.SetBodyString(docs.SwaggerInfo.ReadDoc()) 26 | ctx.SetContentType("application/json") 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/emote-sets/emote-sets.root.go: -------------------------------------------------------------------------------- 1 | package emote_sets 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/global" 6 | "github.com/seventv/common/errors" 7 | ) 8 | 9 | type Route struct { 10 | Ctx global.Context 11 | } 12 | 13 | func New(gctx global.Context) Route { 14 | return Route{gctx} 15 | } 16 | 17 | func (r Route) Config() rest.RouteConfig { 18 | return rest.RouteConfig{ 19 | URI: "/emote-sets", 20 | Method: rest.GET, 21 | Children: []rest.Route{ 22 | newEmoteSetByIDRoute(r.Ctx), 23 | }, 24 | } 25 | } 26 | 27 | // @Summary Search Emote Sets 28 | // @Description Search for Emote Sets 29 | // @Tags emote-sets 30 | // @Produce json 31 | // @Param query query string false "search by emote set name / tags" 32 | // @Success 200 {array} model.EmoteSetModel 33 | // @Router /emote-sets [get] 34 | func (r Route) Handler(ctx *rest.Ctx) rest.APIError { 35 | return errors.ErrUnknownRoute().SetDetail("This route is not implemented yet") 36 | } 37 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/emotes/emotes.by-id.go: -------------------------------------------------------------------------------- 1 | package emotes 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/middleware" 5 | "github.com/seventv/api/internal/api/rest/rest" 6 | "github.com/seventv/api/internal/global" 7 | "github.com/seventv/common/errors" 8 | ) 9 | 10 | type emoteRoute struct { 11 | Ctx global.Context 12 | } 13 | 14 | func newEmote(gctx global.Context) rest.Route { 15 | return &emoteRoute{gctx} 16 | } 17 | 18 | func (r *emoteRoute) Config() rest.RouteConfig { 19 | return rest.RouteConfig{ 20 | URI: "/{emote.id}", 21 | Method: rest.GET, 22 | Children: []rest.Route{}, 23 | Middleware: []rest.Middleware{ 24 | middleware.SetCacheControl(r.Ctx, 3600, []string{"public"}), 25 | }, 26 | } 27 | } 28 | 29 | // @Summary Get Emote 30 | // @Description Get emote by ID 31 | // @Param emoteID path string true "ID of the emote" 32 | // @Tags emotes 33 | // @Produce json 34 | // @Success 200 {object} model.EmoteModel 35 | // @Router /emotes/{emote.id} [get] 36 | func (r *emoteRoute) Handler(ctx *rest.Ctx) rest.APIError { 37 | emoteID, err := ctx.UserValue("emote.id").ObjectID() 38 | if err != nil { 39 | return errors.From(err) 40 | } 41 | 42 | emote, err := r.Ctx.Inst().Loaders.EmoteByID().Load(emoteID) 43 | if err != nil { 44 | return errors.From(err) 45 | } 46 | 47 | return ctx.JSON(rest.OK, r.Ctx.Inst().Modelizer.Emote(emote)) 48 | } 49 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/emotes/emotes.go: -------------------------------------------------------------------------------- 1 | package emotes 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/api/rest/v2/model" 6 | "github.com/seventv/api/internal/global" 7 | ) 8 | 9 | type Route struct { 10 | Ctx global.Context 11 | } 12 | 13 | func New(gCtx global.Context) rest.Route { 14 | listen(gCtx) 15 | return &Route{gCtx} 16 | } 17 | 18 | func (r *Route) Config() rest.RouteConfig { 19 | return rest.RouteConfig{ 20 | URI: "/emotes", 21 | Method: rest.GET, 22 | Children: []rest.Route{ 23 | newCreate(r.Ctx), 24 | newEmote(r.Ctx), 25 | }, 26 | Middleware: []rest.Middleware{}, 27 | } 28 | } 29 | 30 | // @Summary Search Emotes 31 | // @Description Search for emotes 32 | // @Tags emotes 33 | // @Produce json 34 | // @Param query query string false "search by emote name / tags" 35 | // @Success 200 {array} model.EmoteModel 36 | // @Router /emotes [get] 37 | func (r *Route) Handler(ctx *rest.Ctx) rest.APIError { 38 | res := []model.Emote{{}} 39 | return ctx.JSON(rest.OK, &res) 40 | } 41 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/entitlements/entitlements.go: -------------------------------------------------------------------------------- 1 | package entitlements 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/global" 6 | ) 7 | 8 | type entitlementRoute struct { 9 | gctx global.Context 10 | } 11 | 12 | func New(gctx global.Context) rest.Route { 13 | return &entitlementRoute{gctx} 14 | } 15 | 16 | func (r *entitlementRoute) Config() rest.RouteConfig { 17 | return rest.RouteConfig{ 18 | URI: "/entitlements", 19 | Method: rest.GET, 20 | Children: []rest.Route{ 21 | newCreate(r.gctx), 22 | }, 23 | Middleware: []rest.Middleware{}, 24 | } 25 | } 26 | 27 | func (r *entitlementRoute) Handler(ctx *rest.Ctx) rest.APIError { 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/root.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/seventv/api/internal/api/rest/middleware" 8 | "github.com/seventv/api/internal/api/rest/rest" 9 | "github.com/seventv/api/internal/api/rest/v3/routes/auth" 10 | "github.com/seventv/api/internal/api/rest/v3/routes/config" 11 | "github.com/seventv/api/internal/api/rest/v3/routes/docs" 12 | emote_sets "github.com/seventv/api/internal/api/rest/v3/routes/emote-sets" 13 | "github.com/seventv/api/internal/api/rest/v3/routes/emotes" 14 | "github.com/seventv/api/internal/api/rest/v3/routes/entitlements" 15 | "github.com/seventv/api/internal/api/rest/v3/routes/users" 16 | "github.com/seventv/api/internal/global" 17 | ) 18 | 19 | var uptime = time.Now() 20 | 21 | type Route struct { 22 | Ctx global.Context 23 | } 24 | 25 | func New(gCtx global.Context) rest.Route { 26 | return &Route{gCtx} 27 | } 28 | 29 | func (r *Route) Config() rest.RouteConfig { 30 | return rest.RouteConfig{ 31 | URI: "/v3" + r.Ctx.Config().Http.VersionSuffix, 32 | Method: rest.GET, 33 | Children: []rest.Route{ 34 | docs.New(r.Ctx), 35 | config.New(r.Ctx), 36 | auth.New(r.Ctx), 37 | emotes.New(r.Ctx), 38 | emote_sets.New(r.Ctx), 39 | users.New(r.Ctx), 40 | entitlements.New(r.Ctx), 41 | }, 42 | Middleware: []rest.Middleware{ 43 | middleware.SetCacheControl(r.Ctx, 30, nil), 44 | }, 45 | } 46 | } 47 | 48 | func (r *Route) Handler(ctx *rest.Ctx) rest.APIError { 49 | return ctx.JSON(rest.OK, HealthResponse{ 50 | Online: true, 51 | Uptime: strconv.Itoa(int(uptime.UnixMilli())), 52 | }) 53 | } 54 | 55 | type HealthResponse struct { 56 | Online bool `json:"online"` 57 | Uptime string `json:"uptime"` 58 | } 59 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/users/users.delete.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/seventv/common/errors" 5 | "github.com/seventv/common/mongo" 6 | "github.com/seventv/common/structures/v3" 7 | "go.mongodb.org/mongo-driver/bson" 8 | 9 | "github.com/seventv/api/data/mutate" 10 | "github.com/seventv/api/internal/api/rest/middleware" 11 | "github.com/seventv/api/internal/api/rest/rest" 12 | "github.com/seventv/api/internal/global" 13 | ) 14 | 15 | type userDeleteRoute struct { 16 | gctx global.Context 17 | } 18 | 19 | func newUserDeleteRoute(gctx global.Context) *userDeleteRoute { 20 | return &userDeleteRoute{gctx} 21 | } 22 | 23 | func (r *userDeleteRoute) Config() rest.RouteConfig { 24 | return rest.RouteConfig{ 25 | URI: "/{user.id}", 26 | Method: rest.DELETE, 27 | Middleware: []rest.Middleware{ 28 | middleware.Auth(r.gctx, true), 29 | }, 30 | } 31 | } 32 | 33 | func (r *userDeleteRoute) Handler(ctx *rest.Ctx) rest.APIError { 34 | // make sure actor has permission to delete users 35 | actor, ok := ctx.GetActor() 36 | if !ok || !actor.HasPermission(structures.RolePermissionManageUsers) { 37 | return errors.ErrInsufficientPrivilege() 38 | } 39 | 40 | victimID, err := ctx.UserValue("user.id").ObjectID() 41 | if err != nil { 42 | return errors.From(err) 43 | } 44 | 45 | victim, err := r.gctx.Inst().Query.Users(ctx, bson.M{ 46 | "_id": victimID, 47 | }).First() 48 | if err != nil { 49 | if errors.Compare(err, errors.ErrNoItems()) { 50 | return errors.ErrUnknownUser() 51 | } 52 | 53 | return errors.From(err) 54 | } 55 | 56 | res := userDeleteResponse{} 57 | // delete user 58 | if res.DocumentDeletedCount, err = r.gctx.Inst().Mutate.DeleteUser(ctx, mutate.DeleteUserOptions{ 59 | Actor: actor, 60 | Victim: victim, 61 | }); err != nil { 62 | return errors.From(err) 63 | } 64 | 65 | // Create audit log 66 | log := structures.NewAuditLogBuilder(structures.AuditLog{}). 67 | SetKind(structures.AuditLogKindDeleteUser). 68 | SetActor(actor.ID). 69 | SetTargetKind(structures.ObjectKindEmoteSet). 70 | SetTargetID(victimID) 71 | 72 | if _, err = r.gctx.Inst().Mongo.Collection(mongo.CollectionNameAuditLogs).InsertOne(ctx, log.AuditLog); err != nil { 73 | ctx.Log().Errorw("mongo, failed to write audit log entry for deleted user") 74 | } 75 | 76 | ctx.Log().Infow("user deleted", "victim_id", victimID) 77 | 78 | return ctx.JSON(rest.OK, res) 79 | } 80 | 81 | type userDeleteResponse struct { 82 | DocumentDeletedCount int `json:"document_deleted_count"` 83 | } 84 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/users/users.root.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/global" 6 | ) 7 | 8 | type Route struct { 9 | Ctx global.Context 10 | } 11 | 12 | func New(gCtx global.Context) rest.Route { 13 | return &Route{gCtx} 14 | } 15 | 16 | func (r *Route) Config() rest.RouteConfig { 17 | return rest.RouteConfig{ 18 | URI: "/users", 19 | Method: rest.GET, 20 | Children: []rest.Route{ 21 | newUser(r.Ctx), 22 | newUserConnection(r.Ctx), 23 | newPictureUpload(r.Ctx), 24 | newUserPresenceWriteRoute(r.Ctx), 25 | newUserDeleteRoute(r.Ctx), 26 | newUserMergeRoute(r.Ctx), 27 | }, 28 | Middleware: []rest.Middleware{}, 29 | } 30 | } 31 | 32 | // @Summary Search Users 33 | // @Description Search for users 34 | // @Tags users 35 | // @Produce json 36 | // @Param query query string false "search by username, user id, channel name or channel id" 37 | // @Success 200 38 | // @Router /users [get] 39 | func (r *Route) Handler(ctx *rest.Ctx) rest.APIError { 40 | return ctx.JSON(rest.OK, struct{}{}) 41 | } 42 | -------------------------------------------------------------------------------- /internal/api/rest/v3/routes/users/users.update-connection.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/seventv/common/errors" 7 | "github.com/seventv/common/structures/v3" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | 11 | "github.com/seventv/api/internal/api/rest/middleware" 12 | "github.com/seventv/api/internal/api/rest/rest" 13 | "github.com/seventv/api/internal/global" 14 | ) 15 | 16 | type userUpdateConnectionRoute struct { 17 | gctx global.Context 18 | } 19 | 20 | func newUserMergeRoute(gctx global.Context) *userUpdateConnectionRoute { 21 | return &userUpdateConnectionRoute{gctx} 22 | } 23 | 24 | func (r *userUpdateConnectionRoute) Config() rest.RouteConfig { 25 | return rest.RouteConfig{ 26 | URI: "/{user.id}/connections/{user-connection.id}", 27 | Method: rest.PATCH, 28 | Middleware: []rest.Middleware{ 29 | middleware.Auth(r.gctx, true), 30 | }, 31 | } 32 | } 33 | 34 | func (r *userUpdateConnectionRoute) Handler(ctx *rest.Ctx) rest.APIError { 35 | // make sure actor has permission to delete users 36 | actor, ok := ctx.GetActor() 37 | if !ok || !actor.HasPermission(structures.RolePermissionManageUsers) { 38 | return errors.ErrInsufficientPrivilege() 39 | } 40 | 41 | userID, err := ctx.UserValue("user.id").ObjectID() 42 | if err != nil { 43 | return errors.From(err) 44 | } 45 | 46 | connectionID, _ := ctx.UserValue("user-connection.id").String() 47 | 48 | var body updateUserConnections 49 | if err := json.Unmarshal(ctx.Request.Body(), &body); err != nil { 50 | return errors.ErrInvalidRequest() 51 | } 52 | 53 | target, err := r.gctx.Inst().Query.Users(ctx, bson.M{ 54 | "_id": userID, 55 | }).First() 56 | if err != nil { 57 | if errors.Compare(err, errors.ErrNoItems()) { 58 | return errors.ErrUnknownUser().SetDetail("target") 59 | } 60 | 61 | return errors.From(err) 62 | } 63 | 64 | if !body.NewUserID.IsZero() { 65 | victim, err := r.gctx.Inst().Query.Users(ctx, bson.M{ 66 | "_id": body.NewUserID, 67 | }).First() 68 | if err != nil { 69 | if errors.Compare(err, errors.ErrNoItems()) { 70 | return errors.ErrUnknownUser().SetDetail("victim") 71 | } 72 | 73 | return errors.From(err) 74 | } 75 | 76 | if err = r.gctx.Inst().Mutate.TransferUserConnection(ctx, actor, target, victim, connectionID); err != nil { 77 | return errors.From(err) 78 | } 79 | } 80 | 81 | // TODO: add mutation to audit log 82 | 83 | return ctx.JSON(rest.OK, struct{}{}) 84 | } 85 | 86 | type updateUserConnections struct { 87 | NewUserID primitive.ObjectID `json:"new_user_id"` 88 | } 89 | -------------------------------------------------------------------------------- /internal/api/rest/v3/v3.go: -------------------------------------------------------------------------------- 1 | package v3 2 | 3 | import ( 4 | "github.com/seventv/api/internal/api/rest/rest" 5 | "github.com/seventv/api/internal/api/rest/v3/routes" 6 | "github.com/seventv/api/internal/global" 7 | ) 8 | 9 | // @title 7TV REST API 10 | // @version 3.0 11 | // @description This is the REST API for 7TV 12 | // @termsOfService TODO 13 | 14 | // @contact.name 7TV Developers 15 | // @contact.url https://discord.gg/7tv 16 | // @contact.email dev@7tv.io 17 | 18 | // @license.name Apache 2.0 + Commons Clause 19 | // @license.url https://github.com/SevenTV/REST/blob/dev/LICENSE.md 20 | 21 | // @host 7tv.io 22 | // @BasePath /v3 23 | // @schemes http 24 | // @query.collection.format multi 25 | func API(gCtx global.Context, router *rest.Router) rest.Route { 26 | return routes.New(gCtx) 27 | } 28 | -------------------------------------------------------------------------------- /internal/configure/logging.go: -------------------------------------------------------------------------------- 1 | package configure 2 | 3 | import ( 4 | "io" 5 | "log" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | func initLogging(level string) { 12 | log.SetOutput(io.Discard) 13 | 14 | var lvl zapcore.Level 15 | 16 | switch level { 17 | case "debug": 18 | lvl = zap.DebugLevel 19 | case "info": 20 | lvl = zap.InfoLevel 21 | case "warn": 22 | lvl = zap.WarnLevel 23 | case "error": 24 | lvl = zap.ErrorLevel 25 | case "panic": 26 | lvl = zap.PanicLevel 27 | case "fatal": 28 | lvl = zap.FatalLevel 29 | default: 30 | lvl = zap.InfoLevel 31 | } 32 | 33 | cfg := zap.NewProductionConfig() 34 | cfg.Level = zap.NewAtomicLevelAt(lvl) 35 | logger, _ := cfg.Build() 36 | 37 | zap.ReplaceGlobals(logger) 38 | } 39 | -------------------------------------------------------------------------------- /internal/constant/keys.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | type Key string 4 | 5 | const ( 6 | ClientIP Key = "seventv-client-ip" 7 | UserKey Key = "seventv-user" 8 | ) 9 | -------------------------------------------------------------------------------- /internal/externalapis/discord.go: -------------------------------------------------------------------------------- 1 | package externalapis 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/seventv/api/internal/global" 9 | "github.com/seventv/common/structures/v3" 10 | ) 11 | 12 | type discord struct{} 13 | 14 | var Discord = discord{} 15 | 16 | func (discord) GetCurrentUser(gctx global.Context, token string) (structures.UserConnectionDataDiscord, error) { 17 | result := structures.UserConnectionDataDiscord{} 18 | 19 | req, err := Discord.DiscordAPIRequest(gctx, "GET", "/users/@me", "") 20 | if err != nil { 21 | return result, err 22 | } 23 | 24 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 25 | 26 | resp, err := http.DefaultClient.Do(req) 27 | if err != nil { 28 | return result, err 29 | } 30 | defer resp.Body.Close() 31 | 32 | if resp.StatusCode != 200 { 33 | body, err := io.ReadAll(resp.Body) 34 | if err != nil { 35 | return result, err 36 | } 37 | 38 | return result, fmt.Errorf("bad resp from discord: %d - %s", resp.StatusCode, body) 39 | } 40 | 41 | if err = ReadRequestResponse(resp, &result); err != nil { 42 | return result, err 43 | } 44 | 45 | return result, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/externalapis/externalapis.go: -------------------------------------------------------------------------------- 1 | package externalapis 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/seventv/api/internal/global" 10 | ) 11 | 12 | var TwitchHelixBase = "https://api.twitch.tv/helix" 13 | 14 | var DiscordAPIBase = "https://discord.com/api/v10" 15 | 16 | func (twitch) HelixAPIRequest(gctx global.Context, method string, route string, params string) (*http.Request, error) { 17 | uri := fmt.Sprintf("%s%s", TwitchHelixBase, route) 18 | 19 | return http.NewRequestWithContext(gctx, method, uri, nil) 20 | } 21 | 22 | func (discord) DiscordAPIRequest(gctx global.Context, method, route, params string) (*http.Request, error) { 23 | uri := fmt.Sprintf("%s%s", DiscordAPIBase, route) 24 | 25 | return http.NewRequestWithContext(gctx, method, uri, nil) 26 | } 27 | 28 | // ReadRequestResponse: quick utility for decoding an api response to a struct 29 | func ReadRequestResponse(resp *http.Response, out interface{}) error { 30 | b, err := io.ReadAll(resp.Body) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | if err = json.Unmarshal(b, out); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/externalapis/twitch.go: -------------------------------------------------------------------------------- 1 | package externalapis 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/seventv/api/internal/global" 9 | "github.com/seventv/common/structures/v3" 10 | ) 11 | 12 | type twitch struct{} 13 | 14 | var Twitch = twitch{} 15 | 16 | func (twitch) GetUserFromToken(gCtx global.Context, token string) ([]structures.UserConnectionDataTwitch, error) { 17 | req, err := Twitch.HelixAPIRequest(gCtx, "GET", "/users", "") 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | req.Header.Add("Client-Id", gCtx.Config().Platforms.Twitch.ClientID) 23 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 24 | 25 | resp, err := http.DefaultClient.Do(req) 26 | if err != nil { 27 | return nil, err 28 | } 29 | defer resp.Body.Close() 30 | 31 | if resp.StatusCode != 200 { 32 | body, err := io.ReadAll(resp.Body) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return nil, fmt.Errorf("bad resp from twitch: %d - %s", resp.StatusCode, body) 38 | } 39 | 40 | var res getTwitchUsersResp 41 | if err = ReadRequestResponse(resp, &res); err != nil { 42 | return nil, err 43 | } 44 | 45 | return res.Users, nil 46 | } 47 | 48 | type GetTwitchUsersParams struct { 49 | ID string `url:"id"` 50 | Login string `url:"login"` 51 | } 52 | 53 | type getTwitchUsersResp struct { 54 | Users []structures.UserConnectionDataTwitch `json:"data"` 55 | } 56 | -------------------------------------------------------------------------------- /internal/global/context.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/api/internal/configure" 8 | "github.com/seventv/api/internal/instance" 9 | ) 10 | 11 | type Context interface { 12 | context.Context 13 | Config() *configure.Config 14 | Inst() *instance.Instances 15 | } 16 | 17 | type gCtx struct { 18 | context.Context 19 | config *configure.Config 20 | inst *instance.Instances 21 | } 22 | 23 | func (g *gCtx) Config() *configure.Config { 24 | return g.config 25 | } 26 | 27 | func (g *gCtx) Inst() *instance.Instances { 28 | return g.inst 29 | } 30 | 31 | func New(ctx context.Context, config *configure.Config) Context { 32 | return &gCtx{ 33 | Context: ctx, 34 | config: config, 35 | inst: &instance.Instances{}, 36 | } 37 | } 38 | 39 | func WithCancel(ctx Context) (Context, context.CancelFunc) { 40 | cfg := ctx.Config() 41 | inst := ctx.Inst() 42 | 43 | c, cancel := context.WithCancel(ctx) 44 | 45 | return &gCtx{ 46 | Context: c, 47 | config: cfg, 48 | inst: inst, 49 | }, cancel 50 | } 51 | 52 | func WithDeadline(ctx Context, deadline time.Time) (Context, context.CancelFunc) { 53 | cfg := ctx.Config() 54 | inst := ctx.Inst() 55 | 56 | c, cancel := context.WithDeadline(ctx, deadline) 57 | 58 | return &gCtx{ 59 | Context: c, 60 | config: cfg, 61 | inst: inst, 62 | }, cancel 63 | } 64 | 65 | func WithValue(ctx Context, key interface{}, value interface{}) Context { 66 | cfg := ctx.Config() 67 | inst := ctx.Inst() 68 | 69 | return &gCtx{ 70 | Context: context.WithValue(ctx, key, value), 71 | config: cfg, 72 | inst: inst, 73 | } 74 | } 75 | 76 | func WithTimeout(ctx Context, timeout time.Duration) (Context, context.CancelFunc) { 77 | cfg := ctx.Config() 78 | inst := ctx.Inst() 79 | 80 | c, cancel := context.WithTimeout(ctx, timeout) 81 | 82 | return &gCtx{ 83 | Context: c, 84 | config: cfg, 85 | inst: inst, 86 | }, cancel 87 | } 88 | -------------------------------------------------------------------------------- /internal/instance/instances.go: -------------------------------------------------------------------------------- 1 | package instance 2 | 3 | import ( 4 | "github.com/seventv/common/mongo" 5 | "github.com/seventv/common/redis" 6 | "github.com/seventv/common/svc/s3" 7 | "github.com/seventv/compactdisc" 8 | messagequeue "github.com/seventv/message-queue/go" 9 | 10 | "github.com/seventv/api/data/events" 11 | "github.com/seventv/api/data/model" 12 | "github.com/seventv/api/data/mutate" 13 | "github.com/seventv/api/data/query" 14 | "github.com/seventv/api/internal/loaders" 15 | "github.com/seventv/api/internal/search" 16 | "github.com/seventv/api/internal/svc/auth" 17 | "github.com/seventv/api/internal/svc/limiter" 18 | "github.com/seventv/api/internal/svc/presences" 19 | "github.com/seventv/api/internal/svc/prometheus" 20 | "github.com/seventv/api/internal/svc/youtube" 21 | ) 22 | 23 | type Instances struct { 24 | Mongo mongo.Instance 25 | Meilisearch *search.MeiliSearch 26 | Redis redis.Instance 27 | Auth auth.Authorizer 28 | S3 s3.Instance 29 | MessageQueue messagequeue.Instance 30 | Prometheus prometheus.Instance 31 | Events events.Instance 32 | Limiter limiter.Instance 33 | YouTube youtube.Instance 34 | Loaders loaders.Instance 35 | Presences presences.Instance 36 | Modelizer model.Modelizer 37 | CD compactdisc.Instance 38 | 39 | Query *query.Query 40 | Mutate *mutate.Mutate 41 | } 42 | -------------------------------------------------------------------------------- /internal/loaders/emoteset.loader.go: -------------------------------------------------------------------------------- 1 | package loaders 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/common/dataloader" 8 | "github.com/seventv/common/errors" 9 | "github.com/seventv/common/structures/v3" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | 13 | "github.com/seventv/api/data/query" 14 | ) 15 | 16 | func emoteSetByID(ctx context.Context, x inst) EmoteSetLoaderByID { 17 | return dataloader.New(dataloader.Config[primitive.ObjectID, structures.EmoteSet]{ 18 | Wait: time.Millisecond * 100, 19 | Fetch: func(keys []primitive.ObjectID) ([]structures.EmoteSet, []error) { 20 | ctx, cancel := context.WithTimeout(ctx, time.Second*30) 21 | defer cancel() 22 | 23 | // Fetch emote set data from the database 24 | models := make([]structures.EmoteSet, len(keys)) 25 | errs := make([]error, len(keys)) 26 | 27 | result := x.query.EmoteSets(ctx, bson.M{"_id": bson.M{"$in": keys}}, query.QueryEmoteSetsOptions{ 28 | FetchOrigins: true, 29 | }) 30 | if result.Empty() { 31 | return models, errs 32 | } 33 | sets, err := result.Items() 34 | 35 | m := make(map[primitive.ObjectID]structures.EmoteSet) 36 | if err == nil { 37 | for _, set := range sets { 38 | m[set.ID] = set 39 | } 40 | 41 | for i, v := range keys { 42 | if x, ok := m[v]; ok { 43 | models[i] = x 44 | } else { 45 | errs[i] = errors.ErrUnknownEmoteSet() 46 | } 47 | } 48 | } else { 49 | for i := range errs { 50 | errs[i] = err 51 | } 52 | } 53 | 54 | return models, errs 55 | }, 56 | // TODO: find optimal max batch size 57 | MaxBatch: 30, 58 | }) 59 | } 60 | 61 | func emoteSetByUserID(ctx context.Context, x inst) BatchEmoteSetLoaderByID { 62 | return dataloader.New(dataloader.Config[primitive.ObjectID, []structures.EmoteSet]{ 63 | Wait: time.Millisecond * 100, 64 | MaxBatch: 30, 65 | Fetch: func(keys []primitive.ObjectID) ([][]structures.EmoteSet, []error) { 66 | ctx, cancel := context.WithTimeout(ctx, time.Second*30) 67 | defer cancel() 68 | 69 | // Fetch emote sets 70 | modelLists := make([][]structures.EmoteSet, len(keys)) 71 | errs := make([]error, len(keys)) 72 | 73 | sets, err := x.query.UserEmoteSets(ctx, bson.M{"owner_id": bson.M{"$in": keys}}) 74 | 75 | if err == nil { 76 | for i, v := range keys { 77 | if x, ok := sets[v]; ok { 78 | modelLists[i] = x 79 | } 80 | } 81 | } else { 82 | for i := range errs { 83 | errs[i] = err 84 | } 85 | } 86 | 87 | return modelLists, errs 88 | }, 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /internal/loaders/presence.loader.go: -------------------------------------------------------------------------------- 1 | package loaders 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/common/dataloader" 8 | "github.com/seventv/common/mongo" 9 | "github.com/seventv/common/structures/v3" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | func presenceLoader[T structures.UserPresenceData](ctx context.Context, x inst, kind structures.UserPresenceKind, key string) *dataloader.DataLoader[primitive.ObjectID, []structures.UserPresence[T]] { 15 | return dataloader.New(dataloader.Config[primitive.ObjectID, []structures.UserPresence[T]]{ 16 | Wait: time.Millisecond * 75, 17 | MaxBatch: 100, 18 | Fetch: func(keys []primitive.ObjectID) ([][]structures.UserPresence[T], []error) { 19 | ctx, cancel := context.WithTimeout(ctx, time.Second*10) 20 | defer cancel() 21 | 22 | // Fetch presence data from the database 23 | items := make([][]structures.UserPresence[T], len(keys)) 24 | errs := make([]error, len(keys)) 25 | 26 | // Initially fill the response with deleted presences in case some cannot be found 27 | for i := 0; i < len(items); i++ { 28 | items[i] = []structures.UserPresence[T]{} 29 | } 30 | 31 | // Fetch presences 32 | f := bson.M{key: bson.M{"$in": keys}} 33 | 34 | if kind > 0 { 35 | f["kind"] = kind 36 | } 37 | 38 | cur, err := x.mongo.Collection(mongo.CollectionNameUserPresences).Find(ctx, f) 39 | if err != nil { 40 | return items, errs 41 | } 42 | 43 | presences := make([]structures.UserPresence[T], 0) 44 | 45 | presenceMap := make(map[primitive.ObjectID][]structures.UserPresence[T]) 46 | 47 | if err := cur.All(ctx, &presences); err != nil { 48 | return items, errs 49 | } 50 | 51 | if err == nil { 52 | for _, p := range presences { 53 | s, ok := presenceMap[p.UserID] 54 | if !ok { 55 | s = []structures.UserPresence[T]{} 56 | presenceMap[p.UserID] = s 57 | } 58 | 59 | s = append(s, p) 60 | presenceMap[p.UserID] = s 61 | } 62 | 63 | for i, v := range keys { 64 | if x, ok := presenceMap[v]; ok { 65 | items[i] = x 66 | } 67 | } 68 | } 69 | 70 | return items, errs 71 | }, 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /internal/middleware/cors.middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/seventv/api/internal/global" 8 | "github.com/seventv/common/errors" 9 | "github.com/seventv/common/utils" 10 | "github.com/valyala/fasthttp" 11 | ) 12 | 13 | var allowedHeaders = []string{ 14 | "Content-Type", 15 | "Content-Length", 16 | "Accept-Encoding", 17 | "Authorization", 18 | "Cookie", 19 | "X-Emote-Data", 20 | "X-SevenTV-Platform", 21 | "X-SevenTV-Version", 22 | } 23 | 24 | var exposedHeaders = []string{ 25 | "X-Access-Token", 26 | } 27 | 28 | func CORS(gctx global.Context) Middleware { 29 | return func(ctx *fasthttp.RequestCtx) errors.APIError { 30 | reqHost := utils.B2S(ctx.Request.Header.Peek("Origin")) 31 | 32 | allowCredentials := utils.Contains(gctx.Config().Http.Cookie.Whitelist, reqHost) 33 | 34 | ctx.Response.Header.Set("Access-Control-Allow-Credentials", strconv.FormatBool(allowCredentials)) 35 | ctx.Response.Header.Set("Access-Control-Allow-Headers", strings.Join(allowedHeaders, ", ")) 36 | ctx.Response.Header.Set("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", ")) 37 | ctx.Response.Header.Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE") 38 | ctx.Response.Header.Set("Access-Control-Allow-Origin", reqHost) 39 | ctx.Response.Header.Set("Vary", "Origin") 40 | 41 | // cache cors 42 | ctx.Response.Header.Set("Access-Control-Max-Age", "7200") 43 | 44 | return nil 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/seventv/common/errors" 5 | "github.com/valyala/fasthttp" 6 | ) 7 | 8 | type Middleware = func(ctx *fasthttp.RequestCtx) errors.APIError 9 | -------------------------------------------------------------------------------- /internal/search/emote.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/meilisearch/meilisearch-go" 7 | ) 8 | 9 | type EmoteSearchOptions struct { 10 | Limit int64 11 | Page int64 12 | Sort EmoteSortOptions 13 | Exact bool 14 | Personal bool 15 | Listed bool 16 | Lifecycle int32 17 | } 18 | 19 | type EmoteSortOptions struct { 20 | By string 21 | Ascending bool 22 | } 23 | 24 | type EmoteResult struct { 25 | Name string 26 | Id string 27 | } 28 | 29 | func (s *MeiliSearch) SearchEmotes(query string, opt EmoteSearchOptions) ([]EmoteResult, int64, error) { 30 | req := &meilisearch.SearchRequest{} 31 | if opt.Limit != 0 { 32 | req.HitsPerPage = opt.Limit 33 | } 34 | if opt.Page != 0 { 35 | req.Page = opt.Page 36 | } 37 | if opt.Sort.By != "" { 38 | req.Sort = []string{opt.Sort.By + ":" + map[bool]string{true: "asc", false: "desc"}[opt.Sort.Ascending]} 39 | } 40 | 41 | filter := "" 42 | 43 | if opt.Personal { 44 | filter = "personal = true" 45 | } 46 | if opt.Listed { 47 | if filter != "" { 48 | filter += " AND " 49 | } 50 | filter += "listed = true" 51 | } 52 | if opt.Lifecycle != 0 { 53 | if filter != "" { 54 | filter += " AND " 55 | } 56 | filter += "lifecycle = " + strconv.Itoa(int(opt.Lifecycle)) 57 | } 58 | 59 | if filter != "" { 60 | req.Filter = filter 61 | } 62 | 63 | if opt.Exact { 64 | query = "\"" + query + "\"" 65 | } 66 | 67 | res, err := s.emoteIndex.Search(query, req) 68 | 69 | if err != nil { 70 | return nil, 0, err 71 | } 72 | 73 | var hit map[string]interface{} 74 | var emotes []EmoteResult 75 | 76 | for _, result := range res.Hits { 77 | hit = result.(map[string]interface{}) 78 | emotes = append(emotes, EmoteResult{ 79 | Name: hit["name"].(string), 80 | Id: hit["id"].(string), 81 | }) 82 | } 83 | 84 | return emotes, res.TotalHits, nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/search/meilisearch.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "github.com/meilisearch/meilisearch-go" 5 | 6 | "github.com/seventv/api/internal/configure" 7 | ) 8 | 9 | type MeiliSearch struct { 10 | emoteIndex *meilisearch.Index 11 | } 12 | 13 | func New(cfg *configure.Config) *MeiliSearch { 14 | client := meilisearch.NewClient(meilisearch.ClientConfig{ 15 | Host: cfg.Meilisearch.Host, 16 | APIKey: cfg.Meilisearch.Key, 17 | }) 18 | 19 | index := client.Index(cfg.Meilisearch.Index) 20 | 21 | return &MeiliSearch{index} 22 | } 23 | -------------------------------------------------------------------------------- /internal/svc/auth/discord.auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "encoding/json" 4 | 5 | var discordScopes = []string{ 6 | "identify", 7 | "email", 8 | } 9 | 10 | func (a *authorizer) DiscordUserData(grant string) (string, []byte, error) { 11 | client, err := a.discordFactory(grant) 12 | if err != nil { 13 | return "", nil, err 14 | } 15 | 16 | user, err := client.User("@me") 17 | if err != nil { 18 | return "", nil, err 19 | } 20 | 21 | b, err := json.Marshal(user) 22 | 23 | return user.ID, b, err 24 | } 25 | -------------------------------------------------------------------------------- /internal/svc/auth/geoip.auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | ) 9 | 10 | func (a *authorizer) LocateIP(ctx context.Context, addr string) (GeoIPResult, error) { 11 | result := GeoIPResult{} 12 | 13 | // http api request 14 | req, err := http.NewRequestWithContext(ctx, "GET", "https://api.iplocation.net/?ip="+addr, nil) 15 | if err != nil { 16 | return result, err 17 | } 18 | 19 | resp, err := http.DefaultClient.Do(req) 20 | if err != nil { 21 | return result, err 22 | } 23 | 24 | defer resp.Body.Close() 25 | 26 | if resp.StatusCode != 200 { 27 | return result, errors.New("bad response from iplocation.net") 28 | } 29 | 30 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 31 | return result, err 32 | } 33 | 34 | // Fix bad country names 35 | switch result.CountryCode { 36 | case "TW": 37 | result.CountryName = "Taiwan (Republic of China)" 38 | } 39 | 40 | return result, nil 41 | } 42 | 43 | type GeoIPResult struct { 44 | IP string `json:"ip"` 45 | CountryName string `json:"country_name"` 46 | CountryCode string `json:"country_code2"` 47 | } 48 | -------------------------------------------------------------------------------- /internal/svc/auth/jwt.auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/golang-jwt/jwt/v4" 9 | "github.com/seventv/common/utils" 10 | "go.mongodb.org/mongo-driver/bson/primitive" 11 | ) 12 | 13 | func (a *authorizer) SignJWT(secret string, claim jwt.Claims) (string, error) { 14 | // Generate an unsigned token 15 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim) 16 | 17 | // Sign the token 18 | tokenStr, err := token.SignedString(utils.S2B(secret)) 19 | 20 | return tokenStr, err 21 | } 22 | 23 | type JWTClaimUser struct { 24 | UserID string `json:"u"` 25 | TokenVersion float64 `json:"v"` 26 | 27 | jwt.RegisteredClaims 28 | } 29 | 30 | type JWTClaimOAuth2CSRF struct { 31 | State string `json:"s"` 32 | CreatedAt time.Time `json:"at"` 33 | Bind primitive.ObjectID `json:"bind"` 34 | 35 | jwt.RegisteredClaims 36 | } 37 | 38 | func (a *authorizer) VerifyJWT(token []string, out jwt.Claims) (*jwt.Token, error) { 39 | result, err := jwt.ParseWithClaims( 40 | strings.Join(token, "."), 41 | out, 42 | func(t *jwt.Token) (interface{}, error) { 43 | if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { 44 | return nil, fmt.Errorf("bad jwt signing method, expected HMAC but got %v", t.Header["alg"]) 45 | } 46 | 47 | return utils.S2B(a.JWTSecret), nil 48 | }, 49 | ) 50 | 51 | return result, err 52 | } 53 | -------------------------------------------------------------------------------- /internal/svc/auth/kick.auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "math/rand" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | 12 | "github.com/seventv/common/structures/v3" 13 | ) 14 | 15 | type KickUserData struct { 16 | ID int `json:"id"` 17 | UserID int `json:"user_id"` 18 | Slug string `json:"slug"` 19 | User struct { 20 | ID int `json:"id"` 21 | Username string `json:"username"` 22 | Bio string `json:"bio"` 23 | } `json:"user"` 24 | Chatroom struct { 25 | ID int `json:"id"` 26 | } `json:"chatroom"` 27 | } 28 | 29 | func newKickClient(ctx context.Context, token string) *http.Client { 30 | return &http.Client{} 31 | } 32 | 33 | func (a *authorizer) KickUserData(slug string) (string, []byte, error) { 34 | if a.kickClient == nil { 35 | return "", nil, nil 36 | } 37 | 38 | n := rand.Int31() 39 | 40 | res, err := a.kickClient.Do(&http.Request{ 41 | Method: http.MethodGet, 42 | URL: &url.URL{Scheme: "https", Host: "kick.com", Path: "/api/v2/channels/" + slug, RawQuery: "7tv-bust=" + strconv.Itoa(int(n))}, 43 | Header: http.Header{ 44 | "User-Agent": {"SevenTV-API/3"}, 45 | "Content-Type": {"application/json"}, 46 | "x-kick-auth": {a.Config.Kick.ChallengeToken}, 47 | }, 48 | }) 49 | if err != nil { 50 | return "", nil, err 51 | } 52 | 53 | // read body to bytes 54 | b, err := io.ReadAll(res.Body) 55 | if err != nil { 56 | return "", nil, err 57 | } 58 | 59 | u := KickUserData{} 60 | if err = json.Unmarshal(b, &u); err != nil { 61 | return "", nil, err 62 | } 63 | 64 | connData := structures.UserConnectionDataKick{ 65 | ID: strconv.Itoa(u.UserID), 66 | ChatroomID: strconv.Itoa(u.Chatroom.ID), 67 | Username: u.Slug, 68 | DisplayName: u.User.Username, 69 | Bio: u.User.Bio, 70 | } 71 | 72 | b, err = json.Marshal(connData) 73 | 74 | return connData.ID, b, err 75 | } 76 | -------------------------------------------------------------------------------- /internal/svc/auth/twitch.auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/nicklaw5/helix" 7 | ) 8 | 9 | var twitchScopes = []string{ 10 | "user:read:email", 11 | } 12 | 13 | func (a *authorizer) TwichUserData(grant string) (string, []byte, error) { 14 | client, err := a.helixFactory() 15 | if err != nil { 16 | return "", nil, err 17 | } 18 | 19 | client.SetUserAccessToken(grant) 20 | 21 | resp, err := client.GetUsers(&helix.UsersParams{}) 22 | if err != nil { 23 | return "", nil, err 24 | } 25 | 26 | var data helix.User 27 | 28 | if len(resp.Data.Users) > 0 { 29 | data = resp.Data.Users[0] 30 | } 31 | 32 | b, err := json.Marshal(data) 33 | 34 | return data.ID, b, err 35 | } 36 | -------------------------------------------------------------------------------- /internal/svc/auth/userdata.auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "github.com/seventv/common/structures/v3" 4 | 5 | func (a *authorizer) UserData(provider structures.UserConnectionPlatform, token string) (id string, b []byte, err error) { 6 | switch provider { 7 | case structures.UserConnectionPlatformTwitch: 8 | id, b, err = a.TwichUserData(token) 9 | case structures.UserConnectionPlatformDiscord: 10 | id, b, err = a.DiscordUserData(token) 11 | case structures.UserConnectionPlatformKick: 12 | id, b, err = a.KickUserData(token) 13 | } 14 | 15 | if err != nil { 16 | return "", nil, err 17 | } 18 | 19 | return id, b, err 20 | } 21 | -------------------------------------------------------------------------------- /internal/svc/health/health.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/seventv/api/internal/global" 8 | "github.com/valyala/fasthttp" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func New(gctx global.Context) <-chan struct{} { 13 | done := make(chan struct{}) 14 | 15 | srv := fasthttp.Server{ 16 | Handler: func(ctx *fasthttp.RequestCtx) { 17 | defer func() { 18 | if err := recover(); err != nil { 19 | zap.S().Errorw("panic in health", 20 | "panic", err, 21 | ) 22 | } 23 | }() 24 | 25 | var ( 26 | mqDown bool 27 | s3Down bool 28 | redisDown bool 29 | mongoDown bool 30 | ) 31 | 32 | if gctx.Inst().Redis != nil { 33 | lCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) 34 | if err := gctx.Inst().Redis.Ping(lCtx); err != nil { 35 | zap.S().Warnw("redis is not responding", 36 | "error", err, 37 | ) 38 | redisDown = true 39 | } 40 | cancel() 41 | } 42 | 43 | if gctx.Inst().Mongo != nil { 44 | lCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) 45 | if err := gctx.Inst().Mongo.Ping(lCtx); err != nil { 46 | mongoDown = true 47 | zap.S().Warnw("mongo is not responding", 48 | "error", err, 49 | ) 50 | } 51 | cancel() 52 | } 53 | 54 | if mqDown || s3Down || redisDown || mongoDown { 55 | ctx.SetStatusCode(500) 56 | } 57 | }, 58 | } 59 | 60 | go func() { 61 | defer close(done) 62 | zap.S().Infow("Health enabled", 63 | "bind", gctx.Config().Health.Bind, 64 | ) 65 | 66 | if err := srv.ListenAndServe(gctx.Config().Health.Bind); err != nil { 67 | zap.S().Fatalw("failed to bind health", 68 | "error", err, 69 | ) 70 | } 71 | }() 72 | 73 | go func() { 74 | <-gctx.Done() 75 | 76 | _ = srv.Shutdown() 77 | }() 78 | 79 | return done 80 | } 81 | -------------------------------------------------------------------------------- /internal/svc/monitoring/monitoring.go: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "github.com/prometheus/client_golang/prometheus/promhttp" 6 | "github.com/seventv/api/internal/global" 7 | "github.com/valyala/fasthttp" 8 | "github.com/valyala/fasthttp/fasthttpadaptor" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func New(gCtx global.Context) <-chan struct{} { 13 | r := prometheus.NewRegistry() 14 | gCtx.Inst().Prometheus.Register(r) 15 | 16 | server := fasthttp.Server{ 17 | Handler: fasthttpadaptor.NewFastHTTPHandler(promhttp.HandlerFor(r, promhttp.HandlerOpts{ 18 | Registry: r, 19 | EnableOpenMetrics: true, 20 | })), 21 | GetOnly: true, 22 | DisableKeepalive: true, 23 | } 24 | 25 | done := make(chan struct{}) 26 | 27 | go func() { 28 | defer close(done) 29 | zap.S().Infow("Monitoring enabled", 30 | "bind", gCtx.Config().Monitoring.Bind, 31 | ) 32 | 33 | if err := server.ListenAndServe(gCtx.Config().Monitoring.Bind); err != nil { 34 | zap.S().Fatalw("failed to start monitoring bind", 35 | "error", err, 36 | ) 37 | } 38 | }() 39 | 40 | go func() { 41 | <-gCtx.Done() 42 | 43 | _ = server.Shutdown() 44 | }() 45 | 46 | return done 47 | } 48 | -------------------------------------------------------------------------------- /internal/svc/pprof/pprof.go: -------------------------------------------------------------------------------- 1 | package pprof 2 | 3 | import ( 4 | "net/http" 5 | _ "net/http/pprof" 6 | 7 | "github.com/seventv/api/internal/global" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func New(gctx global.Context) <-chan struct{} { 12 | done := make(chan struct{}) 13 | 14 | go func() { 15 | if err := http.ListenAndServe(gctx.Config().PProf.Bind, nil); err != nil { 16 | zap.S().Fatalw("pprof failed to listen", 17 | "error", err, 18 | ) 19 | } 20 | }() 21 | 22 | go func() { 23 | <-gctx.Done() 24 | close(done) 25 | }() 26 | 27 | return done 28 | } 29 | -------------------------------------------------------------------------------- /internal/svc/presences/presences.go: -------------------------------------------------------------------------------- 1 | package presences 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/api/data/events" 7 | "github.com/seventv/api/data/model" 8 | "github.com/seventv/api/internal/configure" 9 | "github.com/seventv/api/internal/loaders" 10 | "github.com/seventv/common/mongo" 11 | "github.com/seventv/common/structures/v3" 12 | "go.mongodb.org/mongo-driver/bson" 13 | "go.mongodb.org/mongo-driver/bson/primitive" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | type Instance interface { 18 | // ChannelPresence returns a PresenceManager for the given actorID, with only Channel presence data. 19 | ChannelPresence(ctx context.Context, actorID primitive.ObjectID) PresenceManager[structures.UserPresenceDataChannel] 20 | ChannelPresenceFanout(ctx context.Context, opt ChannelPresenceFanoutOptions) error 21 | } 22 | 23 | type inst struct { 24 | mongo mongo.Instance 25 | loaders loaders.Instance 26 | events events.Instance 27 | config *configure.Config 28 | 29 | modelizer model.Modelizer 30 | } 31 | 32 | func New(opt Options) Instance { 33 | return &inst{ 34 | mongo: opt.Mongo, 35 | loaders: opt.Loaders, 36 | events: opt.Events, 37 | config: opt.Config, 38 | 39 | modelizer: opt.Modelizer, 40 | } 41 | } 42 | 43 | type Options struct { 44 | Mongo mongo.Instance 45 | Loaders loaders.Instance 46 | Events events.Instance 47 | Config *configure.Config 48 | 49 | Modelizer model.Modelizer 50 | } 51 | 52 | func (p *inst) ChannelPresence(ctx context.Context, actorID primitive.ObjectID) PresenceManager[structures.UserPresenceDataChannel] { 53 | presences, _ := p.loaders.PresenceByActorID().Load(actorID) 54 | 55 | items := filterPresenceList[structures.UserPresenceDataChannel](presences, structures.UserPresenceKindChannel) 56 | 57 | return &presenceManager[structures.UserPresenceDataChannel]{ 58 | inst: p, 59 | kind: structures.UserPresenceKindChannel, 60 | userID: actorID, 61 | items: items, 62 | } 63 | } 64 | 65 | // filterPresenceList filters the given presence list by the given kind. 66 | func filterPresenceList[T structures.UserPresenceData](items []structures.UserPresence[bson.Raw], kind structures.UserPresenceKind) []structures.UserPresence[T] { 67 | var ( 68 | pos int 69 | err error 70 | ) 71 | 72 | result := make([]structures.UserPresence[T], len(items)) 73 | 74 | for _, item := range items { 75 | if item.Kind != kind { 76 | continue 77 | } 78 | 79 | result[pos], err = structures.ConvertPresence[T](item) 80 | if err != nil { 81 | zap.S().Errorw("failed to convert presence", "error", err) 82 | 83 | continue 84 | } 85 | 86 | pos++ 87 | } 88 | 89 | if pos < len(result) { 90 | result = result[:pos] 91 | } 92 | 93 | return result 94 | } 95 | -------------------------------------------------------------------------------- /internal/svc/prometheus/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | ) 6 | 7 | type Instance interface { 8 | Register(r prometheus.Registerer) 9 | } 10 | 11 | type Options struct { 12 | Labels prometheus.Labels 13 | } 14 | 15 | func New(o Options) Instance { 16 | return &promInst{} 17 | } 18 | 19 | type promInst struct { 20 | } 21 | 22 | func (m *promInst) Register(r prometheus.Registerer) { 23 | } 24 | -------------------------------------------------------------------------------- /internal/svc/youtube/youtube.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/seventv/common/errors" 7 | "google.golang.org/api/option" 8 | "google.golang.org/api/youtube/v3" 9 | ) 10 | 11 | type Instance interface { 12 | GetChannelByID(ctx context.Context, id string) (*youtube.Channel, error) 13 | GetChannelByUsername(ctx context.Context, name string) (*youtube.Channel, error) 14 | } 15 | 16 | type youtubeInst struct { 17 | api *youtube.Service 18 | } 19 | 20 | func New(ctx context.Context, opt YouTubeOptions) (Instance, error) { 21 | ytapi, err := youtube.NewService(ctx, option.WithAPIKey(opt.APIKey)) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return &youtubeInst{ 27 | api: ytapi, 28 | }, nil 29 | } 30 | 31 | type YouTubeOptions struct { 32 | APIKey string 33 | } 34 | 35 | func (inst *youtubeInst) GetChannelByID(ctx context.Context, id string) (*youtube.Channel, error) { 36 | res, err := inst.api.Channels.List([]string{"snippet", "statistics"}).Id(id).Context(ctx).Do() 37 | if err != nil { 38 | return nil, errors.ErrInternalServerError() 39 | } 40 | 41 | if len(res.Items) == 0 { 42 | return nil, errors.ErrNoItems() 43 | } 44 | 45 | channel := res.Items[0] 46 | 47 | return channel, nil 48 | } 49 | 50 | func (inst *youtubeInst) GetChannelByUsername(ctx context.Context, name string) (*youtube.Channel, error) { 51 | res, err := inst.api.Channels.List([]string{"snippet", "statistics"}).ForUsername(name).Context(ctx).Do() 52 | if err != nil { 53 | return nil, errors.ErrInternalServerError() 54 | } 55 | 56 | if len(res.Items) == 0 { 57 | return nil, errors.ErrNoItems() 58 | } 59 | 60 | channel := res.Items[0] 61 | 62 | return channel, nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func ReadFile(t *testing.T, file string) []byte { 9 | data, err := os.ReadFile(file) 10 | IsNil(t, err, "File was found") 11 | 12 | return data 13 | } 14 | 15 | func Assert[T comparable](t *testing.T, expected T, value T, message string) { 16 | if expected != value { 17 | t.Fatalf("%s: expected %v got %v", message, expected, value) 18 | } 19 | } 20 | 21 | func AssertErr(t *testing.T, expected error, value error, message string) { 22 | if expected == nil && value == nil { 23 | return 24 | } 25 | 26 | if expected == nil || value == nil || expected.Error() != value.Error() { 27 | t.Fatalf("%s: expected %v got %v", message, expected, value) 28 | } 29 | } 30 | 31 | func IsNil(t *testing.T, value interface{}, message string) { 32 | if value != nil { 33 | t.Fatalf("%s: expected nil got %v", message, value) 34 | } 35 | } 36 | 37 | func IsNotNil(t *testing.T, value interface{}, message string) { 38 | if value == nil { 39 | t.Fatalf("%s: expected not nil got nil", message) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /k8s/.gitignore: -------------------------------------------------------------------------------- 1 | !*.template.yaml 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "prepare": "husky install", 4 | "prettier": "prettier" 5 | }, 6 | "devDependencies": { 7 | "husky": "^7.0.4", 8 | "lint-staged": "^12.1.2", 9 | "prettier": "2.5.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /portal/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{yaml,yml}] 13 | indent_style = space 14 | indent_size = 2 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /portal/.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | VITE_APP_ENV=production 3 | VITE_APP_API_REST=https://7tv.io 4 | -------------------------------------------------------------------------------- /portal/.env.dev: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | VITE_APP_ENV=dev 3 | VITE_APP_API_REST=http://localhost:3100 4 | -------------------------------------------------------------------------------- /portal/.env.stage: -------------------------------------------------------------------------------- 1 | NODE_ENV=stage 2 | VITE_APP_ENV=stage 3 | VITE_APP_API_REST=https://stage.7tv.io 4 | -------------------------------------------------------------------------------- /portal/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | es2021: true, 7 | }, 8 | plugins: ["prettier"], 9 | extends: [ 10 | "plugin:vue/vue3-recommended", 11 | "eslint:recommended", 12 | "@vue/typescript/recommended", 13 | // Add under other rules 14 | "@vue/prettier", 15 | ], 16 | parserOptions: { 17 | ecmaVersion: 2021, 18 | }, 19 | ignorePatterns: ["locale/*.ts"], 20 | rules: { 21 | "prettier/prettier": "error", 22 | "no-console": "warn", 23 | "no-debugger": "error", 24 | quotes: [1, "double"], 25 | "@typescript-eslint/no-unused-vars": "error", 26 | "@typescript-eslint/explicit-module-boundary-types": "off", 27 | "@typescript-eslint/no-namespace": "off", 28 | "vue/multi-word-component-names": "off", 29 | "vue/require-default-prop": "off", 30 | }, 31 | globals: { 32 | defineEmits: "readonly", 33 | defineProps: "readonly", 34 | NodeJS: "readonly", 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /portal/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /portal/.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | -------------------------------------------------------------------------------- /portal/.prettierrc: -------------------------------------------------------------------------------- 1 | trailingComma: all 2 | tabWidth: 4 3 | useTabs: true 4 | semi: true 5 | singleQuote: false 6 | printWidth: 120 7 | -------------------------------------------------------------------------------- /portal/.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["stylelint-scss"], 3 | extends: [ 4 | "stylelint-config-standard", 5 | "stylelint-config-prettier", 6 | "stylelint-config-recommended-scss", 7 | "stylelint-config-recommended-vue", 8 | ], 9 | // add your custom config here 10 | // https://stylelint.io/user-guide/configuration 11 | rules: { 12 | "at-rule-no-unknown": null, 13 | "no-descending-specificity": null, 14 | "selector-pseudo-element-colon-notation": null, 15 | "declaration-empty-line-before": null, 16 | "rule-empty-line-before": null, 17 | "scss/at-import-partial-extension": null, 18 | "scss/no-global-function-names": null, 19 | "color-function-notation": null, 20 | "declaration-block-no-redundant-longhand-properties": null, 21 | "keyframes-name-pattern": null, 22 | "selector-class-pattern": null, 23 | "property-no-vendor-prefix": null, 24 | "no-empty-source": null, 25 | "value-keyword-case": null, 26 | "function-no-unknown": null, 27 | "annotation-no-unknown": null, 28 | }, 29 | ignoreFiles: ["locale/*.ts"], 30 | }; 31 | -------------------------------------------------------------------------------- /portal/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + TypeScript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` 27 | 28 | 29 | -------------------------------------------------------------------------------- /portal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portal", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --mode dev", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "lint:style": "stylelint **/*.{vue,scss,css} --ignore-path .gitignore", 10 | "lint:js": "eslint --ext .js,.vue,.ts --ignore-path .gitignore .", 11 | "lint": "yarn lint:js && yarn lint:style", 12 | "preview": "vite preview" 13 | }, 14 | "dependencies": { 15 | "vue": "^3.2.37" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^18.7.14", 19 | "@types/swagger-schema-official": "^2.0.22", 20 | "@vitejs/plugin-vue": "^3.0.3", 21 | "@vueuse/core": "^9.7.0", 22 | "@vueuse/head": "^0.7.13", 23 | "pinia": "^2.0.21", 24 | "sass": "^1.54.7", 25 | "typescript": "^4.6.4", 26 | "vite": "^3.0.7", 27 | "vue-router": "^4.1.5", 28 | "vue-tsc": "^0.39.5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /portal/public/ico.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /portal/src/App.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /portal/src/assets/style/themes.scss: -------------------------------------------------------------------------------- 1 | $themes: ( 2 | light: ( 3 | color: #1f1f1f, 4 | extreme: #d0d0d0, 5 | primary: #29b6f6, 6 | accent: #3fd63f, 7 | warning: #f44336, 8 | backgroundColor: #cae7ed, 9 | secondaryBackgroundColor: #cae7ed, 10 | navBackgroundColor: mix(black, #cae7ed, 9.4), 11 | footerBackgroundColor: mix(black, #cae7ed, 5), 12 | ), 13 | dark: ( 14 | color: #e6e6e6, 15 | extreme: black, 16 | primary: #0288d1, 17 | accent: #0da212, 18 | warning: #f44336, 19 | backgroundColor: #181d1f, 20 | secondaryBackgroundColor: #1f2122, 21 | navBackgroundColor: mix(black, #29313360, 50%), 22 | footerBackgroundColor: mix(black, #181f1f60, 25%), 23 | ), 24 | ); 25 | 26 | $breakpoints: ( 27 | sm: ( 28 | min: 576px, 29 | max: 575.98px, 30 | ), 31 | md: ( 32 | min: 768px, 33 | max: 767.98px, 34 | ), 35 | lg: ( 36 | min: 992px, 37 | max: 991.98px, 38 | ), 39 | xl: ( 40 | min: 1200px, 41 | max: 1199.98px, 42 | ), 43 | xxl: ( 44 | min: 1400px, 45 | max: 1399.98px, 46 | ), 47 | ); 48 | 49 | @mixin themify($themes: $themes) { 50 | @each $theme, $map in $themes { 51 | &.theme-#{$theme}, 52 | .theme-#{$theme} & { 53 | $theme-map: () !global; 54 | @each $key, $submap in $map { 55 | $value: map-get(map-get($themes, $theme), "#{$key}"); 56 | $theme-map: map-merge( 57 | $theme-map, 58 | ( 59 | $key: $value, 60 | ) 61 | ) !global; 62 | } 63 | 64 | @content; 65 | $theme-map: null !global; 66 | } 67 | } 68 | } 69 | 70 | @function themed($key) { 71 | @return map-get($theme-map, $key); 72 | } 73 | 74 | @mixin breakpoint($breakpoint, $direction: min) { 75 | @if map-has-key($breakpoints, $breakpoint) { 76 | $breakpoint-values: map-get($breakpoints, $breakpoint); 77 | $breakpoint-min: map-get($breakpoint-values, min); 78 | $breakpoint-max: map-get($breakpoint-values, max); 79 | 80 | //check if we are writing styles for larger or smaller screens 81 | @if $direction == min { 82 | @media (min-width: $breakpoint-min) { 83 | @content; 84 | } 85 | } @else { 86 | @media (max-width: $breakpoint-max) { 87 | @content; 88 | } 89 | } 90 | 91 | // use the custom value if the breakpoint is not part of the pre-defined list 92 | } @else { 93 | @if $direction == min { 94 | @media (min-width: $breakpoint) { 95 | @content; 96 | } 97 | } @else { 98 | @media (max-width: $breakpoint) { 99 | @content; 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /portal/src/assets/svg/Logo.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /portal/src/components/util/Icon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | -------------------------------------------------------------------------------- /portal/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp, h } from "vue"; 2 | import { createHead } from "@vueuse/head"; 3 | import { createPinia } from "pinia"; 4 | import router from "@/router/router"; 5 | import App from "./App.vue"; 6 | import "./style.scss"; 7 | 8 | const app = createApp({ 9 | render: () => h(App), 10 | }); 11 | 12 | app.use(createHead()).use(createPinia()).use(router).mount("#app"); 13 | -------------------------------------------------------------------------------- /portal/src/router/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; 2 | 3 | const routes = [ 4 | { 5 | path: "/", 6 | component: () => import("@views/Intro/Intro.vue"), 7 | }, 8 | { 9 | path: "/docs", 10 | component: () => import("@views/Docs/Docs.vue"), 11 | }, 12 | { 13 | path: "/apps", 14 | component: () => import("@views/Apps/Apps.vue"), 15 | }, 16 | ] as RouteRecordRaw[]; 17 | 18 | const router = createRouter({ 19 | history: createWebHistory(import.meta.env.BASE_URL), 20 | routes, 21 | }); 22 | 23 | export default router; 24 | -------------------------------------------------------------------------------- /portal/src/store/main.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | 3 | export interface State { 4 | authToken: string | null; 5 | faPro: boolean; 6 | } 7 | 8 | export const useStore = defineStore("main", { 9 | state: () => ({ 10 | authToken: null, 11 | faPro: import.meta.env.VITE_APP_FA_PRO === "true", 12 | }), 13 | }); 14 | -------------------------------------------------------------------------------- /portal/src/style.scss: -------------------------------------------------------------------------------- 1 | @import "@style/themes.scss"; 2 | 3 | html { 4 | box-sizing: border-box; 5 | font-family: Roboto, sans-serif; 6 | } 7 | 8 | @media screen and (max-width: 650px) { 9 | :root { 10 | font-size: 0.8rem; 11 | } 12 | } 13 | 14 | *, 15 | *:before, 16 | *:after { 17 | box-sizing: inherit; 18 | margin: 0; 19 | } 20 | 21 | body { 22 | display: flex; 23 | height: 100vh; 24 | width: 100vw; 25 | overflow-x: hidden; 26 | @include themify() { 27 | background: themed("backgroundColor"); 28 | color: themed("color"); 29 | } 30 | &.no-transition { 31 | * { 32 | transition: none !important; 33 | } 34 | } 35 | } 36 | 37 | #app { 38 | display: flex; 39 | flex-direction: column; 40 | flex-grow: 1; 41 | } 42 | 43 | .unselectable { 44 | user-select: none; 45 | } 46 | 47 | .app-overlay { 48 | pointer-events: all; 49 | position: fixed; 50 | top: 0; 51 | bottom: 0; 52 | left: 0; 53 | right: 0; 54 | width: 100vw; 55 | height: 100vh; 56 | z-index: 1000; 57 | 58 | &[locked="true"] { 59 | pointer-events: none; 60 | } 61 | } 62 | 63 | *::-webkit-scrollbar { 64 | width: 0.25rem; 65 | } 66 | 67 | /* Track */ 68 | *::-webkit-scrollbar-track { 69 | background: #f1f1f1; 70 | } 71 | 72 | *::-webkit-scrollbar-thumb { 73 | background: #888; 74 | } 75 | 76 | /* Handle on hover */ 77 | *::-webkit-scrollbar-thumb:hover { 78 | background: #555; 79 | } 80 | 81 | .entrypoint { 82 | display: flex; 83 | flex-direction: column; 84 | flex-grow: 1; 85 | 86 | .bouncy { 87 | display: flex; 88 | flex-direction: column; 89 | flex-grow: 1; 90 | 91 | &:not(.home, .store) { 92 | margin-top: 4.5em; 93 | } 94 | } 95 | @media screen and (max-width: 1000px) { 96 | > .hidden { 97 | // height: 0; 98 | max-height: 100vh; 99 | overflow: hidden; 100 | } 101 | } 102 | } 103 | 104 | a:not(.unstyled-link) { 105 | color: #1c64d1; 106 | font-weight: 500; 107 | text-decoration: none; 108 | &:hover { 109 | color: inherit; 110 | font-weight: 500; 111 | } 112 | } 113 | 114 | a.unstyled-link { 115 | color: inherit; 116 | font-weight: normal; 117 | text-decoration: none; 118 | } 119 | -------------------------------------------------------------------------------- /portal/src/views/Apps/Apps.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 22 | -------------------------------------------------------------------------------- /portal/src/views/Intro/Intro.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /portal/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.vue" { 4 | import type { DefineComponent } from "vue"; 5 | const component: DefineComponent<{}, {}, any>; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /portal/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "@/*": ["src/*"], 18 | "@views/*": ["src/views/*"], 19 | "@style/*": ["src/assets/style/*"], 20 | "@svg/*": ["src/assets/svg/*"], 21 | "@components/*": ["src/components/*"] 22 | } 23 | }, 24 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /portal/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /portal/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from "vite"; 2 | import { resolve } from "path"; 3 | import vue from "@vitejs/plugin-vue"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default ({ mode }) => { 7 | process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; 8 | 9 | return defineConfig({ 10 | build: { 11 | sourcemap: true, 12 | target: "es2021", 13 | }, 14 | plugins: [vue()], 15 | resolve: { 16 | alias: { 17 | "@": resolve(__dirname, "src"), 18 | "@views": resolve(__dirname, "src/views"), 19 | "@style": resolve(__dirname, "src/assets/style"), 20 | "@svg": resolve(__dirname, "src/assets/svg"), 21 | "@components": resolve(__dirname, "src/components"), 22 | }, 23 | }, 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /terraform/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "5.7.0" 6 | constraints = "~> 5.7.0" 7 | hashes = [ 8 | "h1:A7p0npQ+UHlnapVuikOzhmgchAq8agtfZGkYiiEOnp0=", 9 | "zh:03240d7fc041d5331db7fd5f2ca4fe031321d07d2a6ca27085c5020dae13f211", 10 | "zh:0b5252b14c354636fe0348823195dd901b457de1a033015f4a7d11cfe998c766", 11 | "zh:2bfb62325b0487be8d1850a964f09cca0d45148faec577459c2a24334ec9977b", 12 | "zh:2f9e317ffc57d2b5117cfe8dc266f88aa139b760bc93d8adeed7ad533a78b5a3", 13 | "zh:36512725c9d7c559927b98fead04be58494a3a997e5270b905a75a468e307427", 14 | "zh:5483e696d3ea764f746d3fe439f7dcc49001c3c774122d7baa51ce01011f0075", 15 | "zh:5967635cc14f969ea26622863a2e3f9d6a7ddd3e7d35a29a7275c5e10579ac8c", 16 | "zh:7e63c94a64af5b7aeb36ea6e3719962f65a7c28074532c02549a67212d410bb8", 17 | "zh:8a7d5f33b11a3f5c7281413b431fa85de149ed8493ec1eea73d50d2d80a475e6", 18 | "zh:8e2ed2d986aaf590975a79a2f6b5e60e0dc7d804ab01a8c03ab181e41cfe9b0f", 19 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 20 | "zh:9c7b8ca1b17489f16a6d0f1fc2aa9c130978ea74c9c861d8435410567a0a888f", 21 | "zh:a54385896a70524063f0c5420be26ff6f88909bd8e6902dd3e922577b21fd546", 22 | "zh:aecd3a8fb70b938b58d93459bfb311540fd6aaf981924bf34abd48f953b4be0d", 23 | "zh:f3de076fa3402768d27af0187c6a677777b47691d1f0f84c9b259ff66e65953e", 24 | ] 25 | } 26 | 27 | provider "registry.terraform.io/hashicorp/kubernetes" { 28 | version = "2.18.1" 29 | constraints = "2.18.1" 30 | hashes = [ 31 | "h1:h4ezMuMNyKRMRhlrErph7QOUToc77U+rVKdR48w6tr8=", 32 | "zh:09d69d244f5e688d9b1582112aa5d151c5336278e43d39c88ae920c26536b753", 33 | "zh:0df4c988056f7d84d9161c6c955ad7346364c261d100ef510a6cc7fa4a235197", 34 | "zh:2d3d0cb2931b6153a7971ce8c6fae92722b1116e16f42abbaef115dba895c8d8", 35 | "zh:47830e8fc1760860bfa4aaf418627ff3c6ffcac6cebbbc490e5e0e6b31287d80", 36 | "zh:49467177b514bada0fb3b6982897a347498af8ef9ef8d9fd611fe21dfded2e25", 37 | "zh:5c7eae2c51ba175822730a63ad59cf41604c76c46c5c97332506ab42023525ce", 38 | "zh:6efae755f02df8ab65ce7a831f33bd4817359db205652fd4bc4b969302072b15", 39 | "zh:7e6e97b79fecd25aaf0f4fb91da945a65c36fe2ba2a4313288a60ede55506aad", 40 | "zh:b75f2c9dd24b355ffe73e7b2fcd3145fc32735068f0ec2eba2df63f792dd16e8", 41 | "zh:dbef9698d842eb49a846db6d7694f159ae5154ffbb7a753a9d4cab88c462a6d4", 42 | "zh:f1b1fd580d92eedd9c8224d463997ccff1a62851fea65106aac299efe9ab622a", 43 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /terraform/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "remote" { 3 | hostname = "app.terraform.io" 4 | organization = "7tv" 5 | 6 | workspaces { 7 | prefix = "seventv-api-" 8 | } 9 | } 10 | } 11 | 12 | locals { 13 | infra_workspace_name = replace(terraform.workspace, "api", "infra") 14 | infra = data.terraform_remote_state.infra.outputs 15 | image_url = var.image_url != null ? var.image_url : format("ghcr.io/seventv/api:%s-latest", trimprefix(terraform.workspace, "seventv-api-")) 16 | s3 = var.s3 != null ? var.s3 : { 17 | region = local.infra.region 18 | ak = local.infra.s3_access_key.id 19 | sk = local.infra.s3_access_key.secret 20 | internal_bucket = local.infra.s3_bucket.internal 21 | public_bucket = local.infra.s3_bucket.public 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /terraform/providers.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "~> 5.7.0" 6 | } 7 | 8 | kubernetes = { 9 | source = "hashicorp/kubernetes" 10 | version = "2.18.1" 11 | } 12 | 13 | random = { 14 | source = "hashicorp/random" 15 | } 16 | } 17 | } 18 | 19 | provider "aws" { 20 | region = var.region 21 | } 22 | 23 | provider "kubernetes" { 24 | host = data.aws_eks_cluster.cluster.endpoint 25 | token = data.aws_eks_cluster_auth.cluster.token 26 | cluster_ca_certificate = base64decode(data.aws_eks_cluster.cluster.certificate_authority.0.data) 27 | } 28 | 29 | data "aws_eks_cluster" "cluster" { 30 | name = local.infra_workspace_name 31 | } 32 | 33 | data "aws_eks_cluster_auth" "cluster" { 34 | name = local.infra_workspace_name 35 | } 36 | -------------------------------------------------------------------------------- /terraform/variables.tf: -------------------------------------------------------------------------------- 1 | data "terraform_remote_state" "infra" { 2 | backend = "remote" 3 | 4 | config = { 5 | organization = "7tv" 6 | workspaces = { 7 | name = local.infra_workspace_name 8 | } 9 | } 10 | } 11 | 12 | variable "region" { 13 | description = "AWS region" 14 | type = string 15 | default = "us-east-2" 16 | } 17 | 18 | variable "namespace" { 19 | type = string 20 | default = "app" 21 | } 22 | 23 | variable "image_url" { 24 | type = string 25 | nullable = true 26 | default = null 27 | } 28 | 29 | variable "image_pull_policy" { 30 | type = string 31 | default = "Always" 32 | } 33 | 34 | variable "cdn_url" { 35 | type = string 36 | } 37 | 38 | variable "http_addr" { 39 | type = string 40 | default = "0.0.0.0" 41 | } 42 | 43 | variable "http_port_gql" { 44 | type = number 45 | default = 3000 46 | } 47 | 48 | variable "http_port_rest" { 49 | type = number 50 | default = 3100 51 | } 52 | 53 | variable "twitch_client_id" { 54 | type = string 55 | default = "" 56 | } 57 | 58 | variable "twitch_client_secret" { 59 | type = string 60 | default = "" 61 | } 62 | 63 | variable "twitch_redirect_uri" { 64 | type = string 65 | default = "" 66 | } 67 | 68 | variable "discord_client_id" { 69 | type = string 70 | default = "" 71 | } 72 | 73 | variable "discord_client_secret" { 74 | type = string 75 | default = "" 76 | } 77 | 78 | variable "discord_redirect_uri" { 79 | type = string 80 | default = "" 81 | } 82 | 83 | variable "kick_challenge_token" { 84 | type = string 85 | default = "" 86 | } 87 | 88 | variable "mongo_use_hedged_reads" { 89 | type = bool 90 | default = true 91 | } 92 | 93 | variable "s3" { 94 | type = object({ 95 | endpoint = string 96 | region = string 97 | ak = string 98 | sk = string 99 | internal_bucket = string 100 | public_bucket = string 101 | }) 102 | nullable = true 103 | default = null 104 | } 105 | 106 | variable "nats_events_subject" { 107 | type = string 108 | default = "" 109 | } 110 | 111 | variable "credentials_jwt_secret" { 112 | type = string 113 | default = "" 114 | } 115 | 116 | variable "meilisearch_key" { 117 | type = string 118 | default = "" 119 | } -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import _ "github.com/99designs/gqlgen" 7 | --------------------------------------------------------------------------------