From a9463896d4186b3e2bebf95f0ecd7785fa24aa40 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 13 Apr 2023 23:33:48 +0200 Subject: [PATCH 001/119] feat(backend): add cors --- backend/server/server.go | 10 ++++++++++ go.mod | 1 + go.sum | 2 ++ 3 files changed, 13 insertions(+) diff --git a/backend/server/server.go b/backend/server/server.go index 70e9725..459832d 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -11,6 +11,7 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/server/rate" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" "github.com/go-chi/httprate" "github.com/go-chi/render" ) @@ -48,6 +49,15 @@ func New() (*Server, error) { s.Router.Use(middleware.Logger) } s.Router.Use(middleware.Recoverer) + // add CORS + s.Router.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, + AllowedMethods: []string{"HEAD", "GET"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: false, + MaxAge: 300, + })) + // enable authentication for all routes (but don't require it) s.Router.Use(s.maybeAuth) diff --git a/go.mod b/go.mod index 9287117..994a3a2 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-chi/cors v1.2.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/uuid v1.3.0 // indirect diff --git a/go.sum b/go.sum index 585e2d5..2f62af8 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/georgysavva/scany/v2 v2.0.0/go.mod h1:sigOdh+0qb/+aOs3TVhehVT10p8qJL7 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.7.1 h1:d5kXARdms2PREQfU4pHvq44S6hJ1hPu4OXLeBKmCKWs= github.com/go-chi/httprate v0.7.1/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A= github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= From 30c086f0f30bba9e87cf038c1a5e371826619245 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 13 Apr 2023 23:34:19 +0200 Subject: [PATCH 002/119] feat(frontend): add toast if avatar is too big --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 7 +++++++ frontend/src/routes/edit/member/[id]/+page.svelte | 12 +++++++++++- frontend/src/routes/edit/profile/+page.svelte | 11 ++++++++++- frontend/tsconfig.json | 1 + 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 6bcead5..1755f23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "jose": "^4.13.1", "luxon": "^3.3.0", "markdown-it": "^13.0.1", + "pretty-bytes": "^6.1.0", "sanitize-html": "^2.10.0" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6f422f5..42f86fc 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -23,6 +23,7 @@ specifiers: markdown-it: ^13.0.1 prettier: ^2.8.7 prettier-plugin-svelte: ^2.10.0 + pretty-bytes: ^6.1.0 sanitize-html: ^2.10.0 svelte: ^3.58.0 svelte-check: ^3.1.4 @@ -41,6 +42,7 @@ dependencies: jose: 4.13.1 luxon: 3.3.0 markdown-it: 13.0.1 + pretty-bytes: 6.1.0 sanitize-html: 2.10.0 devDependencies: @@ -1802,6 +1804,11 @@ packages: hasBin: true dev: true + /pretty-bytes/6.1.0: + resolution: {integrity: sha512-Rk753HI8f4uivXi4ZCIYdhmG1V+WKzvRMg/X+M42a6t7D07RcmopXJMDNk6N++7Bl75URRGsb40ruvg7Hcp2wQ==} + engines: {node: ^14.13.1 || >=16.0.0} + dev: false + /punycode/2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} diff --git a/frontend/src/routes/edit/member/[id]/+page.svelte b/frontend/src/routes/edit/member/[id]/+page.svelte index f60b935..fe3d6f8 100644 --- a/frontend/src/routes/edit/member/[id]/+page.svelte +++ b/frontend/src/routes/edit/member/[id]/+page.svelte @@ -29,6 +29,7 @@ Alert, } from "sveltestrap"; import { encode } from "base64-arraybuffer"; + import prettyBytes from "pretty-bytes"; import { apiFetchClient, fastFetchClient } from "$lib/api/fetch"; import IconButton from "$lib/components/IconButton.svelte"; import EditableField from "../../EditableField.svelte"; @@ -149,7 +150,16 @@ const getAvatar = async (list: FileList | null) => { if (!list || list.length === 0) return null; - if (list[0].size > MAX_AVATAR_BYTES) return null; + if (list[0].size > MAX_AVATAR_BYTES) { + addToast({ + header: "Avatar too large", + body: + `This avatar is too large, please resize it (maximum is ${prettyBytes( + MAX_AVATAR_BYTES, + )}, the file you tried to upload is ${prettyBytes(list[0].size)})`, + }); + return null; + } const buffer = await list[0].arrayBuffer(); const base64 = encode(buffer); diff --git a/frontend/src/routes/edit/profile/+page.svelte b/frontend/src/routes/edit/profile/+page.svelte index a1be3fd..4134de4 100644 --- a/frontend/src/routes/edit/profile/+page.svelte +++ b/frontend/src/routes/edit/profile/+page.svelte @@ -36,6 +36,7 @@ import type { PageData } from "./$types"; import { charCount, renderMarkdown } from "$lib/utils"; import MarkdownHelp from "../MarkdownHelp.svelte"; + import prettyBytes from "pretty-bytes"; const MAX_AVATAR_BYTES = 1_000_000; @@ -137,7 +138,15 @@ const getAvatar = async (list: FileList | null) => { if (!list || list.length === 0) return null; - if (list[0].size > MAX_AVATAR_BYTES) return null; + if (list[0].size > MAX_AVATAR_BYTES) { + addToast({ + header: "Avatar too large", + body: `This avatar is too large, please resize it (maximum is ${prettyBytes( + MAX_AVATAR_BYTES, + )}, the file you tried to upload is ${prettyBytes(list[0].size)})`, + }); + return null; + } const buffer = await list[0].arrayBuffer(); const base64 = encode(buffer); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6ae0c8c..046db0e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { + "ignoreDeprecations": "5.0", "allowJs": true, "checkJs": true, "esModuleInterop": true, From 3ef4c715e72c9a5b6375c4684b8fbbf18801956b Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 17 Apr 2023 16:16:32 +0200 Subject: [PATCH 003/119] fix(frontend): encode pronoun links --- .../src/lib/components/PronounLink.svelte | 20 ++++++++++++++----- .../routes/pronouns/[...pronouns]/+page.ts | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/PronounLink.svelte b/frontend/src/lib/components/PronounLink.svelte index 317dc19..d295a3a 100644 --- a/frontend/src/lib/components/PronounLink.svelte +++ b/frontend/src/lib/components/PronounLink.svelte @@ -11,18 +11,28 @@ return pronouns.display_text; } else { const split = pronouns.pronouns.split("/"); - if (split.length < 2) return split.join("/"); - else return split.slice(0, 2).join("/"); + if (split.length === 5) return split.splice(0, 2).join("/"); + return pronouns.pronouns; } }; let link: string; let shouldLink: boolean; - $: link = pronouns.display_text - ? `${pronouns.pronouns},${pronouns.display_text}` - : pronouns.pronouns; + $: link = linkPronouns(pronouns); $: shouldLink = pronouns.pronouns.split("/").length === 5; + + const linkPronouns = (pronouns: Pronoun) => { + const linkBase = pronouns.pronouns + .split("/") + .map((snippet) => encodeURIComponent(snippet)) + .join("/"); + + if (pronouns.display_text) { + return `${linkBase},${encodeURIComponent(pronouns.display_text)}`; + } + return linkBase; + }; {#if shouldLink} diff --git a/frontend/src/routes/pronouns/[...pronouns]/+page.ts b/frontend/src/routes/pronouns/[...pronouns]/+page.ts index 60f252f..6ab0564 100644 --- a/frontend/src/routes/pronouns/[...pronouns]/+page.ts +++ b/frontend/src/routes/pronouns/[...pronouns]/+page.ts @@ -6,7 +6,7 @@ import pronounsRaw from "$lib/pronouns.json"; const pronouns = pronounsRaw as PronounsJson; export const load = (async ({ params }) => { - const [param, displayText] = params.pronouns.split(","); + const [param, displayText] = decodeURIComponent(params.pronouns).split(","); const arr = param.split("/"); if (arr.length === 0 || params.pronouns === "") { From ec6b04850109682060643da46ae0b0885aa8a8dc Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 17 Apr 2023 16:22:45 +0200 Subject: [PATCH 004/119] fix(frontend): fall back to full pronoun set if it's a malformed set --- frontend/src/lib/components/PartialMemberCard.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/PartialMemberCard.svelte b/frontend/src/lib/components/PartialMemberCard.svelte index 29fcf22..040e060 100644 --- a/frontend/src/lib/components/PartialMemberCard.svelte +++ b/frontend/src/lib/components/PartialMemberCard.svelte @@ -20,8 +20,8 @@ return pronouns.display_text; } else { const split = pronouns.pronouns.split("/"); - if (split.length < 2) return split.join("/"); - else return split.slice(0, 2).join("/"); + if (split.length === 5) return split.splice(0, 2).join("/"); + return pronouns.pronouns; } }) .join(", "); From 94cd4cd6d3ece6cd8673a8d3d8d28e1ff8534f98 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 17 Apr 2023 16:33:05 +0200 Subject: [PATCH 005/119] fix(backend): don't count deleted users + unlisted members in meta endpoint This technically leaked the *existence* of these users and members, but there's never been any way to enumerate users or unlisted members, so this is unlikely to have *actually* leaked any information. Still, for consistency's sake, this commit hides them from the user/member count. --- backend/routes/meta/meta.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/routes/meta/meta.go b/backend/routes/meta/meta.go index 609d691..40d27da 100644 --- a/backend/routes/meta/meta.go +++ b/backend/routes/meta/meta.go @@ -32,12 +32,12 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() var numUsers, numMembers int64 - err := s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&numUsers) + err := s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").Scan(&numUsers) if err != nil { return errors.Wrap(err, "querying user count") } - err = s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM members").Scan(&numMembers) + err = s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM members WHERE unlisted = false AND user_id = ANY(SELECT id FROM users WHERE deleted_at IS NULL)").Scan(&numMembers) if err != nil { return errors.Wrap(err, "querying user count") } From 7a550dc62401a49782157e6c170fa6b4da981d0c Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 17 Apr 2023 16:43:06 +0200 Subject: [PATCH 006/119] feat(frontend): expose user/member count --- frontend/src/routes/+layout.svelte | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 9ef1a8a..ab6d371 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -39,16 +39,21 @@ From e8f502073de0ba72ee958016a6c82062d38a92ec Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 17 Apr 2023 23:10:59 +0200 Subject: [PATCH 007/119] disable sentry for a bit; most errors are 404s anyway --- frontend/src/hooks.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 2fa16c8..e073554 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -7,11 +7,11 @@ Sentry.init({ dsn: PRIVATE_SENTRY_DSN }); export const handleError = (({ error, event }) => { console.log(error); console.log(event); - const id = Sentry.captureException(error); + // const id = Sentry.captureException(error); return { message: "Internal server error", code: 500, - id, + //id, }; }) satisfies HandleServerError; From b4c331daa02a242fa196e5a983a776ec976fde7d Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 17 Apr 2023 23:43:04 +0200 Subject: [PATCH 008/119] fix: fix tokens to expire after 3 months and always inherit admin perms from user --- backend/db/tokens.go | 4 ++-- backend/routes/auth/tokens.go | 7 ++++++- backend/server/auth/auth.go | 8 +++----- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/db/tokens.go b/backend/db/tokens.go index 32376a8..367c492 100644 --- a/backend/db/tokens.go +++ b/backend/db/tokens.go @@ -61,7 +61,7 @@ func (db *DB) Tokens(ctx context.Context, userID xid.ID) (ts []Token, err error) } // 3 months, might be customizable later -const ExpiryTime = 3 * 30 * 24 * time.Hour +const TokenExpiryTime = 3 * 30 * 24 * time.Hour // SaveToken saves a token to the database. func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiOnly, readOnly bool) (t Token, err error) { @@ -69,7 +69,7 @@ func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiO SetMap(map[string]any{ "user_id": userID, "token_id": tokenID, - "expires": time.Now().Add(ExpiryTime), + "expires": time.Now().Add(TokenExpiryTime), "api_only": apiOnly, "read_only": readOnly, }). diff --git a/backend/routes/auth/tokens.go b/backend/routes/auth/tokens.go index 211126d..d08c767 100644 --- a/backend/routes/auth/tokens.go +++ b/backend/routes/auth/tokens.go @@ -96,9 +96,14 @@ func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} } + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting me user") + } + readOnly := r.FormValue("read_only") == "true" tokenID := xid.New() - tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, false, true, !readOnly) + tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, u.IsAdmin, true, !readOnly) if err != nil { return errors.Wrap(err, "creating token") } diff --git a/backend/server/auth/auth.go b/backend/server/auth/auth.go index e9d2bfb..4529155 100644 --- a/backend/server/auth/auth.go +++ b/backend/server/auth/auth.go @@ -6,6 +6,7 @@ import ( "os" "time" + "codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/log" "emperror.dev/errors" "github.com/golang-jwt/jwt/v4" @@ -46,14 +47,11 @@ func New() *Verifier { return &Verifier{key: key} } -// ExpireDays is after how many days the token will expire. -const ExpireDays = 30 - // CreateToken creates a token for the given user ID. -// It expires after 30 days. +// It expires after three months. func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isAPIToken bool, isWriteToken bool) (token string, err error) { now := time.Now() - expires := now.Add(ExpireDays * 24 * time.Hour) + expires := now.Add(db.TokenExpiryTime) t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ UserID: userID, From 5c8c6eed633fe121ce686326935fb7f93c95e64d Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 17 Apr 2023 23:44:21 +0200 Subject: [PATCH 009/119] feat: add prometheus metrics --- backend/db/db.go | 13 ++++++++ backend/db/metrics.go | 63 ++++++++++++++++++++++++++++++++++++ backend/routes/meta/meta.go | 5 ++- backend/routes/mod/routes.go | 3 ++ backend/server/server.go | 13 +++++--- go.mod | 8 ++++- go.sum | 14 +++++++- 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 backend/db/metrics.go diff --git a/backend/db/db.go b/backend/db/db.go index 03caf25..25a214c 100644 --- a/backend/db/db.go +++ b/backend/db/db.go @@ -7,6 +7,7 @@ import ( "net/url" "os" + "codeberg.org/u1f320/pronouns.cc/backend/log" "emperror.dev/errors" "github.com/Masterminds/squirrel" "github.com/jackc/pgx/v5/pgconn" @@ -14,6 +15,7 @@ import ( "github.com/mediocregopher/radix/v4" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" + "github.com/prometheus/client_golang/prometheus" ) var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) @@ -32,19 +34,24 @@ type DB struct { minio *minio.Client minioBucket string baseURL *url.URL + + TotalRequests prometheus.Counter } func New() (*DB, error) { + log.Debug("creating postgres client") pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { return nil, errors.Wrap(err, "creating postgres client") } + log.Debug("creating redis client") redis, err := (&radix.PoolConfig{}).New(context.Background(), "tcp", os.Getenv("REDIS")) if err != nil { return nil, errors.Wrap(err, "creating redis client") } + log.Debug("creating minio client") minioClient, err := minio.New(os.Getenv("MINIO_ENDPOINT"), &minio.Options{ Creds: credentials.NewStaticV4(os.Getenv("MINIO_ACCESS_KEY_ID"), os.Getenv("MINIO_ACCESS_KEY_SECRET"), ""), Secure: os.Getenv("MINIO_SSL") == "true", @@ -67,6 +74,12 @@ func New() (*DB, error) { baseURL: baseURL, } + log.Debug("initializing metrics") + err = db.initMetrics() + if err != nil { + return nil, errors.Wrap(err, "initializing metrics") + } + return db, nil } diff --git a/backend/db/metrics.go b/backend/db/metrics.go new file mode 100644 index 0000000..144c3bc --- /dev/null +++ b/backend/db/metrics.go @@ -0,0 +1,63 @@ +package db + +import ( + "context" + + "codeberg.org/u1f320/pronouns.cc/backend/log" + "emperror.dev/errors" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +func (db *DB) initMetrics() (err error) { + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_total", + Help: "The total number of registered users", + }, func() float64 { + count, err := db.TotalUserCount(context.Background()) + if err != nil { + log.Errorf("getting user count for metrics: %v", err) + } + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering user count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_members_total", + Help: "The total number of registered members", + }, func() float64 { + count, err := db.TotalMemberCount(context.Background()) + if err != nil { + log.Errorf("getting member count for metrics: %v", err) + } + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering member count gauge") + } + + db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{ + Name: "pronouns_api_requests_total", + Help: "The total number of API requests since the last restart", + }) + + return nil +} + +func (db *DB) TotalUserCount(ctx context.Context) (numUsers int64, err error) { + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").Scan(&numUsers) + if err != nil { + return 0, errors.Wrap(err, "querying user count") + } + return numUsers, nil +} + +func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error) { + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM members WHERE unlisted = false AND user_id = ANY(SELECT id FROM users WHERE deleted_at IS NULL)").Scan(&numMembers) + if err != nil { + return 0, errors.Wrap(err, "querying member count") + } + return numMembers, nil +} diff --git a/backend/routes/meta/meta.go b/backend/routes/meta/meta.go index 40d27da..7b6c26e 100644 --- a/backend/routes/meta/meta.go +++ b/backend/routes/meta/meta.go @@ -31,13 +31,12 @@ type MetaResponse struct { func (s *Server) meta(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - var numUsers, numMembers int64 - err := s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").Scan(&numUsers) + numUsers, err := s.DB.TotalUserCount(ctx) if err != nil { return errors.Wrap(err, "querying user count") } - err = s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM members WHERE unlisted = false AND user_id = ANY(SELECT id FROM users WHERE deleted_at IS NULL)").Scan(&numMembers) + numMembers, err := s.DB.TotalMemberCount(ctx) if err != nil { return errors.Wrap(err, "querying user count") } diff --git a/backend/routes/mod/routes.go b/backend/routes/mod/routes.go index 52ff0aa..6aeef87 100644 --- a/backend/routes/mod/routes.go +++ b/backend/routes/mod/routes.go @@ -6,6 +6,7 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/prometheus/client_golang/prometheus/promhttp" ) type Server struct { @@ -23,6 +24,8 @@ func Mount(srv *server.Server, r chi.Router) { r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport)) }) + r.With(MustAdmin).Handle("/metrics", promhttp.Handler()) + r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport)) r.With(server.MustAuth).Post("/members/{id}/reports", server.WrapHandler(s.createMemberReport)) diff --git a/backend/server/server.go b/backend/server/server.go index 459832d..3ed711c 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -107,12 +107,17 @@ func New() (*Server, error) { rateLimiter.Scope("*", "/auth/invites", 10) rateLimiter.Scope("POST", "/auth/discord/*", 10) - // rate limit handling - // - 120 req/minute (2/s) - // - keyed by Authorization header if valid token is provided, otherwise by IP - // - returns rate limit reset info in error s.Router.Use(rateLimiter.Handler()) + // increment the total requests counter whenever a request is made + s.Router.Use(func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + s.DB.TotalRequests.Inc() + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + }) + // return an API error for not found + method not allowed s.Router.NotFound(func(w http.ResponseWriter, r *http.Request) { render.Status(r, errCodeStatuses[ErrNotFound]) diff --git a/go.mod b/go.mod index 994a3a2..b335781 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/georgysavva/scany/v2 v2.0.0 github.com/go-chi/chi/v5 v5.0.8 + github.com/go-chi/cors v1.2.1 github.com/go-chi/httprate v0.7.1 github.com/go-chi/render v1.0.2 github.com/gobwas/glob v0.2.3 @@ -18,6 +19,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mediocregopher/radix/v4 v4.1.2 github.com/minio/minio-go/v7 v7.0.50 + github.com/prometheus/client_golang v1.15.0 github.com/rs/xid v1.4.0 github.com/rubenv/sql-migrate v1.4.0 github.com/urfave/cli/v2 v2.25.1 @@ -27,10 +29,10 @@ require ( require ( github.com/ajg/form v1.5.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-chi/cors v1.2.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/uuid v1.3.0 // indirect @@ -43,11 +45,15 @@ require ( github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/sha256-simd v1.0.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/common v0.42.0 // indirect + github.com/prometheus/procfs v0.9.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/stretchr/objx v0.5.0 // indirect diff --git a/go.sum b/go.sum index 2f62af8..41cb19f 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= @@ -201,7 +203,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -336,6 +338,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mediocregopher/radix/v4 v4.1.2 h1:Pj7XnNK5WuzzFy63g98pnccainAePK+aZNQRvxSvj2I= github.com/mediocregopher/radix/v4 v4.1.2/go.mod h1:ajchozX/6ELmydxWeWM6xCFHVpZ4+67LXHOTOVR0nCE= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= @@ -389,13 +393,21 @@ github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= +github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= From 6131884ba7adb2314555cd7f0d5e65f8051fbe42 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 18 Apr 2023 02:15:45 +0200 Subject: [PATCH 010/119] fix: reject instance domains with @ in them --- backend/routes/auth/fediverse.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/routes/auth/fediverse.go b/backend/routes/auth/fediverse.go index 08a0b49..d8ae722 100644 --- a/backend/routes/auth/fediverse.go +++ b/backend/routes/auth/fediverse.go @@ -25,6 +25,11 @@ func (s *Server) getFediverseURL(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL is empty"} } + // Too many people tried using @username@fediverse.example despite the warning + if strings.Contains(instance, "@") { + return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL should only be the base URL, without username"} + } + app, err := s.DB.FediverseApp(ctx, instance) if err != nil { return s.noAppFediverseURL(ctx, w, r, instance) From 716c1283e7f3d6e6d7279f6700fe13e86f0ca847 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 18 Apr 2023 03:49:37 +0200 Subject: [PATCH 011/119] feat: add tumblr oauth --- backend/db/user.go | 67 +++ backend/exporter/types.go | 5 + backend/routes/auth/routes.go | 17 + backend/routes/auth/tumblr.go | 394 ++++++++++++++++++ backend/routes/user/get_user.go | 5 + backend/routes/user/patch_user.go | 2 + frontend/src/lib/api/entities.ts | 2 + frontend/src/lib/api/responses.ts | 1 + frontend/src/routes/auth/login/+page.svelte | 9 +- .../routes/auth/login/tumblr/+page.server.ts | 38 ++ .../src/routes/auth/login/tumblr/+page.svelte | 64 +++ .../src/routes/settings/auth/+page.svelte | 38 +- scripts/migrate/013_tumblr_oauth.sql | 6 + 13 files changed, 641 insertions(+), 7 deletions(-) create mode 100644 backend/routes/auth/tumblr.go create mode 100644 frontend/src/routes/auth/login/tumblr/+page.server.ts create mode 100644 frontend/src/routes/auth/login/tumblr/+page.svelte create mode 100644 scripts/migrate/013_tumblr_oauth.sql diff --git a/backend/db/user.go b/backend/db/user.go index 3ad8196..8c9d00f 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -36,6 +36,9 @@ type User struct { FediverseAppID *int64 FediverseInstance *string + Tumblr *string + TumblrUsername *string + MaxInvites int IsAdmin bool ListPrivate bool @@ -52,6 +55,9 @@ func (u User) NumProviders() (numProviders int) { if u.Fediverse != nil { numProviders++ } + if u.Tumblr != nil { + numProviders++ + } return numProviders } @@ -240,6 +246,67 @@ func (u *User) UnlinkDiscord(ctx context.Context, ex Execer) error { return nil } +// TumblrUser fetches a user by Tumblr user ID. +func (db *DB) TumblrUser(ctx context.Context, tumblrID string) (u User, err error) { + sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). + From("users").Where("tumblr = ?", tumblrID).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "executing query") + } + return u, nil +} + +func (u *User) UpdateFromTumblr(ctx context.Context, ex Execer, tumblrID, tumblrUsername string) error { + sql, args, err := sq.Update("users"). + Set("tumblr", tumblrID). + Set("tumblr_username", tumblrUsername). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Tumblr = &tumblrID + u.TumblrUsername = &tumblrUsername + + return nil +} + +func (u *User) UnlinkTumblr(ctx context.Context, ex Execer) error { + sql, args, err := sq.Update("users"). + Set("tumblr", nil). + Set("tumblr_username", nil). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Tumblr = nil + u.TumblrUsername = nil + + return nil +} + // User gets a user by ID. func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) { sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). diff --git a/backend/exporter/types.go b/backend/exporter/types.go index 83540f3..48682d0 100644 --- a/backend/exporter/types.go +++ b/backend/exporter/types.go @@ -24,6 +24,9 @@ type userExport struct { Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + MaxInvites int `json:"max_invites"` Warnings []db.Warning `json:"warnings"` @@ -41,6 +44,8 @@ func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExp Fields: db.NotNull(fields), Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, MaxInvites: u.MaxInvites, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 1283472..18a06e5 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -34,6 +34,9 @@ type userResponse struct { Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + Fediverse *string `json:"fediverse"` FediverseUsername *string `json:"fediverse_username"` FediverseInstance *string `json:"fediverse_instance"` @@ -52,6 +55,8 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { Fields: db.NotNull(fields), Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: u.FediverseInstance, @@ -84,6 +89,13 @@ func Mount(srv *server.Server, r chi.Router) { r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.discordUnlink)) }) + r.Route("/tumblr", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(s.tumblrCallback)) + r.Post("/signup", server.WrapHandler(s.tumblrSignup)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.tumblrLink)) + r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.tumblrUnlink)) + }) + r.Route("/mastodon", func(r chi.Router) { r.Post("/callback", server.WrapHandler(s.mastodonCallback)) r.Post("/signup", server.WrapHandler(s.mastodonSignup)) @@ -121,6 +133,7 @@ type oauthURLsRequest struct { type oauthURLsResponse struct { Discord string `json:"discord"` + Tumblr string `json:"tumblr"` } func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { @@ -140,9 +153,13 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { // copy Discord config and set redirect url discordCfg := discordOAuthConfig discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord" + // copy tumblr config + tumblrCfg := tumblrOAuthConfig + tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr" render.JSON(w, r, oauthURLsResponse{ Discord: discordCfg.AuthCodeURL(state) + "&prompt=none", + Tumblr: tumblrCfg.AuthCodeURL(state), }) return nil } diff --git a/backend/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go new file mode 100644 index 0000000..d477b31 --- /dev/null +++ b/backend/routes/auth/tumblr.go @@ -0,0 +1,394 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + "os" + "time" + + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" + "golang.org/x/oauth2" +) + +var tumblrOAuthConfig = oauth2.Config{ + ClientID: os.Getenv("TUMBLR_CLIENT_ID"), + ClientSecret: os.Getenv("TUMBLR_CLIENT_SECRET"), + Endpoint: oauth2.Endpoint{ + AuthURL: "https://www.tumblr.com/oauth2/authorize", + TokenURL: "https://api.tumblr.com/v2/oauth2/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"basic"}, +} + +type partialTumblrResponse struct { + Meta struct { + Status int `json:"status"` + Message string `json:"msg"` + } `json:"meta"` + Response struct { + User struct { + Blogs []struct { + Name string `json:"name"` + Primary bool `json:"primary"` + UUID string `json:"uuid"` + } `json:"blogs"` + } `json:"user"` + } `json:"response"` +} + +type tumblrUserInfo struct { + Name string `json:"name"` + ID string `json:"id"` +} + +type tumblrCallbackResponse struct { + HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Tumblr will be set + + Token string `json:"token,omitempty"` + User *userResponse `json:"user,omitempty"` + + Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` +} + +func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[discordOauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // if the state can't be validated, return + if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { + if err != nil { + return err + } + + return server.APIError{Code: server.ErrInvalidState} + } + + cfg := tumblrOAuthConfig + cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/tumblr" + token, err := cfg.Exchange(r.Context(), decoded.Code) + if err != nil { + log.Errorf("exchanging oauth code: %v", err) + + return server.APIError{Code: server.ErrInvalidOAuthCode} + } + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.tumblr.com/v2/user/info", nil) + if err != nil { + return errors.Wrap(err, "creating user/info request") + } + + req.Header.Set("Content-Type", "application/json") + token.SetAuthHeader(req) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "sending user/info request") + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return errors.New("response had status code < 200 or >= 400") + } + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(err, "reading user/info response") + } + + var tr partialTumblrResponse + err = json.Unmarshal(jb, &tr) + if err != nil { + return errors.Wrap(err, "unmarshaling user/info response") + } + + var tumblrName, tumblrID string + for _, blog := range tr.Response.User.Blogs { + if blog.Primary { + tumblrName = blog.Name + tumblrID = blog.UUID + break + } + } + + if tumblrID == "" { + return server.APIError{Code: server.ErrInternalServerError, Details: "Your Tumblr account doesn't seem to have a primary blog"} + } + + u, err := s.DB.TumblrUser(ctx, tumblrID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromTumblr(ctx, s.DB, tumblrName, tumblrID) + if err != nil { + log.Errorf("updating user %v with Tumblr info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Tumblr info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600") + if err != nil { + log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: false, + Tumblr: tumblrName, + Ticket: ticket, + RequireInvite: s.RequireInvite, + }) + + return nil +} + +func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + req, err := Decode[linkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Tumblr != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + tui := new(tumblrUserInfo) + err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) + if err != nil { + log.Errorf("getting tumblr user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + err = u.UpdateFromTumblr(ctx, s.DB, tui.ID, tui.Name) + if err != nil { + return errors.Wrap(err, "updating user from tumblr") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) tumblrUnlink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Tumblr == nil { + return server.APIError{Code: server.ErrNotLinked} + } + + // cannot unlink last auth provider + if u.NumProviders() <= 1 { + return server.APIError{Code: server.ErrLastProvider} + } + + err = u.UnlinkTumblr(ctx, s.DB) + if err != nil { + return errors.Wrap(err, "updating user in db") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[discordSignupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + tui := new(tumblrUserInfo) + err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) + if err != nil { + log.Errorf("getting tumblr user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "creating user") + } + + err = u.UpdateFromTumblr(ctx, tx, tui.ID, tui.Name) + if err != nil { + return errors.Wrap(err, "updating user from tumblr") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "tumblr:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index b8e3833..9265ce1 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -35,6 +35,9 @@ type GetMeResponse struct { Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + Fediverse *string `json:"fediverse"` FediverseUsername *string `json:"fediverse_username"` FediverseInstance *string `json:"fediverse_instance"` @@ -191,6 +194,8 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { ListPrivate: u.ListPrivate, Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: u.FediverseInstance, diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 01a32c2..bef275a 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -251,6 +251,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { ListPrivate: u.ListPrivate, Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: fediInstance, diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 133b609..18043e6 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -22,6 +22,8 @@ export interface MeUser extends User { max_invites: number; discord: string | null; discord_username: string | null; + tumblr: string | null; + tumblr_username: string | null; fediverse: string | null; fediverse_username: string | null; fediverse_instance: string | null; diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts index b7ba0c5..e3cc6cb 100644 --- a/frontend/src/lib/api/responses.ts +++ b/frontend/src/lib/api/responses.ts @@ -15,6 +15,7 @@ export interface MetaResponse { export interface UrlsResponse { discord: string; + tumblr: string; } export interface ExportResponse { diff --git a/frontend/src/routes/auth/login/+page.svelte b/frontend/src/routes/auth/login/+page.svelte index 339db36..4150e4a 100644 --- a/frontend/src/routes/auth/login/+page.svelte +++ b/frontend/src/routes/auth/login/+page.svelte @@ -60,12 +60,9 @@
- - Fediverse logo Log in with the Fediverse - - - Log in with Discord - + Log in with the Fediverse + Log in with Discord + Log in with Tumblr diff --git a/frontend/src/routes/auth/login/tumblr/+page.server.ts b/frontend/src/routes/auth/login/tumblr/+page.server.ts new file mode 100644 index 0000000..48a0b8c --- /dev/null +++ b/frontend/src/routes/auth/login/tumblr/+page.server.ts @@ -0,0 +1,38 @@ +import type { APIError, MeUser } from "$lib/api/entities"; +import { apiFetch } from "$lib/api/fetch"; +import type { PageServerLoad } from "./$types"; +import { PUBLIC_BASE_URL } from "$env/static/public"; + +export const load = (async ({ url }) => { + try { + const resp = await apiFetch("/auth/tumblr/callback", { + method: "POST", + body: { + callback_domain: PUBLIC_BASE_URL, + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }, + }); + + return { + ...resp, + }; + } catch (e) { + return { error: e as APIError }; + } +}) satisfies PageServerLoad; + +interface CallbackResponse { + has_account: boolean; + token?: string; + user?: MeUser; + + tumblr?: string; + ticket?: string; + require_invite: boolean; + + is_deleted: boolean; + deleted_at?: string; + self_delete?: boolean; + delete_reason?: string; +} diff --git a/frontend/src/routes/auth/login/tumblr/+page.svelte b/frontend/src/routes/auth/login/tumblr/+page.svelte new file mode 100644 index 0000000..c64dcef --- /dev/null +++ b/frontend/src/routes/auth/login/tumblr/+page.svelte @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte index eddd309..27d1217 100644 --- a/frontend/src/routes/settings/auth/+page.svelte +++ b/frontend/src/routes/settings/auth/+page.svelte @@ -21,7 +21,7 @@ let canUnlink = false; $: canUnlink = - [data.user.discord, data.user.fediverse] + [data.user.discord, data.user.fediverse, data.user.tumblr] .map((entry) => (entry === null ? 0 : 1)) .reduce((prev, current) => prev + current) >= 2; @@ -38,6 +38,9 @@ let discordUnlinkModalOpen = false; let toggleDiscordUnlinkModal = () => (discordUnlinkModalOpen = !discordUnlinkModalOpen); + let tumblrUnlinkModalOpen = false; + let toggleTumblrUnlinkModal = () => (tumblrUnlinkModalOpen = !tumblrUnlinkModalOpen); + const fediLogin = async () => { fediDisabled = true; try { @@ -74,6 +77,17 @@ error = e as APIError; } }; + + const tumblrUnlink = async () => { + try { + const resp = await apiFetchClient("/auth/tumblr/remove-provider", "POST"); + data.user = resp; + addToast({ header: "Unlinked account", body: "Successfully unlinked Tumblr account!" }); + toggleTumblrUnlinkModal(); + } catch (e) { + error = e as APIError; + } + };
@@ -126,6 +140,28 @@
+
+ + + Tumblr + + {#if data.user.tumblr} + Your currently linked Tumblr account is {data.user.tumblr_username} + ({data.user.tumblr}). + {:else} + You do not have a linked Tumblr account. + {/if} + + {#if data.user.tumblr} + + {:else} + + {/if} + + +
diff --git a/scripts/migrate/013_tumblr_oauth.sql b/scripts/migrate/013_tumblr_oauth.sql new file mode 100644 index 0000000..0a04c62 --- /dev/null +++ b/scripts/migrate/013_tumblr_oauth.sql @@ -0,0 +1,6 @@ +-- +migrate Up + +-- 2023-04-18: Add tumblr oauth + +alter table users add column tumblr text null; +alter table users add column tumblr_username text null; From 8588da8b80f5d1d7b1542596520557723b8ac153 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 18 Apr 2023 15:48:09 +0000 Subject: [PATCH 012/119] fix: switch arguments in UpdateFromTumblr --- backend/routes/auth/tumblr.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go index d477b31..5d32856 100644 --- a/backend/routes/auth/tumblr.go +++ b/backend/routes/auth/tumblr.go @@ -156,7 +156,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { return nil } - err = u.UpdateFromTumblr(ctx, s.DB, tumblrName, tumblrID) + err = u.UpdateFromTumblr(ctx, s.DB, tumblrID, tumblrName) if err != nil { log.Errorf("updating user %v with Tumblr info: %v", u.ID, err) } From e6c7954a88fee8545c9dc5fd630fec6616ccc425 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 18 Apr 2023 22:44:37 +0200 Subject: [PATCH 013/119] fix: add unlink tumblr modal --- .../src/routes/settings/auth/+page.svelte | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte index 27d1217..0a7ff9c 100644 --- a/frontend/src/routes/settings/auth/+page.svelte +++ b/frontend/src/routes/settings/auth/+page.svelte @@ -221,5 +221,27 @@ + + + +

+ Are you sure you want to unlink your Tumblr account? You will no longer be able to use it + to log in. +

+ {#if error} +
+ +
+ {/if} +
+ + + + +
From 488544dd5f2701bbdac99b6eb52c44b6eccaa950 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 18 Apr 2023 22:52:58 +0200 Subject: [PATCH 014/119] feat: add google oauth --- backend/db/user.go | 67 ++++ backend/exporter/types.go | 5 + backend/routes/auth/discord.go | 8 +- backend/routes/auth/google.go | 361 ++++++++++++++++++ backend/routes/auth/routes.go | 17 + backend/routes/auth/tumblr.go | 4 +- backend/routes/user/get_user.go | 5 + backend/routes/user/patch_user.go | 2 + frontend/src/lib/api/entities.ts | 2 + frontend/src/lib/api/responses.ts | 1 + frontend/src/routes/auth/login/+page.svelte | 2 +- .../routes/auth/login/google/+page.server.ts | 38 ++ .../src/routes/auth/login/google/+page.svelte | 64 ++++ .../src/routes/settings/auth/+page.svelte | 60 ++- go.mod | 18 +- go.sum | 46 ++- scripts/migrate/014_google_oauth.sql | 6 + 17 files changed, 685 insertions(+), 21 deletions(-) create mode 100644 backend/routes/auth/google.go create mode 100644 frontend/src/routes/auth/login/google/+page.server.ts create mode 100644 frontend/src/routes/auth/login/google/+page.svelte create mode 100644 scripts/migrate/014_google_oauth.sql diff --git a/backend/db/user.go b/backend/db/user.go index 8c9d00f..7e1911f 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -39,6 +39,9 @@ type User struct { Tumblr *string TumblrUsername *string + Google *string + GoogleUsername *string + MaxInvites int IsAdmin bool ListPrivate bool @@ -58,6 +61,9 @@ func (u User) NumProviders() (numProviders int) { if u.Tumblr != nil { numProviders++ } + if u.Google != nil { + numProviders++ + } return numProviders } @@ -307,6 +313,67 @@ func (u *User) UnlinkTumblr(ctx context.Context, ex Execer) error { return nil } +// GoogleUser fetches a user by Google user ID. +func (db *DB) GoogleUser(ctx context.Context, googleID string) (u User, err error) { + sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). + From("users").Where("google = ?", googleID).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "executing query") + } + return u, nil +} + +func (u *User) UpdateFromGoogle(ctx context.Context, ex Execer, googleID, googleUsername string) error { + sql, args, err := sq.Update("users"). + Set("google", googleID). + Set("google_username", googleUsername). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Google = &googleID + u.GoogleUsername = &googleUsername + + return nil +} + +func (u *User) UnlinkGoogle(ctx context.Context, ex Execer) error { + sql, args, err := sq.Update("users"). + Set("google", nil). + Set("google_username", nil). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Google = nil + u.GoogleUsername = nil + + return nil +} + // User gets a user by ID. func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) { sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). diff --git a/backend/exporter/types.go b/backend/exporter/types.go index 48682d0..f554e5a 100644 --- a/backend/exporter/types.go +++ b/backend/exporter/types.go @@ -27,6 +27,9 @@ type userExport struct { Tumblr *string `json:"tumblr"` TumblrUsername *string `json:"tumblr_username"` + Google *string `json:"google"` + GoogleUsername *string `json:"google_username"` + MaxInvites int `json:"max_invites"` Warnings []db.Warning `json:"warnings"` @@ -46,6 +49,8 @@ func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExp DiscordUsername: u.DiscordUsername, Tumblr: u.Tumblr, TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, MaxInvites: u.MaxInvites, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index 975accf..7ef3b04 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -27,7 +27,7 @@ var discordOAuthConfig = oauth2.Config{ Scopes: []string{"identify"}, } -type discordOauthCallbackRequest struct { +type oauthCallbackRequest struct { CallbackDomain string `json:"callback_domain"` Code string `json:"code"` State string `json:"state"` @@ -52,7 +52,7 @@ type discordCallbackResponse struct { func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - decoded, err := Decode[discordOauthCallbackRequest](r) + decoded, err := Decode[oauthCallbackRequest](r) if err != nil { return server.APIError{Code: server.ErrBadRequest} } @@ -245,7 +245,7 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error { return nil } -type discordSignupRequest struct { +type signupRequest struct { Ticket string `json:"ticket"` Username string `json:"username"` InviteCode string `json:"invite_code"` @@ -259,7 +259,7 @@ type signupResponse struct { func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - req, err := Decode[discordSignupRequest](r) + req, err := Decode[signupRequest](r) if err != nil { return server.APIError{Code: server.ErrBadRequest} } diff --git a/backend/routes/auth/google.go b/backend/routes/auth/google.go new file mode 100644 index 0000000..d28b48b --- /dev/null +++ b/backend/routes/auth/google.go @@ -0,0 +1,361 @@ +package auth + +import ( + "net/http" + "os" + "time" + + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" + "golang.org/x/oauth2" + "google.golang.org/api/idtoken" +) + +var googleOAuthConfig = oauth2.Config{ + ClientID: os.Getenv("GOOGLE_CLIENT_ID"), + ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + Endpoint: oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"openid", "https://www.googleapis.com/auth/userinfo.email"}, +} + +type googleCallbackResponse struct { + HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Google will be set + + Token string `json:"token,omitempty"` + User *userResponse `json:"user,omitempty"` + + Google string `json:"google,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` +} + +type partialGoogleUser struct { + ID string `json:"id"` + Email string `json:"email"` +} + +func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[oauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // if the state can't be validated, return + if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { + if err != nil { + return err + } + + return server.APIError{Code: server.ErrInvalidState} + } + + cfg := googleOAuthConfig + cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/google" + token, err := cfg.Exchange(r.Context(), decoded.Code) + if err != nil { + log.Errorf("exchanging oauth code: %v", err) + return server.APIError{Code: server.ErrInvalidOAuthCode} + } + rawToken := token.Extra("id_token") + if rawToken == nil { + log.Debug("id_token was nil") + return server.APIError{Code: server.ErrInternalServerError} + } + + idToken, ok := rawToken.(string) + if !ok { + log.Debug("id_token was not a string") + return server.APIError{Code: server.ErrInternalServerError} + } + payload, err := idtoken.Validate(ctx, idToken, "") + if err != nil { + log.Errorf("getting id token payload: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + googleID, ok := payload.Claims["sub"].(string) + if !ok { + log.Debug("id_token.claims.sub was not a string") + return server.APIError{Code: server.ErrInternalServerError} + } + googleUsername, ok := payload.Claims["email"].(string) + if !ok { + log.Debug("id_token.claims.email was not a string") + return server.APIError{Code: server.ErrInternalServerError} + } + + u, err := s.DB.GoogleUser(ctx, googleID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, googleCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromGoogle(ctx, s.DB, googleID, googleUsername) + if err != nil { + log.Errorf("updating user %v with Google info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, googleCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Google info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600") + if err != nil { + log.Errorf("setting Google user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, googleCallbackResponse{ + HasAccount: false, + Google: googleUsername, + Ticket: ticket, + RequireInvite: s.RequireInvite, + }) + + return nil +} + +func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + req, err := Decode[linkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Google != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + gu := new(partialGoogleUser) + err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu) + if err != nil { + log.Errorf("getting google user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + err = u.UpdateFromGoogle(ctx, s.DB, gu.ID, gu.Email) + if err != nil { + return errors.Wrap(err, "updating user from google") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) googleUnlink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Google == nil { + return server.APIError{Code: server.ErrNotLinked} + } + + // cannot unlink last auth provider + if u.NumProviders() <= 1 { + return server.APIError{Code: server.ErrLastProvider} + } + + err = u.UnlinkGoogle(ctx, s.DB) + if err != nil { + return errors.Wrap(err, "updating user in db") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[signupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + gu := new(partialGoogleUser) + err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu) + if err != nil { + log.Errorf("getting google user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "creating user") + } + + err = u.UpdateFromGoogle(ctx, tx, gu.ID, gu.Email) + if err != nil { + return errors.Wrap(err, "updating user from google") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "google:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 18a06e5..7fec89c 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -37,6 +37,9 @@ type userResponse struct { Tumblr *string `json:"tumblr"` TumblrUsername *string `json:"tumblr_username"` + Google *string `json:"google"` + GoogleUsername *string `json:"google_username"` + Fediverse *string `json:"fediverse"` FediverseUsername *string `json:"fediverse_username"` FediverseInstance *string `json:"fediverse_instance"` @@ -57,6 +60,8 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { DiscordUsername: u.DiscordUsername, Tumblr: u.Tumblr, TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: u.FediverseInstance, @@ -96,6 +101,13 @@ func Mount(srv *server.Server, r chi.Router) { r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.tumblrUnlink)) }) + r.Route("/google", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(s.googleCallback)) + r.Post("/signup", server.WrapHandler(s.googleSignup)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.googleLink)) + r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.googleUnlink)) + }) + r.Route("/mastodon", func(r chi.Router) { r.Post("/callback", server.WrapHandler(s.mastodonCallback)) r.Post("/signup", server.WrapHandler(s.mastodonSignup)) @@ -134,6 +146,7 @@ type oauthURLsRequest struct { type oauthURLsResponse struct { Discord string `json:"discord"` Tumblr string `json:"tumblr"` + Google string `json:"google"` } func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { @@ -156,10 +169,14 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { // copy tumblr config tumblrCfg := tumblrOAuthConfig tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr" + // copy google config + googleCfg := googleOAuthConfig + googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google" render.JSON(w, r, oauthURLsResponse{ Discord: discordCfg.AuthCodeURL(state) + "&prompt=none", Tumblr: tumblrCfg.AuthCodeURL(state), + Google: googleCfg.AuthCodeURL(state), }) return nil } diff --git a/backend/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go index d477b31..39dab61 100644 --- a/backend/routes/auth/tumblr.go +++ b/backend/routes/auth/tumblr.go @@ -68,7 +68,7 @@ type tumblrCallbackResponse struct { func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - decoded, err := Decode[discordOauthCallbackRequest](r) + decoded, err := Decode[oauthCallbackRequest](r) if err != nil { return server.APIError{Code: server.ErrBadRequest} } @@ -296,7 +296,7 @@ func (s *Server) tumblrUnlink(w http.ResponseWriter, r *http.Request) error { func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - req, err := Decode[discordSignupRequest](r) + req, err := Decode[signupRequest](r) if err != nil { return server.APIError{Code: server.ErrBadRequest} } diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index 9265ce1..8e36015 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -38,6 +38,9 @@ type GetMeResponse struct { Tumblr *string `json:"tumblr"` TumblrUsername *string `json:"tumblr_username"` + Google *string `json:"google"` + GoogleUsername *string `json:"google_username"` + Fediverse *string `json:"fediverse"` FediverseUsername *string `json:"fediverse_username"` FediverseInstance *string `json:"fediverse_instance"` @@ -196,6 +199,8 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { DiscordUsername: u.DiscordUsername, Tumblr: u.Tumblr, TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: u.FediverseInstance, diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index bef275a..0b2b41e 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -253,6 +253,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { DiscordUsername: u.DiscordUsername, Tumblr: u.Tumblr, TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: fediInstance, diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 18043e6..70dd2a6 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -24,6 +24,8 @@ export interface MeUser extends User { discord_username: string | null; tumblr: string | null; tumblr_username: string | null; + google: string | null; + google_username: string | null; fediverse: string | null; fediverse_username: string | null; fediverse_instance: string | null; diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts index e3cc6cb..561deba 100644 --- a/frontend/src/lib/api/responses.ts +++ b/frontend/src/lib/api/responses.ts @@ -16,6 +16,7 @@ export interface MetaResponse { export interface UrlsResponse { discord: string; tumblr: string; + google: string; } export interface ExportResponse { diff --git a/frontend/src/routes/auth/login/+page.svelte b/frontend/src/routes/auth/login/+page.svelte index 4150e4a..c7ee00b 100644 --- a/frontend/src/routes/auth/login/+page.svelte +++ b/frontend/src/routes/auth/login/+page.svelte @@ -17,7 +17,6 @@ ModalFooter, } from "sveltestrap"; import type { PageData } from "./$types"; - import fediverse from "./fediverse.svg"; export let data: PageData; @@ -63,6 +62,7 @@ Log in with the Fediverse Log in with Discord Log in with Tumblr + Log in with Google diff --git a/frontend/src/routes/auth/login/google/+page.server.ts b/frontend/src/routes/auth/login/google/+page.server.ts new file mode 100644 index 0000000..1e5cb14 --- /dev/null +++ b/frontend/src/routes/auth/login/google/+page.server.ts @@ -0,0 +1,38 @@ +import type { APIError, MeUser } from "$lib/api/entities"; +import { apiFetch } from "$lib/api/fetch"; +import type { PageServerLoad } from "./$types"; +import { PUBLIC_BASE_URL } from "$env/static/public"; + +export const load = (async ({ url }) => { + try { + const resp = await apiFetch("/auth/google/callback", { + method: "POST", + body: { + callback_domain: PUBLIC_BASE_URL, + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }, + }); + + return { + ...resp, + }; + } catch (e) { + return { error: e as APIError }; + } +}) satisfies PageServerLoad; + +interface CallbackResponse { + has_account: boolean; + token?: string; + user?: MeUser; + + google?: string; + ticket?: string; + require_invite: boolean; + + is_deleted: boolean; + deleted_at?: string; + self_delete?: boolean; + delete_reason?: string; +} diff --git a/frontend/src/routes/auth/login/google/+page.svelte b/frontend/src/routes/auth/login/google/+page.svelte new file mode 100644 index 0000000..487917b --- /dev/null +++ b/frontend/src/routes/auth/login/google/+page.svelte @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte index 0a7ff9c..59dfb03 100644 --- a/frontend/src/routes/settings/auth/+page.svelte +++ b/frontend/src/routes/settings/auth/+page.svelte @@ -21,7 +21,7 @@ let canUnlink = false; $: canUnlink = - [data.user.discord, data.user.fediverse, data.user.tumblr] + [data.user.discord, data.user.fediverse, data.user.tumblr, data.user.google] .map((entry) => (entry === null ? 0 : 1)) .reduce((prev, current) => prev + current) >= 2; @@ -41,6 +41,9 @@ let tumblrUnlinkModalOpen = false; let toggleTumblrUnlinkModal = () => (tumblrUnlinkModalOpen = !tumblrUnlinkModalOpen); + let googleUnlinkModalOpen = false; + let toggleGoogleUnlinkModal = () => (googleUnlinkModalOpen = !googleUnlinkModalOpen); + const fediLogin = async () => { fediDisabled = true; try { @@ -88,6 +91,17 @@ error = e as APIError; } }; + + const googleUnlink = async () => { + try { + const resp = await apiFetchClient("/auth/google/remove-provider", "POST"); + data.user = resp; + addToast({ header: "Unlinked account", body: "Successfully unlinked Google account!" }); + toggleGoogleUnlinkModal(); + } catch (e) { + error = e as APIError; + } + };
@@ -162,6 +176,28 @@
+
+ + + Google + + {#if data.user.google} + Your currently linked Google account is {data.user.google_username} + ({data.user.google}). + {:else} + You do not have a linked Google account. + {/if} + + {#if data.user.google} + + {:else} + + {/if} + + +
@@ -243,5 +279,27 @@ + + + +

+ Are you sure you want to unlink your Google account? You will no longer be able to use it + to log in. +

+ {#if error} +
+ +
+ {/if} +
+ + + + +
diff --git a/go.mod b/go.mod index b335781..9b0a4f9 100644 --- a/go.mod +++ b/go.mod @@ -24,18 +24,24 @@ require ( github.com/rubenv/sql-migrate v1.4.0 github.com/urfave/cli/v2 v2.25.1 go.uber.org/zap v1.24.0 - golang.org/x/oauth2 v0.6.0 + golang.org/x/oauth2 v0.7.0 + google.golang.org/api v0.118.0 ) require ( + cloud.google.com/go/compute v1.19.0 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect github.com/ajg/form v1.5.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/s2a-go v0.1.0 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -56,18 +62,20 @@ require ( github.com/prometheus/procfs v0.9.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.9.0 // indirect - github.com/stretchr/objx v0.5.0 // indirect github.com/tilinna/clock v1.1.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.7.0 // indirect golang.org/x/image v0.6.0 // indirect - golang.org/x/net v0.8.0 // indirect + golang.org/x/net v0.9.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/text v0.8.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd // indirect + google.golang.org/grpc v1.54.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 41cb19f..2513e3b 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,10 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/compute v1.19.0 h1:+9zda3WGgW1ZSTlVppLCYFIr48Pa35q1uG2N1itbCEQ= +cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= @@ -71,6 +75,7 @@ github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4Ho github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk= @@ -82,6 +87,10 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -110,6 +119,7 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= @@ -161,6 +171,7 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -220,12 +231,17 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.0 h1:3Qm0liEiCErViKERO2Su5wp+9PfMRiuS6XB5FvpKnYQ= +github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= +github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -463,6 +479,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao= github.com/tilinna/clock v1.1.0 h1:6IQQQCo6KoBxVudv6gwtY8o4eDfhHo8ojA5dP0MfhSs= @@ -493,6 +510,9 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= @@ -603,12 +623,13 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -621,8 +642,8 @@ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= -golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -688,6 +709,7 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -697,14 +719,14 @@ golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -717,8 +739,9 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -804,6 +827,8 @@ google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjR google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= +google.golang.org/api v0.118.0 h1:FNfHq9Z2GKULxu7cEhCaB0wWQHg43UpomrrN+24ZRdE= +google.golang.org/api v0.118.0/go.mod h1:76TtD3vkgmZ66zZzp72bUUklpmQmKlhh6sYtIjYK+5E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -853,6 +878,8 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd h1:sLpv7bNL1AsX3fdnWh9WVh7ejIzXdOc1RRHGeAmeStU= +google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -874,6 +901,9 @@ google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag= +google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/scripts/migrate/014_google_oauth.sql b/scripts/migrate/014_google_oauth.sql new file mode 100644 index 0000000..2fdb0fb --- /dev/null +++ b/scripts/migrate/014_google_oauth.sql @@ -0,0 +1,6 @@ +-- +migrate Up + +-- 2023-04-18: Add Google oauth + +alter table users add column google text null; +alter table users add column google_username text null; From f5d7bc409577d62240fd4d1cce829800fba59b62 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 18 Apr 2023 23:31:57 +0200 Subject: [PATCH 015/119] feat: only show auth providers if they're enabled --- backend/routes/auth/routes.go | 37 ++--- frontend/src/lib/api/responses.ts | 6 +- frontend/src/routes/auth/login/+page.svelte | 12 +- .../src/routes/settings/auth/+page.svelte | 126 +++++++++--------- 4 files changed, 98 insertions(+), 83 deletions(-) diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 7fec89c..1b7ad53 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -144,9 +144,9 @@ type oauthURLsRequest struct { } type oauthURLsResponse struct { - Discord string `json:"discord"` - Tumblr string `json:"tumblr"` - Google string `json:"google"` + Discord string `json:"discord,omitempty"` + Tumblr string `json:"tumblr,omitempty"` + Google string `json:"google,omitempty"` } func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { @@ -162,22 +162,25 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "setting CSRF state") } + var resp oauthURLsResponse - // copy Discord config and set redirect url - discordCfg := discordOAuthConfig - discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord" - // copy tumblr config - tumblrCfg := tumblrOAuthConfig - tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr" - // copy google config - googleCfg := googleOAuthConfig - googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google" + if discordOAuthConfig.ClientID != "" { + discordCfg := discordOAuthConfig + discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord" + resp.Discord = discordCfg.AuthCodeURL(state) + "&prompt=none" + } + if tumblrOAuthConfig.ClientID != "" { + tumblrCfg := tumblrOAuthConfig + tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr" + resp.Tumblr = tumblrCfg.AuthCodeURL(state) + } + if googleOAuthConfig.ClientID != "" { + googleCfg := googleOAuthConfig + googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google" + resp.Google = googleCfg.AuthCodeURL(state) + } - render.JSON(w, r, oauthURLsResponse{ - Discord: discordCfg.AuthCodeURL(state) + "&prompt=none", - Tumblr: tumblrCfg.AuthCodeURL(state), - Google: googleCfg.AuthCodeURL(state), - }) + render.JSON(w, r, resp) return nil } diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts index 561deba..9af4647 100644 --- a/frontend/src/lib/api/responses.ts +++ b/frontend/src/lib/api/responses.ts @@ -14,9 +14,9 @@ export interface MetaResponse { } export interface UrlsResponse { - discord: string; - tumblr: string; - google: string; + discord?: string; + tumblr?: string; + google?: string; } export interface ExportResponse { diff --git a/frontend/src/routes/auth/login/+page.svelte b/frontend/src/routes/auth/login/+page.svelte index c7ee00b..528f7c0 100644 --- a/frontend/src/routes/auth/login/+page.svelte +++ b/frontend/src/routes/auth/login/+page.svelte @@ -60,9 +60,15 @@
Log in with the Fediverse - Log in with Discord - Log in with Tumblr - Log in with Google + {#if data.discord} + Log in with Discord + {/if} + {#if data.tumblr} + Log in with Tumblr + {/if} + {#if data.google} + Log in with Google + {/if} diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte index 59dfb03..7c10704 100644 --- a/frontend/src/routes/settings/auth/+page.svelte +++ b/frontend/src/routes/settings/auth/+page.svelte @@ -132,72 +132,78 @@
-
- - - Discord - + {#if data.user.discord || data.urls.discord} +
+ + + Discord + + {#if data.user.discord} + Your currently linked Discord account is {data.user.discord_username} + ({data.user.discord}). + {:else} + You do not have a linked Discord account. + {/if} + {#if data.user.discord} - Your currently linked Discord account is {data.user.discord_username} - ({data.user.discord}). - {:else} - You do not have a linked Discord account. + + {:else if data.urls.discord} + {/if} - - {#if data.user.discord} - - {:else} - - {/if} - - -
-
- - - Tumblr - + + +
+ {/if} + {#if data.user.tumblr || data.urls.tumblr} +
+ + + Tumblr + + {#if data.user.tumblr} + Your currently linked Tumblr account is {data.user.tumblr_username} + ({data.user.tumblr}). + {:else} + You do not have a linked Tumblr account. + {/if} + {#if data.user.tumblr} - Your currently linked Tumblr account is {data.user.tumblr_username} - ({data.user.tumblr}). - {:else} - You do not have a linked Tumblr account. + + {:else if data.urls.tumblr} + {/if} - - {#if data.user.tumblr} - - {:else} - - {/if} - - -
-
- - - Google - + + +
+ {/if} + {#if data.user.google || data.urls.google} +
+ + + Google + + {#if data.user.google} + Your currently linked Google account is {data.user.google_username} + ({data.user.google}). + {:else} + You do not have a linked Google account. + {/if} + {#if data.user.google} - Your currently linked Google account is {data.user.google_username} - ({data.user.google}). - {:else} - You do not have a linked Google account. + + {:else if data.urls.google} + {/if} - - {#if data.user.google} - - {:else} - - {/if} - - -
+
+
+
+ {/if} From 661f0254fd01ba6c521407cca0aef0b35a15328d Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 19 Apr 2023 00:41:20 +0200 Subject: [PATCH 016/119] fix: log user out if token is invalid (fixes #50) --- frontend/src/lib/api/fetch.ts | 43 +++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/api/fetch.ts b/frontend/src/lib/api/fetch.ts index 20fa072..8448c75 100644 --- a/frontend/src/lib/api/fetch.ts +++ b/frontend/src/lib/api/fetch.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { APIError } from "./entities"; +import { ErrorCode, type APIError } from "./entities"; import { PUBLIC_BASE_URL } from "$env/static/public"; +import { addToast } from "$lib/toast"; +import { userStore } from "$lib/store"; export async function apiFetch( path: string, @@ -26,8 +28,24 @@ export async function apiFetch( return data as T; } -export const apiFetchClient = async (path: string, method = "GET", body: any = null) => - apiFetch(path, { method, body, token: localStorage.getItem("pronouns-token") || undefined }); +export const apiFetchClient = async (path: string, method = "GET", body: any = null) => { + try { + const data = await apiFetch(path, { + method, + body, + token: localStorage.getItem("pronouns-token") || undefined, + }); + return data; + } catch (e) { + if ((e as APIError).code === ErrorCode.InvalidToken) { + addToast({ header: "Token expired", body: "Your token has expired, please log in again." }); + userStore.set(null); + localStorage.removeItem("pronouns-token"); + localStorage.removeItem("pronouns-user"); + } + throw e; + } +}; /** Fetches the specified path without parsing the response body. */ export async function fastFetch( @@ -53,5 +71,20 @@ export async function fastFetch( } /** Fetches the specified path without parsing the response body. */ -export const fastFetchClient = async (path: string, method = "GET", body: any = null) => - fastFetch(path, { method, body, token: localStorage.getItem("pronouns-token") || undefined }); +export const fastFetchClient = async (path: string, method = "GET", body: any = null) => { + try { + await fastFetch(path, { + method, + body, + token: localStorage.getItem("pronouns-token") || undefined, + }); + } catch (e) { + if ((e as APIError).code === ErrorCode.InvalidToken) { + addToast({ header: "Token expired", body: "Your token has expired, please log in again." }); + userStore.set(null); + localStorage.removeItem("pronouns-token"); + localStorage.removeItem("pronouns-user"); + } + throw e; + } +}; From 86a1841f4f3f9526700e6d7a574856192a9adb0f Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Apr 2023 01:30:33 +0200 Subject: [PATCH 017/119] fix(backend): don't use redis GETDEL --- backend/db/db.go | 24 ------------------------ backend/routes/auth/undelete.go | 7 ++++++- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/backend/db/db.go b/backend/db/db.go index 25a214c..620e498 100644 --- a/backend/db/db.go +++ b/backend/db/db.go @@ -137,30 +137,6 @@ func (db *DB) GetJSON(ctx context.Context, key string, v any) error { return nil } -// GetDelJSON gets the given key as a JSON object and deletes it. -func (db *DB) GetDelJSON(ctx context.Context, key string, v any) error { - var b []byte - - err := db.Redis.Do(ctx, radix.Cmd(&b, "GETDEL", key)) - if err != nil { - return errors.Wrap(err, "reading from Redis") - } - - if b == nil { - return nil - } - - if v == nil { - return fmt.Errorf("nil pointer passed into GetDelJSON") - } - - err = json.Unmarshal(b, v) - if err != nil { - return errors.Wrap(err, "unmarshaling json") - } - return nil -} - // NotNull is a little helper that returns an *empty slice* when the slice's length is 0. // This is to prevent nil slices from being marshaled as JSON null func NotNull[T any](slice []T) []T { diff --git a/backend/routes/auth/undelete.go b/backend/routes/auth/undelete.go index a09cb36..5c8643e 100644 --- a/backend/routes/auth/undelete.go +++ b/backend/routes/auth/undelete.go @@ -67,7 +67,7 @@ func (s *Server) saveUndeleteToken(ctx context.Context, userID xid.ID, token str func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid.ID, err error) { var idString string - err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GETDEL", "undelete:"+token)) + err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GET", "undelete:"+token)) if err != nil { return userID, errors.Wrap(err, "getting undelete key") } @@ -76,6 +76,11 @@ func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid if err != nil { return userID, errors.Wrap(err, "parsing ID") } + + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "undelete:"+token)) + if err != nil { + return userID, errors.Wrap(err, "deleting undelete key") + } return userID, nil } From 7ea5efae93ccb2fc5b212ac62d8c7ae4dc50de20 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 19 Apr 2023 11:05:01 +0200 Subject: [PATCH 018/119] feat: start custom preferences on backend --- backend/db/user.go | 42 + backend/icons/icons.go | 1968 ++++++++++++++++++++ backend/routes/member/get_member.go | 18 +- backend/routes/user/get_user.go | 44 +- backend/routes/user/patch_user.go | 35 +- frontend/icons.js | 44 + frontend/src/icons.json | 1 + scripts/migrate/015_custom_preferences.sql | 5 + 8 files changed, 2118 insertions(+), 39 deletions(-) create mode 100644 backend/icons/icons.go create mode 100644 frontend/icons.js create mode 100644 frontend/src/icons.json create mode 100644 scripts/migrate/015_custom_preferences.sql diff --git a/backend/db/user.go b/backend/db/user.go index 7e1911f..dc27b12 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -4,9 +4,12 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "regexp" "time" + "codeberg.org/u1f320/pronouns.cc/backend/common" + "codeberg.org/u1f320/pronouns.cc/backend/icons" "emperror.dev/errors" "github.com/bwmarrin/discordgo" "github.com/georgysavva/scany/v2/pgxscan" @@ -49,8 +52,47 @@ type User struct { DeletedAt *time.Time SelfDelete *bool DeleteReason *string + + CustomPreferences CustomPreferences } +type CustomPreferences = map[string]CustomPreference + +type CustomPreference struct { + Icon string `json:"icon"` + Tooltip string `json:"tooltip"` + Size PreferenceSize `json:"size"` + Muted bool `json:"muted"` + Favourite bool `json:"favourite"` +} + +func (c CustomPreference) Validate() string { + if !icons.IsValid(c.Icon) { + return fmt.Sprintf("custom preference icon %q is invalid", c.Icon) + } + + if c.Tooltip == "" { + return "custom preference tooltip is empty" + } + if common.StringLength(&c.Tooltip) > FieldEntryMaxLength { + return fmt.Sprintf("custom preference tooltip is too long, max %d characters, is %d characters", FieldEntryMaxLength, common.StringLength(&c.Tooltip)) + } + + if c.Size != PreferenceSizeLarge && c.Size != PreferenceSizeNormal && c.Size != PreferenceSizeSmall { + return fmt.Sprintf("custom preference size %q is invalid", string(c.Size)) + } + + return "" +} + +type PreferenceSize string + +const ( + PreferenceSizeLarge PreferenceSize = "large" + PreferenceSizeNormal PreferenceSize = "normal" + PreferenceSizeSmall PreferenceSize = "small" +) + func (u User) NumProviders() (numProviders int) { if u.Discord != nil { numProviders++ diff --git a/backend/icons/icons.go b/backend/icons/icons.go new file mode 100644 index 0000000..3bc0e80 --- /dev/null +++ b/backend/icons/icons.go @@ -0,0 +1,1968 @@ +// Generated code. DO NOT EDIT +package icons + +var icons = [...]string{ + "123", + "alarm-fill", + "alarm", + "align-bottom", + "align-center", + "align-end", + "align-middle", + "align-start", + "align-top", + "alt", + "app-indicator", + "app", + "archive-fill", + "archive", + "arrow-90deg-down", + "arrow-90deg-left", + "arrow-90deg-right", + "arrow-90deg-up", + "arrow-bar-down", + "arrow-bar-left", + "arrow-bar-right", + "arrow-bar-up", + "arrow-clockwise", + "arrow-counterclockwise", + "arrow-down-circle-fill", + "arrow-down-circle", + "arrow-down-left-circle-fill", + "arrow-down-left-circle", + "arrow-down-left-square-fill", + "arrow-down-left-square", + "arrow-down-left", + "arrow-down-right-circle-fill", + "arrow-down-right-circle", + "arrow-down-right-square-fill", + "arrow-down-right-square", + "arrow-down-right", + "arrow-down-short", + "arrow-down-square-fill", + "arrow-down-square", + "arrow-down-up", + "arrow-down", + "arrow-left-circle-fill", + "arrow-left-circle", + "arrow-left-right", + "arrow-left-short", + "arrow-left-square-fill", + "arrow-left-square", + "arrow-left", + "arrow-repeat", + "arrow-return-left", + "arrow-return-right", + "arrow-right-circle-fill", + "arrow-right-circle", + "arrow-right-short", + "arrow-right-square-fill", + "arrow-right-square", + "arrow-right", + "arrow-up-circle-fill", + "arrow-up-circle", + "arrow-up-left-circle-fill", + "arrow-up-left-circle", + "arrow-up-left-square-fill", + "arrow-up-left-square", + "arrow-up-left", + "arrow-up-right-circle-fill", + "arrow-up-right-circle", + "arrow-up-right-square-fill", + "arrow-up-right-square", + "arrow-up-right", + "arrow-up-short", + "arrow-up-square-fill", + "arrow-up-square", + "arrow-up", + "arrows-angle-contract", + "arrows-angle-expand", + "arrows-collapse", + "arrows-expand", + "arrows-fullscreen", + "arrows-move", + "aspect-ratio-fill", + "aspect-ratio", + "asterisk", + "at", + "award-fill", + "award", + "back", + "backspace-fill", + "backspace-reverse-fill", + "backspace-reverse", + "backspace", + "badge-3d-fill", + "badge-3d", + "badge-4k-fill", + "badge-4k", + "badge-8k-fill", + "badge-8k", + "badge-ad-fill", + "badge-ad", + "badge-ar-fill", + "badge-ar", + "badge-cc-fill", + "badge-cc", + "badge-hd-fill", + "badge-hd", + "badge-tm-fill", + "badge-tm", + "badge-vo-fill", + "badge-vo", + "badge-vr-fill", + "badge-vr", + "badge-wc-fill", + "badge-wc", + "bag-check-fill", + "bag-check", + "bag-dash-fill", + "bag-dash", + "bag-fill", + "bag-plus-fill", + "bag-plus", + "bag-x-fill", + "bag-x", + "bag", + "bar-chart-fill", + "bar-chart-line-fill", + "bar-chart-line", + "bar-chart-steps", + "bar-chart", + "basket-fill", + "basket", + "basket2-fill", + "basket2", + "basket3-fill", + "basket3", + "battery-charging", + "battery-full", + "battery-half", + "battery", + "bell-fill", + "bell", + "bezier", + "bezier2", + "bicycle", + "binoculars-fill", + "binoculars", + "blockquote-left", + "blockquote-right", + "book-fill", + "book-half", + "book", + "bookmark-check-fill", + "bookmark-check", + "bookmark-dash-fill", + "bookmark-dash", + "bookmark-fill", + "bookmark-heart-fill", + "bookmark-heart", + "bookmark-plus-fill", + "bookmark-plus", + "bookmark-star-fill", + "bookmark-star", + "bookmark-x-fill", + "bookmark-x", + "bookmark", + "bookmarks-fill", + "bookmarks", + "bookshelf", + "bootstrap-fill", + "bootstrap-reboot", + "bootstrap", + "border-all", + "border-bottom", + "border-center", + "border-inner", + "border-left", + "border-middle", + "border-outer", + "border-right", + "border-style", + "border-top", + "border-width", + "border", + "bounding-box-circles", + "bounding-box", + "box-arrow-down-left", + "box-arrow-down-right", + "box-arrow-down", + "box-arrow-in-down-left", + "box-arrow-in-down-right", + "box-arrow-in-down", + "box-arrow-in-left", + "box-arrow-in-right", + "box-arrow-in-up-left", + "box-arrow-in-up-right", + "box-arrow-in-up", + "box-arrow-left", + "box-arrow-right", + "box-arrow-up-left", + "box-arrow-up-right", + "box-arrow-up", + "box-seam", + "box", + "braces", + "bricks", + "briefcase-fill", + "briefcase", + "brightness-alt-high-fill", + "brightness-alt-high", + "brightness-alt-low-fill", + "brightness-alt-low", + "brightness-high-fill", + "brightness-high", + "brightness-low-fill", + "brightness-low", + "broadcast-pin", + "broadcast", + "brush-fill", + "brush", + "bucket-fill", + "bucket", + "bug-fill", + "bug", + "building", + "bullseye", + "calculator-fill", + "calculator", + "calendar-check-fill", + "calendar-check", + "calendar-date-fill", + "calendar-date", + "calendar-day-fill", + "calendar-day", + "calendar-event-fill", + "calendar-event", + "calendar-fill", + "calendar-minus-fill", + "calendar-minus", + "calendar-month-fill", + "calendar-month", + "calendar-plus-fill", + "calendar-plus", + "calendar-range-fill", + "calendar-range", + "calendar-week-fill", + "calendar-week", + "calendar-x-fill", + "calendar-x", + "calendar", + "calendar2-check-fill", + "calendar2-check", + "calendar2-date-fill", + "calendar2-date", + "calendar2-day-fill", + "calendar2-day", + "calendar2-event-fill", + "calendar2-event", + "calendar2-fill", + "calendar2-minus-fill", + "calendar2-minus", + "calendar2-month-fill", + "calendar2-month", + "calendar2-plus-fill", + "calendar2-plus", + "calendar2-range-fill", + "calendar2-range", + "calendar2-week-fill", + "calendar2-week", + "calendar2-x-fill", + "calendar2-x", + "calendar2", + "calendar3-event-fill", + "calendar3-event", + "calendar3-fill", + "calendar3-range-fill", + "calendar3-range", + "calendar3-week-fill", + "calendar3-week", + "calendar3", + "calendar4-event", + "calendar4-range", + "calendar4-week", + "calendar4", + "camera-fill", + "camera-reels-fill", + "camera-reels", + "camera-video-fill", + "camera-video-off-fill", + "camera-video-off", + "camera-video", + "camera", + "camera2", + "capslock-fill", + "capslock", + "card-checklist", + "card-heading", + "card-image", + "card-list", + "card-text", + "caret-down-fill", + "caret-down-square-fill", + "caret-down-square", + "caret-down", + "caret-left-fill", + "caret-left-square-fill", + "caret-left-square", + "caret-left", + "caret-right-fill", + "caret-right-square-fill", + "caret-right-square", + "caret-right", + "caret-up-fill", + "caret-up-square-fill", + "caret-up-square", + "caret-up", + "cart-check-fill", + "cart-check", + "cart-dash-fill", + "cart-dash", + "cart-fill", + "cart-plus-fill", + "cart-plus", + "cart-x-fill", + "cart-x", + "cart", + "cart2", + "cart3", + "cart4", + "cash-stack", + "cash", + "cast", + "chat-dots-fill", + "chat-dots", + "chat-fill", + "chat-left-dots-fill", + "chat-left-dots", + "chat-left-fill", + "chat-left-quote-fill", + "chat-left-quote", + "chat-left-text-fill", + "chat-left-text", + "chat-left", + "chat-quote-fill", + "chat-quote", + "chat-right-dots-fill", + "chat-right-dots", + "chat-right-fill", + "chat-right-quote-fill", + "chat-right-quote", + "chat-right-text-fill", + "chat-right-text", + "chat-right", + "chat-square-dots-fill", + "chat-square-dots", + "chat-square-fill", + "chat-square-quote-fill", + "chat-square-quote", + "chat-square-text-fill", + "chat-square-text", + "chat-square", + "chat-text-fill", + "chat-text", + "chat", + "check-all", + "check-circle-fill", + "check-circle", + "check-square-fill", + "check-square", + "check", + "check2-all", + "check2-circle", + "check2-square", + "check2", + "chevron-bar-contract", + "chevron-bar-down", + "chevron-bar-expand", + "chevron-bar-left", + "chevron-bar-right", + "chevron-bar-up", + "chevron-compact-down", + "chevron-compact-left", + "chevron-compact-right", + "chevron-compact-up", + "chevron-contract", + "chevron-double-down", + "chevron-double-left", + "chevron-double-right", + "chevron-double-up", + "chevron-down", + "chevron-expand", + "chevron-left", + "chevron-right", + "chevron-up", + "circle-fill", + "circle-half", + "circle-square", + "circle", + "clipboard-check", + "clipboard-data", + "clipboard-minus", + "clipboard-plus", + "clipboard-x", + "clipboard", + "clock-fill", + "clock-history", + "clock", + "cloud-arrow-down-fill", + "cloud-arrow-down", + "cloud-arrow-up-fill", + "cloud-arrow-up", + "cloud-check-fill", + "cloud-check", + "cloud-download-fill", + "cloud-download", + "cloud-drizzle-fill", + "cloud-drizzle", + "cloud-fill", + "cloud-fog-fill", + "cloud-fog", + "cloud-fog2-fill", + "cloud-fog2", + "cloud-hail-fill", + "cloud-hail", + "cloud-haze-fill", + "cloud-haze", + "cloud-haze2-fill", + "cloud-lightning-fill", + "cloud-lightning-rain-fill", + "cloud-lightning-rain", + "cloud-lightning", + "cloud-minus-fill", + "cloud-minus", + "cloud-moon-fill", + "cloud-moon", + "cloud-plus-fill", + "cloud-plus", + "cloud-rain-fill", + "cloud-rain-heavy-fill", + "cloud-rain-heavy", + "cloud-rain", + "cloud-slash-fill", + "cloud-slash", + "cloud-sleet-fill", + "cloud-sleet", + "cloud-snow-fill", + "cloud-snow", + "cloud-sun-fill", + "cloud-sun", + "cloud-upload-fill", + "cloud-upload", + "cloud", + "clouds-fill", + "clouds", + "cloudy-fill", + "cloudy", + "code-slash", + "code-square", + "code", + "collection-fill", + "collection-play-fill", + "collection-play", + "collection", + "columns-gap", + "columns", + "command", + "compass-fill", + "compass", + "cone-striped", + "cone", + "controller", + "cpu-fill", + "cpu", + "credit-card-2-back-fill", + "credit-card-2-back", + "credit-card-2-front-fill", + "credit-card-2-front", + "credit-card-fill", + "credit-card", + "crop", + "cup-fill", + "cup-straw", + "cup", + "cursor-fill", + "cursor-text", + "cursor", + "dash-circle-dotted", + "dash-circle-fill", + "dash-circle", + "dash-square-dotted", + "dash-square-fill", + "dash-square", + "dash", + "diagram-2-fill", + "diagram-2", + "diagram-3-fill", + "diagram-3", + "diamond-fill", + "diamond-half", + "diamond", + "dice-1-fill", + "dice-1", + "dice-2-fill", + "dice-2", + "dice-3-fill", + "dice-3", + "dice-4-fill", + "dice-4", + "dice-5-fill", + "dice-5", + "dice-6-fill", + "dice-6", + "disc-fill", + "disc", + "discord", + "display-fill", + "display", + "distribute-horizontal", + "distribute-vertical", + "door-closed-fill", + "door-closed", + "door-open-fill", + "door-open", + "dot", + "download", + "droplet-fill", + "droplet-half", + "droplet", + "earbuds", + "easel-fill", + "easel", + "egg-fill", + "egg-fried", + "egg", + "eject-fill", + "eject", + "emoji-angry-fill", + "emoji-angry", + "emoji-dizzy-fill", + "emoji-dizzy", + "emoji-expressionless-fill", + "emoji-expressionless", + "emoji-frown-fill", + "emoji-frown", + "emoji-heart-eyes-fill", + "emoji-heart-eyes", + "emoji-laughing-fill", + "emoji-laughing", + "emoji-neutral-fill", + "emoji-neutral", + "emoji-smile-fill", + "emoji-smile-upside-down-fill", + "emoji-smile-upside-down", + "emoji-smile", + "emoji-sunglasses-fill", + "emoji-sunglasses", + "emoji-wink-fill", + "emoji-wink", + "envelope-fill", + "envelope-open-fill", + "envelope-open", + "envelope", + "eraser-fill", + "eraser", + "exclamation-circle-fill", + "exclamation-circle", + "exclamation-diamond-fill", + "exclamation-diamond", + "exclamation-octagon-fill", + "exclamation-octagon", + "exclamation-square-fill", + "exclamation-square", + "exclamation-triangle-fill", + "exclamation-triangle", + "exclamation", + "exclude", + "eye-fill", + "eye-slash-fill", + "eye-slash", + "eye", + "eyedropper", + "eyeglasses", + "facebook", + "file-arrow-down-fill", + "file-arrow-down", + "file-arrow-up-fill", + "file-arrow-up", + "file-bar-graph-fill", + "file-bar-graph", + "file-binary-fill", + "file-binary", + "file-break-fill", + "file-break", + "file-check-fill", + "file-check", + "file-code-fill", + "file-code", + "file-diff-fill", + "file-diff", + "file-earmark-arrow-down-fill", + "file-earmark-arrow-down", + "file-earmark-arrow-up-fill", + "file-earmark-arrow-up", + "file-earmark-bar-graph-fill", + "file-earmark-bar-graph", + "file-earmark-binary-fill", + "file-earmark-binary", + "file-earmark-break-fill", + "file-earmark-break", + "file-earmark-check-fill", + "file-earmark-check", + "file-earmark-code-fill", + "file-earmark-code", + "file-earmark-diff-fill", + "file-earmark-diff", + "file-earmark-easel-fill", + "file-earmark-easel", + "file-earmark-excel-fill", + "file-earmark-excel", + "file-earmark-fill", + "file-earmark-font-fill", + "file-earmark-font", + "file-earmark-image-fill", + "file-earmark-image", + "file-earmark-lock-fill", + "file-earmark-lock", + "file-earmark-lock2-fill", + "file-earmark-lock2", + "file-earmark-medical-fill", + "file-earmark-medical", + "file-earmark-minus-fill", + "file-earmark-minus", + "file-earmark-music-fill", + "file-earmark-music", + "file-earmark-person-fill", + "file-earmark-person", + "file-earmark-play-fill", + "file-earmark-play", + "file-earmark-plus-fill", + "file-earmark-plus", + "file-earmark-post-fill", + "file-earmark-post", + "file-earmark-ppt-fill", + "file-earmark-ppt", + "file-earmark-richtext-fill", + "file-earmark-richtext", + "file-earmark-ruled-fill", + "file-earmark-ruled", + "file-earmark-slides-fill", + "file-earmark-slides", + "file-earmark-spreadsheet-fill", + "file-earmark-spreadsheet", + "file-earmark-text-fill", + "file-earmark-text", + "file-earmark-word-fill", + "file-earmark-word", + "file-earmark-x-fill", + "file-earmark-x", + "file-earmark-zip-fill", + "file-earmark-zip", + "file-earmark", + "file-easel-fill", + "file-easel", + "file-excel-fill", + "file-excel", + "file-fill", + "file-font-fill", + "file-font", + "file-image-fill", + "file-image", + "file-lock-fill", + "file-lock", + "file-lock2-fill", + "file-lock2", + "file-medical-fill", + "file-medical", + "file-minus-fill", + "file-minus", + "file-music-fill", + "file-music", + "file-person-fill", + "file-person", + "file-play-fill", + "file-play", + "file-plus-fill", + "file-plus", + "file-post-fill", + "file-post", + "file-ppt-fill", + "file-ppt", + "file-richtext-fill", + "file-richtext", + "file-ruled-fill", + "file-ruled", + "file-slides-fill", + "file-slides", + "file-spreadsheet-fill", + "file-spreadsheet", + "file-text-fill", + "file-text", + "file-word-fill", + "file-word", + "file-x-fill", + "file-x", + "file-zip-fill", + "file-zip", + "file", + "files-alt", + "files", + "film", + "filter-circle-fill", + "filter-circle", + "filter-left", + "filter-right", + "filter-square-fill", + "filter-square", + "filter", + "flag-fill", + "flag", + "flower1", + "flower2", + "flower3", + "folder-check", + "folder-fill", + "folder-minus", + "folder-plus", + "folder-symlink-fill", + "folder-symlink", + "folder-x", + "folder", + "folder2-open", + "folder2", + "fonts", + "forward-fill", + "forward", + "front", + "fullscreen-exit", + "fullscreen", + "funnel-fill", + "funnel", + "gear-fill", + "gear-wide-connected", + "gear-wide", + "gear", + "gem", + "geo-alt-fill", + "geo-alt", + "geo-fill", + "geo", + "gift-fill", + "gift", + "github", + "globe", + "globe2", + "google", + "graph-down", + "graph-up", + "grid-1x2-fill", + "grid-1x2", + "grid-3x2-gap-fill", + "grid-3x2-gap", + "grid-3x2", + "grid-3x3-gap-fill", + "grid-3x3-gap", + "grid-3x3", + "grid-fill", + "grid", + "grip-horizontal", + "grip-vertical", + "hammer", + "hand-index-fill", + "hand-index-thumb-fill", + "hand-index-thumb", + "hand-index", + "hand-thumbs-down-fill", + "hand-thumbs-down", + "hand-thumbs-up-fill", + "hand-thumbs-up", + "handbag-fill", + "handbag", + "hash", + "hdd-fill", + "hdd-network-fill", + "hdd-network", + "hdd-rack-fill", + "hdd-rack", + "hdd-stack-fill", + "hdd-stack", + "hdd", + "headphones", + "headset", + "heart-fill", + "heart-half", + "heart", + "heptagon-fill", + "heptagon-half", + "heptagon", + "hexagon-fill", + "hexagon-half", + "hexagon", + "hourglass-bottom", + "hourglass-split", + "hourglass-top", + "hourglass", + "house-door-fill", + "house-door", + "house-fill", + "house", + "hr", + "hurricane", + "image-alt", + "image-fill", + "image", + "images", + "inbox-fill", + "inbox", + "inboxes-fill", + "inboxes", + "info-circle-fill", + "info-circle", + "info-square-fill", + "info-square", + "info", + "input-cursor-text", + "input-cursor", + "instagram", + "intersect", + "journal-album", + "journal-arrow-down", + "journal-arrow-up", + "journal-bookmark-fill", + "journal-bookmark", + "journal-check", + "journal-code", + "journal-medical", + "journal-minus", + "journal-plus", + "journal-richtext", + "journal-text", + "journal-x", + "journal", + "journals", + "joystick", + "justify-left", + "justify-right", + "justify", + "kanban-fill", + "kanban", + "key-fill", + "key", + "keyboard-fill", + "keyboard", + "ladder", + "lamp-fill", + "lamp", + "laptop-fill", + "laptop", + "layer-backward", + "layer-forward", + "layers-fill", + "layers-half", + "layers", + "layout-sidebar-inset-reverse", + "layout-sidebar-inset", + "layout-sidebar-reverse", + "layout-sidebar", + "layout-split", + "layout-text-sidebar-reverse", + "layout-text-sidebar", + "layout-text-window-reverse", + "layout-text-window", + "layout-three-columns", + "layout-wtf", + "life-preserver", + "lightbulb-fill", + "lightbulb-off-fill", + "lightbulb-off", + "lightbulb", + "lightning-charge-fill", + "lightning-charge", + "lightning-fill", + "lightning", + "link-45deg", + "link", + "linkedin", + "list-check", + "list-nested", + "list-ol", + "list-stars", + "list-task", + "list-ul", + "list", + "lock-fill", + "lock", + "mailbox", + "mailbox2", + "map-fill", + "map", + "markdown-fill", + "markdown", + "mask", + "megaphone-fill", + "megaphone", + "menu-app-fill", + "menu-app", + "menu-button-fill", + "menu-button-wide-fill", + "menu-button-wide", + "menu-button", + "menu-down", + "menu-up", + "mic-fill", + "mic-mute-fill", + "mic-mute", + "mic", + "minecart-loaded", + "minecart", + "moisture", + "moon-fill", + "moon-stars-fill", + "moon-stars", + "moon", + "mouse-fill", + "mouse", + "mouse2-fill", + "mouse2", + "mouse3-fill", + "mouse3", + "music-note-beamed", + "music-note-list", + "music-note", + "music-player-fill", + "music-player", + "newspaper", + "node-minus-fill", + "node-minus", + "node-plus-fill", + "node-plus", + "nut-fill", + "nut", + "octagon-fill", + "octagon-half", + "octagon", + "option", + "outlet", + "paint-bucket", + "palette-fill", + "palette", + "palette2", + "paperclip", + "paragraph", + "patch-check-fill", + "patch-check", + "patch-exclamation-fill", + "patch-exclamation", + "patch-minus-fill", + "patch-minus", + "patch-plus-fill", + "patch-plus", + "patch-question-fill", + "patch-question", + "pause-btn-fill", + "pause-btn", + "pause-circle-fill", + "pause-circle", + "pause-fill", + "pause", + "peace-fill", + "peace", + "pen-fill", + "pen", + "pencil-fill", + "pencil-square", + "pencil", + "pentagon-fill", + "pentagon-half", + "pentagon", + "people-fill", + "people", + "percent", + "person-badge-fill", + "person-badge", + "person-bounding-box", + "person-check-fill", + "person-check", + "person-circle", + "person-dash-fill", + "person-dash", + "person-fill", + "person-lines-fill", + "person-plus-fill", + "person-plus", + "person-square", + "person-x-fill", + "person-x", + "person", + "phone-fill", + "phone-landscape-fill", + "phone-landscape", + "phone-vibrate-fill", + "phone-vibrate", + "phone", + "pie-chart-fill", + "pie-chart", + "pin-angle-fill", + "pin-angle", + "pin-fill", + "pin", + "pip-fill", + "pip", + "play-btn-fill", + "play-btn", + "play-circle-fill", + "play-circle", + "play-fill", + "play", + "plug-fill", + "plug", + "plus-circle-dotted", + "plus-circle-fill", + "plus-circle", + "plus-square-dotted", + "plus-square-fill", + "plus-square", + "plus", + "power", + "printer-fill", + "printer", + "puzzle-fill", + "puzzle", + "question-circle-fill", + "question-circle", + "question-diamond-fill", + "question-diamond", + "question-octagon-fill", + "question-octagon", + "question-square-fill", + "question-square", + "question", + "rainbow", + "receipt-cutoff", + "receipt", + "reception-0", + "reception-1", + "reception-2", + "reception-3", + "reception-4", + "record-btn-fill", + "record-btn", + "record-circle-fill", + "record-circle", + "record-fill", + "record", + "record2-fill", + "record2", + "reply-all-fill", + "reply-all", + "reply-fill", + "reply", + "rss-fill", + "rss", + "rulers", + "save-fill", + "save", + "save2-fill", + "save2", + "scissors", + "screwdriver", + "search", + "segmented-nav", + "server", + "share-fill", + "share", + "shield-check", + "shield-exclamation", + "shield-fill-check", + "shield-fill-exclamation", + "shield-fill-minus", + "shield-fill-plus", + "shield-fill-x", + "shield-fill", + "shield-lock-fill", + "shield-lock", + "shield-minus", + "shield-plus", + "shield-shaded", + "shield-slash-fill", + "shield-slash", + "shield-x", + "shield", + "shift-fill", + "shift", + "shop-window", + "shop", + "shuffle", + "signpost-2-fill", + "signpost-2", + "signpost-fill", + "signpost-split-fill", + "signpost-split", + "signpost", + "sim-fill", + "sim", + "skip-backward-btn-fill", + "skip-backward-btn", + "skip-backward-circle-fill", + "skip-backward-circle", + "skip-backward-fill", + "skip-backward", + "skip-end-btn-fill", + "skip-end-btn", + "skip-end-circle-fill", + "skip-end-circle", + "skip-end-fill", + "skip-end", + "skip-forward-btn-fill", + "skip-forward-btn", + "skip-forward-circle-fill", + "skip-forward-circle", + "skip-forward-fill", + "skip-forward", + "skip-start-btn-fill", + "skip-start-btn", + "skip-start-circle-fill", + "skip-start-circle", + "skip-start-fill", + "skip-start", + "slack", + "slash-circle-fill", + "slash-circle", + "slash-square-fill", + "slash-square", + "slash", + "sliders", + "smartwatch", + "snow", + "snow2", + "snow3", + "sort-alpha-down-alt", + "sort-alpha-down", + "sort-alpha-up-alt", + "sort-alpha-up", + "sort-down-alt", + "sort-down", + "sort-numeric-down-alt", + "sort-numeric-down", + "sort-numeric-up-alt", + "sort-numeric-up", + "sort-up-alt", + "sort-up", + "soundwave", + "speaker-fill", + "speaker", + "speedometer", + "speedometer2", + "spellcheck", + "square-fill", + "square-half", + "square", + "stack", + "star-fill", + "star-half", + "star", + "stars", + "stickies-fill", + "stickies", + "sticky-fill", + "sticky", + "stop-btn-fill", + "stop-btn", + "stop-circle-fill", + "stop-circle", + "stop-fill", + "stop", + "stoplights-fill", + "stoplights", + "stopwatch-fill", + "stopwatch", + "subtract", + "suit-club-fill", + "suit-club", + "suit-diamond-fill", + "suit-diamond", + "suit-heart-fill", + "suit-heart", + "suit-spade-fill", + "suit-spade", + "sun-fill", + "sun", + "sunglasses", + "sunrise-fill", + "sunrise", + "sunset-fill", + "sunset", + "symmetry-horizontal", + "symmetry-vertical", + "table", + "tablet-fill", + "tablet-landscape-fill", + "tablet-landscape", + "tablet", + "tag-fill", + "tag", + "tags-fill", + "tags", + "telegram", + "telephone-fill", + "telephone-forward-fill", + "telephone-forward", + "telephone-inbound-fill", + "telephone-inbound", + "telephone-minus-fill", + "telephone-minus", + "telephone-outbound-fill", + "telephone-outbound", + "telephone-plus-fill", + "telephone-plus", + "telephone-x-fill", + "telephone-x", + "telephone", + "terminal-fill", + "terminal", + "text-center", + "text-indent-left", + "text-indent-right", + "text-left", + "text-paragraph", + "text-right", + "textarea-resize", + "textarea-t", + "textarea", + "thermometer-half", + "thermometer-high", + "thermometer-low", + "thermometer-snow", + "thermometer-sun", + "thermometer", + "three-dots-vertical", + "three-dots", + "toggle-off", + "toggle-on", + "toggle2-off", + "toggle2-on", + "toggles", + "toggles2", + "tools", + "tornado", + "trash-fill", + "trash", + "trash2-fill", + "trash2", + "tree-fill", + "tree", + "triangle-fill", + "triangle-half", + "triangle", + "trophy-fill", + "trophy", + "tropical-storm", + "truck-flatbed", + "truck", + "tsunami", + "tv-fill", + "tv", + "twitch", + "twitter", + "type-bold", + "type-h1", + "type-h2", + "type-h3", + "type-italic", + "type-strikethrough", + "type-underline", + "type", + "ui-checks-grid", + "ui-checks", + "ui-radios-grid", + "ui-radios", + "umbrella-fill", + "umbrella", + "union", + "unlock-fill", + "unlock", + "upc-scan", + "upc", + "upload", + "vector-pen", + "view-list", + "view-stacked", + "vinyl-fill", + "vinyl", + "voicemail", + "volume-down-fill", + "volume-down", + "volume-mute-fill", + "volume-mute", + "volume-off-fill", + "volume-off", + "volume-up-fill", + "volume-up", + "vr", + "wallet-fill", + "wallet", + "wallet2", + "watch", + "water", + "whatsapp", + "wifi-1", + "wifi-2", + "wifi-off", + "wifi", + "wind", + "window-dock", + "window-sidebar", + "window", + "wrench", + "x-circle-fill", + "x-circle", + "x-diamond-fill", + "x-diamond", + "x-octagon-fill", + "x-octagon", + "x-square-fill", + "x-square", + "x", + "youtube", + "zoom-in", + "zoom-out", + "bank", + "bank2", + "bell-slash-fill", + "bell-slash", + "cash-coin", + "check-lg", + "coin", + "currency-bitcoin", + "currency-dollar", + "currency-euro", + "currency-exchange", + "currency-pound", + "currency-yen", + "dash-lg", + "exclamation-lg", + "file-earmark-pdf-fill", + "file-earmark-pdf", + "file-pdf-fill", + "file-pdf", + "gender-ambiguous", + "gender-female", + "gender-male", + "gender-trans", + "headset-vr", + "info-lg", + "mastodon", + "messenger", + "piggy-bank-fill", + "piggy-bank", + "pin-map-fill", + "pin-map", + "plus-lg", + "question-lg", + "recycle", + "reddit", + "safe-fill", + "safe2-fill", + "safe2", + "sd-card-fill", + "sd-card", + "skype", + "slash-lg", + "translate", + "x-lg", + "safe", + "apple", + "microsoft", + "windows", + "behance", + "dribbble", + "line", + "medium", + "paypal", + "pinterest", + "signal", + "snapchat", + "spotify", + "stack-overflow", + "strava", + "wordpress", + "vimeo", + "activity", + "easel2-fill", + "easel2", + "easel3-fill", + "easel3", + "fan", + "fingerprint", + "graph-down-arrow", + "graph-up-arrow", + "hypnotize", + "magic", + "person-rolodex", + "person-video", + "person-video2", + "person-video3", + "person-workspace", + "radioactive", + "webcam-fill", + "webcam", + "yin-yang", + "bandaid-fill", + "bandaid", + "bluetooth", + "body-text", + "boombox", + "boxes", + "dpad-fill", + "dpad", + "ear-fill", + "ear", + "envelope-check-fill", + "envelope-check", + "envelope-dash-fill", + "envelope-dash", + "envelope-exclamation-fill", + "envelope-exclamation", + "envelope-plus-fill", + "envelope-plus", + "envelope-slash-fill", + "envelope-slash", + "envelope-x-fill", + "envelope-x", + "explicit-fill", + "explicit", + "git", + "infinity", + "list-columns-reverse", + "list-columns", + "meta", + "nintendo-switch", + "pc-display-horizontal", + "pc-display", + "pc-horizontal", + "pc", + "playstation", + "plus-slash-minus", + "projector-fill", + "projector", + "qr-code-scan", + "qr-code", + "quora", + "quote", + "robot", + "send-check-fill", + "send-check", + "send-dash-fill", + "send-dash", + "send-exclamation-fill", + "send-exclamation", + "send-fill", + "send-plus-fill", + "send-plus", + "send-slash-fill", + "send-slash", + "send-x-fill", + "send-x", + "send", + "steam", + "terminal-dash", + "terminal-plus", + "terminal-split", + "ticket-detailed-fill", + "ticket-detailed", + "ticket-fill", + "ticket-perforated-fill", + "ticket-perforated", + "ticket", + "tiktok", + "window-dash", + "window-desktop", + "window-fullscreen", + "window-plus", + "window-split", + "window-stack", + "window-x", + "xbox", + "ethernet", + "hdmi-fill", + "hdmi", + "usb-c-fill", + "usb-c", + "usb-fill", + "usb-plug-fill", + "usb-plug", + "usb-symbol", + "usb", + "boombox-fill", + "displayport", + "gpu-card", + "memory", + "modem-fill", + "modem", + "motherboard-fill", + "motherboard", + "optical-audio-fill", + "optical-audio", + "pci-card", + "router-fill", + "router", + "thunderbolt-fill", + "thunderbolt", + "usb-drive-fill", + "usb-drive", + "usb-micro-fill", + "usb-micro", + "usb-mini-fill", + "usb-mini", + "cloud-haze2", + "device-hdd-fill", + "device-hdd", + "device-ssd-fill", + "device-ssd", + "displayport-fill", + "mortarboard-fill", + "mortarboard", + "terminal-x", + "arrow-through-heart-fill", + "arrow-through-heart", + "badge-sd-fill", + "badge-sd", + "bag-heart-fill", + "bag-heart", + "balloon-fill", + "balloon-heart-fill", + "balloon-heart", + "balloon", + "box2-fill", + "box2-heart-fill", + "box2-heart", + "box2", + "braces-asterisk", + "calendar-heart-fill", + "calendar-heart", + "calendar2-heart-fill", + "calendar2-heart", + "chat-heart-fill", + "chat-heart", + "chat-left-heart-fill", + "chat-left-heart", + "chat-right-heart-fill", + "chat-right-heart", + "chat-square-heart-fill", + "chat-square-heart", + "clipboard-check-fill", + "clipboard-data-fill", + "clipboard-fill", + "clipboard-heart-fill", + "clipboard-heart", + "clipboard-minus-fill", + "clipboard-plus-fill", + "clipboard-pulse", + "clipboard-x-fill", + "clipboard2-check-fill", + "clipboard2-check", + "clipboard2-data-fill", + "clipboard2-data", + "clipboard2-fill", + "clipboard2-heart-fill", + "clipboard2-heart", + "clipboard2-minus-fill", + "clipboard2-minus", + "clipboard2-plus-fill", + "clipboard2-plus", + "clipboard2-pulse-fill", + "clipboard2-pulse", + "clipboard2-x-fill", + "clipboard2-x", + "clipboard2", + "emoji-kiss-fill", + "emoji-kiss", + "envelope-heart-fill", + "envelope-heart", + "envelope-open-heart-fill", + "envelope-open-heart", + "envelope-paper-fill", + "envelope-paper-heart-fill", + "envelope-paper-heart", + "envelope-paper", + "filetype-aac", + "filetype-ai", + "filetype-bmp", + "filetype-cs", + "filetype-css", + "filetype-csv", + "filetype-doc", + "filetype-docx", + "filetype-exe", + "filetype-gif", + "filetype-heic", + "filetype-html", + "filetype-java", + "filetype-jpg", + "filetype-js", + "filetype-jsx", + "filetype-key", + "filetype-m4p", + "filetype-md", + "filetype-mdx", + "filetype-mov", + "filetype-mp3", + "filetype-mp4", + "filetype-otf", + "filetype-pdf", + "filetype-php", + "filetype-png", + "filetype-ppt", + "filetype-psd", + "filetype-py", + "filetype-raw", + "filetype-rb", + "filetype-sass", + "filetype-scss", + "filetype-sh", + "filetype-svg", + "filetype-tiff", + "filetype-tsx", + "filetype-ttf", + "filetype-txt", + "filetype-wav", + "filetype-woff", + "filetype-xls", + "filetype-xml", + "filetype-yml", + "heart-arrow", + "heart-pulse-fill", + "heart-pulse", + "heartbreak-fill", + "heartbreak", + "hearts", + "hospital-fill", + "hospital", + "house-heart-fill", + "house-heart", + "incognito", + "magnet-fill", + "magnet", + "person-heart", + "person-hearts", + "phone-flip", + "plugin", + "postage-fill", + "postage-heart-fill", + "postage-heart", + "postage", + "postcard-fill", + "postcard-heart-fill", + "postcard-heart", + "postcard", + "search-heart-fill", + "search-heart", + "sliders2-vertical", + "sliders2", + "trash3-fill", + "trash3", + "valentine", + "valentine2", + "wrench-adjustable-circle-fill", + "wrench-adjustable-circle", + "wrench-adjustable", + "filetype-json", + "filetype-pptx", + "filetype-xlsx", + "1-circle-fill", + "1-circle", + "1-square-fill", + "1-square", + "2-circle-fill", + "2-circle", + "2-square-fill", + "2-square", + "3-circle-fill", + "3-circle", + "3-square-fill", + "3-square", + "4-circle-fill", + "4-circle", + "4-square-fill", + "4-square", + "5-circle-fill", + "5-circle", + "5-square-fill", + "5-square", + "6-circle-fill", + "6-circle", + "6-square-fill", + "6-square", + "7-circle-fill", + "7-circle", + "7-square-fill", + "7-square", + "8-circle-fill", + "8-circle", + "8-square-fill", + "8-square", + "9-circle-fill", + "9-circle", + "9-square-fill", + "9-square", + "airplane-engines-fill", + "airplane-engines", + "airplane-fill", + "airplane", + "alexa", + "alipay", + "android", + "android2", + "box-fill", + "box-seam-fill", + "browser-chrome", + "browser-edge", + "browser-firefox", + "browser-safari", + "c-circle-fill", + "c-circle", + "c-square-fill", + "c-square", + "capsule-pill", + "capsule", + "car-front-fill", + "car-front", + "cassette-fill", + "cassette", + "cc-circle-fill", + "cc-circle", + "cc-square-fill", + "cc-square", + "cup-hot-fill", + "cup-hot", + "currency-rupee", + "dropbox", + "escape", + "fast-forward-btn-fill", + "fast-forward-btn", + "fast-forward-circle-fill", + "fast-forward-circle", + "fast-forward-fill", + "fast-forward", + "filetype-sql", + "fire", + "google-play", + "h-circle-fill", + "h-circle", + "h-square-fill", + "h-square", + "indent", + "lungs-fill", + "lungs", + "microsoft-teams", + "p-circle-fill", + "p-circle", + "p-square-fill", + "p-square", + "pass-fill", + "pass", + "prescription", + "prescription2", + "r-circle-fill", + "r-circle", + "r-square-fill", + "r-square", + "repeat-1", + "repeat", + "rewind-btn-fill", + "rewind-btn", + "rewind-circle-fill", + "rewind-circle", + "rewind-fill", + "rewind", + "train-freight-front-fill", + "train-freight-front", + "train-front-fill", + "train-front", + "train-lightrail-front-fill", + "train-lightrail-front", + "truck-front-fill", + "truck-front", + "ubuntu", + "unindent", + "unity", + "universal-access-circle", + "universal-access", + "virus", + "virus2", + "wechat", + "yelp", + "sign-stop-fill", + "sign-stop-lights-fill", + "sign-stop-lights", + "sign-stop", + "sign-turn-left-fill", + "sign-turn-left", + "sign-turn-right-fill", + "sign-turn-right", + "sign-turn-slight-left-fill", + "sign-turn-slight-left", + "sign-turn-slight-right-fill", + "sign-turn-slight-right", + "sign-yield-fill", + "sign-yield", + "ev-station-fill", + "ev-station", + "fuel-pump-diesel-fill", + "fuel-pump-diesel", + "fuel-pump-fill", + "fuel-pump", + "0-circle-fill", + "0-circle", + "0-square-fill", + "0-square", + "rocket-fill", + "rocket-takeoff-fill", + "rocket-takeoff", + "rocket", + "stripe", + "subscript", + "superscript", + "trello", + "envelope-at-fill", + "envelope-at", + "regex", + "text-wrap", + "sign-dead-end-fill", + "sign-dead-end", + "sign-do-not-enter-fill", + "sign-do-not-enter", + "sign-intersection-fill", + "sign-intersection-side-fill", + "sign-intersection-side", + "sign-intersection-t-fill", + "sign-intersection-t", + "sign-intersection-y-fill", + "sign-intersection-y", + "sign-intersection", + "sign-merge-left-fill", + "sign-merge-left", + "sign-merge-right-fill", + "sign-merge-right", + "sign-no-left-turn-fill", + "sign-no-left-turn", + "sign-no-parking-fill", + "sign-no-parking", + "sign-no-right-turn-fill", + "sign-no-right-turn", + "sign-railroad-fill", + "sign-railroad", + "building-add", + "building-check", + "building-dash", + "building-down", + "building-exclamation", + "building-fill-add", + "building-fill-check", + "building-fill-dash", + "building-fill-down", + "building-fill-exclamation", + "building-fill-gear", + "building-fill-lock", + "building-fill-slash", + "building-fill-up", + "building-fill-x", + "building-fill", + "building-gear", + "building-lock", + "building-slash", + "building-up", + "building-x", + "buildings-fill", + "buildings", + "bus-front-fill", + "bus-front", + "ev-front-fill", + "ev-front", + "globe-americas", + "globe-asia-australia", + "globe-central-south-asia", + "globe-europe-africa", + "house-add-fill", + "house-add", + "house-check-fill", + "house-check", + "house-dash-fill", + "house-dash", + "house-down-fill", + "house-down", + "house-exclamation-fill", + "house-exclamation", + "house-gear-fill", + "house-gear", + "house-lock-fill", + "house-lock", + "house-slash-fill", + "house-slash", + "house-up-fill", + "house-up", + "house-x-fill", + "house-x", + "person-add", + "person-down", + "person-exclamation", + "person-fill-add", + "person-fill-check", + "person-fill-dash", + "person-fill-down", + "person-fill-exclamation", + "person-fill-gear", + "person-fill-lock", + "person-fill-slash", + "person-fill-up", + "person-fill-x", + "person-gear", + "person-lock", + "person-slash", + "person-up", + "scooter", + "taxi-front-fill", + "taxi-front", + "amd", + "database-add", + "database-check", + "database-dash", + "database-down", + "database-exclamation", + "database-fill-add", + "database-fill-check", + "database-fill-dash", + "database-fill-down", + "database-fill-exclamation", + "database-fill-gear", + "database-fill-lock", + "database-fill-slash", + "database-fill-up", + "database-fill-x", + "database-fill", + "database-gear", + "database-lock", + "database-slash", + "database-up", + "database-x", + "database", + "houses-fill", + "houses", + "nvidia", + "person-vcard-fill", + "person-vcard", + "sina-weibo", + "tencent-qq", + "wikipedia", +} + +// IsValid returns true if the input is the name of a Bootstrap icon. +func IsValid(name string) bool { + for i := range icons { + if icons[i] == name { + return true + } + } + return false +} diff --git a/backend/routes/member/get_member.go b/backend/routes/member/get_member.go index efe6284..1b44798 100644 --- a/backend/routes/member/get_member.go +++ b/backend/routes/member/get_member.go @@ -42,10 +42,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo Fields: db.NotNull(fields), User: PartialUser{ - ID: u.ID, - Username: u.Username, - DisplayName: u.DisplayName, - Avatar: u.Avatar, + ID: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + Avatar: u.Avatar, + CustomPreferences: u.CustomPreferences, }, } @@ -57,10 +58,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo } type PartialUser struct { - ID xid.ID `json:"id"` - Username string `json:"name"` - DisplayName *string `json:"display_name"` - Avatar *string `json:"avatar"` + ID xid.ID `json:"id"` + Username string `json:"name"` + DisplayName *string `json:"display_name"` + Avatar *string `json:"avatar"` + CustomPreferences db.CustomPreferences `json:"custom_preferences"` } func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index 8e36015..b19fe62 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -12,17 +12,18 @@ import ( ) type GetUserResponse struct { - ID xid.ID `json:"id"` - Username string `json:"name"` - DisplayName *string `json:"display_name"` - Bio *string `json:"bio"` - MemberTitle *string `json:"member_title"` - Avatar *string `json:"avatar"` - Links []string `json:"links"` - Names []db.FieldEntry `json:"names"` - Pronouns []db.PronounEntry `json:"pronouns"` - Members []PartialMember `json:"members"` - Fields []db.Field `json:"fields"` + ID xid.ID `json:"id"` + Username string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + MemberTitle *string `json:"member_title"` + Avatar *string `json:"avatar"` + Links []string `json:"links"` + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Members []PartialMember `json:"members"` + Fields []db.Field `json:"fields"` + CustomPreferences db.CustomPreferences `json:"custom_preferences"` } type GetMeResponse struct { @@ -59,16 +60,17 @@ type PartialMember struct { func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse { resp := GetUserResponse{ - ID: u.ID, - Username: u.Username, - DisplayName: u.DisplayName, - Bio: u.Bio, - MemberTitle: u.MemberTitle, - Avatar: u.Avatar, - Links: db.NotNull(u.Links), - Names: db.NotNull(u.Names), - Pronouns: db.NotNull(u.Pronouns), - Fields: db.NotNull(fields), + ID: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + Bio: u.Bio, + MemberTitle: u.MemberTitle, + Avatar: u.Avatar, + Links: db.NotNull(u.Links), + Names: db.NotNull(u.Names), + Pronouns: db.NotNull(u.Pronouns), + Fields: db.NotNull(fields), + CustomPreferences: u.CustomPreferences, } resp.Members = make([]PartialMember, len(members)) diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 0b2b41e..0d3f9f8 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -10,19 +10,21 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/google/uuid" ) type PatchUserRequest struct { - Username *string `json:"username"` - DisplayName *string `json:"display_name"` - Bio *string `json:"bio"` - MemberTitle *string `json:"member_title"` - Links *[]string `json:"links"` - Names *[]db.FieldEntry `json:"names"` - Pronouns *[]db.PronounEntry `json:"pronouns"` - Fields *[]db.Field `json:"fields"` - Avatar *string `json:"avatar"` - ListPrivate *bool `json:"list_private"` + Username *string `json:"username"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + MemberTitle *string `json:"member_title"` + Links *[]string `json:"links"` + Names *[]db.FieldEntry `json:"names"` + Pronouns *[]db.PronounEntry `json:"pronouns"` + Fields *[]db.Field `json:"fields"` + Avatar *string `json:"avatar"` + ListPrivate *bool `json:"list_private"` + CustomPreferences *db.CustomPreferences `json:"custom_preferences"` } // patchUser parses a PatchUserRequest and updates the user with the given ID. @@ -115,6 +117,19 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { return *err } + // validate custom preferences + if req.CustomPreferences != nil { + for k, v := range *req.CustomPreferences { + _, err := uuid.Parse(k) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."} + } + if s := v.Validate(); s != "" { + return server.APIError{Code: server.ErrBadRequest, Details: s} + } + } + } + // update avatar var avatarHash *string = nil if req.Avatar != nil { diff --git a/frontend/icons.js b/frontend/icons.js new file mode 100644 index 0000000..afae4fe --- /dev/null +++ b/frontend/icons.js @@ -0,0 +1,44 @@ +// This script regenerates the list of icons for the frontend (frontend/src/icons.json) +// and the backend (backend/icons/icons.go) from the currently installed version of Bootstrap Icons. +// Run with `pnpm node icons.js` in the frontend directory. + +import { writeFileSync } from "fs"; +import icons from "bootstrap-icons/font/bootstrap-icons.json" assert { type: "json" }; + +const keys = Object.keys(icons); + +console.log(`Found ${keys.length} icons`); +const output = JSON.stringify(keys); +console.log(`Saving file as src/icons.json`); + +writeFileSync("src/icons.json", output); + +const goCode1 = `// Generated code. DO NOT EDIT +package icons + +var icons = [...]string{ +`; + +const goCode2 = `} + +// IsValid returns true if the input is the name of a Bootstrap icon. +func IsValid(name string) bool { + for i := range icons { + if icons[i] == name { + return true + } + } + return false +} +`; + +let goOutput = goCode1; + +keys.forEach((element) => { + goOutput += ` "${element}",\n`; +}); + +goOutput += goCode2; + +console.log("Writing Go code"); +writeFileSync("../backend/icons/icons.go", goOutput); diff --git a/frontend/src/icons.json b/frontend/src/icons.json new file mode 100644 index 0000000..9911824 --- /dev/null +++ b/frontend/src/icons.json @@ -0,0 +1 @@ +["123","alarm-fill","alarm","align-bottom","align-center","align-end","align-middle","align-start","align-top","alt","app-indicator","app","archive-fill","archive","arrow-90deg-down","arrow-90deg-left","arrow-90deg-right","arrow-90deg-up","arrow-bar-down","arrow-bar-left","arrow-bar-right","arrow-bar-up","arrow-clockwise","arrow-counterclockwise","arrow-down-circle-fill","arrow-down-circle","arrow-down-left-circle-fill","arrow-down-left-circle","arrow-down-left-square-fill","arrow-down-left-square","arrow-down-left","arrow-down-right-circle-fill","arrow-down-right-circle","arrow-down-right-square-fill","arrow-down-right-square","arrow-down-right","arrow-down-short","arrow-down-square-fill","arrow-down-square","arrow-down-up","arrow-down","arrow-left-circle-fill","arrow-left-circle","arrow-left-right","arrow-left-short","arrow-left-square-fill","arrow-left-square","arrow-left","arrow-repeat","arrow-return-left","arrow-return-right","arrow-right-circle-fill","arrow-right-circle","arrow-right-short","arrow-right-square-fill","arrow-right-square","arrow-right","arrow-up-circle-fill","arrow-up-circle","arrow-up-left-circle-fill","arrow-up-left-circle","arrow-up-left-square-fill","arrow-up-left-square","arrow-up-left","arrow-up-right-circle-fill","arrow-up-right-circle","arrow-up-right-square-fill","arrow-up-right-square","arrow-up-right","arrow-up-short","arrow-up-square-fill","arrow-up-square","arrow-up","arrows-angle-contract","arrows-angle-expand","arrows-collapse","arrows-expand","arrows-fullscreen","arrows-move","aspect-ratio-fill","aspect-ratio","asterisk","at","award-fill","award","back","backspace-fill","backspace-reverse-fill","backspace-reverse","backspace","badge-3d-fill","badge-3d","badge-4k-fill","badge-4k","badge-8k-fill","badge-8k","badge-ad-fill","badge-ad","badge-ar-fill","badge-ar","badge-cc-fill","badge-cc","badge-hd-fill","badge-hd","badge-tm-fill","badge-tm","badge-vo-fill","badge-vo","badge-vr-fill","badge-vr","badge-wc-fill","badge-wc","bag-check-fill","bag-check","bag-dash-fill","bag-dash","bag-fill","bag-plus-fill","bag-plus","bag-x-fill","bag-x","bag","bar-chart-fill","bar-chart-line-fill","bar-chart-line","bar-chart-steps","bar-chart","basket-fill","basket","basket2-fill","basket2","basket3-fill","basket3","battery-charging","battery-full","battery-half","battery","bell-fill","bell","bezier","bezier2","bicycle","binoculars-fill","binoculars","blockquote-left","blockquote-right","book-fill","book-half","book","bookmark-check-fill","bookmark-check","bookmark-dash-fill","bookmark-dash","bookmark-fill","bookmark-heart-fill","bookmark-heart","bookmark-plus-fill","bookmark-plus","bookmark-star-fill","bookmark-star","bookmark-x-fill","bookmark-x","bookmark","bookmarks-fill","bookmarks","bookshelf","bootstrap-fill","bootstrap-reboot","bootstrap","border-all","border-bottom","border-center","border-inner","border-left","border-middle","border-outer","border-right","border-style","border-top","border-width","border","bounding-box-circles","bounding-box","box-arrow-down-left","box-arrow-down-right","box-arrow-down","box-arrow-in-down-left","box-arrow-in-down-right","box-arrow-in-down","box-arrow-in-left","box-arrow-in-right","box-arrow-in-up-left","box-arrow-in-up-right","box-arrow-in-up","box-arrow-left","box-arrow-right","box-arrow-up-left","box-arrow-up-right","box-arrow-up","box-seam","box","braces","bricks","briefcase-fill","briefcase","brightness-alt-high-fill","brightness-alt-high","brightness-alt-low-fill","brightness-alt-low","brightness-high-fill","brightness-high","brightness-low-fill","brightness-low","broadcast-pin","broadcast","brush-fill","brush","bucket-fill","bucket","bug-fill","bug","building","bullseye","calculator-fill","calculator","calendar-check-fill","calendar-check","calendar-date-fill","calendar-date","calendar-day-fill","calendar-day","calendar-event-fill","calendar-event","calendar-fill","calendar-minus-fill","calendar-minus","calendar-month-fill","calendar-month","calendar-plus-fill","calendar-plus","calendar-range-fill","calendar-range","calendar-week-fill","calendar-week","calendar-x-fill","calendar-x","calendar","calendar2-check-fill","calendar2-check","calendar2-date-fill","calendar2-date","calendar2-day-fill","calendar2-day","calendar2-event-fill","calendar2-event","calendar2-fill","calendar2-minus-fill","calendar2-minus","calendar2-month-fill","calendar2-month","calendar2-plus-fill","calendar2-plus","calendar2-range-fill","calendar2-range","calendar2-week-fill","calendar2-week","calendar2-x-fill","calendar2-x","calendar2","calendar3-event-fill","calendar3-event","calendar3-fill","calendar3-range-fill","calendar3-range","calendar3-week-fill","calendar3-week","calendar3","calendar4-event","calendar4-range","calendar4-week","calendar4","camera-fill","camera-reels-fill","camera-reels","camera-video-fill","camera-video-off-fill","camera-video-off","camera-video","camera","camera2","capslock-fill","capslock","card-checklist","card-heading","card-image","card-list","card-text","caret-down-fill","caret-down-square-fill","caret-down-square","caret-down","caret-left-fill","caret-left-square-fill","caret-left-square","caret-left","caret-right-fill","caret-right-square-fill","caret-right-square","caret-right","caret-up-fill","caret-up-square-fill","caret-up-square","caret-up","cart-check-fill","cart-check","cart-dash-fill","cart-dash","cart-fill","cart-plus-fill","cart-plus","cart-x-fill","cart-x","cart","cart2","cart3","cart4","cash-stack","cash","cast","chat-dots-fill","chat-dots","chat-fill","chat-left-dots-fill","chat-left-dots","chat-left-fill","chat-left-quote-fill","chat-left-quote","chat-left-text-fill","chat-left-text","chat-left","chat-quote-fill","chat-quote","chat-right-dots-fill","chat-right-dots","chat-right-fill","chat-right-quote-fill","chat-right-quote","chat-right-text-fill","chat-right-text","chat-right","chat-square-dots-fill","chat-square-dots","chat-square-fill","chat-square-quote-fill","chat-square-quote","chat-square-text-fill","chat-square-text","chat-square","chat-text-fill","chat-text","chat","check-all","check-circle-fill","check-circle","check-square-fill","check-square","check","check2-all","check2-circle","check2-square","check2","chevron-bar-contract","chevron-bar-down","chevron-bar-expand","chevron-bar-left","chevron-bar-right","chevron-bar-up","chevron-compact-down","chevron-compact-left","chevron-compact-right","chevron-compact-up","chevron-contract","chevron-double-down","chevron-double-left","chevron-double-right","chevron-double-up","chevron-down","chevron-expand","chevron-left","chevron-right","chevron-up","circle-fill","circle-half","circle-square","circle","clipboard-check","clipboard-data","clipboard-minus","clipboard-plus","clipboard-x","clipboard","clock-fill","clock-history","clock","cloud-arrow-down-fill","cloud-arrow-down","cloud-arrow-up-fill","cloud-arrow-up","cloud-check-fill","cloud-check","cloud-download-fill","cloud-download","cloud-drizzle-fill","cloud-drizzle","cloud-fill","cloud-fog-fill","cloud-fog","cloud-fog2-fill","cloud-fog2","cloud-hail-fill","cloud-hail","cloud-haze-fill","cloud-haze","cloud-haze2-fill","cloud-lightning-fill","cloud-lightning-rain-fill","cloud-lightning-rain","cloud-lightning","cloud-minus-fill","cloud-minus","cloud-moon-fill","cloud-moon","cloud-plus-fill","cloud-plus","cloud-rain-fill","cloud-rain-heavy-fill","cloud-rain-heavy","cloud-rain","cloud-slash-fill","cloud-slash","cloud-sleet-fill","cloud-sleet","cloud-snow-fill","cloud-snow","cloud-sun-fill","cloud-sun","cloud-upload-fill","cloud-upload","cloud","clouds-fill","clouds","cloudy-fill","cloudy","code-slash","code-square","code","collection-fill","collection-play-fill","collection-play","collection","columns-gap","columns","command","compass-fill","compass","cone-striped","cone","controller","cpu-fill","cpu","credit-card-2-back-fill","credit-card-2-back","credit-card-2-front-fill","credit-card-2-front","credit-card-fill","credit-card","crop","cup-fill","cup-straw","cup","cursor-fill","cursor-text","cursor","dash-circle-dotted","dash-circle-fill","dash-circle","dash-square-dotted","dash-square-fill","dash-square","dash","diagram-2-fill","diagram-2","diagram-3-fill","diagram-3","diamond-fill","diamond-half","diamond","dice-1-fill","dice-1","dice-2-fill","dice-2","dice-3-fill","dice-3","dice-4-fill","dice-4","dice-5-fill","dice-5","dice-6-fill","dice-6","disc-fill","disc","discord","display-fill","display","distribute-horizontal","distribute-vertical","door-closed-fill","door-closed","door-open-fill","door-open","dot","download","droplet-fill","droplet-half","droplet","earbuds","easel-fill","easel","egg-fill","egg-fried","egg","eject-fill","eject","emoji-angry-fill","emoji-angry","emoji-dizzy-fill","emoji-dizzy","emoji-expressionless-fill","emoji-expressionless","emoji-frown-fill","emoji-frown","emoji-heart-eyes-fill","emoji-heart-eyes","emoji-laughing-fill","emoji-laughing","emoji-neutral-fill","emoji-neutral","emoji-smile-fill","emoji-smile-upside-down-fill","emoji-smile-upside-down","emoji-smile","emoji-sunglasses-fill","emoji-sunglasses","emoji-wink-fill","emoji-wink","envelope-fill","envelope-open-fill","envelope-open","envelope","eraser-fill","eraser","exclamation-circle-fill","exclamation-circle","exclamation-diamond-fill","exclamation-diamond","exclamation-octagon-fill","exclamation-octagon","exclamation-square-fill","exclamation-square","exclamation-triangle-fill","exclamation-triangle","exclamation","exclude","eye-fill","eye-slash-fill","eye-slash","eye","eyedropper","eyeglasses","facebook","file-arrow-down-fill","file-arrow-down","file-arrow-up-fill","file-arrow-up","file-bar-graph-fill","file-bar-graph","file-binary-fill","file-binary","file-break-fill","file-break","file-check-fill","file-check","file-code-fill","file-code","file-diff-fill","file-diff","file-earmark-arrow-down-fill","file-earmark-arrow-down","file-earmark-arrow-up-fill","file-earmark-arrow-up","file-earmark-bar-graph-fill","file-earmark-bar-graph","file-earmark-binary-fill","file-earmark-binary","file-earmark-break-fill","file-earmark-break","file-earmark-check-fill","file-earmark-check","file-earmark-code-fill","file-earmark-code","file-earmark-diff-fill","file-earmark-diff","file-earmark-easel-fill","file-earmark-easel","file-earmark-excel-fill","file-earmark-excel","file-earmark-fill","file-earmark-font-fill","file-earmark-font","file-earmark-image-fill","file-earmark-image","file-earmark-lock-fill","file-earmark-lock","file-earmark-lock2-fill","file-earmark-lock2","file-earmark-medical-fill","file-earmark-medical","file-earmark-minus-fill","file-earmark-minus","file-earmark-music-fill","file-earmark-music","file-earmark-person-fill","file-earmark-person","file-earmark-play-fill","file-earmark-play","file-earmark-plus-fill","file-earmark-plus","file-earmark-post-fill","file-earmark-post","file-earmark-ppt-fill","file-earmark-ppt","file-earmark-richtext-fill","file-earmark-richtext","file-earmark-ruled-fill","file-earmark-ruled","file-earmark-slides-fill","file-earmark-slides","file-earmark-spreadsheet-fill","file-earmark-spreadsheet","file-earmark-text-fill","file-earmark-text","file-earmark-word-fill","file-earmark-word","file-earmark-x-fill","file-earmark-x","file-earmark-zip-fill","file-earmark-zip","file-earmark","file-easel-fill","file-easel","file-excel-fill","file-excel","file-fill","file-font-fill","file-font","file-image-fill","file-image","file-lock-fill","file-lock","file-lock2-fill","file-lock2","file-medical-fill","file-medical","file-minus-fill","file-minus","file-music-fill","file-music","file-person-fill","file-person","file-play-fill","file-play","file-plus-fill","file-plus","file-post-fill","file-post","file-ppt-fill","file-ppt","file-richtext-fill","file-richtext","file-ruled-fill","file-ruled","file-slides-fill","file-slides","file-spreadsheet-fill","file-spreadsheet","file-text-fill","file-text","file-word-fill","file-word","file-x-fill","file-x","file-zip-fill","file-zip","file","files-alt","files","film","filter-circle-fill","filter-circle","filter-left","filter-right","filter-square-fill","filter-square","filter","flag-fill","flag","flower1","flower2","flower3","folder-check","folder-fill","folder-minus","folder-plus","folder-symlink-fill","folder-symlink","folder-x","folder","folder2-open","folder2","fonts","forward-fill","forward","front","fullscreen-exit","fullscreen","funnel-fill","funnel","gear-fill","gear-wide-connected","gear-wide","gear","gem","geo-alt-fill","geo-alt","geo-fill","geo","gift-fill","gift","github","globe","globe2","google","graph-down","graph-up","grid-1x2-fill","grid-1x2","grid-3x2-gap-fill","grid-3x2-gap","grid-3x2","grid-3x3-gap-fill","grid-3x3-gap","grid-3x3","grid-fill","grid","grip-horizontal","grip-vertical","hammer","hand-index-fill","hand-index-thumb-fill","hand-index-thumb","hand-index","hand-thumbs-down-fill","hand-thumbs-down","hand-thumbs-up-fill","hand-thumbs-up","handbag-fill","handbag","hash","hdd-fill","hdd-network-fill","hdd-network","hdd-rack-fill","hdd-rack","hdd-stack-fill","hdd-stack","hdd","headphones","headset","heart-fill","heart-half","heart","heptagon-fill","heptagon-half","heptagon","hexagon-fill","hexagon-half","hexagon","hourglass-bottom","hourglass-split","hourglass-top","hourglass","house-door-fill","house-door","house-fill","house","hr","hurricane","image-alt","image-fill","image","images","inbox-fill","inbox","inboxes-fill","inboxes","info-circle-fill","info-circle","info-square-fill","info-square","info","input-cursor-text","input-cursor","instagram","intersect","journal-album","journal-arrow-down","journal-arrow-up","journal-bookmark-fill","journal-bookmark","journal-check","journal-code","journal-medical","journal-minus","journal-plus","journal-richtext","journal-text","journal-x","journal","journals","joystick","justify-left","justify-right","justify","kanban-fill","kanban","key-fill","key","keyboard-fill","keyboard","ladder","lamp-fill","lamp","laptop-fill","laptop","layer-backward","layer-forward","layers-fill","layers-half","layers","layout-sidebar-inset-reverse","layout-sidebar-inset","layout-sidebar-reverse","layout-sidebar","layout-split","layout-text-sidebar-reverse","layout-text-sidebar","layout-text-window-reverse","layout-text-window","layout-three-columns","layout-wtf","life-preserver","lightbulb-fill","lightbulb-off-fill","lightbulb-off","lightbulb","lightning-charge-fill","lightning-charge","lightning-fill","lightning","link-45deg","link","linkedin","list-check","list-nested","list-ol","list-stars","list-task","list-ul","list","lock-fill","lock","mailbox","mailbox2","map-fill","map","markdown-fill","markdown","mask","megaphone-fill","megaphone","menu-app-fill","menu-app","menu-button-fill","menu-button-wide-fill","menu-button-wide","menu-button","menu-down","menu-up","mic-fill","mic-mute-fill","mic-mute","mic","minecart-loaded","minecart","moisture","moon-fill","moon-stars-fill","moon-stars","moon","mouse-fill","mouse","mouse2-fill","mouse2","mouse3-fill","mouse3","music-note-beamed","music-note-list","music-note","music-player-fill","music-player","newspaper","node-minus-fill","node-minus","node-plus-fill","node-plus","nut-fill","nut","octagon-fill","octagon-half","octagon","option","outlet","paint-bucket","palette-fill","palette","palette2","paperclip","paragraph","patch-check-fill","patch-check","patch-exclamation-fill","patch-exclamation","patch-minus-fill","patch-minus","patch-plus-fill","patch-plus","patch-question-fill","patch-question","pause-btn-fill","pause-btn","pause-circle-fill","pause-circle","pause-fill","pause","peace-fill","peace","pen-fill","pen","pencil-fill","pencil-square","pencil","pentagon-fill","pentagon-half","pentagon","people-fill","people","percent","person-badge-fill","person-badge","person-bounding-box","person-check-fill","person-check","person-circle","person-dash-fill","person-dash","person-fill","person-lines-fill","person-plus-fill","person-plus","person-square","person-x-fill","person-x","person","phone-fill","phone-landscape-fill","phone-landscape","phone-vibrate-fill","phone-vibrate","phone","pie-chart-fill","pie-chart","pin-angle-fill","pin-angle","pin-fill","pin","pip-fill","pip","play-btn-fill","play-btn","play-circle-fill","play-circle","play-fill","play","plug-fill","plug","plus-circle-dotted","plus-circle-fill","plus-circle","plus-square-dotted","plus-square-fill","plus-square","plus","power","printer-fill","printer","puzzle-fill","puzzle","question-circle-fill","question-circle","question-diamond-fill","question-diamond","question-octagon-fill","question-octagon","question-square-fill","question-square","question","rainbow","receipt-cutoff","receipt","reception-0","reception-1","reception-2","reception-3","reception-4","record-btn-fill","record-btn","record-circle-fill","record-circle","record-fill","record","record2-fill","record2","reply-all-fill","reply-all","reply-fill","reply","rss-fill","rss","rulers","save-fill","save","save2-fill","save2","scissors","screwdriver","search","segmented-nav","server","share-fill","share","shield-check","shield-exclamation","shield-fill-check","shield-fill-exclamation","shield-fill-minus","shield-fill-plus","shield-fill-x","shield-fill","shield-lock-fill","shield-lock","shield-minus","shield-plus","shield-shaded","shield-slash-fill","shield-slash","shield-x","shield","shift-fill","shift","shop-window","shop","shuffle","signpost-2-fill","signpost-2","signpost-fill","signpost-split-fill","signpost-split","signpost","sim-fill","sim","skip-backward-btn-fill","skip-backward-btn","skip-backward-circle-fill","skip-backward-circle","skip-backward-fill","skip-backward","skip-end-btn-fill","skip-end-btn","skip-end-circle-fill","skip-end-circle","skip-end-fill","skip-end","skip-forward-btn-fill","skip-forward-btn","skip-forward-circle-fill","skip-forward-circle","skip-forward-fill","skip-forward","skip-start-btn-fill","skip-start-btn","skip-start-circle-fill","skip-start-circle","skip-start-fill","skip-start","slack","slash-circle-fill","slash-circle","slash-square-fill","slash-square","slash","sliders","smartwatch","snow","snow2","snow3","sort-alpha-down-alt","sort-alpha-down","sort-alpha-up-alt","sort-alpha-up","sort-down-alt","sort-down","sort-numeric-down-alt","sort-numeric-down","sort-numeric-up-alt","sort-numeric-up","sort-up-alt","sort-up","soundwave","speaker-fill","speaker","speedometer","speedometer2","spellcheck","square-fill","square-half","square","stack","star-fill","star-half","star","stars","stickies-fill","stickies","sticky-fill","sticky","stop-btn-fill","stop-btn","stop-circle-fill","stop-circle","stop-fill","stop","stoplights-fill","stoplights","stopwatch-fill","stopwatch","subtract","suit-club-fill","suit-club","suit-diamond-fill","suit-diamond","suit-heart-fill","suit-heart","suit-spade-fill","suit-spade","sun-fill","sun","sunglasses","sunrise-fill","sunrise","sunset-fill","sunset","symmetry-horizontal","symmetry-vertical","table","tablet-fill","tablet-landscape-fill","tablet-landscape","tablet","tag-fill","tag","tags-fill","tags","telegram","telephone-fill","telephone-forward-fill","telephone-forward","telephone-inbound-fill","telephone-inbound","telephone-minus-fill","telephone-minus","telephone-outbound-fill","telephone-outbound","telephone-plus-fill","telephone-plus","telephone-x-fill","telephone-x","telephone","terminal-fill","terminal","text-center","text-indent-left","text-indent-right","text-left","text-paragraph","text-right","textarea-resize","textarea-t","textarea","thermometer-half","thermometer-high","thermometer-low","thermometer-snow","thermometer-sun","thermometer","three-dots-vertical","three-dots","toggle-off","toggle-on","toggle2-off","toggle2-on","toggles","toggles2","tools","tornado","trash-fill","trash","trash2-fill","trash2","tree-fill","tree","triangle-fill","triangle-half","triangle","trophy-fill","trophy","tropical-storm","truck-flatbed","truck","tsunami","tv-fill","tv","twitch","twitter","type-bold","type-h1","type-h2","type-h3","type-italic","type-strikethrough","type-underline","type","ui-checks-grid","ui-checks","ui-radios-grid","ui-radios","umbrella-fill","umbrella","union","unlock-fill","unlock","upc-scan","upc","upload","vector-pen","view-list","view-stacked","vinyl-fill","vinyl","voicemail","volume-down-fill","volume-down","volume-mute-fill","volume-mute","volume-off-fill","volume-off","volume-up-fill","volume-up","vr","wallet-fill","wallet","wallet2","watch","water","whatsapp","wifi-1","wifi-2","wifi-off","wifi","wind","window-dock","window-sidebar","window","wrench","x-circle-fill","x-circle","x-diamond-fill","x-diamond","x-octagon-fill","x-octagon","x-square-fill","x-square","x","youtube","zoom-in","zoom-out","bank","bank2","bell-slash-fill","bell-slash","cash-coin","check-lg","coin","currency-bitcoin","currency-dollar","currency-euro","currency-exchange","currency-pound","currency-yen","dash-lg","exclamation-lg","file-earmark-pdf-fill","file-earmark-pdf","file-pdf-fill","file-pdf","gender-ambiguous","gender-female","gender-male","gender-trans","headset-vr","info-lg","mastodon","messenger","piggy-bank-fill","piggy-bank","pin-map-fill","pin-map","plus-lg","question-lg","recycle","reddit","safe-fill","safe2-fill","safe2","sd-card-fill","sd-card","skype","slash-lg","translate","x-lg","safe","apple","microsoft","windows","behance","dribbble","line","medium","paypal","pinterest","signal","snapchat","spotify","stack-overflow","strava","wordpress","vimeo","activity","easel2-fill","easel2","easel3-fill","easel3","fan","fingerprint","graph-down-arrow","graph-up-arrow","hypnotize","magic","person-rolodex","person-video","person-video2","person-video3","person-workspace","radioactive","webcam-fill","webcam","yin-yang","bandaid-fill","bandaid","bluetooth","body-text","boombox","boxes","dpad-fill","dpad","ear-fill","ear","envelope-check-fill","envelope-check","envelope-dash-fill","envelope-dash","envelope-exclamation-fill","envelope-exclamation","envelope-plus-fill","envelope-plus","envelope-slash-fill","envelope-slash","envelope-x-fill","envelope-x","explicit-fill","explicit","git","infinity","list-columns-reverse","list-columns","meta","nintendo-switch","pc-display-horizontal","pc-display","pc-horizontal","pc","playstation","plus-slash-minus","projector-fill","projector","qr-code-scan","qr-code","quora","quote","robot","send-check-fill","send-check","send-dash-fill","send-dash","send-exclamation-fill","send-exclamation","send-fill","send-plus-fill","send-plus","send-slash-fill","send-slash","send-x-fill","send-x","send","steam","terminal-dash","terminal-plus","terminal-split","ticket-detailed-fill","ticket-detailed","ticket-fill","ticket-perforated-fill","ticket-perforated","ticket","tiktok","window-dash","window-desktop","window-fullscreen","window-plus","window-split","window-stack","window-x","xbox","ethernet","hdmi-fill","hdmi","usb-c-fill","usb-c","usb-fill","usb-plug-fill","usb-plug","usb-symbol","usb","boombox-fill","displayport","gpu-card","memory","modem-fill","modem","motherboard-fill","motherboard","optical-audio-fill","optical-audio","pci-card","router-fill","router","thunderbolt-fill","thunderbolt","usb-drive-fill","usb-drive","usb-micro-fill","usb-micro","usb-mini-fill","usb-mini","cloud-haze2","device-hdd-fill","device-hdd","device-ssd-fill","device-ssd","displayport-fill","mortarboard-fill","mortarboard","terminal-x","arrow-through-heart-fill","arrow-through-heart","badge-sd-fill","badge-sd","bag-heart-fill","bag-heart","balloon-fill","balloon-heart-fill","balloon-heart","balloon","box2-fill","box2-heart-fill","box2-heart","box2","braces-asterisk","calendar-heart-fill","calendar-heart","calendar2-heart-fill","calendar2-heart","chat-heart-fill","chat-heart","chat-left-heart-fill","chat-left-heart","chat-right-heart-fill","chat-right-heart","chat-square-heart-fill","chat-square-heart","clipboard-check-fill","clipboard-data-fill","clipboard-fill","clipboard-heart-fill","clipboard-heart","clipboard-minus-fill","clipboard-plus-fill","clipboard-pulse","clipboard-x-fill","clipboard2-check-fill","clipboard2-check","clipboard2-data-fill","clipboard2-data","clipboard2-fill","clipboard2-heart-fill","clipboard2-heart","clipboard2-minus-fill","clipboard2-minus","clipboard2-plus-fill","clipboard2-plus","clipboard2-pulse-fill","clipboard2-pulse","clipboard2-x-fill","clipboard2-x","clipboard2","emoji-kiss-fill","emoji-kiss","envelope-heart-fill","envelope-heart","envelope-open-heart-fill","envelope-open-heart","envelope-paper-fill","envelope-paper-heart-fill","envelope-paper-heart","envelope-paper","filetype-aac","filetype-ai","filetype-bmp","filetype-cs","filetype-css","filetype-csv","filetype-doc","filetype-docx","filetype-exe","filetype-gif","filetype-heic","filetype-html","filetype-java","filetype-jpg","filetype-js","filetype-jsx","filetype-key","filetype-m4p","filetype-md","filetype-mdx","filetype-mov","filetype-mp3","filetype-mp4","filetype-otf","filetype-pdf","filetype-php","filetype-png","filetype-ppt","filetype-psd","filetype-py","filetype-raw","filetype-rb","filetype-sass","filetype-scss","filetype-sh","filetype-svg","filetype-tiff","filetype-tsx","filetype-ttf","filetype-txt","filetype-wav","filetype-woff","filetype-xls","filetype-xml","filetype-yml","heart-arrow","heart-pulse-fill","heart-pulse","heartbreak-fill","heartbreak","hearts","hospital-fill","hospital","house-heart-fill","house-heart","incognito","magnet-fill","magnet","person-heart","person-hearts","phone-flip","plugin","postage-fill","postage-heart-fill","postage-heart","postage","postcard-fill","postcard-heart-fill","postcard-heart","postcard","search-heart-fill","search-heart","sliders2-vertical","sliders2","trash3-fill","trash3","valentine","valentine2","wrench-adjustable-circle-fill","wrench-adjustable-circle","wrench-adjustable","filetype-json","filetype-pptx","filetype-xlsx","1-circle-fill","1-circle","1-square-fill","1-square","2-circle-fill","2-circle","2-square-fill","2-square","3-circle-fill","3-circle","3-square-fill","3-square","4-circle-fill","4-circle","4-square-fill","4-square","5-circle-fill","5-circle","5-square-fill","5-square","6-circle-fill","6-circle","6-square-fill","6-square","7-circle-fill","7-circle","7-square-fill","7-square","8-circle-fill","8-circle","8-square-fill","8-square","9-circle-fill","9-circle","9-square-fill","9-square","airplane-engines-fill","airplane-engines","airplane-fill","airplane","alexa","alipay","android","android2","box-fill","box-seam-fill","browser-chrome","browser-edge","browser-firefox","browser-safari","c-circle-fill","c-circle","c-square-fill","c-square","capsule-pill","capsule","car-front-fill","car-front","cassette-fill","cassette","cc-circle-fill","cc-circle","cc-square-fill","cc-square","cup-hot-fill","cup-hot","currency-rupee","dropbox","escape","fast-forward-btn-fill","fast-forward-btn","fast-forward-circle-fill","fast-forward-circle","fast-forward-fill","fast-forward","filetype-sql","fire","google-play","h-circle-fill","h-circle","h-square-fill","h-square","indent","lungs-fill","lungs","microsoft-teams","p-circle-fill","p-circle","p-square-fill","p-square","pass-fill","pass","prescription","prescription2","r-circle-fill","r-circle","r-square-fill","r-square","repeat-1","repeat","rewind-btn-fill","rewind-btn","rewind-circle-fill","rewind-circle","rewind-fill","rewind","train-freight-front-fill","train-freight-front","train-front-fill","train-front","train-lightrail-front-fill","train-lightrail-front","truck-front-fill","truck-front","ubuntu","unindent","unity","universal-access-circle","universal-access","virus","virus2","wechat","yelp","sign-stop-fill","sign-stop-lights-fill","sign-stop-lights","sign-stop","sign-turn-left-fill","sign-turn-left","sign-turn-right-fill","sign-turn-right","sign-turn-slight-left-fill","sign-turn-slight-left","sign-turn-slight-right-fill","sign-turn-slight-right","sign-yield-fill","sign-yield","ev-station-fill","ev-station","fuel-pump-diesel-fill","fuel-pump-diesel","fuel-pump-fill","fuel-pump","0-circle-fill","0-circle","0-square-fill","0-square","rocket-fill","rocket-takeoff-fill","rocket-takeoff","rocket","stripe","subscript","superscript","trello","envelope-at-fill","envelope-at","regex","text-wrap","sign-dead-end-fill","sign-dead-end","sign-do-not-enter-fill","sign-do-not-enter","sign-intersection-fill","sign-intersection-side-fill","sign-intersection-side","sign-intersection-t-fill","sign-intersection-t","sign-intersection-y-fill","sign-intersection-y","sign-intersection","sign-merge-left-fill","sign-merge-left","sign-merge-right-fill","sign-merge-right","sign-no-left-turn-fill","sign-no-left-turn","sign-no-parking-fill","sign-no-parking","sign-no-right-turn-fill","sign-no-right-turn","sign-railroad-fill","sign-railroad","building-add","building-check","building-dash","building-down","building-exclamation","building-fill-add","building-fill-check","building-fill-dash","building-fill-down","building-fill-exclamation","building-fill-gear","building-fill-lock","building-fill-slash","building-fill-up","building-fill-x","building-fill","building-gear","building-lock","building-slash","building-up","building-x","buildings-fill","buildings","bus-front-fill","bus-front","ev-front-fill","ev-front","globe-americas","globe-asia-australia","globe-central-south-asia","globe-europe-africa","house-add-fill","house-add","house-check-fill","house-check","house-dash-fill","house-dash","house-down-fill","house-down","house-exclamation-fill","house-exclamation","house-gear-fill","house-gear","house-lock-fill","house-lock","house-slash-fill","house-slash","house-up-fill","house-up","house-x-fill","house-x","person-add","person-down","person-exclamation","person-fill-add","person-fill-check","person-fill-dash","person-fill-down","person-fill-exclamation","person-fill-gear","person-fill-lock","person-fill-slash","person-fill-up","person-fill-x","person-gear","person-lock","person-slash","person-up","scooter","taxi-front-fill","taxi-front","amd","database-add","database-check","database-dash","database-down","database-exclamation","database-fill-add","database-fill-check","database-fill-dash","database-fill-down","database-fill-exclamation","database-fill-gear","database-fill-lock","database-fill-slash","database-fill-up","database-fill-x","database-fill","database-gear","database-lock","database-slash","database-up","database-x","database","houses-fill","houses","nvidia","person-vcard-fill","person-vcard","sina-weibo","tencent-qq","wikipedia"] \ No newline at end of file diff --git a/scripts/migrate/015_custom_preferences.sql b/scripts/migrate/015_custom_preferences.sql new file mode 100644 index 0000000..90f112b --- /dev/null +++ b/scripts/migrate/015_custom_preferences.sql @@ -0,0 +1,5 @@ +-- +migrate Up + +-- 2023-04-19: Add custom preferences + +alter table users add column custom_preferences jsonb not null default '{}'; From e8ea64226062ccf20f5e1135779f0182383de90a Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 19 Apr 2023 11:06:52 +0200 Subject: [PATCH 019/119] save frontend icons as ts instead --- frontend/icons.js | 4 ++-- frontend/src/icons.json | 1 - frontend/src/icons.ts | 2 ++ 3 files changed, 4 insertions(+), 3 deletions(-) delete mode 100644 frontend/src/icons.json create mode 100644 frontend/src/icons.ts diff --git a/frontend/icons.js b/frontend/icons.js index afae4fe..6b0e67f 100644 --- a/frontend/icons.js +++ b/frontend/icons.js @@ -9,9 +9,9 @@ const keys = Object.keys(icons); console.log(`Found ${keys.length} icons`); const output = JSON.stringify(keys); -console.log(`Saving file as src/icons.json`); +console.log(`Saving file as src/icons.ts`); -writeFileSync("src/icons.json", output); +writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`); const goCode1 = `// Generated code. DO NOT EDIT package icons diff --git a/frontend/src/icons.json b/frontend/src/icons.json deleted file mode 100644 index 9911824..0000000 --- a/frontend/src/icons.json +++ /dev/null @@ -1 +0,0 @@ -["123","alarm-fill","alarm","align-bottom","align-center","align-end","align-middle","align-start","align-top","alt","app-indicator","app","archive-fill","archive","arrow-90deg-down","arrow-90deg-left","arrow-90deg-right","arrow-90deg-up","arrow-bar-down","arrow-bar-left","arrow-bar-right","arrow-bar-up","arrow-clockwise","arrow-counterclockwise","arrow-down-circle-fill","arrow-down-circle","arrow-down-left-circle-fill","arrow-down-left-circle","arrow-down-left-square-fill","arrow-down-left-square","arrow-down-left","arrow-down-right-circle-fill","arrow-down-right-circle","arrow-down-right-square-fill","arrow-down-right-square","arrow-down-right","arrow-down-short","arrow-down-square-fill","arrow-down-square","arrow-down-up","arrow-down","arrow-left-circle-fill","arrow-left-circle","arrow-left-right","arrow-left-short","arrow-left-square-fill","arrow-left-square","arrow-left","arrow-repeat","arrow-return-left","arrow-return-right","arrow-right-circle-fill","arrow-right-circle","arrow-right-short","arrow-right-square-fill","arrow-right-square","arrow-right","arrow-up-circle-fill","arrow-up-circle","arrow-up-left-circle-fill","arrow-up-left-circle","arrow-up-left-square-fill","arrow-up-left-square","arrow-up-left","arrow-up-right-circle-fill","arrow-up-right-circle","arrow-up-right-square-fill","arrow-up-right-square","arrow-up-right","arrow-up-short","arrow-up-square-fill","arrow-up-square","arrow-up","arrows-angle-contract","arrows-angle-expand","arrows-collapse","arrows-expand","arrows-fullscreen","arrows-move","aspect-ratio-fill","aspect-ratio","asterisk","at","award-fill","award","back","backspace-fill","backspace-reverse-fill","backspace-reverse","backspace","badge-3d-fill","badge-3d","badge-4k-fill","badge-4k","badge-8k-fill","badge-8k","badge-ad-fill","badge-ad","badge-ar-fill","badge-ar","badge-cc-fill","badge-cc","badge-hd-fill","badge-hd","badge-tm-fill","badge-tm","badge-vo-fill","badge-vo","badge-vr-fill","badge-vr","badge-wc-fill","badge-wc","bag-check-fill","bag-check","bag-dash-fill","bag-dash","bag-fill","bag-plus-fill","bag-plus","bag-x-fill","bag-x","bag","bar-chart-fill","bar-chart-line-fill","bar-chart-line","bar-chart-steps","bar-chart","basket-fill","basket","basket2-fill","basket2","basket3-fill","basket3","battery-charging","battery-full","battery-half","battery","bell-fill","bell","bezier","bezier2","bicycle","binoculars-fill","binoculars","blockquote-left","blockquote-right","book-fill","book-half","book","bookmark-check-fill","bookmark-check","bookmark-dash-fill","bookmark-dash","bookmark-fill","bookmark-heart-fill","bookmark-heart","bookmark-plus-fill","bookmark-plus","bookmark-star-fill","bookmark-star","bookmark-x-fill","bookmark-x","bookmark","bookmarks-fill","bookmarks","bookshelf","bootstrap-fill","bootstrap-reboot","bootstrap","border-all","border-bottom","border-center","border-inner","border-left","border-middle","border-outer","border-right","border-style","border-top","border-width","border","bounding-box-circles","bounding-box","box-arrow-down-left","box-arrow-down-right","box-arrow-down","box-arrow-in-down-left","box-arrow-in-down-right","box-arrow-in-down","box-arrow-in-left","box-arrow-in-right","box-arrow-in-up-left","box-arrow-in-up-right","box-arrow-in-up","box-arrow-left","box-arrow-right","box-arrow-up-left","box-arrow-up-right","box-arrow-up","box-seam","box","braces","bricks","briefcase-fill","briefcase","brightness-alt-high-fill","brightness-alt-high","brightness-alt-low-fill","brightness-alt-low","brightness-high-fill","brightness-high","brightness-low-fill","brightness-low","broadcast-pin","broadcast","brush-fill","brush","bucket-fill","bucket","bug-fill","bug","building","bullseye","calculator-fill","calculator","calendar-check-fill","calendar-check","calendar-date-fill","calendar-date","calendar-day-fill","calendar-day","calendar-event-fill","calendar-event","calendar-fill","calendar-minus-fill","calendar-minus","calendar-month-fill","calendar-month","calendar-plus-fill","calendar-plus","calendar-range-fill","calendar-range","calendar-week-fill","calendar-week","calendar-x-fill","calendar-x","calendar","calendar2-check-fill","calendar2-check","calendar2-date-fill","calendar2-date","calendar2-day-fill","calendar2-day","calendar2-event-fill","calendar2-event","calendar2-fill","calendar2-minus-fill","calendar2-minus","calendar2-month-fill","calendar2-month","calendar2-plus-fill","calendar2-plus","calendar2-range-fill","calendar2-range","calendar2-week-fill","calendar2-week","calendar2-x-fill","calendar2-x","calendar2","calendar3-event-fill","calendar3-event","calendar3-fill","calendar3-range-fill","calendar3-range","calendar3-week-fill","calendar3-week","calendar3","calendar4-event","calendar4-range","calendar4-week","calendar4","camera-fill","camera-reels-fill","camera-reels","camera-video-fill","camera-video-off-fill","camera-video-off","camera-video","camera","camera2","capslock-fill","capslock","card-checklist","card-heading","card-image","card-list","card-text","caret-down-fill","caret-down-square-fill","caret-down-square","caret-down","caret-left-fill","caret-left-square-fill","caret-left-square","caret-left","caret-right-fill","caret-right-square-fill","caret-right-square","caret-right","caret-up-fill","caret-up-square-fill","caret-up-square","caret-up","cart-check-fill","cart-check","cart-dash-fill","cart-dash","cart-fill","cart-plus-fill","cart-plus","cart-x-fill","cart-x","cart","cart2","cart3","cart4","cash-stack","cash","cast","chat-dots-fill","chat-dots","chat-fill","chat-left-dots-fill","chat-left-dots","chat-left-fill","chat-left-quote-fill","chat-left-quote","chat-left-text-fill","chat-left-text","chat-left","chat-quote-fill","chat-quote","chat-right-dots-fill","chat-right-dots","chat-right-fill","chat-right-quote-fill","chat-right-quote","chat-right-text-fill","chat-right-text","chat-right","chat-square-dots-fill","chat-square-dots","chat-square-fill","chat-square-quote-fill","chat-square-quote","chat-square-text-fill","chat-square-text","chat-square","chat-text-fill","chat-text","chat","check-all","check-circle-fill","check-circle","check-square-fill","check-square","check","check2-all","check2-circle","check2-square","check2","chevron-bar-contract","chevron-bar-down","chevron-bar-expand","chevron-bar-left","chevron-bar-right","chevron-bar-up","chevron-compact-down","chevron-compact-left","chevron-compact-right","chevron-compact-up","chevron-contract","chevron-double-down","chevron-double-left","chevron-double-right","chevron-double-up","chevron-down","chevron-expand","chevron-left","chevron-right","chevron-up","circle-fill","circle-half","circle-square","circle","clipboard-check","clipboard-data","clipboard-minus","clipboard-plus","clipboard-x","clipboard","clock-fill","clock-history","clock","cloud-arrow-down-fill","cloud-arrow-down","cloud-arrow-up-fill","cloud-arrow-up","cloud-check-fill","cloud-check","cloud-download-fill","cloud-download","cloud-drizzle-fill","cloud-drizzle","cloud-fill","cloud-fog-fill","cloud-fog","cloud-fog2-fill","cloud-fog2","cloud-hail-fill","cloud-hail","cloud-haze-fill","cloud-haze","cloud-haze2-fill","cloud-lightning-fill","cloud-lightning-rain-fill","cloud-lightning-rain","cloud-lightning","cloud-minus-fill","cloud-minus","cloud-moon-fill","cloud-moon","cloud-plus-fill","cloud-plus","cloud-rain-fill","cloud-rain-heavy-fill","cloud-rain-heavy","cloud-rain","cloud-slash-fill","cloud-slash","cloud-sleet-fill","cloud-sleet","cloud-snow-fill","cloud-snow","cloud-sun-fill","cloud-sun","cloud-upload-fill","cloud-upload","cloud","clouds-fill","clouds","cloudy-fill","cloudy","code-slash","code-square","code","collection-fill","collection-play-fill","collection-play","collection","columns-gap","columns","command","compass-fill","compass","cone-striped","cone","controller","cpu-fill","cpu","credit-card-2-back-fill","credit-card-2-back","credit-card-2-front-fill","credit-card-2-front","credit-card-fill","credit-card","crop","cup-fill","cup-straw","cup","cursor-fill","cursor-text","cursor","dash-circle-dotted","dash-circle-fill","dash-circle","dash-square-dotted","dash-square-fill","dash-square","dash","diagram-2-fill","diagram-2","diagram-3-fill","diagram-3","diamond-fill","diamond-half","diamond","dice-1-fill","dice-1","dice-2-fill","dice-2","dice-3-fill","dice-3","dice-4-fill","dice-4","dice-5-fill","dice-5","dice-6-fill","dice-6","disc-fill","disc","discord","display-fill","display","distribute-horizontal","distribute-vertical","door-closed-fill","door-closed","door-open-fill","door-open","dot","download","droplet-fill","droplet-half","droplet","earbuds","easel-fill","easel","egg-fill","egg-fried","egg","eject-fill","eject","emoji-angry-fill","emoji-angry","emoji-dizzy-fill","emoji-dizzy","emoji-expressionless-fill","emoji-expressionless","emoji-frown-fill","emoji-frown","emoji-heart-eyes-fill","emoji-heart-eyes","emoji-laughing-fill","emoji-laughing","emoji-neutral-fill","emoji-neutral","emoji-smile-fill","emoji-smile-upside-down-fill","emoji-smile-upside-down","emoji-smile","emoji-sunglasses-fill","emoji-sunglasses","emoji-wink-fill","emoji-wink","envelope-fill","envelope-open-fill","envelope-open","envelope","eraser-fill","eraser","exclamation-circle-fill","exclamation-circle","exclamation-diamond-fill","exclamation-diamond","exclamation-octagon-fill","exclamation-octagon","exclamation-square-fill","exclamation-square","exclamation-triangle-fill","exclamation-triangle","exclamation","exclude","eye-fill","eye-slash-fill","eye-slash","eye","eyedropper","eyeglasses","facebook","file-arrow-down-fill","file-arrow-down","file-arrow-up-fill","file-arrow-up","file-bar-graph-fill","file-bar-graph","file-binary-fill","file-binary","file-break-fill","file-break","file-check-fill","file-check","file-code-fill","file-code","file-diff-fill","file-diff","file-earmark-arrow-down-fill","file-earmark-arrow-down","file-earmark-arrow-up-fill","file-earmark-arrow-up","file-earmark-bar-graph-fill","file-earmark-bar-graph","file-earmark-binary-fill","file-earmark-binary","file-earmark-break-fill","file-earmark-break","file-earmark-check-fill","file-earmark-check","file-earmark-code-fill","file-earmark-code","file-earmark-diff-fill","file-earmark-diff","file-earmark-easel-fill","file-earmark-easel","file-earmark-excel-fill","file-earmark-excel","file-earmark-fill","file-earmark-font-fill","file-earmark-font","file-earmark-image-fill","file-earmark-image","file-earmark-lock-fill","file-earmark-lock","file-earmark-lock2-fill","file-earmark-lock2","file-earmark-medical-fill","file-earmark-medical","file-earmark-minus-fill","file-earmark-minus","file-earmark-music-fill","file-earmark-music","file-earmark-person-fill","file-earmark-person","file-earmark-play-fill","file-earmark-play","file-earmark-plus-fill","file-earmark-plus","file-earmark-post-fill","file-earmark-post","file-earmark-ppt-fill","file-earmark-ppt","file-earmark-richtext-fill","file-earmark-richtext","file-earmark-ruled-fill","file-earmark-ruled","file-earmark-slides-fill","file-earmark-slides","file-earmark-spreadsheet-fill","file-earmark-spreadsheet","file-earmark-text-fill","file-earmark-text","file-earmark-word-fill","file-earmark-word","file-earmark-x-fill","file-earmark-x","file-earmark-zip-fill","file-earmark-zip","file-earmark","file-easel-fill","file-easel","file-excel-fill","file-excel","file-fill","file-font-fill","file-font","file-image-fill","file-image","file-lock-fill","file-lock","file-lock2-fill","file-lock2","file-medical-fill","file-medical","file-minus-fill","file-minus","file-music-fill","file-music","file-person-fill","file-person","file-play-fill","file-play","file-plus-fill","file-plus","file-post-fill","file-post","file-ppt-fill","file-ppt","file-richtext-fill","file-richtext","file-ruled-fill","file-ruled","file-slides-fill","file-slides","file-spreadsheet-fill","file-spreadsheet","file-text-fill","file-text","file-word-fill","file-word","file-x-fill","file-x","file-zip-fill","file-zip","file","files-alt","files","film","filter-circle-fill","filter-circle","filter-left","filter-right","filter-square-fill","filter-square","filter","flag-fill","flag","flower1","flower2","flower3","folder-check","folder-fill","folder-minus","folder-plus","folder-symlink-fill","folder-symlink","folder-x","folder","folder2-open","folder2","fonts","forward-fill","forward","front","fullscreen-exit","fullscreen","funnel-fill","funnel","gear-fill","gear-wide-connected","gear-wide","gear","gem","geo-alt-fill","geo-alt","geo-fill","geo","gift-fill","gift","github","globe","globe2","google","graph-down","graph-up","grid-1x2-fill","grid-1x2","grid-3x2-gap-fill","grid-3x2-gap","grid-3x2","grid-3x3-gap-fill","grid-3x3-gap","grid-3x3","grid-fill","grid","grip-horizontal","grip-vertical","hammer","hand-index-fill","hand-index-thumb-fill","hand-index-thumb","hand-index","hand-thumbs-down-fill","hand-thumbs-down","hand-thumbs-up-fill","hand-thumbs-up","handbag-fill","handbag","hash","hdd-fill","hdd-network-fill","hdd-network","hdd-rack-fill","hdd-rack","hdd-stack-fill","hdd-stack","hdd","headphones","headset","heart-fill","heart-half","heart","heptagon-fill","heptagon-half","heptagon","hexagon-fill","hexagon-half","hexagon","hourglass-bottom","hourglass-split","hourglass-top","hourglass","house-door-fill","house-door","house-fill","house","hr","hurricane","image-alt","image-fill","image","images","inbox-fill","inbox","inboxes-fill","inboxes","info-circle-fill","info-circle","info-square-fill","info-square","info","input-cursor-text","input-cursor","instagram","intersect","journal-album","journal-arrow-down","journal-arrow-up","journal-bookmark-fill","journal-bookmark","journal-check","journal-code","journal-medical","journal-minus","journal-plus","journal-richtext","journal-text","journal-x","journal","journals","joystick","justify-left","justify-right","justify","kanban-fill","kanban","key-fill","key","keyboard-fill","keyboard","ladder","lamp-fill","lamp","laptop-fill","laptop","layer-backward","layer-forward","layers-fill","layers-half","layers","layout-sidebar-inset-reverse","layout-sidebar-inset","layout-sidebar-reverse","layout-sidebar","layout-split","layout-text-sidebar-reverse","layout-text-sidebar","layout-text-window-reverse","layout-text-window","layout-three-columns","layout-wtf","life-preserver","lightbulb-fill","lightbulb-off-fill","lightbulb-off","lightbulb","lightning-charge-fill","lightning-charge","lightning-fill","lightning","link-45deg","link","linkedin","list-check","list-nested","list-ol","list-stars","list-task","list-ul","list","lock-fill","lock","mailbox","mailbox2","map-fill","map","markdown-fill","markdown","mask","megaphone-fill","megaphone","menu-app-fill","menu-app","menu-button-fill","menu-button-wide-fill","menu-button-wide","menu-button","menu-down","menu-up","mic-fill","mic-mute-fill","mic-mute","mic","minecart-loaded","minecart","moisture","moon-fill","moon-stars-fill","moon-stars","moon","mouse-fill","mouse","mouse2-fill","mouse2","mouse3-fill","mouse3","music-note-beamed","music-note-list","music-note","music-player-fill","music-player","newspaper","node-minus-fill","node-minus","node-plus-fill","node-plus","nut-fill","nut","octagon-fill","octagon-half","octagon","option","outlet","paint-bucket","palette-fill","palette","palette2","paperclip","paragraph","patch-check-fill","patch-check","patch-exclamation-fill","patch-exclamation","patch-minus-fill","patch-minus","patch-plus-fill","patch-plus","patch-question-fill","patch-question","pause-btn-fill","pause-btn","pause-circle-fill","pause-circle","pause-fill","pause","peace-fill","peace","pen-fill","pen","pencil-fill","pencil-square","pencil","pentagon-fill","pentagon-half","pentagon","people-fill","people","percent","person-badge-fill","person-badge","person-bounding-box","person-check-fill","person-check","person-circle","person-dash-fill","person-dash","person-fill","person-lines-fill","person-plus-fill","person-plus","person-square","person-x-fill","person-x","person","phone-fill","phone-landscape-fill","phone-landscape","phone-vibrate-fill","phone-vibrate","phone","pie-chart-fill","pie-chart","pin-angle-fill","pin-angle","pin-fill","pin","pip-fill","pip","play-btn-fill","play-btn","play-circle-fill","play-circle","play-fill","play","plug-fill","plug","plus-circle-dotted","plus-circle-fill","plus-circle","plus-square-dotted","plus-square-fill","plus-square","plus","power","printer-fill","printer","puzzle-fill","puzzle","question-circle-fill","question-circle","question-diamond-fill","question-diamond","question-octagon-fill","question-octagon","question-square-fill","question-square","question","rainbow","receipt-cutoff","receipt","reception-0","reception-1","reception-2","reception-3","reception-4","record-btn-fill","record-btn","record-circle-fill","record-circle","record-fill","record","record2-fill","record2","reply-all-fill","reply-all","reply-fill","reply","rss-fill","rss","rulers","save-fill","save","save2-fill","save2","scissors","screwdriver","search","segmented-nav","server","share-fill","share","shield-check","shield-exclamation","shield-fill-check","shield-fill-exclamation","shield-fill-minus","shield-fill-plus","shield-fill-x","shield-fill","shield-lock-fill","shield-lock","shield-minus","shield-plus","shield-shaded","shield-slash-fill","shield-slash","shield-x","shield","shift-fill","shift","shop-window","shop","shuffle","signpost-2-fill","signpost-2","signpost-fill","signpost-split-fill","signpost-split","signpost","sim-fill","sim","skip-backward-btn-fill","skip-backward-btn","skip-backward-circle-fill","skip-backward-circle","skip-backward-fill","skip-backward","skip-end-btn-fill","skip-end-btn","skip-end-circle-fill","skip-end-circle","skip-end-fill","skip-end","skip-forward-btn-fill","skip-forward-btn","skip-forward-circle-fill","skip-forward-circle","skip-forward-fill","skip-forward","skip-start-btn-fill","skip-start-btn","skip-start-circle-fill","skip-start-circle","skip-start-fill","skip-start","slack","slash-circle-fill","slash-circle","slash-square-fill","slash-square","slash","sliders","smartwatch","snow","snow2","snow3","sort-alpha-down-alt","sort-alpha-down","sort-alpha-up-alt","sort-alpha-up","sort-down-alt","sort-down","sort-numeric-down-alt","sort-numeric-down","sort-numeric-up-alt","sort-numeric-up","sort-up-alt","sort-up","soundwave","speaker-fill","speaker","speedometer","speedometer2","spellcheck","square-fill","square-half","square","stack","star-fill","star-half","star","stars","stickies-fill","stickies","sticky-fill","sticky","stop-btn-fill","stop-btn","stop-circle-fill","stop-circle","stop-fill","stop","stoplights-fill","stoplights","stopwatch-fill","stopwatch","subtract","suit-club-fill","suit-club","suit-diamond-fill","suit-diamond","suit-heart-fill","suit-heart","suit-spade-fill","suit-spade","sun-fill","sun","sunglasses","sunrise-fill","sunrise","sunset-fill","sunset","symmetry-horizontal","symmetry-vertical","table","tablet-fill","tablet-landscape-fill","tablet-landscape","tablet","tag-fill","tag","tags-fill","tags","telegram","telephone-fill","telephone-forward-fill","telephone-forward","telephone-inbound-fill","telephone-inbound","telephone-minus-fill","telephone-minus","telephone-outbound-fill","telephone-outbound","telephone-plus-fill","telephone-plus","telephone-x-fill","telephone-x","telephone","terminal-fill","terminal","text-center","text-indent-left","text-indent-right","text-left","text-paragraph","text-right","textarea-resize","textarea-t","textarea","thermometer-half","thermometer-high","thermometer-low","thermometer-snow","thermometer-sun","thermometer","three-dots-vertical","three-dots","toggle-off","toggle-on","toggle2-off","toggle2-on","toggles","toggles2","tools","tornado","trash-fill","trash","trash2-fill","trash2","tree-fill","tree","triangle-fill","triangle-half","triangle","trophy-fill","trophy","tropical-storm","truck-flatbed","truck","tsunami","tv-fill","tv","twitch","twitter","type-bold","type-h1","type-h2","type-h3","type-italic","type-strikethrough","type-underline","type","ui-checks-grid","ui-checks","ui-radios-grid","ui-radios","umbrella-fill","umbrella","union","unlock-fill","unlock","upc-scan","upc","upload","vector-pen","view-list","view-stacked","vinyl-fill","vinyl","voicemail","volume-down-fill","volume-down","volume-mute-fill","volume-mute","volume-off-fill","volume-off","volume-up-fill","volume-up","vr","wallet-fill","wallet","wallet2","watch","water","whatsapp","wifi-1","wifi-2","wifi-off","wifi","wind","window-dock","window-sidebar","window","wrench","x-circle-fill","x-circle","x-diamond-fill","x-diamond","x-octagon-fill","x-octagon","x-square-fill","x-square","x","youtube","zoom-in","zoom-out","bank","bank2","bell-slash-fill","bell-slash","cash-coin","check-lg","coin","currency-bitcoin","currency-dollar","currency-euro","currency-exchange","currency-pound","currency-yen","dash-lg","exclamation-lg","file-earmark-pdf-fill","file-earmark-pdf","file-pdf-fill","file-pdf","gender-ambiguous","gender-female","gender-male","gender-trans","headset-vr","info-lg","mastodon","messenger","piggy-bank-fill","piggy-bank","pin-map-fill","pin-map","plus-lg","question-lg","recycle","reddit","safe-fill","safe2-fill","safe2","sd-card-fill","sd-card","skype","slash-lg","translate","x-lg","safe","apple","microsoft","windows","behance","dribbble","line","medium","paypal","pinterest","signal","snapchat","spotify","stack-overflow","strava","wordpress","vimeo","activity","easel2-fill","easel2","easel3-fill","easel3","fan","fingerprint","graph-down-arrow","graph-up-arrow","hypnotize","magic","person-rolodex","person-video","person-video2","person-video3","person-workspace","radioactive","webcam-fill","webcam","yin-yang","bandaid-fill","bandaid","bluetooth","body-text","boombox","boxes","dpad-fill","dpad","ear-fill","ear","envelope-check-fill","envelope-check","envelope-dash-fill","envelope-dash","envelope-exclamation-fill","envelope-exclamation","envelope-plus-fill","envelope-plus","envelope-slash-fill","envelope-slash","envelope-x-fill","envelope-x","explicit-fill","explicit","git","infinity","list-columns-reverse","list-columns","meta","nintendo-switch","pc-display-horizontal","pc-display","pc-horizontal","pc","playstation","plus-slash-minus","projector-fill","projector","qr-code-scan","qr-code","quora","quote","robot","send-check-fill","send-check","send-dash-fill","send-dash","send-exclamation-fill","send-exclamation","send-fill","send-plus-fill","send-plus","send-slash-fill","send-slash","send-x-fill","send-x","send","steam","terminal-dash","terminal-plus","terminal-split","ticket-detailed-fill","ticket-detailed","ticket-fill","ticket-perforated-fill","ticket-perforated","ticket","tiktok","window-dash","window-desktop","window-fullscreen","window-plus","window-split","window-stack","window-x","xbox","ethernet","hdmi-fill","hdmi","usb-c-fill","usb-c","usb-fill","usb-plug-fill","usb-plug","usb-symbol","usb","boombox-fill","displayport","gpu-card","memory","modem-fill","modem","motherboard-fill","motherboard","optical-audio-fill","optical-audio","pci-card","router-fill","router","thunderbolt-fill","thunderbolt","usb-drive-fill","usb-drive","usb-micro-fill","usb-micro","usb-mini-fill","usb-mini","cloud-haze2","device-hdd-fill","device-hdd","device-ssd-fill","device-ssd","displayport-fill","mortarboard-fill","mortarboard","terminal-x","arrow-through-heart-fill","arrow-through-heart","badge-sd-fill","badge-sd","bag-heart-fill","bag-heart","balloon-fill","balloon-heart-fill","balloon-heart","balloon","box2-fill","box2-heart-fill","box2-heart","box2","braces-asterisk","calendar-heart-fill","calendar-heart","calendar2-heart-fill","calendar2-heart","chat-heart-fill","chat-heart","chat-left-heart-fill","chat-left-heart","chat-right-heart-fill","chat-right-heart","chat-square-heart-fill","chat-square-heart","clipboard-check-fill","clipboard-data-fill","clipboard-fill","clipboard-heart-fill","clipboard-heart","clipboard-minus-fill","clipboard-plus-fill","clipboard-pulse","clipboard-x-fill","clipboard2-check-fill","clipboard2-check","clipboard2-data-fill","clipboard2-data","clipboard2-fill","clipboard2-heart-fill","clipboard2-heart","clipboard2-minus-fill","clipboard2-minus","clipboard2-plus-fill","clipboard2-plus","clipboard2-pulse-fill","clipboard2-pulse","clipboard2-x-fill","clipboard2-x","clipboard2","emoji-kiss-fill","emoji-kiss","envelope-heart-fill","envelope-heart","envelope-open-heart-fill","envelope-open-heart","envelope-paper-fill","envelope-paper-heart-fill","envelope-paper-heart","envelope-paper","filetype-aac","filetype-ai","filetype-bmp","filetype-cs","filetype-css","filetype-csv","filetype-doc","filetype-docx","filetype-exe","filetype-gif","filetype-heic","filetype-html","filetype-java","filetype-jpg","filetype-js","filetype-jsx","filetype-key","filetype-m4p","filetype-md","filetype-mdx","filetype-mov","filetype-mp3","filetype-mp4","filetype-otf","filetype-pdf","filetype-php","filetype-png","filetype-ppt","filetype-psd","filetype-py","filetype-raw","filetype-rb","filetype-sass","filetype-scss","filetype-sh","filetype-svg","filetype-tiff","filetype-tsx","filetype-ttf","filetype-txt","filetype-wav","filetype-woff","filetype-xls","filetype-xml","filetype-yml","heart-arrow","heart-pulse-fill","heart-pulse","heartbreak-fill","heartbreak","hearts","hospital-fill","hospital","house-heart-fill","house-heart","incognito","magnet-fill","magnet","person-heart","person-hearts","phone-flip","plugin","postage-fill","postage-heart-fill","postage-heart","postage","postcard-fill","postcard-heart-fill","postcard-heart","postcard","search-heart-fill","search-heart","sliders2-vertical","sliders2","trash3-fill","trash3","valentine","valentine2","wrench-adjustable-circle-fill","wrench-adjustable-circle","wrench-adjustable","filetype-json","filetype-pptx","filetype-xlsx","1-circle-fill","1-circle","1-square-fill","1-square","2-circle-fill","2-circle","2-square-fill","2-square","3-circle-fill","3-circle","3-square-fill","3-square","4-circle-fill","4-circle","4-square-fill","4-square","5-circle-fill","5-circle","5-square-fill","5-square","6-circle-fill","6-circle","6-square-fill","6-square","7-circle-fill","7-circle","7-square-fill","7-square","8-circle-fill","8-circle","8-square-fill","8-square","9-circle-fill","9-circle","9-square-fill","9-square","airplane-engines-fill","airplane-engines","airplane-fill","airplane","alexa","alipay","android","android2","box-fill","box-seam-fill","browser-chrome","browser-edge","browser-firefox","browser-safari","c-circle-fill","c-circle","c-square-fill","c-square","capsule-pill","capsule","car-front-fill","car-front","cassette-fill","cassette","cc-circle-fill","cc-circle","cc-square-fill","cc-square","cup-hot-fill","cup-hot","currency-rupee","dropbox","escape","fast-forward-btn-fill","fast-forward-btn","fast-forward-circle-fill","fast-forward-circle","fast-forward-fill","fast-forward","filetype-sql","fire","google-play","h-circle-fill","h-circle","h-square-fill","h-square","indent","lungs-fill","lungs","microsoft-teams","p-circle-fill","p-circle","p-square-fill","p-square","pass-fill","pass","prescription","prescription2","r-circle-fill","r-circle","r-square-fill","r-square","repeat-1","repeat","rewind-btn-fill","rewind-btn","rewind-circle-fill","rewind-circle","rewind-fill","rewind","train-freight-front-fill","train-freight-front","train-front-fill","train-front","train-lightrail-front-fill","train-lightrail-front","truck-front-fill","truck-front","ubuntu","unindent","unity","universal-access-circle","universal-access","virus","virus2","wechat","yelp","sign-stop-fill","sign-stop-lights-fill","sign-stop-lights","sign-stop","sign-turn-left-fill","sign-turn-left","sign-turn-right-fill","sign-turn-right","sign-turn-slight-left-fill","sign-turn-slight-left","sign-turn-slight-right-fill","sign-turn-slight-right","sign-yield-fill","sign-yield","ev-station-fill","ev-station","fuel-pump-diesel-fill","fuel-pump-diesel","fuel-pump-fill","fuel-pump","0-circle-fill","0-circle","0-square-fill","0-square","rocket-fill","rocket-takeoff-fill","rocket-takeoff","rocket","stripe","subscript","superscript","trello","envelope-at-fill","envelope-at","regex","text-wrap","sign-dead-end-fill","sign-dead-end","sign-do-not-enter-fill","sign-do-not-enter","sign-intersection-fill","sign-intersection-side-fill","sign-intersection-side","sign-intersection-t-fill","sign-intersection-t","sign-intersection-y-fill","sign-intersection-y","sign-intersection","sign-merge-left-fill","sign-merge-left","sign-merge-right-fill","sign-merge-right","sign-no-left-turn-fill","sign-no-left-turn","sign-no-parking-fill","sign-no-parking","sign-no-right-turn-fill","sign-no-right-turn","sign-railroad-fill","sign-railroad","building-add","building-check","building-dash","building-down","building-exclamation","building-fill-add","building-fill-check","building-fill-dash","building-fill-down","building-fill-exclamation","building-fill-gear","building-fill-lock","building-fill-slash","building-fill-up","building-fill-x","building-fill","building-gear","building-lock","building-slash","building-up","building-x","buildings-fill","buildings","bus-front-fill","bus-front","ev-front-fill","ev-front","globe-americas","globe-asia-australia","globe-central-south-asia","globe-europe-africa","house-add-fill","house-add","house-check-fill","house-check","house-dash-fill","house-dash","house-down-fill","house-down","house-exclamation-fill","house-exclamation","house-gear-fill","house-gear","house-lock-fill","house-lock","house-slash-fill","house-slash","house-up-fill","house-up","house-x-fill","house-x","person-add","person-down","person-exclamation","person-fill-add","person-fill-check","person-fill-dash","person-fill-down","person-fill-exclamation","person-fill-gear","person-fill-lock","person-fill-slash","person-fill-up","person-fill-x","person-gear","person-lock","person-slash","person-up","scooter","taxi-front-fill","taxi-front","amd","database-add","database-check","database-dash","database-down","database-exclamation","database-fill-add","database-fill-check","database-fill-dash","database-fill-down","database-fill-exclamation","database-fill-gear","database-fill-lock","database-fill-slash","database-fill-up","database-fill-x","database-fill","database-gear","database-lock","database-slash","database-up","database-x","database","houses-fill","houses","nvidia","person-vcard-fill","person-vcard","sina-weibo","tencent-qq","wikipedia"] \ No newline at end of file diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts new file mode 100644 index 0000000..5df52c1 --- /dev/null +++ b/frontend/src/icons.ts @@ -0,0 +1,2 @@ +const icons = ["123","alarm-fill","alarm","align-bottom","align-center","align-end","align-middle","align-start","align-top","alt","app-indicator","app","archive-fill","archive","arrow-90deg-down","arrow-90deg-left","arrow-90deg-right","arrow-90deg-up","arrow-bar-down","arrow-bar-left","arrow-bar-right","arrow-bar-up","arrow-clockwise","arrow-counterclockwise","arrow-down-circle-fill","arrow-down-circle","arrow-down-left-circle-fill","arrow-down-left-circle","arrow-down-left-square-fill","arrow-down-left-square","arrow-down-left","arrow-down-right-circle-fill","arrow-down-right-circle","arrow-down-right-square-fill","arrow-down-right-square","arrow-down-right","arrow-down-short","arrow-down-square-fill","arrow-down-square","arrow-down-up","arrow-down","arrow-left-circle-fill","arrow-left-circle","arrow-left-right","arrow-left-short","arrow-left-square-fill","arrow-left-square","arrow-left","arrow-repeat","arrow-return-left","arrow-return-right","arrow-right-circle-fill","arrow-right-circle","arrow-right-short","arrow-right-square-fill","arrow-right-square","arrow-right","arrow-up-circle-fill","arrow-up-circle","arrow-up-left-circle-fill","arrow-up-left-circle","arrow-up-left-square-fill","arrow-up-left-square","arrow-up-left","arrow-up-right-circle-fill","arrow-up-right-circle","arrow-up-right-square-fill","arrow-up-right-square","arrow-up-right","arrow-up-short","arrow-up-square-fill","arrow-up-square","arrow-up","arrows-angle-contract","arrows-angle-expand","arrows-collapse","arrows-expand","arrows-fullscreen","arrows-move","aspect-ratio-fill","aspect-ratio","asterisk","at","award-fill","award","back","backspace-fill","backspace-reverse-fill","backspace-reverse","backspace","badge-3d-fill","badge-3d","badge-4k-fill","badge-4k","badge-8k-fill","badge-8k","badge-ad-fill","badge-ad","badge-ar-fill","badge-ar","badge-cc-fill","badge-cc","badge-hd-fill","badge-hd","badge-tm-fill","badge-tm","badge-vo-fill","badge-vo","badge-vr-fill","badge-vr","badge-wc-fill","badge-wc","bag-check-fill","bag-check","bag-dash-fill","bag-dash","bag-fill","bag-plus-fill","bag-plus","bag-x-fill","bag-x","bag","bar-chart-fill","bar-chart-line-fill","bar-chart-line","bar-chart-steps","bar-chart","basket-fill","basket","basket2-fill","basket2","basket3-fill","basket3","battery-charging","battery-full","battery-half","battery","bell-fill","bell","bezier","bezier2","bicycle","binoculars-fill","binoculars","blockquote-left","blockquote-right","book-fill","book-half","book","bookmark-check-fill","bookmark-check","bookmark-dash-fill","bookmark-dash","bookmark-fill","bookmark-heart-fill","bookmark-heart","bookmark-plus-fill","bookmark-plus","bookmark-star-fill","bookmark-star","bookmark-x-fill","bookmark-x","bookmark","bookmarks-fill","bookmarks","bookshelf","bootstrap-fill","bootstrap-reboot","bootstrap","border-all","border-bottom","border-center","border-inner","border-left","border-middle","border-outer","border-right","border-style","border-top","border-width","border","bounding-box-circles","bounding-box","box-arrow-down-left","box-arrow-down-right","box-arrow-down","box-arrow-in-down-left","box-arrow-in-down-right","box-arrow-in-down","box-arrow-in-left","box-arrow-in-right","box-arrow-in-up-left","box-arrow-in-up-right","box-arrow-in-up","box-arrow-left","box-arrow-right","box-arrow-up-left","box-arrow-up-right","box-arrow-up","box-seam","box","braces","bricks","briefcase-fill","briefcase","brightness-alt-high-fill","brightness-alt-high","brightness-alt-low-fill","brightness-alt-low","brightness-high-fill","brightness-high","brightness-low-fill","brightness-low","broadcast-pin","broadcast","brush-fill","brush","bucket-fill","bucket","bug-fill","bug","building","bullseye","calculator-fill","calculator","calendar-check-fill","calendar-check","calendar-date-fill","calendar-date","calendar-day-fill","calendar-day","calendar-event-fill","calendar-event","calendar-fill","calendar-minus-fill","calendar-minus","calendar-month-fill","calendar-month","calendar-plus-fill","calendar-plus","calendar-range-fill","calendar-range","calendar-week-fill","calendar-week","calendar-x-fill","calendar-x","calendar","calendar2-check-fill","calendar2-check","calendar2-date-fill","calendar2-date","calendar2-day-fill","calendar2-day","calendar2-event-fill","calendar2-event","calendar2-fill","calendar2-minus-fill","calendar2-minus","calendar2-month-fill","calendar2-month","calendar2-plus-fill","calendar2-plus","calendar2-range-fill","calendar2-range","calendar2-week-fill","calendar2-week","calendar2-x-fill","calendar2-x","calendar2","calendar3-event-fill","calendar3-event","calendar3-fill","calendar3-range-fill","calendar3-range","calendar3-week-fill","calendar3-week","calendar3","calendar4-event","calendar4-range","calendar4-week","calendar4","camera-fill","camera-reels-fill","camera-reels","camera-video-fill","camera-video-off-fill","camera-video-off","camera-video","camera","camera2","capslock-fill","capslock","card-checklist","card-heading","card-image","card-list","card-text","caret-down-fill","caret-down-square-fill","caret-down-square","caret-down","caret-left-fill","caret-left-square-fill","caret-left-square","caret-left","caret-right-fill","caret-right-square-fill","caret-right-square","caret-right","caret-up-fill","caret-up-square-fill","caret-up-square","caret-up","cart-check-fill","cart-check","cart-dash-fill","cart-dash","cart-fill","cart-plus-fill","cart-plus","cart-x-fill","cart-x","cart","cart2","cart3","cart4","cash-stack","cash","cast","chat-dots-fill","chat-dots","chat-fill","chat-left-dots-fill","chat-left-dots","chat-left-fill","chat-left-quote-fill","chat-left-quote","chat-left-text-fill","chat-left-text","chat-left","chat-quote-fill","chat-quote","chat-right-dots-fill","chat-right-dots","chat-right-fill","chat-right-quote-fill","chat-right-quote","chat-right-text-fill","chat-right-text","chat-right","chat-square-dots-fill","chat-square-dots","chat-square-fill","chat-square-quote-fill","chat-square-quote","chat-square-text-fill","chat-square-text","chat-square","chat-text-fill","chat-text","chat","check-all","check-circle-fill","check-circle","check-square-fill","check-square","check","check2-all","check2-circle","check2-square","check2","chevron-bar-contract","chevron-bar-down","chevron-bar-expand","chevron-bar-left","chevron-bar-right","chevron-bar-up","chevron-compact-down","chevron-compact-left","chevron-compact-right","chevron-compact-up","chevron-contract","chevron-double-down","chevron-double-left","chevron-double-right","chevron-double-up","chevron-down","chevron-expand","chevron-left","chevron-right","chevron-up","circle-fill","circle-half","circle-square","circle","clipboard-check","clipboard-data","clipboard-minus","clipboard-plus","clipboard-x","clipboard","clock-fill","clock-history","clock","cloud-arrow-down-fill","cloud-arrow-down","cloud-arrow-up-fill","cloud-arrow-up","cloud-check-fill","cloud-check","cloud-download-fill","cloud-download","cloud-drizzle-fill","cloud-drizzle","cloud-fill","cloud-fog-fill","cloud-fog","cloud-fog2-fill","cloud-fog2","cloud-hail-fill","cloud-hail","cloud-haze-fill","cloud-haze","cloud-haze2-fill","cloud-lightning-fill","cloud-lightning-rain-fill","cloud-lightning-rain","cloud-lightning","cloud-minus-fill","cloud-minus","cloud-moon-fill","cloud-moon","cloud-plus-fill","cloud-plus","cloud-rain-fill","cloud-rain-heavy-fill","cloud-rain-heavy","cloud-rain","cloud-slash-fill","cloud-slash","cloud-sleet-fill","cloud-sleet","cloud-snow-fill","cloud-snow","cloud-sun-fill","cloud-sun","cloud-upload-fill","cloud-upload","cloud","clouds-fill","clouds","cloudy-fill","cloudy","code-slash","code-square","code","collection-fill","collection-play-fill","collection-play","collection","columns-gap","columns","command","compass-fill","compass","cone-striped","cone","controller","cpu-fill","cpu","credit-card-2-back-fill","credit-card-2-back","credit-card-2-front-fill","credit-card-2-front","credit-card-fill","credit-card","crop","cup-fill","cup-straw","cup","cursor-fill","cursor-text","cursor","dash-circle-dotted","dash-circle-fill","dash-circle","dash-square-dotted","dash-square-fill","dash-square","dash","diagram-2-fill","diagram-2","diagram-3-fill","diagram-3","diamond-fill","diamond-half","diamond","dice-1-fill","dice-1","dice-2-fill","dice-2","dice-3-fill","dice-3","dice-4-fill","dice-4","dice-5-fill","dice-5","dice-6-fill","dice-6","disc-fill","disc","discord","display-fill","display","distribute-horizontal","distribute-vertical","door-closed-fill","door-closed","door-open-fill","door-open","dot","download","droplet-fill","droplet-half","droplet","earbuds","easel-fill","easel","egg-fill","egg-fried","egg","eject-fill","eject","emoji-angry-fill","emoji-angry","emoji-dizzy-fill","emoji-dizzy","emoji-expressionless-fill","emoji-expressionless","emoji-frown-fill","emoji-frown","emoji-heart-eyes-fill","emoji-heart-eyes","emoji-laughing-fill","emoji-laughing","emoji-neutral-fill","emoji-neutral","emoji-smile-fill","emoji-smile-upside-down-fill","emoji-smile-upside-down","emoji-smile","emoji-sunglasses-fill","emoji-sunglasses","emoji-wink-fill","emoji-wink","envelope-fill","envelope-open-fill","envelope-open","envelope","eraser-fill","eraser","exclamation-circle-fill","exclamation-circle","exclamation-diamond-fill","exclamation-diamond","exclamation-octagon-fill","exclamation-octagon","exclamation-square-fill","exclamation-square","exclamation-triangle-fill","exclamation-triangle","exclamation","exclude","eye-fill","eye-slash-fill","eye-slash","eye","eyedropper","eyeglasses","facebook","file-arrow-down-fill","file-arrow-down","file-arrow-up-fill","file-arrow-up","file-bar-graph-fill","file-bar-graph","file-binary-fill","file-binary","file-break-fill","file-break","file-check-fill","file-check","file-code-fill","file-code","file-diff-fill","file-diff","file-earmark-arrow-down-fill","file-earmark-arrow-down","file-earmark-arrow-up-fill","file-earmark-arrow-up","file-earmark-bar-graph-fill","file-earmark-bar-graph","file-earmark-binary-fill","file-earmark-binary","file-earmark-break-fill","file-earmark-break","file-earmark-check-fill","file-earmark-check","file-earmark-code-fill","file-earmark-code","file-earmark-diff-fill","file-earmark-diff","file-earmark-easel-fill","file-earmark-easel","file-earmark-excel-fill","file-earmark-excel","file-earmark-fill","file-earmark-font-fill","file-earmark-font","file-earmark-image-fill","file-earmark-image","file-earmark-lock-fill","file-earmark-lock","file-earmark-lock2-fill","file-earmark-lock2","file-earmark-medical-fill","file-earmark-medical","file-earmark-minus-fill","file-earmark-minus","file-earmark-music-fill","file-earmark-music","file-earmark-person-fill","file-earmark-person","file-earmark-play-fill","file-earmark-play","file-earmark-plus-fill","file-earmark-plus","file-earmark-post-fill","file-earmark-post","file-earmark-ppt-fill","file-earmark-ppt","file-earmark-richtext-fill","file-earmark-richtext","file-earmark-ruled-fill","file-earmark-ruled","file-earmark-slides-fill","file-earmark-slides","file-earmark-spreadsheet-fill","file-earmark-spreadsheet","file-earmark-text-fill","file-earmark-text","file-earmark-word-fill","file-earmark-word","file-earmark-x-fill","file-earmark-x","file-earmark-zip-fill","file-earmark-zip","file-earmark","file-easel-fill","file-easel","file-excel-fill","file-excel","file-fill","file-font-fill","file-font","file-image-fill","file-image","file-lock-fill","file-lock","file-lock2-fill","file-lock2","file-medical-fill","file-medical","file-minus-fill","file-minus","file-music-fill","file-music","file-person-fill","file-person","file-play-fill","file-play","file-plus-fill","file-plus","file-post-fill","file-post","file-ppt-fill","file-ppt","file-richtext-fill","file-richtext","file-ruled-fill","file-ruled","file-slides-fill","file-slides","file-spreadsheet-fill","file-spreadsheet","file-text-fill","file-text","file-word-fill","file-word","file-x-fill","file-x","file-zip-fill","file-zip","file","files-alt","files","film","filter-circle-fill","filter-circle","filter-left","filter-right","filter-square-fill","filter-square","filter","flag-fill","flag","flower1","flower2","flower3","folder-check","folder-fill","folder-minus","folder-plus","folder-symlink-fill","folder-symlink","folder-x","folder","folder2-open","folder2","fonts","forward-fill","forward","front","fullscreen-exit","fullscreen","funnel-fill","funnel","gear-fill","gear-wide-connected","gear-wide","gear","gem","geo-alt-fill","geo-alt","geo-fill","geo","gift-fill","gift","github","globe","globe2","google","graph-down","graph-up","grid-1x2-fill","grid-1x2","grid-3x2-gap-fill","grid-3x2-gap","grid-3x2","grid-3x3-gap-fill","grid-3x3-gap","grid-3x3","grid-fill","grid","grip-horizontal","grip-vertical","hammer","hand-index-fill","hand-index-thumb-fill","hand-index-thumb","hand-index","hand-thumbs-down-fill","hand-thumbs-down","hand-thumbs-up-fill","hand-thumbs-up","handbag-fill","handbag","hash","hdd-fill","hdd-network-fill","hdd-network","hdd-rack-fill","hdd-rack","hdd-stack-fill","hdd-stack","hdd","headphones","headset","heart-fill","heart-half","heart","heptagon-fill","heptagon-half","heptagon","hexagon-fill","hexagon-half","hexagon","hourglass-bottom","hourglass-split","hourglass-top","hourglass","house-door-fill","house-door","house-fill","house","hr","hurricane","image-alt","image-fill","image","images","inbox-fill","inbox","inboxes-fill","inboxes","info-circle-fill","info-circle","info-square-fill","info-square","info","input-cursor-text","input-cursor","instagram","intersect","journal-album","journal-arrow-down","journal-arrow-up","journal-bookmark-fill","journal-bookmark","journal-check","journal-code","journal-medical","journal-minus","journal-plus","journal-richtext","journal-text","journal-x","journal","journals","joystick","justify-left","justify-right","justify","kanban-fill","kanban","key-fill","key","keyboard-fill","keyboard","ladder","lamp-fill","lamp","laptop-fill","laptop","layer-backward","layer-forward","layers-fill","layers-half","layers","layout-sidebar-inset-reverse","layout-sidebar-inset","layout-sidebar-reverse","layout-sidebar","layout-split","layout-text-sidebar-reverse","layout-text-sidebar","layout-text-window-reverse","layout-text-window","layout-three-columns","layout-wtf","life-preserver","lightbulb-fill","lightbulb-off-fill","lightbulb-off","lightbulb","lightning-charge-fill","lightning-charge","lightning-fill","lightning","link-45deg","link","linkedin","list-check","list-nested","list-ol","list-stars","list-task","list-ul","list","lock-fill","lock","mailbox","mailbox2","map-fill","map","markdown-fill","markdown","mask","megaphone-fill","megaphone","menu-app-fill","menu-app","menu-button-fill","menu-button-wide-fill","menu-button-wide","menu-button","menu-down","menu-up","mic-fill","mic-mute-fill","mic-mute","mic","minecart-loaded","minecart","moisture","moon-fill","moon-stars-fill","moon-stars","moon","mouse-fill","mouse","mouse2-fill","mouse2","mouse3-fill","mouse3","music-note-beamed","music-note-list","music-note","music-player-fill","music-player","newspaper","node-minus-fill","node-minus","node-plus-fill","node-plus","nut-fill","nut","octagon-fill","octagon-half","octagon","option","outlet","paint-bucket","palette-fill","palette","palette2","paperclip","paragraph","patch-check-fill","patch-check","patch-exclamation-fill","patch-exclamation","patch-minus-fill","patch-minus","patch-plus-fill","patch-plus","patch-question-fill","patch-question","pause-btn-fill","pause-btn","pause-circle-fill","pause-circle","pause-fill","pause","peace-fill","peace","pen-fill","pen","pencil-fill","pencil-square","pencil","pentagon-fill","pentagon-half","pentagon","people-fill","people","percent","person-badge-fill","person-badge","person-bounding-box","person-check-fill","person-check","person-circle","person-dash-fill","person-dash","person-fill","person-lines-fill","person-plus-fill","person-plus","person-square","person-x-fill","person-x","person","phone-fill","phone-landscape-fill","phone-landscape","phone-vibrate-fill","phone-vibrate","phone","pie-chart-fill","pie-chart","pin-angle-fill","pin-angle","pin-fill","pin","pip-fill","pip","play-btn-fill","play-btn","play-circle-fill","play-circle","play-fill","play","plug-fill","plug","plus-circle-dotted","plus-circle-fill","plus-circle","plus-square-dotted","plus-square-fill","plus-square","plus","power","printer-fill","printer","puzzle-fill","puzzle","question-circle-fill","question-circle","question-diamond-fill","question-diamond","question-octagon-fill","question-octagon","question-square-fill","question-square","question","rainbow","receipt-cutoff","receipt","reception-0","reception-1","reception-2","reception-3","reception-4","record-btn-fill","record-btn","record-circle-fill","record-circle","record-fill","record","record2-fill","record2","reply-all-fill","reply-all","reply-fill","reply","rss-fill","rss","rulers","save-fill","save","save2-fill","save2","scissors","screwdriver","search","segmented-nav","server","share-fill","share","shield-check","shield-exclamation","shield-fill-check","shield-fill-exclamation","shield-fill-minus","shield-fill-plus","shield-fill-x","shield-fill","shield-lock-fill","shield-lock","shield-minus","shield-plus","shield-shaded","shield-slash-fill","shield-slash","shield-x","shield","shift-fill","shift","shop-window","shop","shuffle","signpost-2-fill","signpost-2","signpost-fill","signpost-split-fill","signpost-split","signpost","sim-fill","sim","skip-backward-btn-fill","skip-backward-btn","skip-backward-circle-fill","skip-backward-circle","skip-backward-fill","skip-backward","skip-end-btn-fill","skip-end-btn","skip-end-circle-fill","skip-end-circle","skip-end-fill","skip-end","skip-forward-btn-fill","skip-forward-btn","skip-forward-circle-fill","skip-forward-circle","skip-forward-fill","skip-forward","skip-start-btn-fill","skip-start-btn","skip-start-circle-fill","skip-start-circle","skip-start-fill","skip-start","slack","slash-circle-fill","slash-circle","slash-square-fill","slash-square","slash","sliders","smartwatch","snow","snow2","snow3","sort-alpha-down-alt","sort-alpha-down","sort-alpha-up-alt","sort-alpha-up","sort-down-alt","sort-down","sort-numeric-down-alt","sort-numeric-down","sort-numeric-up-alt","sort-numeric-up","sort-up-alt","sort-up","soundwave","speaker-fill","speaker","speedometer","speedometer2","spellcheck","square-fill","square-half","square","stack","star-fill","star-half","star","stars","stickies-fill","stickies","sticky-fill","sticky","stop-btn-fill","stop-btn","stop-circle-fill","stop-circle","stop-fill","stop","stoplights-fill","stoplights","stopwatch-fill","stopwatch","subtract","suit-club-fill","suit-club","suit-diamond-fill","suit-diamond","suit-heart-fill","suit-heart","suit-spade-fill","suit-spade","sun-fill","sun","sunglasses","sunrise-fill","sunrise","sunset-fill","sunset","symmetry-horizontal","symmetry-vertical","table","tablet-fill","tablet-landscape-fill","tablet-landscape","tablet","tag-fill","tag","tags-fill","tags","telegram","telephone-fill","telephone-forward-fill","telephone-forward","telephone-inbound-fill","telephone-inbound","telephone-minus-fill","telephone-minus","telephone-outbound-fill","telephone-outbound","telephone-plus-fill","telephone-plus","telephone-x-fill","telephone-x","telephone","terminal-fill","terminal","text-center","text-indent-left","text-indent-right","text-left","text-paragraph","text-right","textarea-resize","textarea-t","textarea","thermometer-half","thermometer-high","thermometer-low","thermometer-snow","thermometer-sun","thermometer","three-dots-vertical","three-dots","toggle-off","toggle-on","toggle2-off","toggle2-on","toggles","toggles2","tools","tornado","trash-fill","trash","trash2-fill","trash2","tree-fill","tree","triangle-fill","triangle-half","triangle","trophy-fill","trophy","tropical-storm","truck-flatbed","truck","tsunami","tv-fill","tv","twitch","twitter","type-bold","type-h1","type-h2","type-h3","type-italic","type-strikethrough","type-underline","type","ui-checks-grid","ui-checks","ui-radios-grid","ui-radios","umbrella-fill","umbrella","union","unlock-fill","unlock","upc-scan","upc","upload","vector-pen","view-list","view-stacked","vinyl-fill","vinyl","voicemail","volume-down-fill","volume-down","volume-mute-fill","volume-mute","volume-off-fill","volume-off","volume-up-fill","volume-up","vr","wallet-fill","wallet","wallet2","watch","water","whatsapp","wifi-1","wifi-2","wifi-off","wifi","wind","window-dock","window-sidebar","window","wrench","x-circle-fill","x-circle","x-diamond-fill","x-diamond","x-octagon-fill","x-octagon","x-square-fill","x-square","x","youtube","zoom-in","zoom-out","bank","bank2","bell-slash-fill","bell-slash","cash-coin","check-lg","coin","currency-bitcoin","currency-dollar","currency-euro","currency-exchange","currency-pound","currency-yen","dash-lg","exclamation-lg","file-earmark-pdf-fill","file-earmark-pdf","file-pdf-fill","file-pdf","gender-ambiguous","gender-female","gender-male","gender-trans","headset-vr","info-lg","mastodon","messenger","piggy-bank-fill","piggy-bank","pin-map-fill","pin-map","plus-lg","question-lg","recycle","reddit","safe-fill","safe2-fill","safe2","sd-card-fill","sd-card","skype","slash-lg","translate","x-lg","safe","apple","microsoft","windows","behance","dribbble","line","medium","paypal","pinterest","signal","snapchat","spotify","stack-overflow","strava","wordpress","vimeo","activity","easel2-fill","easel2","easel3-fill","easel3","fan","fingerprint","graph-down-arrow","graph-up-arrow","hypnotize","magic","person-rolodex","person-video","person-video2","person-video3","person-workspace","radioactive","webcam-fill","webcam","yin-yang","bandaid-fill","bandaid","bluetooth","body-text","boombox","boxes","dpad-fill","dpad","ear-fill","ear","envelope-check-fill","envelope-check","envelope-dash-fill","envelope-dash","envelope-exclamation-fill","envelope-exclamation","envelope-plus-fill","envelope-plus","envelope-slash-fill","envelope-slash","envelope-x-fill","envelope-x","explicit-fill","explicit","git","infinity","list-columns-reverse","list-columns","meta","nintendo-switch","pc-display-horizontal","pc-display","pc-horizontal","pc","playstation","plus-slash-minus","projector-fill","projector","qr-code-scan","qr-code","quora","quote","robot","send-check-fill","send-check","send-dash-fill","send-dash","send-exclamation-fill","send-exclamation","send-fill","send-plus-fill","send-plus","send-slash-fill","send-slash","send-x-fill","send-x","send","steam","terminal-dash","terminal-plus","terminal-split","ticket-detailed-fill","ticket-detailed","ticket-fill","ticket-perforated-fill","ticket-perforated","ticket","tiktok","window-dash","window-desktop","window-fullscreen","window-plus","window-split","window-stack","window-x","xbox","ethernet","hdmi-fill","hdmi","usb-c-fill","usb-c","usb-fill","usb-plug-fill","usb-plug","usb-symbol","usb","boombox-fill","displayport","gpu-card","memory","modem-fill","modem","motherboard-fill","motherboard","optical-audio-fill","optical-audio","pci-card","router-fill","router","thunderbolt-fill","thunderbolt","usb-drive-fill","usb-drive","usb-micro-fill","usb-micro","usb-mini-fill","usb-mini","cloud-haze2","device-hdd-fill","device-hdd","device-ssd-fill","device-ssd","displayport-fill","mortarboard-fill","mortarboard","terminal-x","arrow-through-heart-fill","arrow-through-heart","badge-sd-fill","badge-sd","bag-heart-fill","bag-heart","balloon-fill","balloon-heart-fill","balloon-heart","balloon","box2-fill","box2-heart-fill","box2-heart","box2","braces-asterisk","calendar-heart-fill","calendar-heart","calendar2-heart-fill","calendar2-heart","chat-heart-fill","chat-heart","chat-left-heart-fill","chat-left-heart","chat-right-heart-fill","chat-right-heart","chat-square-heart-fill","chat-square-heart","clipboard-check-fill","clipboard-data-fill","clipboard-fill","clipboard-heart-fill","clipboard-heart","clipboard-minus-fill","clipboard-plus-fill","clipboard-pulse","clipboard-x-fill","clipboard2-check-fill","clipboard2-check","clipboard2-data-fill","clipboard2-data","clipboard2-fill","clipboard2-heart-fill","clipboard2-heart","clipboard2-minus-fill","clipboard2-minus","clipboard2-plus-fill","clipboard2-plus","clipboard2-pulse-fill","clipboard2-pulse","clipboard2-x-fill","clipboard2-x","clipboard2","emoji-kiss-fill","emoji-kiss","envelope-heart-fill","envelope-heart","envelope-open-heart-fill","envelope-open-heart","envelope-paper-fill","envelope-paper-heart-fill","envelope-paper-heart","envelope-paper","filetype-aac","filetype-ai","filetype-bmp","filetype-cs","filetype-css","filetype-csv","filetype-doc","filetype-docx","filetype-exe","filetype-gif","filetype-heic","filetype-html","filetype-java","filetype-jpg","filetype-js","filetype-jsx","filetype-key","filetype-m4p","filetype-md","filetype-mdx","filetype-mov","filetype-mp3","filetype-mp4","filetype-otf","filetype-pdf","filetype-php","filetype-png","filetype-ppt","filetype-psd","filetype-py","filetype-raw","filetype-rb","filetype-sass","filetype-scss","filetype-sh","filetype-svg","filetype-tiff","filetype-tsx","filetype-ttf","filetype-txt","filetype-wav","filetype-woff","filetype-xls","filetype-xml","filetype-yml","heart-arrow","heart-pulse-fill","heart-pulse","heartbreak-fill","heartbreak","hearts","hospital-fill","hospital","house-heart-fill","house-heart","incognito","magnet-fill","magnet","person-heart","person-hearts","phone-flip","plugin","postage-fill","postage-heart-fill","postage-heart","postage","postcard-fill","postcard-heart-fill","postcard-heart","postcard","search-heart-fill","search-heart","sliders2-vertical","sliders2","trash3-fill","trash3","valentine","valentine2","wrench-adjustable-circle-fill","wrench-adjustable-circle","wrench-adjustable","filetype-json","filetype-pptx","filetype-xlsx","1-circle-fill","1-circle","1-square-fill","1-square","2-circle-fill","2-circle","2-square-fill","2-square","3-circle-fill","3-circle","3-square-fill","3-square","4-circle-fill","4-circle","4-square-fill","4-square","5-circle-fill","5-circle","5-square-fill","5-square","6-circle-fill","6-circle","6-square-fill","6-square","7-circle-fill","7-circle","7-square-fill","7-square","8-circle-fill","8-circle","8-square-fill","8-square","9-circle-fill","9-circle","9-square-fill","9-square","airplane-engines-fill","airplane-engines","airplane-fill","airplane","alexa","alipay","android","android2","box-fill","box-seam-fill","browser-chrome","browser-edge","browser-firefox","browser-safari","c-circle-fill","c-circle","c-square-fill","c-square","capsule-pill","capsule","car-front-fill","car-front","cassette-fill","cassette","cc-circle-fill","cc-circle","cc-square-fill","cc-square","cup-hot-fill","cup-hot","currency-rupee","dropbox","escape","fast-forward-btn-fill","fast-forward-btn","fast-forward-circle-fill","fast-forward-circle","fast-forward-fill","fast-forward","filetype-sql","fire","google-play","h-circle-fill","h-circle","h-square-fill","h-square","indent","lungs-fill","lungs","microsoft-teams","p-circle-fill","p-circle","p-square-fill","p-square","pass-fill","pass","prescription","prescription2","r-circle-fill","r-circle","r-square-fill","r-square","repeat-1","repeat","rewind-btn-fill","rewind-btn","rewind-circle-fill","rewind-circle","rewind-fill","rewind","train-freight-front-fill","train-freight-front","train-front-fill","train-front","train-lightrail-front-fill","train-lightrail-front","truck-front-fill","truck-front","ubuntu","unindent","unity","universal-access-circle","universal-access","virus","virus2","wechat","yelp","sign-stop-fill","sign-stop-lights-fill","sign-stop-lights","sign-stop","sign-turn-left-fill","sign-turn-left","sign-turn-right-fill","sign-turn-right","sign-turn-slight-left-fill","sign-turn-slight-left","sign-turn-slight-right-fill","sign-turn-slight-right","sign-yield-fill","sign-yield","ev-station-fill","ev-station","fuel-pump-diesel-fill","fuel-pump-diesel","fuel-pump-fill","fuel-pump","0-circle-fill","0-circle","0-square-fill","0-square","rocket-fill","rocket-takeoff-fill","rocket-takeoff","rocket","stripe","subscript","superscript","trello","envelope-at-fill","envelope-at","regex","text-wrap","sign-dead-end-fill","sign-dead-end","sign-do-not-enter-fill","sign-do-not-enter","sign-intersection-fill","sign-intersection-side-fill","sign-intersection-side","sign-intersection-t-fill","sign-intersection-t","sign-intersection-y-fill","sign-intersection-y","sign-intersection","sign-merge-left-fill","sign-merge-left","sign-merge-right-fill","sign-merge-right","sign-no-left-turn-fill","sign-no-left-turn","sign-no-parking-fill","sign-no-parking","sign-no-right-turn-fill","sign-no-right-turn","sign-railroad-fill","sign-railroad","building-add","building-check","building-dash","building-down","building-exclamation","building-fill-add","building-fill-check","building-fill-dash","building-fill-down","building-fill-exclamation","building-fill-gear","building-fill-lock","building-fill-slash","building-fill-up","building-fill-x","building-fill","building-gear","building-lock","building-slash","building-up","building-x","buildings-fill","buildings","bus-front-fill","bus-front","ev-front-fill","ev-front","globe-americas","globe-asia-australia","globe-central-south-asia","globe-europe-africa","house-add-fill","house-add","house-check-fill","house-check","house-dash-fill","house-dash","house-down-fill","house-down","house-exclamation-fill","house-exclamation","house-gear-fill","house-gear","house-lock-fill","house-lock","house-slash-fill","house-slash","house-up-fill","house-up","house-x-fill","house-x","person-add","person-down","person-exclamation","person-fill-add","person-fill-check","person-fill-dash","person-fill-down","person-fill-exclamation","person-fill-gear","person-fill-lock","person-fill-slash","person-fill-up","person-fill-x","person-gear","person-lock","person-slash","person-up","scooter","taxi-front-fill","taxi-front","amd","database-add","database-check","database-dash","database-down","database-exclamation","database-fill-add","database-fill-check","database-fill-dash","database-fill-down","database-fill-exclamation","database-fill-gear","database-fill-lock","database-fill-slash","database-fill-up","database-fill-x","database-fill","database-gear","database-lock","database-slash","database-up","database-x","database","houses-fill","houses","nvidia","person-vcard-fill","person-vcard","sina-weibo","tencent-qq","wikipedia"]; +export default icons; \ No newline at end of file From 2c71741d7c54f9af96268c9208f13747d12166e5 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 19 Apr 2023 12:00:21 +0200 Subject: [PATCH 020/119] feat(backend): add custom preferences --- backend/db/entries.go | 14 +++++++------- backend/db/field.go | 4 ++-- backend/db/user.go | 6 +++++- backend/routes/member/create_member.go | 12 ++++++------ backend/routes/member/patch_member.go | 16 ++++++++-------- backend/routes/user/patch_user.go | 17 +++++++++-------- go.mod | 2 +- scripts/seeddb/main.go | 2 +- 8 files changed, 39 insertions(+), 34 deletions(-) diff --git a/backend/db/entries.go b/backend/db/entries.go index 9a2c28f..1b4b8cd 100644 --- a/backend/db/entries.go +++ b/backend/db/entries.go @@ -40,13 +40,13 @@ func (w *WordStatus) UnmarshalJSON(src []byte) error { return nil } -func (w WordStatus) Valid(extra ...WordStatus) bool { +func (w WordStatus) Valid(extra CustomPreferences) bool { if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid { return true } - for i := range extra { - if w == extra[i] { + for k := range extra { + if string(w) == k { return true } } @@ -58,7 +58,7 @@ type FieldEntry struct { Status WordStatus `json:"status"` } -func (fe FieldEntry) Validate() string { +func (fe FieldEntry) Validate(custom CustomPreferences) string { if fe.Value == "" { return "value cannot be empty" } @@ -67,7 +67,7 @@ func (fe FieldEntry) Validate() string { return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value))) } - if !fe.Status.Valid() { + if !fe.Status.Valid(custom) { return "status is invalid" } @@ -80,7 +80,7 @@ type PronounEntry struct { Status WordStatus `json:"status"` } -func (p PronounEntry) Validate() string { +func (p PronounEntry) Validate(custom CustomPreferences) string { if p.Pronouns == "" { return "pronouns cannot be empty" } @@ -95,7 +95,7 @@ func (p PronounEntry) Validate() string { return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns))) } - if !p.Status.Valid() { + if !p.Status.Valid(custom) { return "status is invalid" } diff --git a/backend/db/field.go b/backend/db/field.go index 69a2b2e..285d5d4 100644 --- a/backend/db/field.go +++ b/backend/db/field.go @@ -24,7 +24,7 @@ type Field struct { } // Validate validates this field. If it is invalid, a non-empty string is returned as error message. -func (f Field) Validate() string { +func (f Field) Validate(custom CustomPreferences) string { if f.Name == "" { return "name cannot be empty" } @@ -42,7 +42,7 @@ func (f Field) Validate() string { return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length) } - if !entry.Status.Valid() { + if !entry.Status.Valid(custom) { return fmt.Sprintf("entries.%d: status is invalid", i) } } diff --git a/backend/db/user.go b/backend/db/user.go index dc27b12..6d8e24f 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -498,8 +498,9 @@ func (db *DB) UpdateUser( memberTitle *string, listPrivate *bool, links *[]string, avatar *string, + customPreferences *CustomPreferences, ) (u User, err error) { - if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil { + if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil { sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql() if err != nil { return u, errors.Wrap(err, "building sql") @@ -541,6 +542,9 @@ func (db *DB) UpdateUser( if listPrivate != nil { builder = builder.Set("list_private", *listPrivate) } + if customPreferences != nil { + builder = builder.Set("custom_preferences", *customPreferences) + } if avatar != nil { if *avatar == "" { diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go index b18f8c8..3724cd6 100644 --- a/backend/routes/member/create_member.go +++ b/backend/routes/member/create_member.go @@ -103,15 +103,15 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error } } - if err := validateSlicePtr("name", &cmr.Names); err != nil { + if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil { return *err } - if err := validateSlicePtr("pronoun", &cmr.Pronouns); err != nil { + if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil { return *err } - if err := validateSlicePtr("field", &cmr.Fields); err != nil { + if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil { return *err } @@ -186,12 +186,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error } type validator interface { - Validate() string + Validate(custom db.CustomPreferences) string } // validateSlicePtr validates a slice of validators. // If the slice is nil, a nil error is returned (assuming that the field is not required) -func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError { +func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError { if slice == nil { return nil } @@ -211,7 +211,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError { // validate all fields for i, pronouns := range *slice { - if s := pronouns.Validate(); s != "" { + if s := pronouns.Validate(custom); s != "" { return &server.APIError{ Code: server.ErrBadRequest, Details: fmt.Sprintf("%s %d: %s", typ, i+1, s), diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go index a61d620..4621401 100644 --- a/backend/routes/member/patch_member.go +++ b/backend/routes/member/patch_member.go @@ -41,6 +41,11 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrMemberNotFound} } + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + m, err := s.DB.Member(ctx, id) if err != nil { if err == db.ErrMemberNotFound { @@ -148,15 +153,15 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } } - if err := validateSlicePtr("name", req.Names); err != nil { + if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil { return *err } - if err := validateSlicePtr("pronoun", req.Pronouns); err != nil { + if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil { return *err } - if err := validateSlicePtr("field", req.Fields); err != nil { + if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil { return *err } @@ -271,11 +276,6 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { return err } - u, err := s.DB.User(ctx, claims.UserID) - if err != nil { - return errors.Wrap(err, "getting user") - } - // echo the updated member back on success render.JSON(w, r, dbMemberToMember(u, m, fields, true)) return nil diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 0d3f9f8..5507b45 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -59,7 +59,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { req.Fields == nil && req.Names == nil && req.Pronouns == nil && - req.Avatar == nil { + req.Avatar == nil && + req.CustomPreferences == nil { return server.APIError{ Code: server.ErrBadRequest, Details: "Data must not be empty", @@ -105,15 +106,15 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } - if err := validateSlicePtr("name", req.Names); err != nil { + if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil { return *err } - if err := validateSlicePtr("pronoun", req.Pronouns); err != nil { + if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil { return *err } - if err := validateSlicePtr("field", req.Fields); err != nil { + if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil { return *err } @@ -201,7 +202,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } - u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash) + u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences) if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { log.Errorf("updating user: %v", err) return err @@ -278,12 +279,12 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } type validator interface { - Validate() string + Validate(custom db.CustomPreferences) string } // validateSlicePtr validates a slice of validators. // If the slice is nil, a nil error is returned (assuming that the field is not required) -func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError { +func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError { if slice == nil { return nil } @@ -303,7 +304,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError { // validate all fields for i, pronouns := range *slice { - if s := pronouns.Validate(); s != "" { + if s := pronouns.Validate(custom); s != "" { return &server.APIError{ Code: server.ErrBadRequest, Details: fmt.Sprintf("%s %d: %s", typ, i+1, s), diff --git a/go.mod b/go.mod index 9b0a4f9..8cd6ce4 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-chi/render v1.0.2 github.com/gobwas/glob v0.2.3 github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/google/uuid v1.3.0 github.com/jackc/pgx/v5 v5.3.1 github.com/joho/godotenv v1.5.1 github.com/mediocregopher/radix/v4 v4.1.2 @@ -40,7 +41,6 @@ require ( github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/s2a-go v0.1.0 // indirect - github.com/google/uuid v1.3.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/scripts/seeddb/main.go b/scripts/seeddb/main.go index acb91a2..a430e72 100644 --- a/scripts/seeddb/main.go +++ b/scripts/seeddb/main.go @@ -48,7 +48,7 @@ func run(c *cli.Context) error { return err } - _, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil) + _, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil, nil) if err != nil { fmt.Println("error setting user info:", err) return err From 8bda5f98607e4a33a24ce29ddcb5bd88dd2203f9 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 19 Apr 2023 12:24:34 +0200 Subject: [PATCH 021/119] feat(frontend): show custom preferences --- frontend/src/lib/api/default_preferences.ts | 41 ++++++++++++++ frontend/src/lib/api/entities.ts | 20 +++++++ frontend/src/lib/components/FieldCard.svelte | 5 +- frontend/src/lib/components/StatusIcon.svelte | 55 +++++-------------- frontend/src/lib/components/StatusLine.svelte | 44 ++++++++++++--- frontend/src/routes/@[username]/+page.svelte | 6 +- .../@[username]/[memberName]/+page.svelte | 6 +- 7 files changed, 120 insertions(+), 57 deletions(-) create mode 100644 frontend/src/lib/api/default_preferences.ts diff --git a/frontend/src/lib/api/default_preferences.ts b/frontend/src/lib/api/default_preferences.ts new file mode 100644 index 0000000..ad8a2dc --- /dev/null +++ b/frontend/src/lib/api/default_preferences.ts @@ -0,0 +1,41 @@ +import { type CustomPreferences, PreferenceSize } from "./entities"; + +const defaultPreferences: CustomPreferences = { + favourite: { + icon: "heart-fill", + tooltip: "Favourite", + size: PreferenceSize.Large, + muted: false, + favourite: true, + }, + okay: { + icon: "hand-thumbs-up", + tooltip: "Okay", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }, + jokingly: { + icon: "emoji-laughing", + tooltip: "Jokingly", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }, + friends_only: { + icon: "people", + tooltip: "Friends only", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }, + avoid: { + icon: "people", + tooltip: "Avoid", + size: PreferenceSize.Small, + muted: true, + favourite: false, + }, +}; + +export default defaultPreferences; diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 70dd2a6..b5cac81 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -16,6 +16,25 @@ export interface User { pronouns: Pronoun[]; members: PartialMember[]; fields: Field[]; + custom_preferences: CustomPreferences; +} + +export interface CustomPreferences { + [key: string]: CustomPreference; +} + +export interface CustomPreference { + icon: string; + tooltip: string; + size: PreferenceSize; + muted: boolean; + favourite: boolean; +} + +export enum PreferenceSize { + Large = "large", + Normal = "normal", + Small = "small", } export interface MeUser extends User { @@ -80,6 +99,7 @@ export interface MemberPartialUser { name: string; display_name: string | null; avatar: string | null; + custom_preferences: CustomPreferences; } export interface Invite { diff --git a/frontend/src/lib/components/FieldCard.svelte b/frontend/src/lib/components/FieldCard.svelte index 9d2a9f5..8abaffd 100644 --- a/frontend/src/lib/components/FieldCard.svelte +++ b/frontend/src/lib/components/FieldCard.svelte @@ -1,16 +1,17 @@

{field.name}

    {#each field.entries as entry} -
  • {entry.value}
  • +
  • {entry.value}
  • {/each}
diff --git a/frontend/src/lib/components/StatusIcon.svelte b/frontend/src/lib/components/StatusIcon.svelte index ca92c9e..b26b03c 100644 --- a/frontend/src/lib/components/StatusIcon.svelte +++ b/frontend/src/lib/components/StatusIcon.svelte @@ -1,53 +1,24 @@ - -{statusText} + +{currentPreference.tooltip} diff --git a/frontend/src/lib/components/StatusLine.svelte b/frontend/src/lib/components/StatusLine.svelte index ada28f6..77ae5d0 100644 --- a/frontend/src/lib/components/StatusLine.svelte +++ b/frontend/src/lib/components/StatusLine.svelte @@ -1,14 +1,44 @@ -{#if status === WordStatus.Favourite} - -{:else if status === WordStatus.Avoid} - +{#if currentPreference.size === PreferenceSize.Large} + {:else} - + {/if} diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index 5c6ea6c..6d7cb2c 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -146,7 +146,7 @@

Names

    {#each data.names as name} -
  • {name.value}
  • +
  • {name.value}
  • {/each}
@@ -156,14 +156,14 @@

Pronouns

    {#each data.pronouns as pronouns} -
  • +
  • {/each}
{/if} {#each data.fields as field}
- +
{/each} diff --git a/frontend/src/routes/@[username]/[memberName]/+page.svelte b/frontend/src/routes/@[username]/[memberName]/+page.svelte index c6ca432..7ef25d9 100644 --- a/frontend/src/routes/@[username]/[memberName]/+page.svelte +++ b/frontend/src/routes/@[username]/[memberName]/+page.svelte @@ -81,7 +81,7 @@

Names

    {#each data.names as name} -
  • {name.value}
  • +
  • {name.value}
  • {/each}
@@ -91,14 +91,14 @@

Pronouns

    {#each data.pronouns as pronouns} -
  • +
  • {/each}
{/if} {#each data.fields as field}
- +
{/each} From 9a80bb2e9b7cfa40312366fa3ad075426674e2fb Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 19 Apr 2023 17:17:44 +0200 Subject: [PATCH 022/119] feat(frontend): allow editing + using custom preferences --- frontend/src/lib/api/default_preferences.ts | 9 +- frontend/src/lib/api/entities.ts | 4 +- frontend/src/lib/components/StatusIcon.svelte | 2 +- frontend/src/lib/components/StatusLine.svelte | 2 +- frontend/src/routes/edit/EditableField.svelte | 6 +- frontend/src/routes/edit/EditableName.svelte | 75 +++++----------- .../src/routes/edit/EditablePronouns.svelte | 76 +++++----------- frontend/src/routes/edit/FieldEntry.svelte | 75 +++++----------- .../src/routes/edit/member/[id]/+page.svelte | 10 ++- frontend/src/routes/edit/profile/+page.svelte | 61 ++++++++++++- .../edit/profile/CustomPreference.svelte | 86 +++++++++++++++++++ 11 files changed, 229 insertions(+), 177 deletions(-) create mode 100644 frontend/src/routes/edit/profile/CustomPreference.svelte diff --git a/frontend/src/lib/api/default_preferences.ts b/frontend/src/lib/api/default_preferences.ts index ad8a2dc..0d564d2 100644 --- a/frontend/src/lib/api/default_preferences.ts +++ b/frontend/src/lib/api/default_preferences.ts @@ -30,12 +30,19 @@ const defaultPreferences: CustomPreferences = { favourite: false, }, avoid: { - icon: "people", + icon: "hand-thumbs-down", tooltip: "Avoid", size: PreferenceSize.Small, muted: true, favourite: false, }, + missing: { + icon: "question-lg", + tooltip: "Unknown (missing)", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }, }; export default defaultPreferences; diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index b5cac81..ccb3b22 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -58,13 +58,13 @@ export interface Field { export interface FieldEntry { value: string; - status: WordStatus; + status: string; } export interface Pronoun { pronouns: string; display_text: string | null; - status: WordStatus; + status: string; } export enum WordStatus { diff --git a/frontend/src/lib/components/StatusIcon.svelte b/frontend/src/lib/components/StatusIcon.svelte index b26b03c..be4977c 100644 --- a/frontend/src/lib/components/StatusIcon.svelte +++ b/frontend/src/lib/components/StatusIcon.svelte @@ -13,7 +13,7 @@ let currentPreference: CustomPreference; $: currentPreference = - status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.okay; + status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing; let iconElement: HTMLElement; diff --git a/frontend/src/lib/components/StatusLine.svelte b/frontend/src/lib/components/StatusLine.svelte index 77ae5d0..021326c 100644 --- a/frontend/src/lib/components/StatusLine.svelte +++ b/frontend/src/lib/components/StatusLine.svelte @@ -13,7 +13,7 @@ let currentPreference: CustomPreference; $: currentPreference = - status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.okay; + status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing; let classes: string; $: classes = setClasses(currentPreference); diff --git a/frontend/src/routes/edit/EditableField.svelte b/frontend/src/routes/edit/EditableField.svelte index 446fe71..66be6c9 100644 --- a/frontend/src/routes/edit/EditableField.svelte +++ b/frontend/src/routes/edit/EditableField.svelte @@ -1,10 +1,11 @@
@@ -58,30 +36,17 @@ - {textFor(status)} + {currentPreference.tooltip} - + - (status = WordStatus.Favourite)} - active={status === WordStatus.Favourite}>Favourite - (status = WordStatus.Okay)} active={status === WordStatus.Okay} - >Okay - (status = WordStatus.Jokingly)} - active={status === WordStatus.Jokingly}>Jokingly - (status = WordStatus.FriendsOnly)} - active={status === WordStatus.FriendsOnly}>Friends only - (status = WordStatus.Avoid)} - active={status === WordStatus.Avoid}>Avoid + {#each preferenceIds as id} + (status = id)} active={status === id}> + + {mergedPreferences[id].tooltip} + + {/each} diff --git a/frontend/src/routes/edit/EditablePronouns.svelte b/frontend/src/routes/edit/EditablePronouns.svelte index b1df161..d1a1548 100644 --- a/frontend/src/routes/edit/EditablePronouns.svelte +++ b/frontend/src/routes/edit/EditablePronouns.svelte @@ -1,5 +1,6 @@
@@ -75,31 +55,17 @@ click={toggleDisplay} /> - {textFor(pronoun.status)} + {currentPreference.tooltip} - + - (pronoun.status = WordStatus.Favourite)} - active={pronoun.status === WordStatus.Favourite}>Favourite - (pronoun.status = WordStatus.Okay)} - active={pronoun.status === WordStatus.Okay}>Okay - (pronoun.status = WordStatus.Jokingly)} - active={pronoun.status === WordStatus.Jokingly}>Jokingly - (pronoun.status = WordStatus.FriendsOnly)} - active={pronoun.status === WordStatus.FriendsOnly}>Friends only - (pronoun.status = WordStatus.Avoid)} - active={pronoun.status === WordStatus.Avoid}>Avoid + {#each preferenceIds as id} + (pronoun.status = id)} active={pronoun.status === id}> + + {mergedPreferences[id].tooltip} + + {/each} diff --git a/frontend/src/routes/edit/FieldEntry.svelte b/frontend/src/routes/edit/FieldEntry.svelte index d03595d..2a9b086 100644 --- a/frontend/src/routes/edit/FieldEntry.svelte +++ b/frontend/src/routes/edit/FieldEntry.svelte @@ -1,5 +1,6 @@
@@ -58,30 +36,17 @@ - {textFor(status)} + {currentPreference.tooltip} - + - (status = WordStatus.Favourite)} - active={status === WordStatus.Favourite}>Favourite - (status = WordStatus.Okay)} active={status === WordStatus.Okay} - >Okay - (status = WordStatus.Jokingly)} - active={status === WordStatus.Jokingly}>Jokingly - (status = WordStatus.FriendsOnly)} - active={status === WordStatus.FriendsOnly}>Friends only - (status = WordStatus.Avoid)} - active={status === WordStatus.Avoid}>Avoid + {#each preferenceIds as id} + (status = id)} active={status === id}> + + {mergedPreferences[id].tooltip} + + {/each} diff --git a/frontend/src/routes/edit/member/[id]/+page.svelte b/frontend/src/routes/edit/member/[id]/+page.svelte index fe3d6f8..f095364 100644 --- a/frontend/src/routes/edit/member/[id]/+page.svelte +++ b/frontend/src/routes/edit/member/[id]/+page.svelte @@ -153,10 +153,9 @@ if (list[0].size > MAX_AVATAR_BYTES) { addToast({ header: "Avatar too large", - body: - `This avatar is too large, please resize it (maximum is ${prettyBytes( - MAX_AVATAR_BYTES, - )}, the file you tried to upload is ${prettyBytes(list[0].size)})`, + body: `This avatar is too large, please resize it (maximum is ${prettyBytes( + MAX_AVATAR_BYTES, + )}, the file you tried to upload is ${prettyBytes(list[0].size)})`, }); return null; } @@ -440,6 +439,7 @@ moveName(index, true)} moveDown={() => moveName(index, false)} remove={() => removeName(index)} @@ -479,6 +479,7 @@ {#each pronouns as _, index} movePronoun(index, true)} moveDown={() => movePronoun(index, false)} remove={() => removePronoun(index)} @@ -520,6 +521,7 @@ {#each fields as _, index} removeField(index)} moveField={(up) => moveField(index, up)} /> diff --git a/frontend/src/routes/edit/profile/+page.svelte b/frontend/src/routes/edit/profile/+page.svelte index 4134de4..a3ef65c 100644 --- a/frontend/src/routes/edit/profile/+page.svelte +++ b/frontend/src/routes/edit/profile/+page.svelte @@ -8,6 +8,8 @@ type FieldEntry, type MeUser, type Pronoun, + PreferenceSize, + type CustomPreferences, } from "$lib/api/entities"; import FallbackImage from "$lib/components/FallbackImage.svelte"; import { userStore } from "$lib/store"; @@ -37,6 +39,7 @@ import { charCount, renderMarkdown } from "$lib/utils"; import MarkdownHelp from "../MarkdownHelp.svelte"; import prettyBytes from "pretty-bytes"; + import CustomPreference from "./CustomPreference.svelte"; const MAX_AVATAR_BYTES = 1_000_000; @@ -52,6 +55,7 @@ let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns); let fields: Field[] = window.structuredClone(data.user.fields); let list_private = data.user.list_private; + let custom_preferences = window.structuredClone(data.user.custom_preferences); let avatar: string | null; let avatar_files: FileList | null; @@ -60,6 +64,9 @@ let newPronouns = ""; let newLink = ""; + let preferenceIds: string[]; + $: preferenceIds = Object.keys(custom_preferences); + let modified = false; $: modified = isModified( @@ -73,6 +80,7 @@ avatar, member_title, list_private, + custom_preferences, ); $: getAvatar(avatar_files).then((b64) => (avatar = b64)); @@ -87,6 +95,7 @@ avatar: string | null, member_title: string, list_private: boolean, + custom_preferences: CustomPreferences, ) => { if (bio !== (user.bio || "")) return true; if (display_name !== (user.display_name || "")) return true; @@ -95,6 +104,7 @@ if (!fieldsEqual(fields, user.fields)) return true; if (!namesEqual(names, user.names)) return true; if (!pronounsEqual(pronouns, user.pronouns)) return true; + if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true; if (avatar !== null) return true; if (list_private !== user.list_private) return true; @@ -136,6 +146,21 @@ return arr1.every((_, i) => arr1[i] === arr2[i]); }; + const customPreferencesEqual = (obj1: CustomPreferences, obj2: CustomPreferences) => { + return Object.keys(obj1) + .map((key) => { + if (!(key in obj2)) return false; + return ( + obj1[key].icon === obj2[key].icon && + obj1[key].tooltip === obj2[key].tooltip && + obj1[key].favourite === obj2[key].favourite && + obj1[key].muted === obj2[key].muted && + obj1[key].size === obj2[key].size + ); + }) + .every((entry) => entry); + }; + const getAvatar = async (list: FileList | null) => { if (!list || list.length === 0) return null; if (list[0].size > MAX_AVATAR_BYTES) { @@ -226,6 +251,19 @@ newLink = ""; }; + const addPreference = () => { + const id = crypto.randomUUID(); + + custom_preferences[id] = { + icon: "question", + tooltip: "New preference", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }; + custom_preferences = custom_preferences; + }; + const removeName = (index: number) => { names.splice(index, 1); names = [...names]; @@ -246,6 +284,11 @@ fields = [...fields]; }; + const removePreference = (id: string) => { + delete custom_preferences[id]; + custom_preferences = custom_preferences; + }; + const updateUser = async () => { const toastId = addToast({ header: "Saving changes", @@ -264,6 +307,7 @@ fields, member_title, list_private, + custom_preferences, }); data.user = resp; @@ -367,6 +411,7 @@ moveName(index, true)} moveDown={() => moveName(index, false)} remove={() => removeName(index)} @@ -447,6 +492,7 @@ {#each fields as _, index} removeField(index)} moveField={(up) => moveField(index, up)} /> @@ -478,7 +524,7 @@
- +
@@ -510,5 +556,18 @@

+
+

+ Preferences +

+ {#each preferenceIds as id} + removePreference(id)} + /> + {/each} +
diff --git a/frontend/src/routes/edit/profile/CustomPreference.svelte b/frontend/src/routes/edit/profile/CustomPreference.svelte new file mode 100644 index 0000000..84e49a0 --- /dev/null +++ b/frontend/src/routes/edit/profile/CustomPreference.svelte @@ -0,0 +1,86 @@ + + + + + Change icon + + + + +

+ +

+ + {#each filteredIcons as icon} + (preference.icon = icon)} + > {icon} + {:else} +

Start typing to filter

+ {/each} +
+
+ + Change text size + + + + + + (preference.size = PreferenceSize.Large)}>Large + (preference.size = PreferenceSize.Normal)}>Medium + (preference.size = PreferenceSize.Small)}>Small + + + + + +
From cd8f165a17b8d37f90b938e76dd589fa5610de8e Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 19 Apr 2023 17:21:02 +0200 Subject: [PATCH 023/119] fix(backend): check number of custom preferences in patch --- backend/routes/user/patch_user.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 5507b45..8375a0a 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -120,6 +120,10 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { // validate custom preferences if req.CustomPreferences != nil { + if count := len(*req.CustomPreferences); count > db.MaxFields { + return server.APIError{Code: server.ErrBadRequest, Details: fmt.Sprintf("Too many custom preferences (max %d, current %d)", db.MaxFields, count)} + } + for k, v := range *req.CustomPreferences { _, err := uuid.Parse(k) if err != nil { From b4501f5edec30401d74464bf06955c234df5ab96 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 19 Apr 2023 17:22:20 +0200 Subject: [PATCH 024/119] fix(frontend): search icons by prefix, not contains --- frontend/src/routes/edit/profile/CustomPreference.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/edit/profile/CustomPreference.svelte b/frontend/src/routes/edit/profile/CustomPreference.svelte index 84e49a0..6a36c3a 100644 --- a/frontend/src/routes/edit/profile/CustomPreference.svelte +++ b/frontend/src/routes/edit/profile/CustomPreference.svelte @@ -24,7 +24,12 @@ let searchBox = ""; let filteredIcons: string[] = []; - $: filteredIcons = searchBox ? icons.filter((icon) => icon.includes(searchBox)).slice(0, 15) : []; + $: filteredIcons = searchBox + ? icons + .filter((icon) => icon.startsWith(searchBox)) + .sort() + .slice(0, 15) + : []; From d691d4b151a2cbaa285816777008aac165952170 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Apr 2023 09:12:44 +0200 Subject: [PATCH 025/119] feat(frontend): show favourite preferences in user and member page descriptions --- frontend/src/routes/@[username]/+page.svelte | 30 +++++++++++++--- .../@[username]/[memberName]/+page.svelte | 35 +++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index 6d7cb2c..07d072c 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -20,10 +20,12 @@ MAX_MEMBERS, pronounDisplay, userAvatars, - WordStatus, type APIError, type Member, type PartialMember, + type CustomPreferences, + type FieldEntry, + type Pronoun, } from "$lib/api/entities"; import { PUBLIC_BASE_URL } from "$env/static/public"; import { apiFetchClient } from "$lib/api/fetch"; @@ -34,6 +36,7 @@ import ProfileLink from "./ProfileLink.svelte"; import { memberNameRegex } from "$lib/api/regex"; import StatusLine from "$lib/components/StatusLine.svelte"; + import defaultPreferences from "$lib/api/default_preferences"; export let data: PageData; @@ -84,8 +87,17 @@ } }; - const favNames = data.names.filter((entry) => entry.status === WordStatus.Favourite); - const favPronouns = data.pronouns.filter((entry) => entry.status === WordStatus.Favourite); + let mergedPreferences: CustomPreferences; + $: mergedPreferences = Object.assign(defaultPreferences, data.custom_preferences); + + let favNames: FieldEntry[]; + $: favNames = data.names.filter( + (entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite, + ); + let favPronouns: Pronoun[]; + $: favPronouns = data.pronouns.filter( + (entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite, + ); let profileEmpty = false; $: profileEmpty = @@ -146,7 +158,11 @@

Names

    {#each data.names as name} -
  • {name.value}
  • +
  • + {name.value} +
  • {/each}
@@ -156,7 +172,11 @@

Pronouns

    {#each data.pronouns as pronouns} -
  • +
  • + +
  • {/each}
diff --git a/frontend/src/routes/@[username]/[memberName]/+page.svelte b/frontend/src/routes/@[username]/[memberName]/+page.svelte index 7ef25d9..a90ff5e 100644 --- a/frontend/src/routes/@[username]/[memberName]/+page.svelte +++ b/frontend/src/routes/@[username]/[memberName]/+page.svelte @@ -2,25 +2,40 @@ import FieldCard from "$lib/components/FieldCard.svelte"; import type { PageData } from "./$types"; - import StatusIcon from "$lib/components/StatusIcon.svelte"; import PronounLink from "$lib/components/PronounLink.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte"; import { Alert, Button, Icon } from "sveltestrap"; - import { memberAvatars, pronounDisplay, WordStatus } from "$lib/api/entities"; + import { + memberAvatars, + pronounDisplay, + type CustomPreferences, + type FieldEntry, + type Pronoun, + } from "$lib/api/entities"; import { PUBLIC_BASE_URL } from "$env/static/public"; import { userStore } from "$lib/store"; import { renderMarkdown } from "$lib/utils"; import ReportButton from "../ReportButton.svelte"; import ProfileLink from "../ProfileLink.svelte"; import StatusLine from "$lib/components/StatusLine.svelte"; + import defaultPreferences from "$lib/api/default_preferences"; export let data: PageData; let bio: string | null; $: bio = renderMarkdown(data.bio); - const favNames = data.names.filter((entry) => entry.status === WordStatus.Favourite); - const favPronouns = data.pronouns.filter((entry) => entry.status === WordStatus.Favourite); + let mergedPreferences: CustomPreferences; + $: mergedPreferences = Object.assign(defaultPreferences, data.user.custom_preferences); + + let favNames: FieldEntry[]; + $: favNames = data.names.filter( + (entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite, + ); + let favPronouns: Pronoun[]; + $: favPronouns = data.pronouns.filter( + (entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite, + ); let profileEmpty = false; $: profileEmpty = @@ -81,7 +96,11 @@

Names

    {#each data.names as name} -
  • {name.value}
  • +
  • + {name.value} +
  • {/each}
@@ -91,7 +110,11 @@

Pronouns

    {#each data.pronouns as pronouns} -
  • +
  • + +
  • {/each}
From 70b4417128c7c6f9818cba22cd7156ca31d3b25b Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Apr 2023 09:29:47 +0200 Subject: [PATCH 026/119] fix(frontend): fix eslint errors --- frontend/src/lib/components/IconButton.svelte | 4 ++-- frontend/src/lib/store.ts | 2 +- frontend/src/routes/@[username]/+page.svelte | 2 +- frontend/src/routes/edit/EditableField.svelte | 2 +- frontend/src/routes/edit/EditablePronouns.svelte | 1 - frontend/src/routes/nav/Navigation.svelte | 2 +- frontend/src/routes/reports/ReportCard.svelte | 2 +- frontend/src/routes/settings/tokens/+page.svelte | 2 +- frontend/src/routes/settings/warnings/+page.svelte | 2 +- 9 files changed, 9 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte index cfbb617..f340cfc 100644 --- a/frontend/src/lib/components/IconButton.svelte +++ b/frontend/src/lib/components/IconButton.svelte @@ -4,8 +4,8 @@ export let icon: string; export let color: "primary" | "secondary" | "success" | "danger"; export let tooltip: string; - export let active: boolean = false; - export let disabled: boolean = false; + export let active = false; + export let disabled = false; export let type: string | undefined = undefined; export let id: string | undefined = undefined; diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index 492b0c7..99a94d8 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -6,7 +6,7 @@ import type { MeUser } from "./api/entities"; const initialUserValue = null; export const userStore = writable(initialUserValue); -let defaultThemeValue = "dark"; +const defaultThemeValue = "dark"; const initialThemeValue = browser ? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue : defaultThemeValue; diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index 07d072c..ad06da9 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -43,7 +43,7 @@ let bio: string | null; $: bio = renderMarkdown(data.bio); - let memberPage: number = 0; + let memberPage = 0; let memberSlice: PartialMember[] = []; $: memberSlice = data.members.slice(memberPage * 20, (memberPage + 1) * 20); const totalPages = Math.ceil(data.members.length / 20); diff --git a/frontend/src/routes/edit/EditableField.svelte b/frontend/src/routes/edit/EditableField.svelte index 66be6c9..1c3f229 100644 --- a/frontend/src/routes/edit/EditableField.svelte +++ b/frontend/src/routes/edit/EditableField.svelte @@ -9,7 +9,7 @@ export let deleteField: () => void; export let moveField: (up: boolean) => void; - let newEntry: string = ""; + let newEntry = ""; const addEntry = (event: Event) => { event.preventDefault(); diff --git a/frontend/src/routes/edit/EditablePronouns.svelte b/frontend/src/routes/edit/EditablePronouns.svelte index d1a1548..5179a81 100644 --- a/frontend/src/routes/edit/EditablePronouns.svelte +++ b/frontend/src/routes/edit/EditablePronouns.svelte @@ -3,7 +3,6 @@ import type { Pronoun, CustomPreference, CustomPreferences } from "$lib/api/entities"; import IconButton from "$lib/components/IconButton.svelte"; import { - Button, ButtonDropdown, Collapse, DropdownItem, diff --git a/frontend/src/routes/nav/Navigation.svelte b/frontend/src/routes/nav/Navigation.svelte index 2b2c33b..ef1e992 100644 --- a/frontend/src/routes/nav/Navigation.svelte +++ b/frontend/src/routes/nav/Navigation.svelte @@ -31,7 +31,7 @@ let theme: string; let currentUser: MeUser | null; - let showMenu: boolean = false; + let showMenu = false; let isAdmin = false; let numReports = 0; diff --git a/frontend/src/routes/reports/ReportCard.svelte b/frontend/src/routes/reports/ReportCard.svelte index ac1718c..6af81b7 100644 --- a/frontend/src/routes/reports/ReportCard.svelte +++ b/frontend/src/routes/reports/ReportCard.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/routes/settings/tokens/+page.svelte b/frontend/src/routes/settings/tokens/+page.svelte index f0f8e29..1e0f585 100644 --- a/frontend/src/routes/settings/tokens/+page.svelte +++ b/frontend/src/routes/settings/tokens/+page.svelte @@ -1,6 +1,6 @@ + + If you find pronouns.cc useful and have the means, I would really appreciate a donation + to keep the site running, fast, and ad-free! +
+ It's not required and doesn't give you any perks, but running a website is expensive so it would really + help. +
+ + (If you want to hide this alert, press the in the top right to hide + it on this computer or phone) + +
+

Your profile

From 75abe1a89748bd08b6471c8711008629e89b6391 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Apr 2023 10:27:57 +0200 Subject: [PATCH 028/119] tweak wording --- frontend/src/routes/settings/+page.svelte | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 83fa159..0ec1b4e 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -105,9 +105,8 @@ > to keep the site running, fast, and ad-free!
- It's not required and doesn't give you any perks, but running a website is expensive so it would really - help. -
+ It's not required and doesn't give you any perks, but running this website takes time and money so + it would really help. (If you want to hide this alert, press the in the top right to hide it on this computer or phone) From 61b69d10262a3c10a5753aeb0d277e6a2b5417c2 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Apr 2023 10:28:07 +0200 Subject: [PATCH 029/119] feat: add changelog --- frontend/src/lib/store.ts | 2 ++ frontend/src/routes/+layout.svelte | 1 + frontend/src/routes/nav/Navigation.svelte | 12 ++++++++- .../src/routes/page/changelog/+page.svelte | 25 +++++++++++++++++++ frontend/src/routes/page/changelog/+page.ts | 0 .../src/routes/page/changelog/changelog.md | 17 +++++++++++++ 6 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/page/changelog/+page.svelte create mode 100644 frontend/src/routes/page/changelog/+page.ts create mode 100644 frontend/src/routes/page/changelog/changelog.md diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index 99a94d8..cac34b8 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -12,3 +12,5 @@ const initialThemeValue = browser : defaultThemeValue; export const themeStore = writable(initialThemeValue); + +export const CURRENT_CHANGELOG = "0.4.0"; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 32c55d3..11861e5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -49,6 +49,7 @@ >) {/if} · About & contact · + Changelog · Donate · diff --git a/frontend/src/routes/nav/Navigation.svelte b/frontend/src/routes/nav/Navigation.svelte index ef1e992..b2f43b1 100644 --- a/frontend/src/routes/nav/Navigation.svelte +++ b/frontend/src/routes/nav/Navigation.svelte @@ -16,7 +16,7 @@ } from "sveltestrap"; import Logo from "./Logo.svelte"; - import { userStore, themeStore } from "$lib/store"; + import { userStore, themeStore, CURRENT_CHANGELOG } from "$lib/store"; import { ErrorCode, type APIError, @@ -36,11 +36,14 @@ let isAdmin = false; let numReports = 0; let numWarnings = 0; + let changelogRead = "99.99.99"; $: currentUser = $userStore; $: theme = $themeStore; onMount(() => { + changelogRead = localStorage.getItem("changelog-read") || "0.0.0"; + const localUser = localStorage.getItem("pronouns-user"); userStore.set(localUser ? JSON.parse(localUser) : null); @@ -146,6 +149,13 @@ {/if} + {#if changelogRead < CURRENT_CHANGELOG} + + + Changelog v{CURRENT_CHANGELOG} + + + {/if} {:else} Log in diff --git a/frontend/src/routes/page/changelog/+page.svelte b/frontend/src/routes/page/changelog/+page.svelte new file mode 100644 index 0000000..51cc848 --- /dev/null +++ b/frontend/src/routes/page/changelog/+page.svelte @@ -0,0 +1,25 @@ + + + + Changelog - pronouns.cc + + + + +
+
+
+
+ {@html html} +
+
+
+
diff --git a/frontend/src/routes/page/changelog/+page.ts b/frontend/src/routes/page/changelog/+page.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/routes/page/changelog/changelog.md b/frontend/src/routes/page/changelog/changelog.md new file mode 100644 index 0000000..bca87ce --- /dev/null +++ b/frontend/src/routes/page/changelog/changelog.md @@ -0,0 +1,17 @@ +# Changelog + +## 0.4.0 - April 20th 2023 + +- **Added custom preferences!** + These can be used instead of, or in addition to, the existing five preferences + and can use any icon in [Bootstrap Icons](https://icons.getbootstrap.com/). + Change them in your [profile settings](/edit/profile). +- Added the ability to sign in with Tumblr and Google accounts, + in addition to Discord and Mastodon. +- Added this changelog. +- Added a donation link to the footer, and a message on the settings page. Sadly, running a website like this costs money :( + +--- + +- The website will now correctly handle tokens expiring, so you don't get stuck in an infinite loop as soon as they expire anymore. +- Fixed pronoun links, pronouns with special characters will link to the correct page now. From 6dd3478ff94c937dd7e0edee4e27ff8545462f19 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 21 Apr 2023 00:07:02 +0200 Subject: [PATCH 030/119] fix: abort if oauth user info is invalid --- backend/routes/auth/discord.go | 10 ++++++++++ backend/routes/auth/fedi_mastodon.go | 10 ++++++++++ backend/routes/auth/fedi_misskey.go | 10 ++++++++++ backend/routes/auth/google.go | 10 ++++++++++ backend/routes/auth/tumblr.go | 10 ++++++++++ 5 files changed, 50 insertions(+) diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index 7ef3b04..34b0f93 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -193,6 +193,11 @@ func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + if du.ID == "" { + log.Errorf("linking user with id %v: discord user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"} + } + err = u.UpdateFromDiscord(ctx, s.DB, du) if err != nil { return errors.Wrap(err, "updating user from discord") @@ -302,6 +307,11 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { return errors.Wrap(err, "creating user") } + if du.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"} + } + err = u.UpdateFromDiscord(ctx, tx, du) if err != nil { return errors.Wrap(err, "updating user from discord") diff --git a/backend/routes/auth/fedi_mastodon.go b/backend/routes/auth/fedi_mastodon.go index aece379..85bab13 100644 --- a/backend/routes/auth/fedi_mastodon.go +++ b/backend/routes/auth/fedi_mastodon.go @@ -220,6 +220,11 @@ func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + if mu.ID == "" { + log.Errorf("linking user with id %v: user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"} + } + err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) if err != nil { return errors.Wrap(err, "updating user from mastoAPI") @@ -330,6 +335,11 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { return errors.Wrap(err, "creating user") } + if mu.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"} + } + err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) if err != nil { return errors.Wrap(err, "updating user from mastoAPI") diff --git a/backend/routes/auth/fedi_misskey.go b/backend/routes/auth/fedi_misskey.go index 9d869ad..69b0d94 100644 --- a/backend/routes/auth/fedi_misskey.go +++ b/backend/routes/auth/fedi_misskey.go @@ -195,6 +195,11 @@ func (s *Server) misskeyLink(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + if mu.ID == "" { + log.Errorf("linking user with id %v: user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"} + } + err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) if err != nil { return errors.Wrap(err, "updating user from misskey") @@ -260,6 +265,11 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error { return errors.Wrap(err, "creating user") } + if mu.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"} + } + err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) if err != nil { return errors.Wrap(err, "updating user from misskey") diff --git a/backend/routes/auth/google.go b/backend/routes/auth/google.go index d28b48b..9edb21e 100644 --- a/backend/routes/auth/google.go +++ b/backend/routes/auth/google.go @@ -208,6 +208,11 @@ func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + if gu.ID == "" { + log.Errorf("linking user with id %v: user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"} + } + err = u.UpdateFromGoogle(ctx, s.DB, gu.ID, gu.Email) if err != nil { return errors.Wrap(err, "updating user from google") @@ -306,6 +311,11 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error { return errors.Wrap(err, "creating user") } + if gu.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"} + } + err = u.UpdateFromGoogle(ctx, tx, gu.ID, gu.Email) if err != nil { return errors.Wrap(err, "updating user from google") diff --git a/backend/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go index 22ee4e4..8fdddf1 100644 --- a/backend/routes/auth/tumblr.go +++ b/backend/routes/auth/tumblr.go @@ -241,6 +241,11 @@ func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + if tui.ID == "" { + log.Errorf("linking user with id %v: user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"} + } + err = u.UpdateFromTumblr(ctx, s.DB, tui.ID, tui.Name) if err != nil { return errors.Wrap(err, "updating user from tumblr") @@ -339,6 +344,11 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error { return errors.Wrap(err, "creating user") } + if tui.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"} + } + err = u.UpdateFromTumblr(ctx, tx, tui.ID, tui.Name) if err != nil { return errors.Wrap(err, "updating user from tumblr") From 5594463a09f30c0279d065718645524d0f1d993b Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 21 Apr 2023 16:35:13 +0200 Subject: [PATCH 031/119] fix(backend): use to-be-set custom preferences when validating fields, remove constants --- backend/db/entries.go | 11 +----- backend/routes/bot/bot.go | 2 +- backend/routes/user/patch_user.go | 28 ++++++++------- scripts/seeddb/main.go | 58 +++++++++++++++---------------- 4 files changed, 47 insertions(+), 52 deletions(-) diff --git a/backend/db/entries.go b/backend/db/entries.go index 1b4b8cd..86e7a25 100644 --- a/backend/db/entries.go +++ b/backend/db/entries.go @@ -7,15 +7,6 @@ import ( type WordStatus string -const ( - StatusUnknown WordStatus = "" - StatusFavourite WordStatus = "favourite" - StatusOkay WordStatus = "okay" - StatusJokingly WordStatus = "jokingly" - StatusFriendsOnly WordStatus = "friends_only" - StatusAvoid WordStatus = "avoid" -) - func (w *WordStatus) UnmarshalJSON(src []byte) error { if string(src) == "null" { return nil @@ -41,7 +32,7 @@ func (w *WordStatus) UnmarshalJSON(src []byte) error { } func (w WordStatus) Valid(extra CustomPreferences) bool { - if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid { + if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" { return true } diff --git a/backend/routes/bot/bot.go b/backend/routes/bot/bot.go index b5d7b42..3dd4515 100644 --- a/backend/routes/bot/bot.go +++ b/backend/routes/bot/bot.go @@ -144,7 +144,7 @@ func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discord var favs []db.FieldEntry for _, e := range field.Entries { - if e.Status == db.StatusFavourite { + if e.Status == "favourite" { favs = append(favs, e) } } diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 8375a0a..7fa1db5 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -106,18 +106,6 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } - if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil { - return *err - } - - if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil { - return *err - } - - if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil { - return *err - } - // validate custom preferences if req.CustomPreferences != nil { if count := len(*req.CustomPreferences); count > db.MaxFields { @@ -134,6 +122,22 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } } + customPreferences := u.CustomPreferences + if req.CustomPreferences != nil { + customPreferences = *req.CustomPreferences + } + + if err := validateSlicePtr("name", req.Names, customPreferences); err != nil { + return *err + } + + if err := validateSlicePtr("pronoun", req.Pronouns, customPreferences); err != nil { + return *err + } + + if err := validateSlicePtr("field", req.Fields, customPreferences); err != nil { + return *err + } // update avatar var avatarHash *string = nil diff --git a/scripts/seeddb/main.go b/scripts/seeddb/main.go index a430e72..c321f66 100644 --- a/scripts/seeddb/main.go +++ b/scripts/seeddb/main.go @@ -55,11 +55,11 @@ func run(c *cli.Context) error { } err = pg.SetUserNamesPronouns(ctx, tx, u.ID, []db.FieldEntry{ - {Value: "testing 1", Status: db.StatusFavourite}, - {Value: "testing 2", Status: db.StatusOkay}, + {Value: "testing 1", Status: "favourite"}, + {Value: "testing 2", Status: "okay"}, }, []db.PronounEntry{ - {Pronouns: "it/it/its/its/itself", DisplayText: ptr("it/its"), Status: db.StatusFavourite}, - {Pronouns: "they/them/their/theirs/themself", Status: db.StatusOkay}, + {Pronouns: "it/it/its/its/itself", DisplayText: ptr("it/its"), Status: "favourite"}, + {Pronouns: "they/them/their/theirs/themself", Status: "okay"}, }) if err != nil { fmt.Println("error setting pronouns:", err) @@ -70,51 +70,51 @@ func run(c *cli.Context) error { { Name: "Field 1", Entries: []db.FieldEntry{ - {Value: "Favourite 1", Status: db.StatusFavourite}, - {Value: "Okay 1", Status: db.StatusOkay}, - {Value: "Jokingly 1", Status: db.StatusJokingly}, - {Value: "Friends only 1", Status: db.StatusFriendsOnly}, - {Value: "Avoid 1", Status: db.StatusAvoid}, + {Value: "Favourite 1", Status: "favourite"}, + {Value: "Okay 1", Status: "okay"}, + {Value: "Jokingly 1", Status: "jokingly"}, + {Value: "Friends only 1", Status: "friends_only"}, + {Value: "Avoid 1", Status: "avoid"}, }, }, { Name: "Field 2", Entries: []db.FieldEntry{ - {Value: "Favourite 2", Status: db.StatusFavourite}, - {Value: "Okay 2", Status: db.StatusOkay}, - {Value: "Jokingly 2", Status: db.StatusJokingly}, - {Value: "Friends only 2", Status: db.StatusFriendsOnly}, - {Value: "Avoid 2", Status: db.StatusAvoid}, + {Value: "Favourite 2", Status: "favourite"}, + {Value: "Okay 2", Status: "okay"}, + {Value: "Jokingly 2", Status: "jokingly"}, + {Value: "Friends only 2", Status: "friends_only"}, + {Value: "Avoid 2", Status: "avoid"}, }, }, { Name: "Field 3", Entries: []db.FieldEntry{ - {Value: "Favourite 3", Status: db.StatusFavourite}, - {Value: "Okay 3", Status: db.StatusOkay}, - {Value: "Jokingly 3", Status: db.StatusJokingly}, - {Value: "Friends only 3", Status: db.StatusFriendsOnly}, - {Value: "Avoid 3", Status: db.StatusAvoid}, + {Value: "Favourite 3", Status: "favourite"}, + {Value: "Okay 3", Status: "okay"}, + {Value: "Jokingly 3", Status: "jokingly"}, + {Value: "Friends only 3", Status: "friends_only"}, + {Value: "Avoid 3", Status: "avoid"}, }, }, { Name: "Field 4", Entries: []db.FieldEntry{ - {Value: "Favourite 4", Status: db.StatusFavourite}, - {Value: "Okay 4", Status: db.StatusOkay}, - {Value: "Jokingly 4", Status: db.StatusJokingly}, - {Value: "Friends only 4", Status: db.StatusFriendsOnly}, - {Value: "Avoid 4", Status: db.StatusAvoid}, + {Value: "Favourite 4", Status: "favourite"}, + {Value: "Okay 4", Status: "okay"}, + {Value: "Jokingly 4", Status: "jokingly"}, + {Value: "Friends only 4", Status: "friends_only"}, + {Value: "Avoid 4", Status: "avoid"}, }, }, { Name: "Field 5", Entries: []db.FieldEntry{ - {Value: "Favourite 5", Status: db.StatusFavourite}, - {Value: "Okay 5", Status: db.StatusOkay}, - {Value: "Jokingly 5", Status: db.StatusJokingly}, - {Value: "Friends only 5", Status: db.StatusFriendsOnly}, - {Value: "Avoid 5", Status: db.StatusAvoid}, + {Value: "Favourite 5", Status: "favourite"}, + {Value: "Okay 5", Status: "okay"}, + {Value: "Jokingly 5", Status: "jokingly"}, + {Value: "Friends only 5", Status: "friends_only"}, + {Value: "Avoid 5", Status: "avoid"}, }, }, }) From bd279a7dae2141c06cee1e3fe61ff448b2148529 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 21 Apr 2023 16:37:26 +0200 Subject: [PATCH 032/119] fix(frontend): use 'treat as favourite' preferences as favourites in member list --- .../src/lib/components/PartialMemberCard.svelte | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/PartialMemberCard.svelte b/frontend/src/lib/components/PartialMemberCard.svelte index 040e060..1d883d9 100644 --- a/frontend/src/lib/components/PartialMemberCard.svelte +++ b/frontend/src/lib/components/PartialMemberCard.svelte @@ -1,5 +1,11 @@
@@ -187,11 +195,19 @@
{/each}
- {#if $userStore && $userStore.id !== data.id} -
- +
+
+ + + {#if $userStore && $userStore.id !== data.id} + + {/if} +
- {/if} +
+
{#if data.members.length > 0 || ($userStore && $userStore.id === data.id)}
diff --git a/frontend/src/routes/@[username]/ReportButton.svelte b/frontend/src/routes/@[username]/ReportButton.svelte index f33f5c0..4f55f9d 100644 --- a/frontend/src/routes/@[username]/ReportButton.svelte +++ b/frontend/src/routes/@[username]/ReportButton.svelte @@ -28,11 +28,9 @@ }; -
- -
+ diff --git a/frontend/src/routes/@[username]/[memberName]/+page.svelte b/frontend/src/routes/@[username]/[memberName]/+page.svelte index a90ff5e..28330e6 100644 --- a/frontend/src/routes/@[username]/[memberName]/+page.svelte +++ b/frontend/src/routes/@[username]/[memberName]/+page.svelte @@ -4,7 +4,7 @@ import type { PageData } from "./$types"; import PronounLink from "$lib/components/PronounLink.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte"; - import { Alert, Button, Icon } from "sveltestrap"; + import { Alert, Button, Icon, InputGroup } from "sveltestrap"; import { memberAvatars, pronounDisplay, @@ -19,6 +19,7 @@ import ProfileLink from "../ProfileLink.svelte"; import StatusLine from "$lib/components/StatusLine.svelte"; import defaultPreferences from "$lib/api/default_preferences"; + import { addToast } from "$lib/toast"; export let data: PageData; @@ -43,6 +44,12 @@ data.pronouns.length === 0 && data.fields.length === 0 && (!data.bio || data.bio.length === 0); + + const copyURL = async () => { + const url = `${PUBLIC_BASE_URL}/@${data.user.name}/${data.name}`; + await navigator.clipboard.writeText(url); + addToast({ body: "Copied the link to your clipboard!", duration: 2000 }); + };
@@ -125,11 +132,19 @@
{/each}
- {#if $userStore && $userStore.id !== data.user.id} -
- +
+
+ + + {#if $userStore && $userStore.id !== data.id} + + {/if} +
- {/if} +
+
From 848d0787a50a42db31b0ee8a590e38dfe5ad5e4e Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 23 Apr 2023 04:01:02 +0200 Subject: [PATCH 036/119] feat(frontend): add move buttons to links (fixes #54) --- .../src/routes/edit/member/[id]/+page.svelte | 23 +++++++++++++++++++ frontend/src/routes/edit/profile/+page.svelte | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/frontend/src/routes/edit/member/[id]/+page.svelte b/frontend/src/routes/edit/member/[id]/+page.svelte index 8f8dab7..864ad80 100644 --- a/frontend/src/routes/edit/member/[id]/+page.svelte +++ b/frontend/src/routes/edit/member/[id]/+page.svelte @@ -200,6 +200,17 @@ fields[newIndex] = temp; }; + const moveLink = (index: number, up: boolean) => { + if (up && index == 0) return; + if (!up && index == links.length - 1) return; + + const newIndex = up ? index - 1 : index + 1; + + const temp = links[index]; + links[index] = links[newIndex]; + links[newIndex] = temp; + }; + const addName = (event: Event) => { event.preventDefault(); @@ -534,6 +545,18 @@
{#each links as _, index}
+ moveLink(index, true)} + /> + moveLink(index, false)} + /> { + if (up && index == 0) return; + if (!up && index == links.length - 1) return; + + const newIndex = up ? index - 1 : index + 1; + + const temp = links[index]; + links[index] = links[newIndex]; + links[newIndex] = temp; + }; + const addName = (event: Event) => { event.preventDefault(); @@ -507,6 +518,18 @@
{#each links as _, index}
+ moveLink(index, true)} + /> + moveLink(index, false)} + /> Date: Sun, 23 Apr 2023 23:06:53 +0200 Subject: [PATCH 037/119] fix(frontend): fix save button not showing up when deleting custom preferences (fixes #55) --- frontend/src/routes/edit/profile/+page.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/routes/edit/profile/+page.svelte b/frontend/src/routes/edit/profile/+page.svelte index 74c67f3..b6fda9f 100644 --- a/frontend/src/routes/edit/profile/+page.svelte +++ b/frontend/src/routes/edit/profile/+page.svelte @@ -146,6 +146,8 @@ }; const customPreferencesEqual = (obj1: CustomPreferences, obj2: CustomPreferences) => { + if (Object.keys(obj2).some((key) => !(key in obj1))) return false; + return Object.keys(obj1) .map((key) => { if (!(key in obj2)) return false; From 6f7eb5eeeecaa56fe16b8755c5024be5e2479900 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 24 Apr 2023 16:51:55 +0200 Subject: [PATCH 038/119] feat: add captcha when signing up (closes #53) --- backend/routes/auth/captcha.go | 58 +++++++++++++++++++ backend/routes/auth/discord.go | 36 ++++++++---- backend/routes/auth/fedi_mastodon.go | 38 ++++++++---- backend/routes/auth/fedi_misskey.go | 22 +++++-- backend/routes/auth/google.go | 29 +++++++--- backend/routes/auth/routes.go | 11 +++- backend/routes/auth/tumblr.go | 29 +++++++--- backend/server/errors.go | 3 + frontend/package.json | 1 + frontend/pnpm-lock.yaml | 6 ++ frontend/src/app.d.ts | 31 ++++++++-- frontend/src/lib/api/entities.ts | 1 + .../src/routes/auth/login/CallbackPage.svelte | 43 +++++++++++++- .../routes/auth/login/discord/+page.server.ts | 1 + .../routes/auth/login/discord/+page.svelte | 13 ++++- .../routes/auth/login/google/+page.server.ts | 1 + .../src/routes/auth/login/google/+page.svelte | 13 ++++- .../login/mastodon/[instance]/+page.server.ts | 1 + .../login/mastodon/[instance]/+page.svelte | 13 ++++- .../login/misskey/[instance]/+page.server.ts | 1 + .../login/misskey/[instance]/+page.svelte | 12 +++- .../routes/auth/login/tumblr/+page.server.ts | 1 + .../src/routes/auth/login/tumblr/+page.svelte | 13 ++++- 23 files changed, 316 insertions(+), 61 deletions(-) create mode 100644 backend/routes/auth/captcha.go diff --git a/backend/routes/auth/captcha.go b/backend/routes/auth/captcha.go new file mode 100644 index 0000000..d7ef04b --- /dev/null +++ b/backend/routes/auth/captcha.go @@ -0,0 +1,58 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" +) + +const hcaptchaURL = "https://hcaptcha.com/siteverify" + +type hcaptchaResponse struct { + Success bool `json:"success"` +} + +// verifyCaptcha verifies a captcha response. +func (s *Server) verifyCaptcha(ctx context.Context, response string) (ok bool, err error) { + vals := url.Values{ + "response": []string{response}, + "secret": []string{s.hcaptchaSecret}, + "sitekey": []string{s.hcaptchaSitekey}, + } + + req, err := http.NewRequestWithContext(ctx, "POST", hcaptchaURL, strings.NewReader(vals.Encode())) + if err != nil { + return false, errors.Wrap(err, "creating request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, errors.Wrap(err, "sending request") + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return false, errors.Sentinel("error status code") + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return false, errors.Wrap(err, "reading body") + } + fmt.Println(string(b)) + var hr hcaptchaResponse + err = json.Unmarshal(b, &hr) + if err != nil { + return false, errors.Wrap(err, "unmarshaling json") + } + + return hr.Success, nil +} diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index 34b0f93..2432b98 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -39,9 +39,10 @@ type discordCallbackResponse struct { Token string `json:"token,omitempty"` User *userResponse `json:"user,omitempty"` - Discord string `json:"discord,omitempty"` // username, for UI purposes - Ticket string `json:"ticket,omitempty"` - RequireInvite bool `json:"require_invite"` // require an invite for signing up + Discord string `json:"discord,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + RequireCaptcha bool `json:"require_captcha"` IsDeleted bool `json:"is_deleted"` DeletedAt *time.Time `json:"deleted_at,omitempty"` @@ -148,10 +149,11 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { } render.JSON(w, r, discordCallbackResponse{ - HasAccount: false, - Discord: du.String(), - Ticket: ticket, - RequireInvite: s.RequireInvite, + HasAccount: false, + Discord: du.String(), + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", }) return nil @@ -251,9 +253,10 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error { } type signupRequest struct { - Ticket string `json:"ticket"` - Username string `json:"username"` - InviteCode string `json:"invite_code"` + Ticket string `json:"ticket"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` + CaptchaResponse string `json:"captcha_response"` } type signupResponse struct { @@ -298,6 +301,19 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + u, err := s.DB.CreateUser(ctx, tx, req.Username) if err != nil { if errors.Cause(err) == db.ErrUsernameTaken { diff --git a/backend/routes/auth/fedi_mastodon.go b/backend/routes/auth/fedi_mastodon.go index 85bab13..7c44922 100644 --- a/backend/routes/auth/fedi_mastodon.go +++ b/backend/routes/auth/fedi_mastodon.go @@ -27,9 +27,10 @@ type fediCallbackResponse struct { Token string `json:"token,omitempty"` User *userResponse `json:"user,omitempty"` - Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes - Ticket string `json:"ticket,omitempty"` - RequireInvite bool `json:"require_invite"` // require an invite for signing up + Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + RequireCaptcha bool `json:"require_captcha"` IsDeleted bool `json:"is_deleted"` DeletedAt *time.Time `json:"deleted_at,omitempty"` @@ -169,10 +170,11 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error } render.JSON(w, r, fediCallbackResponse{ - HasAccount: false, - Fediverse: mu.Username, - Ticket: ticket, - RequireInvite: s.RequireInvite, + HasAccount: false, + Fediverse: mu.Username, + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", }) return nil @@ -278,10 +280,11 @@ func (s *Server) mastodonUnlink(w http.ResponseWriter, r *http.Request) error { } type fediSignupRequest struct { - Instance string `json:"instance"` - Ticket string `json:"ticket"` - Username string `json:"username"` - InviteCode string `json:"invite_code"` + Instance string `json:"instance"` + Ticket string `json:"ticket"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` + CaptchaResponse string `json:"captcha_response"` } func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { @@ -326,6 +329,19 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + u, err := s.DB.CreateUser(ctx, tx, req.Username) if err != nil { if errors.Cause(err) == db.ErrUsernameTaken { diff --git a/backend/routes/auth/fedi_misskey.go b/backend/routes/auth/fedi_misskey.go index 69b0d94..a7c28c3 100644 --- a/backend/routes/auth/fedi_misskey.go +++ b/backend/routes/auth/fedi_misskey.go @@ -149,10 +149,11 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { } render.JSON(w, r, fediCallbackResponse{ - HasAccount: false, - Fediverse: mu.User.Username, - Ticket: ticket, - RequireInvite: s.RequireInvite, + HasAccount: false, + Fediverse: mu.User.Username, + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", }) return nil @@ -256,6 +257,19 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + u, err := s.DB.CreateUser(ctx, tx, req.Username) if err != nil { if errors.Cause(err) == db.ErrUsernameTaken { diff --git a/backend/routes/auth/google.go b/backend/routes/auth/google.go index 9edb21e..38896e8 100644 --- a/backend/routes/auth/google.go +++ b/backend/routes/auth/google.go @@ -33,9 +33,10 @@ type googleCallbackResponse struct { Token string `json:"token,omitempty"` User *userResponse `json:"user,omitempty"` - Google string `json:"google,omitempty"` // username, for UI purposes - Ticket string `json:"ticket,omitempty"` - RequireInvite bool `json:"require_invite"` // require an invite for signing up + Google string `json:"google,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + RequireCaptcha bool `json:"require_captcha"` IsDeleted bool `json:"is_deleted"` DeletedAt *time.Time `json:"deleted_at,omitempty"` @@ -167,10 +168,11 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { } render.JSON(w, r, googleCallbackResponse{ - HasAccount: false, - Google: googleUsername, - Ticket: ticket, - RequireInvite: s.RequireInvite, + HasAccount: false, + Google: googleUsername, + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", }) return nil @@ -302,6 +304,19 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + u, err := s.DB.CreateUser(ctx, tx, req.Username) if err != nil { if errors.Cause(err) == db.ErrUsernameTaken { diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 1b7ad53..ad5c92d 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -18,6 +18,9 @@ type Server struct { RequireInvite bool BaseURL string + + hcaptchaSitekey string + hcaptchaSecret string } type userResponse struct { @@ -70,9 +73,11 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { func Mount(srv *server.Server, r chi.Router) { s := &Server{ - Server: srv, - RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", - BaseURL: os.Getenv("BASE_URL"), + Server: srv, + RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", + BaseURL: os.Getenv("BASE_URL"), + hcaptchaSitekey: os.Getenv("HCAPTCHA_SITEKEY"), + hcaptchaSecret: os.Getenv("HCAPTCHA_SECRET"), } r.Route("/auth", func(r chi.Router) { diff --git a/backend/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go index 8fdddf1..c896e76 100644 --- a/backend/routes/auth/tumblr.go +++ b/backend/routes/auth/tumblr.go @@ -55,9 +55,10 @@ type tumblrCallbackResponse struct { Token string `json:"token,omitempty"` User *userResponse `json:"user,omitempty"` - Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes - Ticket string `json:"ticket,omitempty"` - RequireInvite bool `json:"require_invite"` // require an invite for signing up + Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + RequireCaptcha bool `json:"require_captcha"` IsDeleted bool `json:"is_deleted"` DeletedAt *time.Time `json:"deleted_at,omitempty"` @@ -200,10 +201,11 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { } render.JSON(w, r, tumblrCallbackResponse{ - HasAccount: false, - Tumblr: tumblrName, - Ticket: ticket, - RequireInvite: s.RequireInvite, + HasAccount: false, + Tumblr: tumblrName, + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", }) return nil @@ -335,6 +337,19 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrInvalidTicket} } + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + u, err := s.DB.CreateUser(ctx, tx, req.Username) if err != nil { if errors.Cause(err) == db.ErrUsernameTaken { diff --git a/backend/server/errors.go b/backend/server/errors.go index 0b0ae3d..18bec03 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -97,6 +97,7 @@ const ( ErrAlreadyLinked = 1014 // user already has linked account of the same type ErrNotLinked = 1015 // user already doesn't have a linked account ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method + ErrInvalidCaptcha = 1017 // invalid or missing captcha response // User-related error codes ErrUserNotFound = 2001 @@ -141,6 +142,7 @@ var errCodeMessages = map[int]string{ ErrAlreadyLinked: "Your account is already linked to an account of this type", ErrNotLinked: "Your account is already not linked to an account of this type", ErrLastProvider: "This is your account's only authentication provider", + ErrInvalidCaptcha: "Invalid or missing captcha response", ErrUserNotFound: "User not found", ErrMemberListPrivate: "This user's member list is private.", @@ -181,6 +183,7 @@ var errCodeStatuses = map[int]int{ ErrAlreadyLinked: http.StatusBadRequest, ErrNotLinked: http.StatusBadRequest, ErrLastProvider: http.StatusBadRequest, + ErrInvalidCaptcha: http.StatusBadRequest, ErrUserNotFound: http.StatusNotFound, ErrMemberListPrivate: http.StatusForbidden, diff --git a/frontend/package.json b/frontend/package.json index 1755f23..54aa8ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "prettier-plugin-svelte": "^2.10.0", "svelte": "^3.58.0", "svelte-check": "^3.1.4", + "svelte-hcaptcha": "^0.1.1", "sveltestrap": "^5.10.0", "tslib": "^2.5.0", "typescript": "^4.9.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 42f86fc..248aa30 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -27,6 +27,7 @@ specifiers: sanitize-html: ^2.10.0 svelte: ^3.58.0 svelte-check: ^3.1.4 + svelte-hcaptcha: ^0.1.1 sveltestrap: ^5.10.0 tslib: ^2.5.0 typescript: ^4.9.5 @@ -62,6 +63,7 @@ devDependencies: prettier-plugin-svelte: 2.10.0_ur5pqdgn24bclu6l6i7qojopk4 svelte: 3.58.0 svelte-check: 3.1.4_svelte@3.58.0 + svelte-hcaptcha: 0.1.1 sveltestrap: 5.10.0_svelte@3.58.0 tslib: 2.5.0 typescript: 4.9.5 @@ -2018,6 +2020,10 @@ packages: - sugarss dev: true + /svelte-hcaptcha/0.1.1: + resolution: {integrity: sha512-iFF3HwfrCRciJnDs4Y9/rpP/BM2U/5zt+vh+9d4tALPAHVkcANiJIKqYuS835pIaTm6gt+xOzjfFI3cgiRI29A==} + dev: true + /svelte-hmr/0.15.1_svelte@3.58.0: resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==} engines: {node: ^12.20 || ^14.13.1 || >= 16} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index f59b884..9176095 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,12 +1,31 @@ // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface Platform {} - } + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface Platform {} + } +} + +declare module "svelte-hcaptcha" { + import type { SvelteComponent } from "svelte"; + + export interface HCaptchaProps { + sitekey?: string; + apihost?: string; + hl?: string; + reCaptchaCompat?: boolean; + theme?: CaptchaTheme; + size?: string; + } + + declare class HCaptcha extends SvelteComponent { + $$prop_def: HCaptchaProps; + } + + export default HCaptcha; } export {}; diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 267061e..22fe815 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -150,6 +150,7 @@ export enum ErrorCode { AlreadyLinked = 1014, NotLinked = 1015, LastProvider = 1016, + InvalidCaptcha = 1017, UserNotFound = 2001, diff --git a/frontend/src/routes/auth/login/CallbackPage.svelte b/frontend/src/routes/auth/login/CallbackPage.svelte index 6a9b27d..0138dff 100644 --- a/frontend/src/routes/auth/login/CallbackPage.svelte +++ b/frontend/src/routes/auth/login/CallbackPage.svelte @@ -4,6 +4,8 @@ import { fastFetch } from "$lib/api/fetch"; import { usernameRegex } from "$lib/api/regex"; import ErrorAlert from "$lib/components/ErrorAlert.svelte"; + import { PUBLIC_HCAPTCHA_SITEKEY } from "$env/static/public"; + import HCaptcha from "svelte-hcaptcha"; import { userStore } from "$lib/store"; import { addToast } from "$lib/toast"; import { DateTime } from "luxon"; @@ -23,6 +25,7 @@ export let remoteName: string | undefined; export let error: APIError | undefined; export let requireInvite: boolean | undefined; + export let requireCaptcha: boolean | undefined; export let isDeleted: boolean | undefined; export let ticket: string | undefined; export let token: string | undefined; @@ -54,7 +57,31 @@ let toggleForceDeleteModal = () => (forceDeleteModalOpen = !forceDeleteModalOpen); export let linkAccount: () => Promise; - export let signupForm: (username: string, inviteCode: string) => Promise; + export let signupForm: ( + username: string, + inviteCode: string, + captchaToken: string, + ) => Promise; + + let captchaToken = ""; + let captcha: any; + + const captchaSuccess = (token: any) => { + captchaToken = token.detail.token; + }; + + let canSubmit = false; + $: canSubmit = usernameValid && (!!captchaToken || !requireCaptcha); + + const captchaError = () => { + addToast({ + header: "Captcha failed", + body: "There was an error verifying the captcha, please try again.", + }); + captcha.reset(); + }; + + export const resetCaptcha = (): void => captcha.reset(); const forceDeleteAccount = async () => { try { @@ -116,7 +143,7 @@
{:else if ticket} -
signupForm(username, inviteCode)}> + signupForm(username, inviteCode, captchaToken)}>
@@ -144,12 +171,22 @@
{/if} + {#if requireCaptcha} +
+ +
+ {/if}
By signing up, you agree to the terms of service and the privacy policy.

- + {#if !usernameValid && username.length > 0} That username is not valid. {/if} diff --git a/frontend/src/routes/auth/login/discord/+page.server.ts b/frontend/src/routes/auth/login/discord/+page.server.ts index b1df685..59ca72e 100644 --- a/frontend/src/routes/auth/login/discord/+page.server.ts +++ b/frontend/src/routes/auth/login/discord/+page.server.ts @@ -30,6 +30,7 @@ interface CallbackResponse { discord?: string; ticket?: string; require_invite: boolean; + require_captcha: boolean; is_deleted: boolean; deleted_at?: string; diff --git a/frontend/src/routes/auth/login/discord/+page.svelte b/frontend/src/routes/auth/login/discord/+page.svelte index 703e10c..66feeb8 100644 --- a/frontend/src/routes/auth/login/discord/+page.svelte +++ b/frontend/src/routes/auth/login/discord/+page.svelte @@ -1,6 +1,6 @@ import { goto } from "$app/navigation"; - import type { APIError, MeUser } from "$lib/api/entities"; + import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities"; import { apiFetch, apiFetchClient } from "$lib/api/fetch"; import { userStore } from "$lib/store"; import type { PageData } from "./$types"; @@ -10,7 +10,9 @@ export let data: PageData; - const signupForm = async (username: string, invite: string) => { + let callbackPage: any; + + const signupForm = async (username: string, invite: string, captchaToken: string) => { try { const resp = await apiFetch("/auth/google/signup", { method: "POST", @@ -18,6 +20,7 @@ ticket: data.ticket, username: username, invite_code: invite, + captcha_response: captchaToken, }, }); @@ -27,6 +30,10 @@ addToast({ header: "Welcome!", body: "Signed up successfully!" }); goto(`/@${resp.user.name}`); } catch (e) { + if ((e as APIError).code === ErrorCode.InvalidCaptcha) { + callbackPage.resetCaptcha(); + } + data.error = e as APIError; } }; @@ -48,10 +55,12 @@ import { goto } from "$app/navigation"; - import type { APIError, MeUser } from "$lib/api/entities"; + import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities"; import { apiFetch, apiFetchClient } from "$lib/api/fetch"; import { userStore } from "$lib/store"; import type { PageData } from "./$types"; @@ -10,7 +10,9 @@ export let data: PageData; - const signupForm = async (username: string, invite: string) => { + let callbackPage: any; + + const signupForm = async (username: string, invite: string, captchaToken: string) => { try { const resp = await apiFetch("/auth/mastodon/signup", { method: "POST", @@ -19,6 +21,7 @@ ticket: data.ticket, username: username, invite_code: invite, + captcha_response: captchaToken, }, }); @@ -28,6 +31,10 @@ addToast({ header: "Welcome!", body: "Signed up successfully!" }); goto(`/@${resp.user.name}`); } catch (e) { + if ((e as APIError).code === ErrorCode.InvalidCaptcha) { + callbackPage.resetCaptcha(); + } + data.error = e as APIError; } }; @@ -50,10 +57,12 @@ import { goto } from "$app/navigation"; - import type { APIError, MeUser } from "$lib/api/entities"; + import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities"; import { apiFetch, apiFetchClient } from "$lib/api/fetch"; import { userStore } from "$lib/store"; import type { PageData } from "./$types"; @@ -10,7 +10,9 @@ export let data: PageData; - const signupForm = async (username: string, invite: string) => { + let callbackPage: any; + + const signupForm = async (username: string, invite: string, captchaToken: string) => { try { const resp = await apiFetch("/auth/misskey/signup", { method: "POST", @@ -19,6 +21,7 @@ ticket: data.ticket, username: username, invite_code: invite, + captcha_response: captchaToken, }, }); @@ -28,6 +31,10 @@ addToast({ header: "Welcome!", body: "Signed up successfully!" }); goto(`/@${resp.user.name}`); } catch (e) { + if ((e as APIError).code === ErrorCode.InvalidCaptcha) { + callbackPage.resetCaptcha(); + } + data.error = e as APIError; } }; @@ -54,6 +61,7 @@ remoteName="{data.fediverse}@{data.instance}" error={data.error} requireInvite={data.require_invite} + requireCaptcha={data.require_captcha} isDeleted={data.is_deleted} ticket={data.ticket} token={data.token} diff --git a/frontend/src/routes/auth/login/tumblr/+page.server.ts b/frontend/src/routes/auth/login/tumblr/+page.server.ts index 48a0b8c..c045030 100644 --- a/frontend/src/routes/auth/login/tumblr/+page.server.ts +++ b/frontend/src/routes/auth/login/tumblr/+page.server.ts @@ -30,6 +30,7 @@ interface CallbackResponse { tumblr?: string; ticket?: string; require_invite: boolean; + require_captcha: boolean; is_deleted: boolean; deleted_at?: string; diff --git a/frontend/src/routes/auth/login/tumblr/+page.svelte b/frontend/src/routes/auth/login/tumblr/+page.svelte index c64dcef..1316f33 100644 --- a/frontend/src/routes/auth/login/tumblr/+page.svelte +++ b/frontend/src/routes/auth/login/tumblr/+page.svelte @@ -1,6 +1,6 @@ Date: Mon, 24 Apr 2023 17:03:05 +0200 Subject: [PATCH 039/119] feat: add page buttons below member list too --- frontend/src/routes/@[username]/+page.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index c2c8673..407d80a 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -241,6 +241,19 @@ {/each}

+ {#if totalPages > 1} +
+ + + + + +
+ {/if} {:else}

From 95e7951c767ebbb9fda203e2caeab1b2007aee05 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 24 Apr 2023 17:04:48 +0200 Subject: [PATCH 040/119] update changelog --- frontend/src/lib/store.ts | 2 +- frontend/src/routes/page/changelog/changelog.md | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index cac34b8..058e9e4 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -13,4 +13,4 @@ const initialThemeValue = browser export const themeStore = writable(initialThemeValue); -export const CURRENT_CHANGELOG = "0.4.0"; +export const CURRENT_CHANGELOG = "0.4.1"; diff --git a/frontend/src/routes/page/changelog/changelog.md b/frontend/src/routes/page/changelog/changelog.md index bca87ce..d732215 100644 --- a/frontend/src/routes/page/changelog/changelog.md +++ b/frontend/src/routes/page/changelog/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 0.4.1 - April 24th 2023 + +- Added buttons to change the order of links on profiles. +- Added a "copy link" button to user and member profiles. +- Added a second set of page buttons below the member list. +- Added a captcha when signing up to prevent spam bots. +- Custom preferences with "treat as favourite" checked are now shown in the member list. +- Changed how the member list is displayed: the site will now show between two and five members per row depending on screen size. +- Fixed the save button sometimes not showing up when editing custom preferences. +- Fixed custom preferences not showing up if they were added in the current editing session. + ## 0.4.0 - April 20th 2023 - **Added custom preferences!** @@ -10,8 +21,5 @@ in addition to Discord and Mastodon. - Added this changelog. - Added a donation link to the footer, and a message on the settings page. Sadly, running a website like this costs money :( - ---- - - The website will now correctly handle tokens expiring, so you don't get stuck in an infinite loop as soon as they expire anymore. - Fixed pronoun links, pronouns with special characters will link to the correct page now. From 181d33517e31af3172aa15d630667eb9354b2975 Mon Sep 17 00:00:00 2001 From: lucdev Date: Mon, 24 Apr 2023 12:05:28 -0300 Subject: [PATCH 041/119] use npm package to self-host fonts --- frontend/package.json | 1 + frontend/src/routes/+layout.svelte | 4 ++++ frontend/src/routes/main.css | 34 ------------------------------ 3 files changed, 5 insertions(+), 34 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 54aa8ac..80b8720 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ }, "type": "module", "dependencies": { + "@fontsource/firago": "^4.5.3", "@popperjs/core": "^2.11.7", "@sentry/node": "^7.46.0", "base64-arraybuffer": "^1.0.2", diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 11861e5..0e13ce1 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,5 +1,9 @@ - - Change icon - - - - -

- -

- - {#each filteredIcons as icon} - (preference.icon = icon)} - > {icon} - {:else} -

Start typing to filter

- {/each} - - + Change text size diff --git a/frontend/src/routes/edit/profile/IconPicker.svelte b/frontend/src/routes/edit/profile/IconPicker.svelte new file mode 100644 index 0000000..9538233 --- /dev/null +++ b/frontend/src/routes/edit/profile/IconPicker.svelte @@ -0,0 +1,72 @@ + + + + Change icon + + + + +

+ +

+
    + {#each filteredIcons as iconOption} +
  • + (icon = iconOption)} + /> +
  • + {/each} +
+ {#if iconCount > MAX_VISIBLE_ITEMS || showAll} + + (showAll = !showAll)} + >{showAll ? "Stop showing all results" : `Show all results (${iconCount})`} + {/if} +
+
+ + From 26b0d297ab6593a4211f37a90550eb736c94fb23 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 8 May 2023 22:59:25 +0200 Subject: [PATCH 054/119] feat: add warning on edit member page if member list is private --- frontend/src/routes/edit/member/[id]/+page.svelte | 7 +++++++ frontend/src/routes/edit/profile/IconPicker.svelte | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/edit/member/[id]/+page.svelte b/frontend/src/routes/edit/member/[id]/+page.svelte index 864ad80..34fc26d 100644 --- a/frontend/src/routes/edit/member/[id]/+page.svelte +++ b/frontend/src/routes/edit/member/[id]/+page.svelte @@ -580,6 +580,13 @@

+ {#if data.user.list_private} + + Your member list is currently hidden, so this setting has no effect. If + you want to make your member list visible again, + edit your user profile. +
+ {/if} This only hides this member from your member list. diff --git a/frontend/src/routes/edit/profile/IconPicker.svelte b/frontend/src/routes/edit/profile/IconPicker.svelte index 9538233..eef2ddc 100644 --- a/frontend/src/routes/edit/profile/IconPicker.svelte +++ b/frontend/src/routes/edit/profile/IconPicker.svelte @@ -1,6 +1,5 @@

From 1c5fe1e25d8e3ea2921ccbb552ca8798d86e8f43 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 8 May 2023 23:14:27 +0200 Subject: [PATCH 056/119] feat: make 'dev' indicator less intrusive --- frontend/src/routes/nav/Navigation.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/nav/Navigation.svelte b/frontend/src/routes/nav/Navigation.svelte index af9f26c..8052181 100644 --- a/frontend/src/routes/nav/Navigation.svelte +++ b/frontend/src/routes/nav/Navigation.svelte @@ -121,9 +121,10 @@ > - beta {#if commit === "[unknown]"} - DEV + dev + {:else} + beta {/if} From ee25781f2b9b195f6bf6a26213a6933e449c9071 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 9 May 2023 14:27:26 +0200 Subject: [PATCH 057/119] feat: default to dark theme while loading pages --- frontend/src/app.html | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/app.html b/frontend/src/app.html index dd330c4..0f9a71c 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -1,12 +1,12 @@ - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ From 4e056632c8206b46612f00236a16a456b9f142d3 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 10 May 2023 00:46:25 +0200 Subject: [PATCH 058/119] fix(backend): return display_name in GET /users/:id/members --- backend/routes/member/get_members.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/backend/routes/member/get_members.go b/backend/routes/member/get_members.go index 34c0b35..12afd6e 100644 --- a/backend/routes/member/get_members.go +++ b/backend/routes/member/get_members.go @@ -26,13 +26,14 @@ func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse { resps := make([]memberListResponse, len(ms)) for i := range ms { resps[i] = memberListResponse{ - ID: ms[i].ID, - Name: ms[i].Name, - Bio: ms[i].Bio, - Avatar: ms[i].Avatar, - Links: db.NotNull(ms[i].Links), - Names: db.NotNull(ms[i].Names), - Pronouns: db.NotNull(ms[i].Pronouns), + ID: ms[i].ID, + Name: ms[i].Name, + DisplayName: ms[i].DisplayName, + Bio: ms[i].Bio, + Avatar: ms[i].Avatar, + Links: db.NotNull(ms[i].Links), + Names: db.NotNull(ms[i].Names), + Pronouns: db.NotNull(ms[i].Pronouns), } if isSelf { From 7c7f948ad64c6f7611c032d998f6b07bb6f01d1e Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 11 May 2023 01:13:32 +0200 Subject: [PATCH 059/119] feat: move remaining go scripts to main executable --- main.go | 11 +++++++++++ scripts/genid/main.go | 40 +++++++++++++++++++++++++++++++++++++--- scripts/genkey/main.go | 13 +++++++++++-- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index 40b4534..14e9b03 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,8 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/exporter" "codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/u1f320/pronouns.cc/scripts/cleandb" + "codeberg.org/u1f320/pronouns.cc/scripts/genid" + "codeberg.org/u1f320/pronouns.cc/scripts/genkey" "codeberg.org/u1f320/pronouns.cc/scripts/migrate" "codeberg.org/u1f320/pronouns.cc/scripts/seeddb" "github.com/urfave/cli/v2" @@ -30,6 +32,15 @@ var app = &cli.App{ cleandb.Command, }, }, + { + Name: "generate", + Aliases: []string{"gen"}, + Usage: "Generate various strings", + Subcommands: []*cli.Command{ + genid.Command, + genkey.Command, + }, + }, }, } diff --git a/scripts/genid/main.go b/scripts/genid/main.go index 08c6224..9baf90b 100644 --- a/scripts/genid/main.go +++ b/scripts/genid/main.go @@ -1,11 +1,45 @@ -package main +package genid import ( "fmt" + "time" "github.com/rs/xid" + "github.com/urfave/cli/v2" ) -func main() { - fmt.Println(xid.New()) +var Command = &cli.Command{ + Name: "id", + Usage: "Generate a time-based ID", + Flags: []cli.Flag{ + &cli.TimestampFlag{ + Name: "timestamp", + Aliases: []string{"T"}, + Usage: "The timestamp to generate an ID for (format: 2006-01-02T15:04:05)", + Layout: "2006-01-02T15:04:05", + }, + &cli.UintFlag{ + Name: "days-ago", + Aliases: []string{"D"}, + Usage: "The number of days ago to generate an ID for", + Value: 0, + }, + }, + Action: run, +} + +func run(c *cli.Context) error { + if t := c.Timestamp("timestamp"); t != nil { + fmt.Printf("ID for %v: %v\n", t.Format(time.RFC1123), xid.NewWithTime(*t)) + return nil + } + if daysAgo := c.Uint("days-ago"); daysAgo != 0 { + t := time.Now().Add(time.Duration(-daysAgo) * 24 * time.Hour) + + fmt.Printf("ID for %v days ago: %v\n", daysAgo, xid.NewWithTime(t)) + return nil + } + + fmt.Printf("ID for now: %v\n", xid.New()) + return nil } diff --git a/scripts/genkey/main.go b/scripts/genkey/main.go index 93c5ce6..433da90 100644 --- a/scripts/genkey/main.go +++ b/scripts/genkey/main.go @@ -1,12 +1,20 @@ -package main +package genkey import ( "crypto/rand" "encoding/base64" "fmt" + + "github.com/urfave/cli/v2" ) -func main() { +var Command = &cli.Command{ + Name: "key", + Usage: "Generate a secure 64-byte base 64 key", + Action: run, +} + +func run(c *cli.Context) error { b := make([]byte, 64) _, err := rand.Read(b) @@ -17,4 +25,5 @@ func main() { s := base64.URLEncoding.EncodeToString(b) fmt.Println(s) + return nil } From 0e9ac347c059bbd0f1f7b0633c64fbd11cf2c020 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 11 May 2023 01:38:11 +0200 Subject: [PATCH 060/119] update changelog --- frontend/src/lib/store.ts | 2 +- frontend/src/routes/page/changelog/changelog.md | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index 058e9e4..443ff67 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -13,4 +13,4 @@ const initialThemeValue = browser export const themeStore = writable(initialThemeValue); -export const CURRENT_CHANGELOG = "0.4.1"; +export const CURRENT_CHANGELOG = "0.4.2"; diff --git a/frontend/src/routes/page/changelog/changelog.md b/frontend/src/routes/page/changelog/changelog.md index d732215..6dfa390 100644 --- a/frontend/src/routes/page/changelog/changelog.md +++ b/frontend/src/routes/page/changelog/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 0.4.2 - May 11th 2023 + +- The report button will no longer show up for your own members. +- Added a warning on the edit member page if your member list is hidden. +- Fixed hidden members not showing up when viewing your _own_ user page. +- The site will now default to a dark theme while loading, to remove the white flash when using a dark theme. + Sadly, it's one or the other, so light theme users will now see a _dark_ flash when the site is loading— + having asked multiple users, the general consensus is that a dark flash is better than a light flash. + ## 0.4.1 - April 24th 2023 - Added buttons to change the order of links on profiles. From 4f43e32fdb41a4d23233a29131e29acdaeb1e2ab Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 12 May 2023 01:09:02 +0200 Subject: [PATCH 061/119] fix(backend): disallow '.' and '..' in user and member names --- backend/db/member.go | 5 +++++ backend/db/user.go | 34 +++++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/backend/db/member.go b/backend/db/member.go index 08e75e7..2cb377a 100644 --- a/backend/db/member.go +++ b/backend/db/member.go @@ -38,6 +38,11 @@ const ( var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,]{1,100}$") func MemberNameValid(name string) bool { + // These two names will break routing, but periods should still be allowed in names otherwise. + if name == "." || name == ".." { + return false + } + return memberNameRegex.MatchString(name) } diff --git a/backend/db/user.go b/backend/db/user.go index f7bdfaa..63b8173 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -113,6 +113,24 @@ func (u User) NumProviders() (numProviders int) { // usernames must match this regex var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`) +func UsernameValid(username string) (err error) { + // This name would break routing, but periods should still be allowed in names otherwise. + if username == ".." { + return ErrInvalidUsername + } + + if !usernameRegex.MatchString(username) { + if len(username) < 2 { + return ErrUsernameTooShort + } else if len(username) > 40 { + return ErrUsernameTooLong + } + + return ErrInvalidUsername + } + return nil +} + const ( ErrUserNotFound = errors.Sentinel("user not found") @@ -139,14 +157,8 @@ const ( func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) { // check if the username is valid // if not, return an error depending on what failed - if !usernameRegex.MatchString(username) { - if len(username) < 2 { - return u, ErrUsernameTooShort - } else if len(username) > 40 { - return u, ErrUsernameTooLong - } - - return u, ErrInvalidUsername + if err := UsernameValid(username); err != nil { + return u, err } sql, args, err := sq.Insert("users").Columns("id", "username").Values(xid.New(), username).Suffix("RETURNING *").ToSql() @@ -458,7 +470,7 @@ func (db *DB) Username(ctx context.Context, name string) (u User, err error) { // UsernameTaken checks if the given username is already taken. func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) { - if !usernameRegex.MatchString(username) { + if err := UsernameValid(username); err != nil { return false, false, nil } @@ -468,8 +480,8 @@ func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken // UpdateUsername validates the given username, then updates the given user's name to it if valid. func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName string) error { - if !usernameRegex.MatchString(newName) { - return ErrInvalidUsername + if err := UsernameValid(newName); err != nil { + return err } sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql() From 9c4e29e64f37f294b652083a95b9351aa66814f0 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 12 May 2023 01:39:02 +0200 Subject: [PATCH 062/119] fix(backend): mention disallowed names in error messages --- backend/routes/member/create_member.go | 2 +- backend/routes/member/patch_member.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go index 13fd878..1156674 100644 --- a/backend/routes/member/create_member.go +++ b/backend/routes/member/create_member.go @@ -80,7 +80,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error if !db.MemberNameValid(cmr.Name) { return server.APIError{ Code: server.ErrBadRequest, - Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, ,", + Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.", } } diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go index 8361d7f..a6d0881 100644 --- a/backend/routes/member/patch_member.go +++ b/backend/routes/member/patch_member.go @@ -109,7 +109,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { if !db.MemberNameValid(*req.Name) { return server.APIError{ Code: server.ErrBadRequest, - Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,", + Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.", } } } From 13193666378fb9bde6cdc68a4667f80c7a846207 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 11 May 2023 00:59:40 +0200 Subject: [PATCH 063/119] feat(backend): switch to libvips for avatar resizing --- backend/db/avatars.go | 37 +++++++++++++++++++------------------ backend/main.go | 4 ++++ go.mod | 3 +-- go.sum | 11 ++++++----- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/backend/db/avatars.go b/backend/db/avatars.go index 5d64f6c..f90f5d4 100644 --- a/backend/db/avatars.go +++ b/backend/db/avatars.go @@ -6,19 +6,15 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" - "image" _ "image/gif" - "image/jpeg" _ "image/png" "io" "strings" "emperror.dev/errors" - "github.com/disintegration/imaging" + "github.com/davidbyttow/govips/v2/vips" "github.com/minio/minio-go/v7" "github.com/rs/xid" - - "github.com/chai2010/webp" ) const ErrInvalidDataURI = errors.Sentinel("invalid data URI") @@ -30,6 +26,8 @@ func (db *DB) ConvertAvatar(data string) ( jpgOut *bytes.Buffer, err error, ) { + defer vips.ShutdownThread() + data = strings.TrimSpace(data) if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") { return nil, nil, ErrInvalidDataURI @@ -41,28 +39,31 @@ func (db *DB) ConvertAvatar(data string) ( return nil, nil, errors.Wrap(err, "invalid base64 data") } - img, _, err := image.Decode(bytes.NewReader(rawData)) + image, err := vips.LoadImageFromBuffer(rawData, nil) if err != nil { return nil, nil, errors.Wrap(err, "decoding image") } - resized := imaging.Fill(img, 512, 512, imaging.Center, imaging.Linear) - - webpOut = new(bytes.Buffer) - err = webp.Encode(webpOut, resized, &webp.Options{ - Quality: 90, - }) + err = image.ThumbnailWithSize(512, 512, vips.InterestingCentre, vips.SizeBoth) if err != nil { - return nil, nil, errors.Wrap(err, "encoding WebP image") + return nil, nil, errors.Wrap(err, "resizing image") } - jpgOut = new(bytes.Buffer) - err = jpeg.Encode(jpgOut, resized, &jpeg.Options{ - Quality: 80, - }) + webpExport := vips.NewWebpExportParams() + webpExport.Quality = 90 + webpB, _, err := image.ExportWebp(webpExport) if err != nil { - return nil, nil, errors.Wrap(err, "encoding JPEG image") + return nil, nil, errors.Wrap(err, "exporting webp image") } + webpOut = bytes.NewBuffer(webpB) + + jpegExport := vips.NewJpegExportParams() + jpegExport.Quality = 80 + jpegB, _, err := image.ExportJpeg(jpegExport) + if err != nil { + return nil, nil, errors.Wrap(err, "exporting jpeg image") + } + jpgOut = bytes.NewBuffer(jpegB) return webpOut, jpgOut, nil } diff --git a/backend/main.go b/backend/main.go index 947f76b..ad26cd9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -10,6 +10,7 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/server" + "github.com/davidbyttow/govips/v2/vips" "github.com/go-chi/render" _ "github.com/joho/godotenv/autoload" "github.com/urfave/cli/v2" @@ -22,6 +23,9 @@ var Command = &cli.Command{ } func run(c *cli.Context) error { + vips.Startup(nil) + defer vips.Shutdown() + port := ":" + os.Getenv("PORT") s, err := server.New() diff --git a/go.mod b/go.mod index 8cd6ce4..8c58191 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,7 @@ require ( emperror.dev/errors v0.8.1 github.com/Masterminds/squirrel v1.5.4 github.com/bwmarrin/discordgo v0.27.1 - github.com/chai2010/webp v1.1.1 - github.com/disintegration/imaging v1.6.2 + github.com/davidbyttow/govips/v2 v2.13.0 github.com/georgysavva/scany/v2 v2.0.0 github.com/go-chi/chi/v5 v5.0.8 github.com/go-chi/cors v1.2.1 diff --git a/go.sum b/go.sum index 2513e3b..1df87f9 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,6 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk= -github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -106,11 +104,11 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidbyttow/govips/v2 v2.13.0 h1:5MK9ZcXZC5GzUR9Ca8fJwOYqMgll/H096ec0PJP59QM= +github.com/davidbyttow/govips/v2 v2.13.0/go.mod h1:LPTrwWtNa5n4yl9UC52YBOEGdZcY5hDTP4Ms2QWasTw= github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -389,6 +387,7 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nelsam/hel/v2 v2.3.2/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w= github.com/nelsam/hel/v2 v2.3.3/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -555,7 +554,7 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4= golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -628,6 +627,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -921,6 +921,7 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= From a72546658f780cda1cf75700345d5a2e2181a9e9 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 17 May 2023 11:38:17 +0200 Subject: [PATCH 064/119] feat: add plausible analytics --- frontend/src/routes/+layout.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3ec6937..beecfc0 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -27,6 +27,7 @@ +
From 130a1996d7c538bf72f2cd88c336daf3ecdfdcef Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 17 May 2023 13:26:23 +0200 Subject: [PATCH 065/119] feat: improve report ui --- backend/db/report.go | 16 ++++++- frontend/src/routes/reports/+page.svelte | 61 +++++++++++++++++++++++- frontend/src/routes/reports/+page.ts | 37 ++++++++++++-- 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/backend/db/report.go b/backend/db/report.go index de6e211..2f6c4c0 100644 --- a/backend/db/report.go +++ b/backend/db/report.go @@ -59,7 +59,13 @@ func (db *DB) Reports(ctx context.Context, closed bool, before int) (rs []Report } func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs []Report, err error) { - builder := sq.Select("*").From("reports").Where("user_id = ?", userID).Limit(ReportPageSize).OrderBy("id DESC") + builder := sq.Select("*", + "(SELECT username FROM users WHERE id = reports.user_id) AS user_name", + "(SELECT name FROM members WHERE id = reports.member_id) AS member_name"). + From("reports"). + Where("user_id = ?", userID). + Limit(ReportPageSize). + OrderBy("id DESC") if before != 0 { builder = builder.Where("id < ?", before) } @@ -79,7 +85,13 @@ func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs } func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before int) (rs []Report, err error) { - builder := sq.Select("*").From("reports").Where("reporter_id = ?", reporterID).Limit(ReportPageSize).OrderBy("id DESC") + builder := sq.Select("*", + "(SELECT username FROM users WHERE id = reports.user_id) AS user_name", + "(SELECT name FROM members WHERE id = reports.member_id) AS member_name"). + From("reports"). + Where("reporter_id = ?", reporterID). + Limit(ReportPageSize). + OrderBy("id DESC") if before != 0 { builder = builder.Where("id < ?", before) } diff --git a/frontend/src/routes/reports/+page.svelte b/frontend/src/routes/reports/+page.svelte index 152b9c4..b94597b 100644 --- a/frontend/src/routes/reports/+page.svelte +++ b/frontend/src/routes/reports/+page.svelte @@ -3,7 +3,7 @@ import { fastFetchClient } from "$lib/api/fetch"; import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import { addToast } from "$lib/toast"; - import { Button, FormGroup, Modal, ModalBody, ModalFooter } from "sveltestrap"; + import { Button, ButtonGroup, FormGroup, Modal, ModalBody, ModalFooter } from "sveltestrap"; import type { PageData } from "./$types"; import ReportCard from "./ReportCard.svelte"; @@ -88,6 +88,18 @@ error = e as APIError; } }; + + const urlParamsWith = (name: string, value: string) => { + const params = new URLSearchParams(window.location.search); + params.set(name, value); + return params.toString(); + }; + + const urlParamsWithout = (name: string) => { + const params = new URLSearchParams(window.location.search); + params.delete(name); + return params.toString(); + }; @@ -97,6 +109,36 @@

Reports

+
+ + {#if data.userId || data.reporterId} + + {:else if data.isClosed} + + {:else} + + {/if} + {#if data.userId} + + {:else} + + {/if} + {#if data.reporterId} + + {:else} + + {/if} + +
+
{#each data.reports as report, index}
@@ -111,13 +153,28 @@ + • + Show all reports of this user + • + Show all reports by this reporter
{:else} - There are no open reports :) + There are no reports matching your search! {/each}
+ {#if data.reports.length >= 100} + + {/if} + {#if error} diff --git a/frontend/src/routes/reports/+page.ts b/frontend/src/routes/reports/+page.ts index 85ef6f4..9b519ca 100644 --- a/frontend/src/routes/reports/+page.ts +++ b/frontend/src/routes/reports/+page.ts @@ -2,10 +2,39 @@ import { ErrorCode, type APIError, type Report } from "$lib/api/entities"; import { apiFetchClient } from "$lib/api/fetch"; import { error } from "@sveltejs/kit"; -export const load = async () => { +export const load = async ({ url }) => { + const { searchParams } = url; + + const before = +(searchParams.get("before") || 0); + const userId = searchParams.get("user_id"); + const reporterId = searchParams.get("reporter_id"); + const isClosed = searchParams.get("closed") === "true"; + try { - const reports = await apiFetchClient("/admin/reports"); - return { page: 0, isClosed: false, userId: null, reporterId: null, reports } as PageLoadData; + let reports: Report[]; + if (userId) { + const params = new URLSearchParams(); + if (before) params.append("before", before.toString()); + + reports = await apiFetchClient( + `/admin/reports/by-user/${userId}?${params.toString()}`, + ); + } else if (reporterId) { + const params = new URLSearchParams(); + if (before) params.append("before", before.toString()); + + reports = await apiFetchClient( + `/admin/reports/by-reporter/${reporterId}?${params.toString()}`, + ); + } else { + const params = new URLSearchParams(); + if (before) params.append("before", before.toString()); + if (isClosed) params.append("closed", "true"); + + reports = await apiFetchClient(`/admin/reports?${params.toString()}`); + } + + return { before, isClosed, userId, reporterId, reports } as PageLoadData; } catch (e) { if ((e as APIError).code === ErrorCode.Forbidden) { throw error(400, "You're not an admin"); @@ -15,7 +44,7 @@ export const load = async () => { }; interface PageLoadData { - page: number; + before: number; isClosed: boolean; userId: string | null; reporterId: string | null; From c3291edd4f63b90a216c353f03336b208547a99b Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 19 May 2023 03:13:46 +0200 Subject: [PATCH 066/119] feat: expose some more info in /settings --- backend/routes/user/get_user.go | 4 +++ frontend/src/lib/api/entities.ts | 3 +++ frontend/src/routes/settings/+page.svelte | 31 ++++++++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index b19fe62..2466e2e 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -2,6 +2,7 @@ package user import ( "net/http" + "time" "codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/log" @@ -29,6 +30,8 @@ type GetUserResponse struct { type GetMeResponse struct { GetUserResponse + CreatedAt time.Time `json:"created_at"` + MaxInvites int `json:"max_invites"` IsAdmin bool `json:"is_admin"` ListPrivate bool `json:"list_private"` @@ -194,6 +197,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { render.JSON(w, r, GetMeResponse{ GetUserResponse: dbUserToResponse(u, fields, members), + CreatedAt: u.ID.Time(), MaxInvites: u.MaxInvites, IsAdmin: u.IsAdmin, ListPrivate: u.ListPrivate, diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 22fe815..4cba7b4 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -1,6 +1,7 @@ import { PUBLIC_BASE_URL } from "$env/static/public"; export const MAX_MEMBERS = 500; +export const MAX_FIELDS = 25; export const MAX_DESCRIPTION_LENGTH = 1000; export interface User { @@ -38,7 +39,9 @@ export enum PreferenceSize { } export interface MeUser extends User { + created_at: string; max_invites: number; + is_admin: boolean; discord: string | null; discord_username: string | null; tumblr: string | null; diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 3df8a86..0b9e229 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -1,6 +1,12 @@ + + + + + +

pronouns.cc (1.0.0)

Download OpenAPI specification:Download

License: GNU AGPLv3

The pronouns.cc REST API

+

Get a user

Get a user object. Accepts either ID or username.

+
path Parameters
userRef
required
string

A user reference, either an ID or a username. +IDs are always prioritized, if a user's username is the same as another user's ID, the user with that ID is returned.

+

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "member_title": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "members": [
    ],
  • "fields": [
    ],
  • "custom_preferences": {
    }
}

Get your own user

Get the user object associated with the provided token.

+
Authorizations:
TokenAuth

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "member_title": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "members": [
    ],
  • "fields": [
    ],
  • "custom_preferences": {
    },
  • "max_invites": 0,
  • "is_admin": true,
  • "list_private": true,
  • "discord": "string",
  • "discord_username": "string",
  • "tumblr": "string",
  • "tumblr_username": "string",
  • "google": "string",
  • "google_username": "string",
  • "fediverse": "string",
  • "fediverse_username": "string",
  • "fediverse_instance": "string"
}

Update your own user

Update the current user.

+
Request Body schema: application/json
name
string [ 2 .. 40 ] characters

The user's username, a unique string that identifies them in URLs.

+
display_name
string [ 1 .. 100 ] characters

The user's display name.

+
bio
string [ 1 .. 1000 ] characters

The user's bio/description.

+
member_title
string

Optional text used for the "Members" heading on the user's profile page.

+
avatar
string

A hash of the user's avatar, if set.

+

When editing, a base64-encoded PNG, JPEG, GIF, or WebP image file.

+
links
Array of strings

Links the user has added to their profile.

+
Array of objects (Root Type for FieldEntry)

The user's preferred names.

+
Array of objects (Root Type for FieldEntry)

The user's preferred pronouns.

+
Array of objects (Field)
object (CustomPreferences)

A user's custom preferences.

+
list_private
boolean

Whether your member list is private.

+

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "member_title": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "custom_preferences": {
    },
  • "list_private": true
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "member_title": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "members": [
    ],
  • "fields": [
    ],
  • "custom_preferences": {
    },
  • "max_invites": 0,
  • "is_admin": true,
  • "list_private": true,
  • "discord": "string",
  • "discord_username": "string",
  • "tumblr": "string",
  • "tumblr_username": "string",
  • "google": "string",
  • "google_username": "string",
  • "fediverse": "string",
  • "fediverse_username": "string",
  • "fediverse_instance": "string"
}

Get a user's member list

path Parameters
userRef
required
string

A user ID, username, or @me for yourself.

+

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Create a member

Authorizations:
TokenAuth
Request Body schema: application/json
name
string

The member's unique (per-user) name, used to identify them in URLs. Case insensitive.

+
display_name
string

The member's display name.

+
bio
string

The member's bio/description.

+
avatar
string

A hash of the member's avatar, if set.

+

When editing, a base64-encoded PNG, JPEG, GIF, or WebP image file.

+
links
Array of strings

The member's profile links.

+
Array of objects (Root Type for FieldEntry)

The member's preferred names.

+
Array of objects (PronounEntry)

The member's preferred pronouns.

+
Array of objects (Field)

The member's custom label fields.

+
object (Root Type for PartialUser)

A partial user object as returned from a member endpoint.

+

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get a member by ID

path Parameters
memberRef
required
string

The member's unique ID.

+

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Delete a member

Authorizations:
TokenAuth
path Parameters
memberRef
required
string

The member's unique ID.

+

Responses

Response samples

Content type
application/json
{
  • "code": 2001,
  • "message": "User not found"
}

Update a member

Authorizations:
TokenAuth
path Parameters
memberRef
required
string

The member's unique ID.

+
Request Body schema: application/json
name
string

The member's unique (per-user) name, used to identify them in URLs. Case insensitive.

+
display_name
string

The member's display name.

+
bio
string

The member's bio/description.

+
avatar
string

A hash of the member's avatar, if set.

+

When editing, a base64-encoded PNG, JPEG, GIF, or WebP image file.

+
links
Array of strings

The member's profile links.

+
Array of objects (Root Type for FieldEntry)

The member's preferred names.

+
Array of objects (PronounEntry)

The member's preferred pronouns.

+
Array of objects (Field)

The member's custom label fields.

+
object (Root Type for PartialUser)

A partial user object as returned from a member endpoint.

+

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get a member by ID or name

path Parameters
userRef
required
string

A user ID or username.

+
memberRef
required
string

A member ID or name.

+

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get meta info

Responses

Response samples

Content type
application/json
{
  • "git_commit": "130a199",
  • "users": {
    },
  • "members": 11462,
  • "require_invite": false
}
+ + + + diff --git a/backend/routes.go b/backend/routes.go index d97a3dc..a056595 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -1,6 +1,8 @@ package backend import ( + "net/http" + "codeberg.org/u1f320/pronouns.cc/backend/routes/auth" "codeberg.org/u1f320/pronouns.cc/backend/routes/bot" "codeberg.org/u1f320/pronouns.cc/backend/routes/member" @@ -9,8 +11,14 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/routes/user" "codeberg.org/u1f320/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + + _ "embed" ) +//go:embed openapi.html +var openapi string + // mountRoutes mounts all API routes on the server's router. // they are all mounted under /v1/ func mountRoutes(s *server.Server) { @@ -23,4 +31,9 @@ func mountRoutes(s *server.Server) { meta.Mount(s, r) mod.Mount(s, r) }) + + // API docs + s.Router.Get("/", func(w http.ResponseWriter, r *http.Request) { + render.HTML(w, r, openapi) + }) } diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..c34c96a --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,978 @@ +--- +openapi: 3.0.2 +info: + title: pronouns.cc + version: 1.0.0 + description: The pronouns.cc REST API + license: + name: GNU AGPLv3 + url: https://www.gnu.org/licenses/agpl.txt +servers: +- url: https://pronouns.cc/api/v1 + description: "" +paths: + /users/{userRef}: + get: + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: User was found and is not deleted. + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + examples: + User not found: + value: + code: 2001 + message: User not found + description: User was not found or is deleted. + operationId: GetUser + summary: Get a user + description: Get a user object. Accepts either ID or username. + parameters: + - name: userRef + description: |- + A user reference, either an ID or a username. + IDs are always prioritized, if a user's username is the same as another user's ID, the user with that ID is returned. + schema: + type: string + in: path + required: true + /users/@me: + get: + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/MeUser' + description: The token is valid. + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The token is invalid. + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: No token was supplied. + security: + - TokenAuth: [] + operationId: GetMe + summary: Get your own user + description: Get the user object associated with the provided token. + patch: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MeUser' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/MeUser' + description: The full updated user object. + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The token is invalid. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The supplied data is invalid. + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: No token was supplied. + operationId: PatchMe + summary: Update your own user + description: Update the current user. + /users/{userRef}/members: + get: + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PartialMember' + description: The user was found. + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The user was not found. + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The user's member list is private. + operationId: GetMembers + summary: Get a user's member list + parameters: + - name: userRef + description: "A user ID, username, or `@me` for yourself." + schema: + type: string + in: path + required: true + /members: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + description: The created member. + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The provided token is read-only or this is not your member. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: "You have reached the member limit, or the request was empty\ + \ or has invalid fields." + security: + - TokenAuth: [] + operationId: CreateMember + summary: Create a member + /members/{memberRef}: + get: + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + description: The member was found. + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The member was not found. + operationId: GetMember + summary: Get a member by ID + delete: + responses: + "204": + description: The member was successfully deleted. + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The member was not found. + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The provided token is read-only or this is not your member. + security: + - TokenAuth: [] + operationId: DeleteMember + summary: Delete a member + patch: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + description: The updated member. + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The provided token is read-only or this is not your member. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The provided member was invalid or empty. + security: + - TokenAuth: [] + operationId: PatchMember + summary: Update a member + parameters: + - name: memberRef + description: The member's unique ID. + schema: + type: string + in: path + required: true + /users/{userRef}/members/{memberRef}: + get: + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + description: The user and member were found. + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: The user or member was not found. + operationId: GetUserMember + summary: Get a member by ID or name + parameters: + - name: userRef + description: A user ID or username. + schema: + type: string + in: path + required: true + - name: memberRef + description: A member ID or name. + schema: + type: string + in: path + required: true + /meta: + get: + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Meta' + description: Successfully fetched meta data. + operationId: GetMeta + summary: Get meta info +components: + schemas: + User: + title: Root Type for User + description: An unauthenticated user object + required: [] + type: object + properties: + id: + description: The user's unique and unchanging ID. + type: string + readOnly: true + name: + description: "The user's username, a unique string that identifies them\ + \ in URLs." + maxLength: 40 + minLength: 2 + type: string + display_name: + description: The user's display name. + maxLength: 100 + minLength: 1 + type: string + bio: + description: The user's bio/description. + type: string + member_title: + description: Optional text used for the "Members" heading on the user's + profile page. + type: string + avatar: + description: "A hash of the user's avatar, if set." + type: string + links: + description: Links the user has added to their profile. + type: array + items: + type: string + names: + description: The user's preferred names. + type: array + items: + $ref: '#/components/schemas/FieldEntry' + pronouns: + description: The user's preferred pronouns. + type: array + items: + $ref: '#/components/schemas/FieldEntry' + members: + description: List of the user's members. + type: array + items: + $ref: '#/components/schemas/PartialMember' + readOnly: true + fields: + description: The user's custom label fields. + type: array + items: + $ref: '#/components/schemas/Field' + custom_preferences: + $ref: '#/components/schemas/CustomPreferences' + description: The user's custom preferences. + properties: + "375d362a-6215-4cc0-a80e-3396281760b9": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "4811fd6f-153c-4780-b6c9-6990d5da8a22": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "6877632b-1d50-48cb-b300-d5f379ad71e1": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "75622b3d-58d0-4576-9fc9-12d71106b4db": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + FieldEntry: + title: Root Type for FieldEntry + description: A field entry or name object. + required: + - status + - value + type: object + properties: + value: + description: The field entry's value. + maxLength: 100 + minLength: 1 + type: string + status: + $ref: '#/components/schemas/EntryPreference' + description: The field entry's status. + EntryPreference: + description: |- + A unique string to denote the preference associated with a name, label, or pronoun. + Can be any of "favourite", "okay", "jokingly", "friends_only", "avoid", or a custom preference UUID. + type: string + PronounEntry: + description: A version of FieldEntry for pronouns. + required: + - pronouns + - status + type: object + properties: + pronouns: + description: The full version of the pronoun set. + type: string + display_text: + description: "The pronoun set's display text. If not set, is derived from\ + \ the first two forms of `pronouns`." + type: string + status: + $ref: '#/components/schemas/EntryPreference' + description: The pronoun entry's status. + APIError: + title: Root Type for APIError + description: An API error response. + required: + - code + - message + type: object + properties: + code: + format: int32 + description: A numeric error code. + type: integer + readOnly: false + message: + description: A human-readable error description. + type: string + details: + description: Optional extra details about an error. + type: string + ratelimit_reset: + format: int64 + description: "If this is a rate limit error, the time the rate limit resets\ + \ at." + type: integer + example: + code: 2001 + message: User not found + CustomPreference: + title: Root Type for CustomPreference + description: A single custom preference. + required: + - icon + - size + - tooltip + type: object + properties: + icon: + description: The name of the Bootstrap icon used for the preference. + type: string + tooltip: + description: The tooltip used to describe the preference. + type: string + size: + description: The size of the preference text. + enum: + - large + - normal + - small + type: string + muted: + description: Whether or not the preference will show up in a muted colour. + type: boolean + favourite: + description: Whether or not the preference will be treated as favourite + in embeds. + type: boolean + Field: + description: A label field. + required: + - name + - entries + type: object + properties: + name: + description: The field's name or title. + type: string + entries: + description: The field's entries. + type: array + items: + $ref: '#/components/schemas/FieldEntry' + PartialMember: + title: Root Type for PartialMember + description: A partial member object. + required: + - links + - id + - name + - names + - pronouns + type: object + properties: + id: + writeOnly: false + description: The member's unique ID. + type: string + readOnly: true + name: + description: The member's unique name. + type: string + display_name: + type: string + bio: + type: string + avatar: + type: string + links: + description: The member's profile links. + type: array + items: + type: string + names: + description: The member's preferred names. + type: array + items: + $ref: '#/components/schemas/FieldEntry' + pronouns: + description: The member's preferred pronouns. + type: array + items: + $ref: '#/components/schemas/PronounEntry' + MeUser: + title: Root Type for User + description: An authenticated user object. + required: [] + type: object + properties: + id: + description: The user's unique and unchanging ID. + type: string + readOnly: true + name: + description: "The user's username, a unique string that identifies them\ + \ in URLs." + maxLength: 40 + minLength: 2 + type: string + display_name: + description: The user's display name. + maxLength: 100 + minLength: 1 + type: string + bio: + description: The user's bio/description. + maxLength: 1000 + minLength: 1 + type: string + member_title: + description: Optional text used for the "Members" heading on the user's + profile page. + type: string + avatar: + description: |- + A hash of the user's avatar, if set. + + When editing, a base64-encoded PNG, JPEG, GIF, or WebP image file. + type: string + links: + description: Links the user has added to their profile. + type: array + items: + type: string + names: + description: The user's preferred names. + type: array + items: + $ref: '#/components/schemas/FieldEntry' + pronouns: + description: The user's preferred pronouns. + type: array + items: + $ref: '#/components/schemas/FieldEntry' + members: + description: List of your members. + type: array + items: + $ref: '#/components/schemas/PartialMember' + readOnly: true + fields: + type: array + items: + $ref: '#/components/schemas/Field' + custom_preferences: + $ref: '#/components/schemas/CustomPreferences' + description: The user's custom preferences. + properties: + "375d362a-6215-4cc0-a80e-3396281760b9": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "4811fd6f-153c-4780-b6c9-6990d5da8a22": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "6877632b-1d50-48cb-b300-d5f379ad71e1": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "75622b3d-58d0-4576-9fc9-12d71106b4db": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + max_invites: + description: "The maximum number of invites you can make, only useful if\ + \ invites are required." + type: integer + readOnly: true + is_admin: + description: Whether you are an admin. + type: boolean + readOnly: true + list_private: + description: Whether your member list is private. + type: boolean + discord: + description: "Your Discord user ID, if linked." + type: string + readOnly: true + discord_username: + description: The linked Discord user's username. + type: string + readOnly: true + tumblr: + description: The linked Tumblr account's ID. + type: string + readOnly: true + tumblr_username: + description: The linked Tumblr account's username. + type: string + readOnly: true + google: + description: The linked Google account's ID. + type: string + readOnly: true + google_username: + description: The linked Google account's email. + type: string + readOnly: true + fediverse: + description: The linked fediverse account's ID. + type: string + readOnly: true + fediverse_username: + description: The linked fediverse account's (local) username. + type: string + readOnly: true + fediverse_instance: + description: The linked fediverse account's instance. + type: string + readOnly: true + PartialUser: + title: Root Type for PartialUser + description: A partial user object as returned from a member endpoint. + required: [] + type: object + properties: + id: + description: The user's unique unchanging ID. + type: string + name: + description: "The user's username, a unique string that identifies them\ + \ in URLs." + type: string + display_name: + description: "The user's display name, if set." + type: string + avatar: + description: "A hash of the user's avatar, if set." + type: string + custom_preferences: + $ref: '#/components/schemas/CustomPreferences' + description: The user's custom preferences. + properties: + "375d362a-6215-4cc0-a80e-3396281760b9": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "4811fd6f-153c-4780-b6c9-6990d5da8a22": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "6877632b-1d50-48cb-b300-d5f379ad71e1": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "75622b3d-58d0-4576-9fc9-12d71106b4db": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + CustomPreferences: + description: A user's custom preferences. + type: object + additionalProperties: + $ref: '#/components/schemas/CustomPreference' + Member: + title: Root Type for Member + description: A full member object. + type: object + properties: + id: + description: "The member's unique, unchanging ID." + type: string + readOnly: true + name: + description: "The member's unique (per-user) name, used to identify them\ + \ in URLs. Case insensitive." + type: string + display_name: + description: The member's display name. + type: string + bio: + description: The member's bio/description. + type: string + avatar: + description: |- + A hash of the member's avatar, if set. + + When editing, a base64-encoded PNG, JPEG, GIF, or WebP image file. + type: string + links: + description: The member's profile links. + type: array + items: + type: string + names: + description: The member's preferred names. + type: array + items: + $ref: '#/components/schemas/FieldEntry' + pronouns: + description: The member's preferred pronouns. + type: array + items: + $ref: '#/components/schemas/PronounEntry' + fields: + description: The member's custom label fields. + type: array + items: + $ref: '#/components/schemas/Field' + user: + $ref: '#/components/schemas/PartialUser' + description: A partial user object. + properties: + id: + type: string + name: + type: string + display_name: + type: string + avatar: + type: string + custom_preferences: + type: object + properties: + "375d362a-6215-4cc0-a80e-3396281760b9": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "4811fd6f-153c-4780-b6c9-6990d5da8a22": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "6877632b-1d50-48cb-b300-d5f379ad71e1": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + "75622b3d-58d0-4576-9fc9-12d71106b4db": + type: object + properties: + icon: + type: string + tooltip: + type: string + size: + type: string + muted: + type: boolean + favourite: + type: boolean + Meta: + title: Root Type for Meta + description: The response from the /api/v1/meta endpoint. + type: object + properties: + git_repository: + description: URL to the Git repository. + type: string + git_commit: + description: The Git commit the server is currently running. + type: string + users: + $ref: '#/components/schemas/MetaUsers' + description: Number of users. + properties: + total: + format: int32 + type: integer + active_month: + format: int32 + type: integer + active_week: + format: int32 + type: integer + active_day: + format: int32 + type: integer + members: + format: int32 + description: Total number of members. + type: integer + require_invite: + description: Whether this instance requires an invite. + type: boolean + example: + git_repository: https://codeberg.org/u1f320/pronouns.cc + git_commit: 130a199 + users: + total: 3985 + active_month: 3985 + active_week: 1327 + active_day: 276 + members: 11462 + require_invite: false + MetaUsers: + title: Root Type for MetaUsers + description: "" + type: object + properties: + total: + format: int32 + type: integer + active_month: + format: int32 + type: integer + active_week: + format: int32 + type: integer + active_day: + format: int32 + type: integer + example: + total: 3985 + active_month: 3985 + active_week: 1327 + active_day: 276 + securitySchemes: + TokenAuth: + type: apiKey + description: Token auth + name: Authorization + in: header From ed4882b81717cef65365f3e1a7f323c89c9af600 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 19 May 2023 04:52:58 +0200 Subject: [PATCH 068/119] feat: add link to API docs --- frontend/src/routes/settings/+layout.svelte | 2 +- frontend/src/routes/settings/tokens/+page.svelte | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index 8e254bd..785c43b 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -79,7 +79,7 @@ active={$page.url.pathname === "/settings/tokens"} href="/settings/tokens" > - Tokens + API tokens

- Tokens ({data.tokens.length}) + API tokens ({data.tokens.length}) +

From 4123f957f078f1d95fed8f0c1b8c20a0ad17b5c5 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 22 May 2023 00:36:21 +0200 Subject: [PATCH 069/119] fix: silence libvips --- backend/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/main.go b/backend/main.go index ad26cd9..d1dca4f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -23,6 +23,9 @@ var Command = &cli.Command{ } func run(c *cli.Context) error { + // set vips log level to WARN, else it will spam logs on info level + vips.LoggingSettings(nil, vips.LogLevelWarning) + vips.Startup(nil) defer vips.Shutdown() From 71ae1b1aa585e4f7ec1290ca38625a8b4219a34e Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 22 May 2023 14:48:48 +0200 Subject: [PATCH 070/119] feat: allow separate domain for media --- frontend/src/lib/api/entities.ts | 10 +++++----- frontend/src/routes/settings/export/+page.svelte | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 4cba7b4..acb2767 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -1,4 +1,4 @@ -import { PUBLIC_BASE_URL } from "$env/static/public"; +import { PUBLIC_BASE_URL, PUBLIC_MEDIA_URL } from "$env/static/public"; export const MAX_MEMBERS = 500; export const MAX_FIELDS = 25; @@ -178,8 +178,8 @@ export const userAvatars = (user: User | MeUser | MemberPartialUser) => { if (!user.avatar) return defaultAvatars; return [ - `${PUBLIC_BASE_URL}/media/users/${user.id}/${user.avatar}.webp`, - `${PUBLIC_BASE_URL}/media/users/${user.id}/${user.avatar}.jpg`, + `${PUBLIC_MEDIA_URL}/users/${user.id}/${user.avatar}.webp`, + `${PUBLIC_MEDIA_URL}/users/${user.id}/${user.avatar}.jpg`, ]; }; @@ -187,8 +187,8 @@ export const memberAvatars = (member: Member | PartialMember) => { if (!member.avatar) return defaultAvatars; return [ - `${PUBLIC_BASE_URL}/media/members/${member.id}/${member.avatar}.webp`, - `${PUBLIC_BASE_URL}/media/members/${member.id}/${member.avatar}.jpg`, + `${PUBLIC_MEDIA_URL}/members/${member.id}/${member.avatar}.webp`, + `${PUBLIC_MEDIA_URL}/members/${member.id}/${member.avatar}.jpg`, ]; }; diff --git a/frontend/src/routes/settings/export/+page.svelte b/frontend/src/routes/settings/export/+page.svelte index d33e177..983dcce 100644 --- a/frontend/src/routes/settings/export/+page.svelte +++ b/frontend/src/routes/settings/export/+page.svelte @@ -2,7 +2,7 @@ import type { PageData } from "./$types"; import { DateTime, Duration } from "luxon"; import { Alert, Button } from "sveltestrap"; - import { PUBLIC_BASE_URL } from "$env/static/public"; + import { PUBLIC_MEDIA_URL } from "$env/static/public"; import { fastFetchClient } from "$lib/api/fetch"; import type { APIError } from "$lib/api/entities"; import { addToast } from "$lib/toast"; @@ -69,7 +69,7 @@ Download your export file below:

-

From 295b76aad24627934020f9b886c7393b42ae9548 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 22 May 2023 15:00:05 +0200 Subject: [PATCH 071/119] fix cloudflare r2 support? --- backend/db/avatars.go | 12 ++++++++---- backend/db/export.go | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/db/avatars.go b/backend/db/avatars.go index f90f5d4..27e41e7 100644 --- a/backend/db/avatars.go +++ b/backend/db/avatars.go @@ -81,14 +81,16 @@ func (db *DB) WriteUserAvatar(ctx context.Context, hash = hex.EncodeToString(hasher.Sum(nil)) _, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ - ContentType: "image/webp", + ContentType: "image/webp", + SendContentMd5: true, }) if err != nil { return "", errors.Wrap(err, "uploading webp avatar") } _, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ - ContentType: "image/jpeg", + ContentType: "image/jpeg", + SendContentMd5: true, }) if err != nil { return "", errors.Wrap(err, "uploading jpeg avatar") @@ -110,14 +112,16 @@ func (db *DB) WriteMemberAvatar(ctx context.Context, hash = hex.EncodeToString(hasher.Sum(nil)) _, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ - ContentType: "image/webp", + ContentType: "image/webp", + SendContentMd5: true, }) if err != nil { return "", errors.Wrap(err, "uploading webp avatar") } _, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ - ContentType: "image/jpeg", + ContentType: "image/jpeg", + SendContentMd5: true, }) if err != nil { return "", errors.Wrap(err, "uploading jpeg avatar") diff --git a/backend/db/export.go b/backend/db/export.go index 5c35552..7741a8d 100644 --- a/backend/db/export.go +++ b/backend/db/export.go @@ -67,7 +67,8 @@ func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string, } _, err = db.minio.PutObject(ctx, db.minioBucket, de.Path(), file, int64(file.Len()), minio.PutObjectOptions{ - ContentType: "application/zip", + ContentType: "application/zip", + SendContentMd5: true, }) if err != nil { return de, errors.Wrap(err, "writing export file") From bf34c77269d5a628d6a3f8fad46cf8a1e4d549c4 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 22 May 2023 15:59:49 +0200 Subject: [PATCH 072/119] fix: remove leading / from s3 paths --- backend/db/avatars.go | 16 ++++++++-------- backend/db/export.go | 2 +- frontend/src/routes/settings/export/+page.svelte | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/db/avatars.go b/backend/db/avatars.go index 27e41e7..6d32394 100644 --- a/backend/db/avatars.go +++ b/backend/db/avatars.go @@ -111,7 +111,7 @@ func (db *DB) WriteMemberAvatar(ctx context.Context, } hash = hex.EncodeToString(hasher.Sum(nil)) - _, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ + _, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ ContentType: "image/webp", SendContentMd5: true, }) @@ -119,7 +119,7 @@ func (db *DB) WriteMemberAvatar(ctx context.Context, return "", errors.Wrap(err, "uploading webp avatar") } - _, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ + _, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ ContentType: "image/jpeg", SendContentMd5: true, }) @@ -131,12 +131,12 @@ func (db *DB) WriteMemberAvatar(ctx context.Context, } func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string) error { - err := db.minio.RemoveObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) + err := db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) if err != nil { return errors.Wrap(err, "deleting webp avatar") } - err = db.minio.RemoveObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{}) + err = db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{}) if err != nil { return errors.Wrap(err, "deleting jpeg avatar") } @@ -145,12 +145,12 @@ func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string) } func (db *DB) DeleteMemberAvatar(ctx context.Context, memberID xid.ID, hash string) error { - err := db.minio.RemoveObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) + err := db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) if err != nil { return errors.Wrap(err, "deleting webp avatar") } - err = db.minio.RemoveObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{}) + err = db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{}) if err != nil { return errors.Wrap(err, "deleting jpeg avatar") } @@ -159,7 +159,7 @@ func (db *DB) DeleteMemberAvatar(ctx context.Context, memberID xid.ID, hash stri } func (db *DB) UserAvatar(ctx context.Context, userID xid.ID, hash string) (io.ReadCloser, error) { - obj, err := db.minio.GetObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) + obj, err := db.minio.GetObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) if err != nil { return nil, errors.Wrap(err, "getting object") } @@ -167,7 +167,7 @@ func (db *DB) UserAvatar(ctx context.Context, userID xid.ID, hash string) (io.Re } func (db *DB) MemberAvatar(ctx context.Context, memberID xid.ID, hash string) (io.ReadCloser, error) { - obj, err := db.minio.GetObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) + obj, err := db.minio.GetObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) if err != nil { return nil, errors.Wrap(err, "getting object") } diff --git a/backend/db/export.go b/backend/db/export.go index 7741a8d..6141aac 100644 --- a/backend/db/export.go +++ b/backend/db/export.go @@ -20,7 +20,7 @@ type DataExport struct { } func (de DataExport) Path() string { - return "/exports/" + de.UserID.String() + "/" + de.Filename + ".zip" + return "exports/" + de.UserID.String() + "/" + de.Filename + ".zip" } const ErrNoExport = errors.Sentinel("no data export exists") diff --git a/frontend/src/routes/settings/export/+page.svelte b/frontend/src/routes/settings/export/+page.svelte index 983dcce..31f87e7 100644 --- a/frontend/src/routes/settings/export/+page.svelte +++ b/frontend/src/routes/settings/export/+page.svelte @@ -69,7 +69,7 @@ Download your export file below:

-

From 23f79b0fec58b96b7b63ed82666282c85cbff3d2 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 22 May 2023 16:02:00 +0200 Subject: [PATCH 073/119] fix: i missed one path --- backend/db/avatars.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/db/avatars.go b/backend/db/avatars.go index 6d32394..f35ef26 100644 --- a/backend/db/avatars.go +++ b/backend/db/avatars.go @@ -80,7 +80,7 @@ func (db *DB) WriteUserAvatar(ctx context.Context, } hash = hex.EncodeToString(hasher.Sum(nil)) - _, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ + _, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ ContentType: "image/webp", SendContentMd5: true, }) @@ -88,7 +88,7 @@ func (db *DB) WriteUserAvatar(ctx context.Context, return "", errors.Wrap(err, "uploading webp avatar") } - _, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ + _, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ ContentType: "image/jpeg", SendContentMd5: true, }) From 9a70245c2d8b6030ad3a7e1cf6fbf14c3042c42e Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 23 May 2023 16:32:02 +0200 Subject: [PATCH 074/119] feat: add /users/@me/members/{memberRef} route (closes #62) --- backend/openapi.html | 4 ++-- backend/routes/member/get_member.go | 26 ++++++++++++++++++++++++++ backend/routes/member/routes.go | 1 + openapi.yaml | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/openapi.html b/backend/openapi.html index ded5c4d..90ce8d0 100644 --- a/backend/openapi.html +++ b/backend/openapi.html @@ -462,14 +462,14 @@ IDs are always prioritized, if a user's username is the same as another user

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get a member by ID or name

path Parameters
userRef
required
string

A user ID or username.

+

Request samples

Content type
application/json
{
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get a member by ID or name

path Parameters
userRef
required
string

A user ID, username, or @me for yourself.

memberRef
required
string

A member ID or name.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get meta info

Responses

Response samples

Content type
application/json
{
  • "git_commit": "130a199",
  • "users": {
    },
  • "members": 11462,
  • "require_invite": false
}
@@ -12,6 +12,13 @@ + + Maintenance notice: I will be migrating pronouns.cc to a new server starting on May 27th at 12:00 + UTC. I do not expect this migration to take very long, but the site will not be available during + that time. + No data will be lost. + +

pronouns.cc

From 1f138bee16455edb5dd812c127e87237f8f12e89 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 27 May 2023 04:22:33 +0200 Subject: [PATCH 079/119] Revert "announce server migration" This reverts commit 2cf5473a06e4b583017dee720501768b3d6a21cd. --- frontend/src/routes/+page.svelte | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2761abd..fbf9a95 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,6 +1,6 @@ @@ -12,13 +12,6 @@ - - Maintenance notice: I will be migrating pronouns.cc to a new server starting on May 27th at 12:00 - UTC. I do not expect this migration to take very long, but the site will not be available during - that time. - No data will be lost. - -

pronouns.cc

From 7435604dabb5d9635ca32618c46215cf23e7ccef Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 27 May 2023 23:46:12 +0200 Subject: [PATCH 080/119] add Caddyfile to docs --- docs/Caddyfile | 12 ++++++++++++ docs/production.md | 12 ++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 docs/Caddyfile diff --git a/docs/Caddyfile b/docs/Caddyfile new file mode 100644 index 0000000..d8649e9 --- /dev/null +++ b/docs/Caddyfile @@ -0,0 +1,12 @@ +http://pronouns.local { + handle /media* { + uri path_regexp ^/media /pronouns.cc + reverse_proxy localhost:9000 + } + + handle_path /api* { + reverse_proxy localhost:8080 + } + + reverse_proxy localhost:5173 +} diff --git a/docs/production.md b/docs/production.md index 8588b71..85d9e34 100644 --- a/docs/production.md +++ b/docs/production.md @@ -8,6 +8,7 @@ You might have to change paths and ports, but they should work fine as-is. ```bash git clone https://codeberg.org/u1f320/pronouns.cc.git pronouns cd pronouns +git checkout stable make all # if running for the first time @@ -43,6 +44,8 @@ one in the repository root (for the backend) and one in the frontend directory. - `PUBLIC_BASE_URL`: the base URL for the frontend. - `PRIVATE_SENTRY_DSN`: your Sentry DSN. +- `PUBLIC_MEDIA_URL`: the base URL for media. + If you're proxying your media through nginx as in `pronounscc.nginx`, set this to `$PUBLIC_BASE_URL/media`. ## Updating @@ -60,9 +63,6 @@ systemctl start pronouns-exporter # if the exporter was stopped Both the backend and frontend are expected to run behind a reverse proxy such as Caddy or nginx. This directory contains a sample configuration file for nginx. -Every path should be proxied to the frontend, except: - -- `/api/`: this should be proxied to the backend, with the URL being rewritten to remove `/api` - (for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`) -- `/media/`: this should be proxied to your object storage. - Make sure to rewrite `/media` into your storage bucket's name. \ No newline at end of file +Every path should be proxied to the frontend, except for `/api/`: +this should be proxied to the backend, with the URL being rewritten to remove `/api` +(for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`) From c69c777fc8596e0b83c2c045e662e0126bb5f9a4 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 10 May 2023 00:45:31 +0200 Subject: [PATCH 081/119] feat: GET /users/@me/flags, POST /users/@me/flags --- backend/db/flags.go | 226 ++++++++++++++++++++++++++++ backend/routes/user/flags.go | 121 +++++++++++++++ backend/routes/user/routes.go | 5 + backend/server/errors.go | 5 +- scripts/migrate/017_pride_flags.sql | 24 +++ 5 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 backend/db/flags.go create mode 100644 backend/routes/user/flags.go create mode 100644 scripts/migrate/017_pride_flags.sql diff --git a/backend/db/flags.go b/backend/db/flags.go new file mode 100644 index 0000000..cc2195a --- /dev/null +++ b/backend/db/flags.go @@ -0,0 +1,226 @@ +package db + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "strings" + + "emperror.dev/errors" + "github.com/davidbyttow/govips/v2/vips" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/minio/minio-go/v7" + "github.com/rs/xid" +) + +type PrideFlag struct { + ID xid.ID `json:"id"` + UserID xid.ID `json:"-"` + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +type UserFlag struct { + ID int64 `json:"-"` + UserID xid.ID `json:"-"` + FlagID xid.ID `json:"id"` + + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +type MemberFlag struct { + ID int64 `json:"-"` + MemberID xid.ID `json:"-"` + FlagID xid.ID `json:"id"` + + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +const ( + MaxPrideFlags = 100 + MaxPrideFlagTitleLength = 100 + MaxPrideFlagDescLength = 200 +) + +func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) { + sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("id").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) { + sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description"). + From("user_flags AS u"). + Where("u.user_id = $1"). + Join("pride_flags AS f ON u.flag_id = f.id"). + OrderBy("u.id ASC"). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) MemberFlags(ctx context.Context, userID xid.ID) (fs []MemberFlag, err error) { + sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description"). + From("member_flags AS m"). + Where("m.member_id = $1"). + Join("pride_flags AS f ON m.flag_id = f.id"). + OrderBy("m.id ASC"). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) { + description := &desc + if desc == "" { + description = nil + } + + sql, args, err := sq.Insert("pride_flags"). + SetMap(map[string]any{ + "id": xid.New(), + "hash": "", + "user_id": userID.String(), + "name": name, + "description": description, + }).Suffix("RETURNING *").ToSql() + if err != nil { + return f, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, tx, &f, sql, args...) + if err != nil { + return f, errors.Wrap(err, "executing query") + } + return f, nil +} + +func (db *DB) EditFlag(ctx context.Context, tx pgx.Tx, flagID xid.ID, name, desc, hash *string) (f PrideFlag, err error) { + b := sq.Update("pride_flags"). + Where("id = ?", flagID) + if name != nil { + b = b.Set("name", *name) + } + if desc != nil { + if *desc == "" { + b = b.Set("description", nil) + } else { + b = b.Set("description", *desc) + } + } + if hash != nil { + b = b.Set("hash", *hash) + } + + sql, args, err := b.Suffix("RETURNING *").ToSql() + if err != nil { + return f, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &f, sql, args...) + if err != nil { + return f, errors.Wrap(err, "executing query") + } + return f, nil +} + +func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) (hash string, err error) { + hasher := sha256.New() + _, err = hasher.Write(flag.Bytes()) + if err != nil { + return "", errors.Wrap(err, "hashing flag") + } + hash = hex.EncodeToString(hasher.Sum(nil)) + + _, err = db.minio.PutObject(ctx, db.minioBucket, "/flags/"+hash+".webp", flag, -1, minio.PutObjectOptions{ + ContentType: "image/webp", + }) + if err != nil { + return "", errors.Wrap(err, "uploading flag") + } + + return hash, nil +} + +func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error { + err := db.minio.RemoveObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) + if err != nil { + return errors.Wrap(err, "deleting flag") + } + + return nil +} + +func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.ReadCloser, error) { + obj, err := db.minio.GetObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) + if err != nil { + return nil, errors.Wrap(err, "getting object") + } + return obj, nil +} + +// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result. +func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) { + defer vips.ShutdownThread() + + data = strings.TrimSpace(data) + if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") { + return nil, ErrInvalidDataURI + } + split := strings.Split(data, ",") + + rawData, err := base64.StdEncoding.DecodeString(split[1]) + if err != nil { + return nil, errors.Wrap(err, "invalid base64 data") + } + + image, err := vips.LoadImageFromBuffer(rawData, nil) + if err != nil { + return nil, errors.Wrap(err, "decoding image") + } + + err = image.ThumbnailWithSize(256, 256, vips.InterestingNone, vips.SizeBoth) + if err != nil { + return nil, errors.Wrap(err, "resizing image") + } + + webpExport := vips.NewWebpExportParams() + webpExport.Lossless = true + webpB, _, err := image.ExportWebp(webpExport) + if err != nil { + return nil, errors.Wrap(err, "exporting webp image") + } + webpOut = bytes.NewBuffer(webpB) + + return webpOut, nil +} diff --git a/backend/routes/user/flags.go b/backend/routes/user/flags.go new file mode 100644 index 0000000..2402137 --- /dev/null +++ b/backend/routes/user/flags.go @@ -0,0 +1,121 @@ +package user + +import ( + "fmt" + "net/http" + "strings" + + "codeberg.org/u1f320/pronouns.cc/backend/common" + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + flags, err := s.DB.AccountFlags(ctx, claims.UserID) + if err != nil { + return errors.Wrapf(err, "getting flags for account %v", claims.UserID) + } + + render.JSON(w, r, flags) + return nil +} + +type postUserFlagRequest struct { + Flag string `json:"flag"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + flags, err := s.DB.AccountFlags(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting current user flags") + } + if len(flags) >= db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrFlagLimitReached, + } + } + + var req postUserFlagRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // remove whitespace from all fields + req.Name = strings.TrimSpace(req.Name) + req.Description = strings.TrimSpace(req.Description) + + if s := common.StringLength(&req.Name); s > db.MaxPrideFlagTitleLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s), + } + } + if s := common.StringLength(&req.Description); s > db.MaxPrideFlagDescLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s), + } + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "starting transaction") + } + defer tx.Rollback(ctx) + + flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description) + if err != nil { + log.Errorf("creating flag: %v", err) + return errors.Wrap(err, "creating flag") + } + + webp, err := s.DB.ConvertFlag(req.Flag) + if err != nil { + if err == db.ErrInvalidDataURI { + return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"} + } + return errors.Wrap(err, "converting flag") + } + + hash, err := s.DB.WriteFlag(ctx, flag.ID, webp) + if err != nil { + return errors.Wrap(err, "writing flag") + } + + flag, err = s.DB.EditFlag(ctx, tx, flag.ID, nil, nil, &hash) + if err != nil { + return errors.Wrap(err, "setting hash for flag") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, flag) + return nil +} + +func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error { + return nil +} + +func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error { + return nil +} diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go index 974fa55..dbd943d 100644 --- a/backend/routes/user/routes.go +++ b/backend/routes/user/routes.go @@ -29,6 +29,11 @@ func Mount(srv *server.Server, r chi.Router) { r.Get("/@me/export/start", server.WrapHandler(s.startExport)) r.Get("/@me/export", server.WrapHandler(s.getExport)) + + r.Get("/@me/flags", server.WrapHandler(s.getUserFlags)) + r.Post("/@me/flags", server.WrapHandler(s.postUserFlag)) + r.Patch("/@me/flags", server.WrapHandler(s.patchUserFlag)) + r.Delete("/@me/flags", server.WrapHandler(s.deleteUserFlag)) }) }) } diff --git a/backend/server/errors.go b/backend/server/errors.go index 18bec03..deb901b 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -102,6 +102,7 @@ const ( // User-related error codes ErrUserNotFound = 2001 ErrMemberListPrivate = 2002 + ErrFlagLimitReached = 2003 // Member-related error codes ErrMemberNotFound = 3001 @@ -145,7 +146,8 @@ var errCodeMessages = map[int]string{ ErrInvalidCaptcha: "Invalid or missing captcha response", ErrUserNotFound: "User not found", - ErrMemberListPrivate: "This user's member list is private.", + ErrMemberListPrivate: "This user's member list is private", + ErrFlagLimitReached: "Maximum number of pride flags reached", ErrMemberNotFound: "Member not found", ErrMemberLimitReached: "Member limit reached", @@ -187,6 +189,7 @@ var errCodeStatuses = map[int]int{ ErrUserNotFound: http.StatusNotFound, ErrMemberListPrivate: http.StatusForbidden, + ErrFlagLimitReached: http.StatusBadRequest, ErrMemberNotFound: http.StatusNotFound, ErrMemberLimitReached: http.StatusBadRequest, diff --git a/scripts/migrate/017_pride_flags.sql b/scripts/migrate/017_pride_flags.sql new file mode 100644 index 0000000..9c9b622 --- /dev/null +++ b/scripts/migrate/017_pride_flags.sql @@ -0,0 +1,24 @@ +-- +migrate Up + +-- 2023-05-09: Add pride flags +-- Hashes are a separate table so we can deduplicate flags. + +create table pride_flags ( + id text primary key, + user_id text not null references users (id) on delete cascade, + hash text not null, + name text not null, + description text +); + +create table user_flags ( + id bigint generated by default as identity primary key, + user_id text not null references users (id) on delete cascade, + flag_id text not null references pride_flags (id) on delete cascade +); + +create table member_flags ( + id bigint generated by default as identity primary key, + member_id text not null references members (id) on delete cascade, + flag_id text not null references pride_flags (id) on delete cascade +); From 1b78462f50f663e05d9b0c2bb7a5c450ac9eae8f Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 25 May 2023 13:40:15 +0200 Subject: [PATCH 082/119] feat: add flags to PATCH /users/@me --- backend/db/db.go | 5 ++ backend/db/flags.go | 74 +++++++++++++++++++++++-- backend/db/member.go | 4 +- backend/db/user.go | 4 +- backend/routes/member/create_member.go | 2 +- backend/routes/member/get_member.go | 18 +++++-- backend/routes/member/patch_member.go | 2 +- backend/routes/user/get_user.go | 75 +++++++++++--------------- backend/routes/user/patch_user.go | 27 +++++++++- 9 files changed, 153 insertions(+), 58 deletions(-) diff --git a/backend/db/db.go b/backend/db/db.go index 620e498..75ededf 100644 --- a/backend/db/db.go +++ b/backend/db/db.go @@ -22,6 +22,11 @@ var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) const ErrNothingToUpdate = errors.Sentinel("nothing to update") +const ( + uniqueViolation = "23505" + foreignKeyViolation = "23503" +) + type Execer interface { Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error) } diff --git a/backend/db/flags.go b/backend/db/flags.go index cc2195a..15e11fb 100644 --- a/backend/db/flags.go +++ b/backend/db/flags.go @@ -9,10 +9,12 @@ import ( "io" "strings" + "codeberg.org/u1f320/pronouns.cc/backend/log" "emperror.dev/errors" "github.com/davidbyttow/govips/v2/vips" "github.com/georgysavva/scany/v2/pgxscan" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/minio/minio-go/v7" "github.com/rs/xid" ) @@ -51,6 +53,10 @@ const ( MaxPrideFlagDescLength = 200 ) +const ( + ErrInvalidFlagID = errors.Sentinel("invalid flag ID") +) + func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) { sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("id").ToSql() if err != nil { @@ -67,7 +73,7 @@ func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) { sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description"). From("user_flags AS u"). - Where("u.user_id = $1"). + Where("u.user_id = $1", userID). Join("pride_flags AS f ON u.flag_id = f.id"). OrderBy("u.id ASC"). ToSql() @@ -82,10 +88,10 @@ func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err return NotNull(fs), nil } -func (db *DB) MemberFlags(ctx context.Context, userID xid.ID) (fs []MemberFlag, err error) { +func (db *DB) MemberFlags(ctx context.Context, memberID xid.ID) (fs []MemberFlag, err error) { sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description"). From("member_flags AS m"). - Where("m.member_id = $1"). + Where("m.member_id = $1", memberID). Join("pride_flags AS f ON m.flag_id = f.id"). OrderBy("m.id ASC"). ToSql() @@ -100,6 +106,68 @@ func (db *DB) MemberFlags(ctx context.Context, userID xid.ID) (fs []MemberFlag, return NotNull(fs), nil } +func (db *DB) SetUserFlags(ctx context.Context, tx pgx.Tx, userID xid.ID, flags []xid.ID) (err error) { + sql, args, err := sq.Delete("user_flags").Where("user_id = ?", userID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "deleting existing flags") + } + + n, err := tx.CopyFrom(ctx, pgx.Identifier{"user_flags"}, []string{"user_id", "flag_id"}, + pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) { + return []any{userID, flags[i]}, nil + })) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + if pge.Code == foreignKeyViolation { + return ErrInvalidFlagID + } + } + + return errors.Wrap(err, "copying new flags") + } + if n > 0 { + log.Debugf("set %v flags for user %v", n, userID) + } + return nil +} + +func (db *DB) SetMemberFlags(ctx context.Context, tx pgx.Tx, memberID xid.ID, flags []xid.ID) (err error) { + sql, args, err := sq.Delete("member_flags").Where("member_id = ?", memberID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "deleting existing flags") + } + + n, err := tx.CopyFrom(ctx, pgx.Identifier{"member_flags"}, []string{"member_id", "flag_id"}, + pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) { + return []any{memberID, flags[i]}, nil + })) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + if pge.Code == foreignKeyViolation { + return ErrInvalidFlagID + } + } + + return errors.Wrap(err, "copying new flags") + } + if n > 0 { + log.Debugf("set %v flags for member %v", n, memberID) + } + return nil +} + func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) { description := &desc if desc == "" { diff --git a/backend/db/member.go b/backend/db/member.go index 2cb377a..43682c5 100644 --- a/backend/db/member.go +++ b/backend/db/member.go @@ -116,7 +116,7 @@ func (db *DB) CreateMember( pge := &pgconn.PgError{} if errors.As(err, &pge) { // unique constraint violation - if pge.Code == "23505" { + if pge.Code == uniqueViolation { return m, ErrMemberNameInUse } } @@ -223,7 +223,7 @@ func (db *DB) UpdateMember( if err != nil { pge := &pgconn.PgError{} if errors.As(err, &pge) { - if pge.Code == "23505" { + if pge.Code == uniqueViolation { return m, ErrMemberNameInUse } } diff --git a/backend/db/user.go b/backend/db/user.go index 63b8173..0370088 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -171,7 +171,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use pge := &pgconn.PgError{} if errors.As(err, &pge) { // unique constraint violation - if pge.Code == "23505" { + if pge.Code == uniqueViolation { return u, ErrUsernameTaken } } @@ -494,7 +494,7 @@ func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName pge := &pgconn.PgError{} if errors.As(err, &pge) { // unique constraint violation - if pge.Code == "23505" { + if pge.Code == uniqueViolation { return ErrUsernameTaken } } diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go index 1156674..b2d4198 100644 --- a/backend/routes/member/create_member.go +++ b/backend/routes/member/create_member.go @@ -188,7 +188,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error return errors.Wrap(err, "committing transaction") } - render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, true)) + render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true)) return nil } diff --git a/backend/routes/member/get_member.go b/backend/routes/member/get_member.go index c109b7f..d92aa63 100644 --- a/backend/routes/member/get_member.go +++ b/backend/routes/member/get_member.go @@ -23,13 +23,14 @@ type GetMemberResponse struct { Names []db.FieldEntry `json:"names"` Pronouns []db.PronounEntry `json:"pronouns"` Fields []db.Field `json:"fields"` + Flags []db.MemberFlag `json:"flags"` User PartialUser `json:"user"` Unlisted *bool `json:"unlisted,omitempty"` } -func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember bool) GetMemberResponse { +func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse { r := GetMemberResponse{ ID: m.ID, Name: m.Name, @@ -41,6 +42,7 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo Names: db.NotNull(m.Names), Pronouns: db.NotNull(m.Pronouns), Fields: db.NotNull(fields), + Flags: flags, User: PartialUser{ ID: u.ID, @@ -102,7 +104,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { return err } - render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember)) + flags, err := s.DB.MemberFlags(ctx, m.ID) + if err != nil { + return err + } + + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) return nil } @@ -137,7 +144,12 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error { return err } - render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember)) + flags, err := s.DB.MemberFlags(ctx, m.ID) + if err != nil { + return err + } + + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) return nil } diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go index a6d0881..8e1d7af 100644 --- a/backend/routes/member/patch_member.go +++ b/backend/routes/member/patch_member.go @@ -284,6 +284,6 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } // echo the updated member back on success - render.JSON(w, r, dbMemberToMember(u, m, fields, true)) + render.JSON(w, r, dbMemberToMember(u, m, fields, nil, true)) return nil } diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index 2466e2e..c04f48d 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -25,6 +25,7 @@ type GetUserResponse struct { Members []PartialMember `json:"members"` Fields []db.Field `json:"fields"` CustomPreferences db.CustomPreferences `json:"custom_preferences"` + Flags []db.UserFlag `json:"flags"` } type GetMeResponse struct { @@ -61,7 +62,7 @@ type PartialMember struct { Pronouns []db.PronounEntry `json:"pronouns"` } -func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse { +func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse { resp := GetUserResponse{ ID: u.ID, Username: u.Username, @@ -74,6 +75,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser Pronouns: db.NotNull(u.Pronouns), Fields: db.NotNull(fields), CustomPreferences: u.CustomPreferences, + Flags: flags, } resp.Members = make([]PartialMember, len(members)) @@ -93,56 +95,29 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser return resp } -func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error { +func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) { ctx := r.Context() userRef := chi.URLParamFromCtx(ctx, "userRef") + var u db.User if id, err := xid.FromString(userRef); err == nil { - u, err := s.DB.User(ctx, id) - if err == nil { - if u.DeletedAt != nil { - return server.APIError{Code: server.ErrUserNotFound} - } - - isSelf := false - if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { - isSelf = true - } - - fields, err := s.DB.UserFields(ctx, u.ID) - if err != nil { - log.Errorf("Error getting user fields: %v", err) - return err - } - - var members []db.Member - if !u.ListPrivate || isSelf { - members, err = s.DB.UserMembers(ctx, u.ID, isSelf) - if err != nil { - log.Errorf("Error getting user members: %v", err) - return err - } - } - - render.JSON(w, r, dbUserToResponse(u, fields, members)) - return nil - } else if err != db.ErrUserNotFound { - log.Errorf("Error getting user by ID: %v", err) - return err + u, err = s.DB.User(ctx, id) + if err != nil { + log.Errorf("getting user by ID: %v", err) } - // otherwise, we fall back to checking usernames } - u, err := s.DB.Username(ctx, userRef) - if err == db.ErrUserNotFound { - return server.APIError{ - Code: server.ErrUserNotFound, + if u.ID.IsNil() { + u, err = s.DB.Username(ctx, userRef) + if err == db.ErrUserNotFound { + return server.APIError{ + Code: server.ErrUserNotFound, + } + } else if err != nil { + log.Errorf("Error getting user by username: %v", err) + return err } - - } else if err != nil { - log.Errorf("Error getting user by username: %v", err) - return err } if u.DeletedAt != nil { @@ -160,6 +135,12 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error { return err } + flags, err := s.DB.UserFlags(ctx, u.ID) + if err != nil { + log.Errorf("getting user flags: %v", err) + return err + } + var members []db.Member if !u.ListPrivate || isSelf { members, err = s.DB.UserMembers(ctx, u.ID, isSelf) @@ -169,7 +150,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error { } } - render.JSON(w, r, dbUserToResponse(u, fields, members)) + render.JSON(w, r, dbUserToResponse(u, fields, members, flags)) return nil } @@ -195,8 +176,14 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { return err } + flags, err := s.DB.UserFlags(ctx, u.ID) + if err != nil { + log.Errorf("getting user flags: %v", err) + return err + } + render.JSON(w, r, GetMeResponse{ - GetUserResponse: dbUserToResponse(u, fields, members), + GetUserResponse: dbUserToResponse(u, fields, members, flags), CreatedAt: u.ID.Time(), MaxInvites: u.MaxInvites, IsAdmin: u.IsAdmin, diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index ab07a58..3b015bb 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -11,6 +11,7 @@ import ( "emperror.dev/errors" "github.com/go-chi/render" "github.com/google/uuid" + "github.com/rs/xid" ) type PatchUserRequest struct { @@ -25,6 +26,7 @@ type PatchUserRequest struct { Avatar *string `json:"avatar"` ListPrivate *bool `json:"list_private"` CustomPreferences *db.CustomPreferences `json:"custom_preferences"` + Flags *[]xid.ID `json:"flags"` } // patchUser parses a PatchUserRequest and updates the user with the given ID. @@ -60,7 +62,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { req.Names == nil && req.Pronouns == nil && req.Avatar == nil && - req.CustomPreferences == nil { + req.CustomPreferences == nil && + req.Flags == nil { return server.APIError{ Code: server.ErrBadRequest, Details: "Data must not be empty", @@ -252,6 +255,19 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } + // update flags + if req.Flags != nil { + err = s.DB.SetUserFlags(ctx, tx, claims.UserID, *req.Flags) + if err != nil { + if err == db.ErrInvalidFlagID { + return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"} + } + + log.Errorf("updating flags for user %v: %v", claims.UserID, err) + return err + } + } + // update last active time err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) if err != nil { @@ -274,9 +290,16 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } + // get flags to return (we need to return full flag objects, not the array of IDs in the request body) + flags, err := s.DB.UserFlags(ctx, u.ID) + if err != nil { + log.Errorf("getting user flags: %v", err) + return err + } + // echo the updated user back on success render.JSON(w, r, GetMeResponse{ - GetUserResponse: dbUserToResponse(u, fields, nil), + GetUserResponse: dbUserToResponse(u, fields, nil, flags), MaxInvites: u.MaxInvites, IsAdmin: u.IsAdmin, ListPrivate: u.ListPrivate, From ea2ae94742451fac0e3a7d39a3f18f873b30aaac Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 25 May 2023 15:21:50 +0200 Subject: [PATCH 083/119] feat: add flags to PATCH /members/{id} --- backend/routes/member/patch_member.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go index 8e1d7af..d4545eb 100644 --- a/backend/routes/member/patch_member.go +++ b/backend/routes/member/patch_member.go @@ -25,6 +25,7 @@ type PatchMemberRequest struct { Fields *[]db.Field `json:"fields"` Avatar *string `json:"avatar"` Unlisted *bool `json:"unlisted"` + Flags *[]xid.ID `json:"flags"` } func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { @@ -74,7 +75,8 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { req.Fields == nil && req.Names == nil && req.Pronouns == nil && - req.Avatar == nil { + req.Avatar == nil && + req.Flags == nil { return server.APIError{ Code: server.ErrBadRequest, Details: "Data must not be empty", @@ -270,6 +272,19 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } } + // update flags + if req.Flags != nil { + err = s.DB.SetMemberFlags(ctx, tx, m.ID, *req.Flags) + if err != nil { + if err == db.ErrInvalidFlagID { + return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"} + } + + log.Errorf("updating flags for member %v: %v", m.ID, err) + return err + } + } + // update last active time err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) if err != nil { @@ -283,7 +298,14 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { return err } + // get flags to return (we need to return full flag objects, not the array of IDs in the request body) + flags, err := s.DB.MemberFlags(ctx, m.ID) + if err != nil { + log.Errorf("getting user flags: %v", err) + return err + } + // echo the updated member back on success - render.JSON(w, r, dbMemberToMember(u, m, fields, nil, true)) + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) return nil } From 1360a524880e0b70d6ca35cb93f16b664d97ded7 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 26 May 2023 15:22:27 +0200 Subject: [PATCH 084/119] add PATCH /users/@me/flags/{id} --- backend/db/flags.go | 2 +- backend/routes/user/flags.go | 86 +++++++++++++++++++++++++++++++++++ backend/routes/user/routes.go | 2 +- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/backend/db/flags.go b/backend/db/flags.go index 15e11fb..1393a5a 100644 --- a/backend/db/flags.go +++ b/backend/db/flags.go @@ -50,7 +50,7 @@ type MemberFlag struct { const ( MaxPrideFlags = 100 MaxPrideFlagTitleLength = 100 - MaxPrideFlagDescLength = 200 + MaxPrideFlagDescLength = 500 ) const ( diff --git a/backend/routes/user/flags.go b/backend/routes/user/flags.go index 2402137..2ad6ebd 100644 --- a/backend/routes/user/flags.go +++ b/backend/routes/user/flags.go @@ -10,7 +10,9 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/server" "emperror.dev/errors" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/rs/xid" ) func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error { @@ -112,7 +114,91 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error { return nil } +type patchUserFlagRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` +} + func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + flagID, err := xid.FromString(chi.URLParam(r, "flagID")) + if err != nil { + return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"} + } + + flags, err := s.DB.AccountFlags(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting current user flags") + } + if len(flags) >= db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrFlagLimitReached, + } + } + var found bool + for _, flag := range flags { + if flag.ID == flagID { + found = true + break + } + } + if !found { + return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"} + } + + var req patchUserFlagRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if req.Name != nil { + *req.Name = strings.TrimSpace(*req.Name) + } + if req.Description != nil { + *req.Description = strings.TrimSpace(*req.Description) + } + + if req.Name == nil && req.Description == nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Request cannot be empty"} + } + + if s := common.StringLength(req.Name); s > db.MaxPrideFlagTitleLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s), + } + } + if s := common.StringLength(req.Description); s > db.MaxPrideFlagDescLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s), + } + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + flag, err := s.DB.EditFlag(ctx, tx, flagID, req.Name, req.Description, nil) + if err != nil { + return errors.Wrap(err, "updating flag") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, flag) return nil } diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go index dbd943d..c5998f1 100644 --- a/backend/routes/user/routes.go +++ b/backend/routes/user/routes.go @@ -32,7 +32,7 @@ func Mount(srv *server.Server, r chi.Router) { r.Get("/@me/flags", server.WrapHandler(s.getUserFlags)) r.Post("/@me/flags", server.WrapHandler(s.postUserFlag)) - r.Patch("/@me/flags", server.WrapHandler(s.patchUserFlag)) + r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag)) r.Delete("/@me/flags", server.WrapHandler(s.deleteUserFlag)) }) }) From a4698e179a4696f06c4e92dc018dc36ba1ba20bb Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 28 May 2023 16:19:42 +0200 Subject: [PATCH 085/119] feat: add DELETE /users/@me/flags/{id} --- backend/db/flags.go | 30 +++++++++++++++++++++++++++++- backend/routes/user/flags.go | 30 ++++++++++++++++++++++++++++++ backend/routes/user/routes.go | 2 +- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/backend/db/flags.go b/backend/db/flags.go index 1393a5a..6f3458b 100644 --- a/backend/db/flags.go +++ b/backend/db/flags.go @@ -55,6 +55,7 @@ const ( const ( ErrInvalidFlagID = errors.Sentinel("invalid flag ID") + ErrFlagNotFound = errors.Sentinel("flag not found") ) func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) { @@ -70,6 +71,23 @@ func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, return NotNull(fs), nil } +func (db *DB) UserFlag(ctx context.Context, flagID xid.ID) (f PrideFlag, err error) { + sql, args, err := sq.Select("*").From("pride_flags").Where("id = ?", flagID).ToSql() + if err != nil { + return f, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, db, &f, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return f, ErrFlagNotFound + } + + return f, errors.Wrap(err, "executing query") + } + return f, nil +} + func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) { sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description"). From("user_flags AS u"). @@ -241,7 +259,17 @@ func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) } func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error { - err := db.minio.RemoveObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) + sql, args, err := sq.Delete("pride_flags").Where("id = ?", flagID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = db.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + err = db.minio.RemoveObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) if err != nil { return errors.Wrap(err, "deleting flag") } diff --git a/backend/routes/user/flags.go b/backend/routes/user/flags.go index 2ad6ebd..77654f3 100644 --- a/backend/routes/user/flags.go +++ b/backend/routes/user/flags.go @@ -203,5 +203,35 @@ func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error { } func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + flagID, err := xid.FromString(chi.URLParam(r, "flagID")) + if err != nil { + return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"} + } + + flag, err := s.DB.UserFlag(ctx, flagID) + if err != nil { + if err == db.ErrFlagNotFound { + return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"} + } + + return errors.Wrap(err, "getting flag object") + } + if flag.UserID != claims.UserID { + return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"} + } + + err = s.DB.DeleteFlag(ctx, flag.ID, flag.Hash) + if err != nil { + return errors.Wrap(err, "deleting flag") + } + + render.NoContent(w, r) return nil } diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go index c5998f1..fd6e9a2 100644 --- a/backend/routes/user/routes.go +++ b/backend/routes/user/routes.go @@ -33,7 +33,7 @@ func Mount(srv *server.Server, r chi.Router) { r.Get("/@me/flags", server.WrapHandler(s.getUserFlags)) r.Post("/@me/flags", server.WrapHandler(s.postUserFlag)) r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag)) - r.Delete("/@me/flags", server.WrapHandler(s.deleteUserFlag)) + r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag)) }) }) } From 8b03521382b16eb247c4805eaad25beb61e5526a Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 29 May 2023 00:18:02 +0200 Subject: [PATCH 086/119] feat: add list/upload flag UI --- backend/db/avatars.go | 1 + backend/db/flags.go | 8 +- backend/routes/user/flags.go | 2 + frontend/src/lib/api/entities.ts | 9 + frontend/src/routes/settings/+layout.svelte | 19 +- frontend/src/routes/settings/+page.svelte | 5 +- .../src/routes/settings/flags/+page.svelte | 164 ++++++++++++++++++ frontend/src/routes/settings/flags/+page.ts | 7 + .../src/routes/settings/flags/Flag.svelte | 20 +++ .../routes/settings/flags/unknown_flag.png | Bin 0 -> 4840 bytes 10 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 frontend/src/routes/settings/flags/+page.svelte create mode 100644 frontend/src/routes/settings/flags/+page.ts create mode 100644 frontend/src/routes/settings/flags/Flag.svelte create mode 100644 frontend/src/routes/settings/flags/unknown_flag.png diff --git a/backend/db/avatars.go b/backend/db/avatars.go index f35ef26..e59c682 100644 --- a/backend/db/avatars.go +++ b/backend/db/avatars.go @@ -19,6 +19,7 @@ import ( const ErrInvalidDataURI = errors.Sentinel("invalid data URI") const ErrInvalidContentType = errors.Sentinel("invalid avatar content type") +const ErrFileTooLarge = errors.Sentinel("file to be converted exceeds maximum size") // ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results. func (db *DB) ConvertAvatar(data string) ( diff --git a/backend/db/flags.go b/backend/db/flags.go index 6f3458b..94e70ea 100644 --- a/backend/db/flags.go +++ b/backend/db/flags.go @@ -59,7 +59,7 @@ const ( ) func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) { - sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("id").ToSql() + sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)").ToSql() if err != nil { return nil, errors.Wrap(err, "building query") } @@ -285,6 +285,8 @@ func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.Re return obj, nil } +const MaxFlagInputSize = 512_000 + // ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result. func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) { defer vips.ShutdownThread() @@ -300,6 +302,10 @@ func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) { return nil, errors.Wrap(err, "invalid base64 data") } + if len(rawData) > MaxFlagInputSize { + return nil, ErrFileTooLarge + } + image, err := vips.LoadImageFromBuffer(rawData, nil) if err != nil { return nil, errors.Wrap(err, "decoding image") diff --git a/backend/routes/user/flags.go b/backend/routes/user/flags.go index 77654f3..60219b9 100644 --- a/backend/routes/user/flags.go +++ b/backend/routes/user/flags.go @@ -91,6 +91,8 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error { if err != nil { if err == db.ErrInvalidDataURI { return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"} + } else if err == db.ErrFileTooLarge { + return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"} } return errors.Wrap(err, "converting flag") } diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index acb2767..df2ad47 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -96,6 +96,13 @@ export interface MemberPartialUser { custom_preferences: CustomPreferences; } +export interface PrideFlag { + id: string; + hash: string; + name: string; + description: string | null; +} + export interface Invite { code: string; created: string; @@ -192,6 +199,8 @@ export const memberAvatars = (member: Member | PartialMember) => { ]; }; +export const flagURL = ({ hash }: PrideFlag) => `${PUBLIC_MEDIA_URL}/flags/${hash}.webp`; + export const defaultAvatars = [ `${PUBLIC_BASE_URL}/default/512.webp`, `${PUBLIC_BASE_URL}/default/512.jpg`, diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index 785c43b..4e66453 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -42,20 +42,13 @@
-
+

Settings

Your profile - - Authentication - {#if hasHiddenMembers} +
+ {#if data.invitesEnabled} Log out
-
+
diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 0b9e229..c28872e 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -155,8 +155,9 @@ {/if}
- -

+

+ +
To change your avatar, go to edit profile.

diff --git a/frontend/src/routes/settings/flags/+page.svelte b/frontend/src/routes/settings/flags/+page.svelte new file mode 100644 index 0000000..01e1dfa --- /dev/null +++ b/frontend/src/routes/settings/flags/+page.svelte @@ -0,0 +1,164 @@ + + +

Pride flags ({data.flags.length})

+ +

+ You can upload pride flags to use on your profiles here. Flags you upload here will not automatically + show up on your profile. +

+ +
+ + +
+ +
+ {#each filtered as flag} + + {:else} + {#if data.flags.length === 0} + You haven't uploaded any flags yet, press the button above to do so. + {:else} + There are no flags matching your search {search} + {/if} + {/each} +
+ + + Upload flag + + {#if error} + + {/if} +
+ New flag + +
+

+ Only PNG, JPEG, GIF, and WebP images can be uploaded + as flags. The file cannot be larger than 512 kilobytes. +

+

+ + +

+

+ This name will be shown beside the flag. +

+

+ +