diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad1d1e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# Key used to sign tokens. Generate this with `go run . generate key` +HMAC_KEY= + +# PostgreSQL connection URL (postgresql://user:pass@host:port/dbname) +DATABASE_URL= + +# Redis connection URL (redis://user:pass@host:port) +REDIS= + +# Port for the backend to listen on; frontend assumes this will be 8080 for dev +PORT=8080 + +# Frontend base URL, used to construct URLs that point back to the frontend +BASE_URL=http://localhost:5173 + +# S3/MinIO configuration, required for avatars, pride flags, and data exports +# Note: MINIO_ENDPOINT must be set and look like a minio endpoint, but doesn't +# have to actually point to anything real +MINIO_ENDPOINT=example.com +MINIO_BUCKET= +MINIO_ACCESS_KEY_ID= +MINIO_ACCESS_KEY_SECRET= +MINIO_SSL= + +# IP address of the frontend; requests from here will never be ratelimited +FRONTEND_IP= + +# Auth providers - fill in OAuth app info to enable OAuth login for each + +# https://discord.com/developers/applications +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +# https://developers.google.com/identity/protocols/oauth2#basicsteps +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# https://www.tumblr.com/oauth/apps +TUMBLR_CLIENT_ID= +TUMBLR_CLIENT_SECRET= + +# Discord bot config - provide the app's public key in addition to client ID/ +# secret above to let the bot respond to command interactions over HTTP +DISCORD_PUBLIC_KEY= diff --git a/Makefile b/Makefile index 8bbfdd4..1b15eff 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ all: generate backend frontend .PHONY: backend backend: - go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/u1f320/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/u1f320/pronouns.cc/backend/server.Tag=`git describe --tags --long`" . + go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long`" . .PHONY: generate generate: diff --git a/README.md b/README.md index 61c0a99..c0ac541 100644 --- a/README.md +++ b/README.md @@ -25,18 +25,20 @@ Requirements: - PostgreSQL (any currently supported version should work) - Redis 6.0 or later - Node.js (latest version) -- MinIO **if using avatars or data exports** (_not_ required otherwise) +- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise) ### Setup -1. Create a PostgreSQL user and database (the user should own the database) +1. Create a PostgreSQL user and database (the user should own the database). For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;` -2. Create a `.env` file in the repository root containing at least `HMAC_KEY`, `DATABASE_URL`, `REDIS`, `PORT`, and `MINIO_ENDPOINT` keys. +2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options. 3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user. 4. Run `go run -v . web` to run the backend. -5. Create `frontend/.env` with the following content: `PUBLIC_BASE_URL=http://localhost:5173` +5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary. 6. cd into the `frontend` directory and run `pnpm dev` to run the frontend. +See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files. + ## License Copyright (C) 2022 Sam diff --git a/backend/db/avatars.go b/backend/db/avatars.go index a3ca1ca..e59c682 100644 --- a/backend/db/avatars.go +++ b/backend/db/avatars.go @@ -6,23 +6,20 @@ 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") 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) ( @@ -30,6 +27,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 +40,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, "decodign image") + 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 } @@ -79,15 +81,17 @@ 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", + _, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ + 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", + _, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ + ContentType: "image/jpeg", + SendContentMd5: true, }) if err != nil { return "", errors.Wrap(err, "uploading jpeg avatar") @@ -108,15 +112,17 @@ 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", + _, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ + 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", + _, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ + ContentType: "image/jpeg", + SendContentMd5: true, }) if err != nil { return "", errors.Wrap(err, "uploading jpeg avatar") @@ -126,12 +132,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") } @@ -140,12 +146,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") } @@ -154,7 +160,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") } @@ -162,7 +168,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/db.go b/backend/db/db.go index 03caf25..13e12f2 100644 --- a/backend/db/db.go +++ b/backend/db/db.go @@ -6,7 +6,9 @@ import ( "fmt" "net/url" "os" + "sync" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "emperror.dev/errors" "github.com/Masterminds/squirrel" "github.com/jackc/pgx/v5/pgconn" @@ -14,12 +16,18 @@ 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) 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) } @@ -32,19 +40,28 @@ type DB struct { minio *minio.Client minioBucket string baseURL *url.URL + + TotalRequests prometheus.Counter + + activeUsersDay, activeUsersWeek, activeUsersMonth int64 + usersTotal, membersTotal int64 + countMu sync.RWMutex } 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 +84,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 } @@ -124,30 +147,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/db/entries.go b/backend/db/entries.go index 9a2c28f..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 @@ -40,13 +31,13 @@ func (w *WordStatus) UnmarshalJSON(src []byte) error { return nil } -func (w WordStatus) Valid(extra ...WordStatus) bool { - if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid { +func (w WordStatus) Valid(extra CustomPreferences) bool { + if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" { return true } - for i := range extra { - if w == extra[i] { + for k := range extra { + if string(w) == k { return true } } @@ -58,7 +49,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 +58,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 +71,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 +86,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/export.go b/backend/db/export.go index 5c35552..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") @@ -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") diff --git a/backend/db/fediverse.go b/backend/db/fediverse.go index d319cad..eeadd79 100644 --- a/backend/db/fediverse.go +++ b/backend/db/fediverse.go @@ -48,7 +48,7 @@ func (f FediverseApp) ClientConfig() *oauth2.Config { } func (f FediverseApp) MastodonCompatible() bool { - return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" + return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial" } func (f FediverseApp) Misskey() bool { 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/flags.go b/backend/db/flags.go new file mode 100644 index 0000000..151cb92 --- /dev/null +++ b/backend/db/flags.go @@ -0,0 +1,323 @@ +package db + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "strings" + + "codeberg.org/pronounscc/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" +) + +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 = 500 + MaxPrideFlagTitleLength = 100 + MaxPrideFlagDescLength = 500 +) + +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) { + sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)", "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) 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"). + Where("u.user_id = $1", userID). + 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, 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", memberID). + 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) 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 == "" { + 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", + SendContentMd5: true, + }) + 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 { + 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") + } + 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 +} + +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() + + 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") + } + + if len(rawData) > MaxFlagInputSize { + return nil, ErrFileTooLarge + } + + 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/db/member.go b/backend/db/member.go index 85ad5a2..e893348 100644 --- a/backend/db/member.go +++ b/backend/db/member.go @@ -3,8 +3,10 @@ package db import ( "context" "regexp" + "time" "emperror.dev/errors" + "github.com/Masterminds/squirrel" "github.com/georgysavva/scany/v2/pgxscan" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -19,6 +21,7 @@ const ( type Member struct { ID xid.ID UserID xid.ID + SID string `db:"sid"` Name string DisplayName *string Bio *string @@ -35,9 +38,14 @@ const ( ) // member names must match this regex -var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"'$%&()+<=>^|~`,]{1,100}$") +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) } @@ -68,6 +76,25 @@ func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) ( return m, nil } +// MemberBySID gets a user by their short ID. +func (db *DB) MemberBySID(ctx context.Context, sid string) (u Member, err error) { + sql, args, err := sq.Select("*").From("members").Where("sid = ?", sid).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, ErrMemberNotFound + } + + return u, errors.Wrap(err, "getting members from db") + } + + return u, nil +} + // UserMembers returns all of a user's members, sorted by name. func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) { builder := sq.Select("*"). @@ -99,8 +126,8 @@ func (db *DB) CreateMember( name string, displayName *string, bio string, links []string, ) (m Member, err error) { sql, args, err := sq.Insert("members"). - Columns("user_id", "id", "name", "display_name", "bio", "links"). - Values(userID, xid.New(), name, displayName, bio, links). + Columns("user_id", "id", "sid", "name", "display_name", "bio", "links"). + Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links). Suffix("RETURNING *").ToSql() if err != nil { return m, errors.Wrap(err, "building sql") @@ -111,7 +138,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 } } @@ -218,7 +245,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 } } @@ -227,3 +254,43 @@ func (db *DB) UpdateMember( } return m, nil } + +func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (newID string, err error) { + tx, err := db.Begin(ctx) + if err != nil { + return "", errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + sql, args, err := sq.Update("members"). + Set("sid", squirrel.Expr("find_free_member_sid()")). + Where("id = ?", memberID). + Suffix("RETURNING sid").ToSql() + if err != nil { + return "", errors.Wrap(err, "building sql") + } + + err = tx.QueryRow(ctx, sql, args...).Scan(&newID) + if err != nil { + return "", errors.Wrap(err, "executing query") + } + + sql, args, err = sq.Update("users"). + Set("last_sid_reroll", time.Now()). + Where("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, "executing query") + } + + err = tx.Commit(ctx) + if err != nil { + return "", errors.Wrap(err, "committing transaction") + } + + return newID, nil +} diff --git a/backend/db/metrics.go b/backend/db/metrics.go new file mode 100644 index 0000000..96661dd --- /dev/null +++ b/backend/db/metrics.go @@ -0,0 +1,198 @@ +package db + +import ( + "context" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "emperror.dev/errors" + "github.com/jackc/pgx/v5/pgconn" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/rs/xid" +) + +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) + } + + db.countMu.Lock() + db.usersTotal = count + db.countMu.Unlock() + + 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) + } + + db.countMu.Lock() + db.membersTotal = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering member count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_active", + Help: "The number of users active in the past 30 days", + }, func() float64 { + count, err := db.ActiveUsers(context.Background(), ActiveMonth) + if err != nil { + log.Errorf("getting active user count for metrics: %v", err) + } + + db.countMu.Lock() + db.activeUsersMonth = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering active user count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_active_week", + Help: "The number of users active in the past 7 days", + }, func() float64 { + count, err := db.ActiveUsers(context.Background(), ActiveWeek) + if err != nil { + log.Errorf("getting active user count for metrics: %v", err) + } + + db.countMu.Lock() + db.activeUsersWeek = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering active user count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_active_day", + Help: "The number of users active in the past 1 day", + }, func() float64 { + count, err := db.ActiveUsers(context.Background(), ActiveDay) + if err != nil { + log.Errorf("getting active user count for metrics: %v", err) + } + + db.countMu.Lock() + db.activeUsersDay = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering active user count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_database_latency", + Help: "The latency to the database in nanoseconds", + }, func() float64 { + start := time.Now() + _, err = db.Exec(context.Background(), "SELECT 1") + if err != nil { + log.Errorf("pinging database: %v", err) + return -1 + } + return float64(time.Since(start)) + })) + if err != nil { + return errors.Wrap(err, "registering database latency 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) Counts(ctx context.Context) (numUsers, numMembers, usersDay, usersWeek, usersMonth int64) { + db.countMu.Lock() + if db.usersTotal != 0 { + defer db.countMu.Unlock() + return db.usersTotal, db.membersTotal, db.activeUsersDay, db.activeUsersWeek, db.activeUsersMonth + } + db.countMu.Unlock() + + numUsers, _ = db.TotalUserCount(ctx) + numMembers, _ = db.TotalMemberCount(ctx) + usersDay, _ = db.ActiveUsers(ctx, ActiveDay) + usersWeek, _ = db.ActiveUsers(ctx, ActiveWeek) + usersMonth, _ = db.ActiveUsers(ctx, ActiveMonth) + return numUsers, numMembers, usersDay, usersWeek, usersMonth +} + +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 +} + +const ( + ActiveMonth = 30 * 24 * time.Hour + ActiveWeek = 7 * 24 * time.Hour + ActiveDay = 24 * time.Hour +) + +func (db *DB) ActiveUsers(ctx context.Context, dur time.Duration) (numUsers int64, err error) { + t := time.Now().Add(-dur) + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND last_active > $1", t).Scan(&numUsers) + if err != nil { + return 0, errors.Wrap(err, "querying active user count") + } + return numUsers, nil +} + +type connOrTx interface { + Exec(ctx context.Context, sql string, arguments ...any) (commandTag pgconn.CommandTag, err error) +} + +// UpdateActiveTime is called on create and update endpoints (PATCH /users/@me, POST/PATCH/DELETE /members) +func (db *DB) UpdateActiveTime(ctx context.Context, tx connOrTx, userID xid.ID) (err error) { + sql, args, err := sq.Update("users").Set("last_active", time.Now().UTC()).Where("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, "executing query") + } + return nil +} 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/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/db/user.go b/backend/db/user.go index 3ad8196..ccb0965 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -4,10 +4,14 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "regexp" "time" + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/icons" "emperror.dev/errors" + "github.com/Masterminds/squirrel" "github.com/bwmarrin/discordgo" "github.com/georgysavva/scany/v2/pgxscan" "github.com/jackc/pgx/v5" @@ -17,10 +21,12 @@ import ( type User struct { ID xid.ID + SID string `db:"sid"` Username string DisplayName *string Bio *string MemberTitle *string + LastActive time.Time Avatar *string Links []string @@ -36,15 +42,61 @@ type User struct { FediverseAppID *int64 FediverseInstance *string - MaxInvites int - IsAdmin bool - ListPrivate bool + Tumblr *string + TumblrUsername *string + + Google *string + GoogleUsername *string + + MaxInvites int + IsAdmin bool + ListPrivate bool + LastSIDReroll time.Time `db:"last_sid_reroll"` 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++ @@ -52,12 +104,42 @@ func (u User) NumProviders() (numProviders int) { if u.Fediverse != nil { numProviders++ } + if u.Tumblr != nil { + numProviders++ + } + if u.Google != nil { + numProviders++ + } return numProviders } +type Badge int32 + +const ( + BadgeAdmin Badge = 1 << 0 +) + // 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") @@ -84,17 +166,11 @@ 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() + sql, args, err := sq.Insert("users").Columns("id", "username", "sid").Values(xid.New(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql() if err != nil { return u, errors.Wrap(err, "building sql") } @@ -104,7 +180,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 } } @@ -240,6 +316,128 @@ 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 +} + +// 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"). @@ -279,9 +477,28 @@ func (db *DB) Username(ctx context.Context, name string) (u User, err error) { return u, nil } +// UserBySID gets a user by their short ID. +func (db *DB) UserBySID(ctx context.Context, sid string) (u User, err error) { + sql, args, err := sq.Select("*").From("users").Where("sid = ?", sid).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, "getting user from db") + } + + return u, nil +} + // 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 } @@ -291,8 +508,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() @@ -305,7 +522,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 } } @@ -322,8 +539,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") @@ -365,6 +583,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 == "" { @@ -403,6 +624,23 @@ func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete b return nil } +func (db *DB) RerollUserSID(ctx context.Context, id xid.ID) (newID string, err error) { + sql, args, err := sq.Update("users"). + Set("sid", squirrel.Expr("find_free_user_sid()")). + Set("last_sid_reroll", time.Now()). + Where("id = ?", id). + Suffix("RETURNING sid").ToSql() + if err != nil { + return "", errors.Wrap(err, "building sql") + } + + err = db.QueryRow(ctx, sql, args...).Scan(&newID) + if err != nil { + return "", errors.Wrap(err, "executing query") + } + return newID, nil +} + func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error { sql, args, err := sq.Update("users"). Set("deleted_at", nil). diff --git a/backend/exporter/exporter.go b/backend/exporter/exporter.go index 9f7e564..1c5dbc6 100644 --- a/backend/exporter/exporter.go +++ b/backend/exporter/exporter.go @@ -13,8 +13,8 @@ import ( "os/signal" "sync" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/rs/xid" diff --git a/backend/exporter/types.go b/backend/exporter/types.go index 83540f3..3d4f14e 100644 --- a/backend/exporter/types.go +++ b/backend/exporter/types.go @@ -1,7 +1,7 @@ package exporter import ( - "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/db" "github.com/rs/xid" ) @@ -24,6 +24,12 @@ type userExport struct { Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + 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"` @@ -41,6 +47,10 @@ 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, + Google: u.Google, + GoogleUsername: u.GoogleUsername, MaxInvites: u.MaxInvites, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, 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/main.go b/backend/main.go index 947f76b..7c9226a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -7,9 +7,10 @@ import ( "os" "os/signal" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/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,12 @@ 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() + port := ":" + os.Getenv("PORT") s, err := server.New() diff --git a/backend/openapi.html b/backend/openapi.html new file mode 100644 index 0000000..bb77d6f --- /dev/null +++ b/backend/openapi.html @@ -0,0 +1,480 @@ + + + + + + pronouns.cc + + + + + + + + + +

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, 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
{}
+ + + + diff --git a/backend/prns/main.go b/backend/prns/main.go new file mode 100644 index 0000000..07413ef --- /dev/null +++ b/backend/prns/main.go @@ -0,0 +1,99 @@ +package prns + +import ( + "context" + "net/http" + "os" + "os/signal" + "strings" + + dbpkg "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "github.com/urfave/cli/v2" +) + +var Command = &cli.Command{ + Name: "shortener", + Usage: "URL shortener service", + Action: run, +} + +func run(c *cli.Context) error { + port := ":" + os.Getenv("PRNS_PORT") + baseURL := os.Getenv("BASE_URL") + + db, err := dbpkg.New() + if err != nil { + log.Fatalf("creating database: %v", err) + return err + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + defer func() { + if r := recover(); r != nil { + log.Errorf("recovered from panic: %v", err) + } + }() + + id := strings.TrimPrefix(r.URL.Path, "/") + if len(id) == 5 { + u, err := db.UserBySID(r.Context(), id) + if err != nil { + if err != dbpkg.ErrUserNotFound { + log.Errorf("getting user: %v", err) + } + + http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect) + return + } + http.Redirect(w, r, baseURL+"/@"+u.Username, http.StatusTemporaryRedirect) + return + } + + if len(id) == 6 { + m, err := db.MemberBySID(r.Context(), id) + if err != nil { + if err != dbpkg.ErrMemberNotFound { + log.Errorf("getting member: %v", err) + } + + http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect) + return + } + + u, err := db.User(r.Context(), m.UserID) + if err != nil { + log.Errorf("getting user for member %v: %v", m.ID, err) + + http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect) + return + } + + http.Redirect(w, r, baseURL+"/@"+u.Username+"/"+m.Name, http.StatusTemporaryRedirect) + return + } + + http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect) + }) + + e := make(chan error) + go func() { + e <- http.ListenAndServe(port, nil) + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + log.Infof("API server running at %v!", port) + + select { + case <-ctx.Done(): + log.Info("Interrupt signal received, shutting down...") + db.Close() + return nil + case err := <-e: + log.Fatalf("Error running server: %v", err) + } + + return nil +} diff --git a/backend/routes.go b/backend/routes.go index d97a3dc..0f9c90c 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -1,16 +1,24 @@ package backend import ( - "codeberg.org/u1f320/pronouns.cc/backend/routes/auth" - "codeberg.org/u1f320/pronouns.cc/backend/routes/bot" - "codeberg.org/u1f320/pronouns.cc/backend/routes/member" - "codeberg.org/u1f320/pronouns.cc/backend/routes/meta" - "codeberg.org/u1f320/pronouns.cc/backend/routes/mod" - "codeberg.org/u1f320/pronouns.cc/backend/routes/user" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/routes/auth" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/bot" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/member" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/meta" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/mod" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/user" + "codeberg.org/pronounscc/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/backend/routes/auth/captcha.go b/backend/routes/auth/captcha.go new file mode 100644 index 0000000..a9af4bf --- /dev/null +++ b/backend/routes/auth/captcha.go @@ -0,0 +1,57 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "codeberg.org/pronounscc/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") + } + + 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 975accf..f22518c 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -5,9 +5,9 @@ import ( "os" "time" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/bwmarrin/discordgo" "github.com/go-chi/render" @@ -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"` @@ -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"` @@ -52,7 +53,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} } @@ -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 @@ -193,6 +195,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") @@ -245,10 +252,11 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error { return nil } -type discordSignupRequest struct { - Ticket string `json:"ticket"` - Username string `json:"username"` - InviteCode string `json:"invite_code"` +type signupRequest struct { + Ticket string `json:"ticket"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` + CaptchaResponse string `json:"captcha_response"` } type signupResponse struct { @@ -259,7 +267,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} } @@ -293,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 { @@ -302,6 +323,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..1cae10b 100644 --- a/backend/routes/auth/fedi_mastodon.go +++ b/backend/routes/auth/fedi_mastodon.go @@ -6,9 +6,9 @@ import ( "net/http" "time" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" "github.com/mediocregopher/radix/v4" @@ -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 @@ -220,6 +222,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") @@ -273,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 { @@ -321,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 { @@ -330,6 +351,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..864b852 100644 --- a/backend/routes/auth/fedi_misskey.go +++ b/backend/routes/auth/fedi_misskey.go @@ -7,9 +7,9 @@ import ( "io" "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" "github.com/mediocregopher/radix/v4" @@ -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 @@ -195,6 +196,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") @@ -251,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 { @@ -260,6 +279,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/fedi_nodeinfo.go b/backend/routes/auth/fedi_nodeinfo.go index 4df63ee..fc56c78 100644 --- a/backend/routes/auth/fedi_nodeinfo.go +++ b/backend/routes/auth/fedi_nodeinfo.go @@ -6,7 +6,7 @@ import ( "io" "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" ) diff --git a/backend/routes/auth/fediverse.go b/backend/routes/auth/fediverse.go index 08a0b49..b3f2f62 100644 --- a/backend/routes/auth/fediverse.go +++ b/backend/routes/auth/fediverse.go @@ -8,8 +8,8 @@ import ( "net/url" "strings" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" ) @@ -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) @@ -62,9 +67,12 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r switch softwareName { case "misskey", "foundkey", "calckey": return s.noAppMisskeyURL(ctx, w, r, softwareName, instance) - case "mastodon", "pleroma", "akkoma", "pixelfed": + case "mastodon", "pleroma", "akkoma", "pixelfed", "gotosocial": + case "glitchcafe": + // plural.cafe (potentially other instances too?) runs Mastodon but changes the software name + // changing it back to mastodon here for consistency + softwareName = "mastodon" default: - // sorry, misskey :( TODO: support misskey return server.APIError{Code: server.ErrUnsupportedInstance} } diff --git a/backend/routes/auth/google.go b/backend/routes/auth/google.go new file mode 100644 index 0000000..182c8a6 --- /dev/null +++ b/backend/routes/auth/google.go @@ -0,0 +1,386 @@ +package auth + +import ( + "net/http" + "os" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/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 + RequireCaptcha bool `json:"require_captcha"` + + 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, + RequireCaptcha: s.hcaptchaSecret != "", + }) + + 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} + } + + 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") + } + + 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} + } + + // 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 { + return server.APIError{Code: server.ErrUsernameTaken} + } + + 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") + } + + 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/invite.go b/backend/routes/auth/invite.go index cb9e661..44e7f0c 100644 --- a/backend/routes/auth/invite.go +++ b/backend/routes/auth/invite.go @@ -4,8 +4,8 @@ import ( "net/http" "time" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" ) diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 1283472..7699084 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -4,9 +4,9 @@ import ( "net/http" "os" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" @@ -18,6 +18,9 @@ type Server struct { RequireInvite bool BaseURL string + + hcaptchaSitekey string + hcaptchaSecret string } type userResponse struct { @@ -34,6 +37,12 @@ type userResponse struct { Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + 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"` @@ -52,6 +61,10 @@ 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, + Google: u.Google, + GoogleUsername: u.GoogleUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: u.FediverseInstance, @@ -60,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) { @@ -84,6 +99,20 @@ 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("/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)) @@ -120,7 +149,9 @@ type oauthURLsRequest struct { } type oauthURLsResponse struct { - Discord string `json:"discord"` + 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 { @@ -136,14 +167,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" + 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", - }) + render.JSON(w, r, resp) return nil } diff --git a/backend/routes/auth/tokens.go b/backend/routes/auth/tokens.go index 211126d..e48662d 100644 --- a/backend/routes/auth/tokens.go +++ b/backend/routes/auth/tokens.go @@ -4,8 +4,8 @@ import ( "net/http" "time" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" "github.com/rs/xid" @@ -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/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go new file mode 100644 index 0000000..d35781a --- /dev/null +++ b/backend/routes/auth/tumblr.go @@ -0,0 +1,419 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + "os" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/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 + RequireCaptcha bool `json:"require_captcha"` + + 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[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 := 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, tumblrID, tumblrName) + 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, + RequireCaptcha: s.hcaptchaSecret != "", + }) + + 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} + } + + 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") + } + + 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[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) + + 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} + } + + // 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 { + return server.APIError{Code: server.ErrUsernameTaken} + } + + 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") + } + + 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/auth/undelete.go b/backend/routes/auth/undelete.go index a09cb36..b557d23 100644 --- a/backend/routes/auth/undelete.go +++ b/backend/routes/auth/undelete.go @@ -6,8 +6,8 @@ import ( "encoding/base64" "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" "github.com/mediocregopher/radix/v4" @@ -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 } diff --git a/backend/routes/bot/bot.go b/backend/routes/bot/bot.go index b5d7b42..1fa8f4c 100644 --- a/backend/routes/bot/bot.go +++ b/backend/routes/bot/bot.go @@ -8,9 +8,9 @@ import ( "net/http" "os" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/bwmarrin/discordgo" "github.com/go-chi/chi/v5" "github.com/go-chi/render" @@ -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/member/create_member.go b/backend/routes/member/create_member.go index b18f8c8..e26ab74 100644 --- a/backend/routes/member/create_member.go +++ b/backend/routes/member/create_member.go @@ -5,10 +5,10 @@ import ( "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" + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" ) @@ -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.", } } @@ -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 } @@ -176,22 +176,29 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error } } + // update last active time + err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + err = tx.Commit(ctx) if err != nil { 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 } 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 +218,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/delete_member.go b/backend/routes/member/delete_member.go index f02cbd3..5ce49c0 100644 --- a/backend/routes/member/delete_member.go +++ b/backend/routes/member/delete_member.go @@ -8,8 +8,9 @@ import ( "github.com/go-chi/render" "github.com/rs/xid" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" ) func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error { @@ -51,6 +52,13 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error { } } + // update last active time + err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + render.NoContent(w, r) return nil } diff --git a/backend/routes/member/get_member.go b/backend/routes/member/get_member.go index efe6284..ddc94d8 100644 --- a/backend/routes/member/get_member.go +++ b/backend/routes/member/get_member.go @@ -4,8 +4,9 @@ import ( "context" "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/rs/xid" @@ -13,6 +14,7 @@ import ( type GetMemberResponse struct { ID xid.ID `json:"id"` + SID string `json:"sid"` Name string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` @@ -22,15 +24,17 @@ 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, + SID: m.SID, Name: m.Name, DisplayName: m.DisplayName, Bio: m.Bio, @@ -40,12 +44,14 @@ 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, - 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 +63,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 { @@ -99,7 +106,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 } @@ -134,7 +146,42 @@ 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 +} + +func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting me user") + } + + m, err := s.DB.UserMember(ctx, claims.UserID, chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{ + Code: server.ErrMemberNotFound, + } + } + + fields, err := s.DB.MemberFields(ctx, m.ID) + if err != nil { + return err + } + + flags, err := s.DB.MemberFlags(ctx, m.ID) + if err != nil { + return err + } + + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) return nil } diff --git a/backend/routes/member/get_members.go b/backend/routes/member/get_members.go index 34c0b35..6b08239 100644 --- a/backend/routes/member/get_members.go +++ b/backend/routes/member/get_members.go @@ -3,8 +3,8 @@ package member import ( "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/rs/xid" @@ -12,6 +12,7 @@ import ( type memberListResponse struct { ID xid.ID `json:"id"` + SID string `json:"sid"` Name string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` @@ -26,13 +27,15 @@ 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, + SID: ms[i].SID, + 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 { diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go index a61d620..5884efe 100644 --- a/backend/routes/member/patch_member.go +++ b/backend/routes/member/patch_member.go @@ -4,11 +4,12 @@ import ( "fmt" "net/http" "strings" + "time" - "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" + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" @@ -25,6 +26,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 { @@ -41,6 +43,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 { @@ -69,7 +76,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", @@ -104,7 +112,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.", } } } @@ -148,15 +156,25 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } } - if err := validateSlicePtr("name", req.Names); err != nil { + // validate flag length + if req.Flags != nil { + if len(*req.Flags) > db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags), + } + } + } + + 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 } @@ -265,18 +283,86 @@ 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 { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + err = tx.Commit(ctx) if err != nil { log.Errorf("committing transaction: %v", err) 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, flags, true)) + return nil +} + +func (s *Server) rerollMemberSID(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"} + } + + id, err := xid.FromString(chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{Code: server.ErrMemberNotFound} + } + 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)) + m, err := s.DB.Member(ctx, id) + if err != nil { + if err == db.ErrMemberNotFound { + return server.APIError{Code: server.ErrMemberNotFound} + } + + return errors.Wrap(err, "getting member") + } + + if m.UserID != claims.UserID { + return server.APIError{Code: server.ErrNotOwnMember} + } + + if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) { + return server.APIError{Code: server.ErrRerollingTooQuickly} + } + + newID, err := s.DB.RerollMemberSID(ctx, u.ID, m.ID) + if err != nil { + return errors.Wrap(err, "updating member SID") + } + + m.SID = newID + render.JSON(w, r, dbMemberToMember(u, m, nil, nil, true)) return nil } diff --git a/backend/routes/member/routes.go b/backend/routes/member/routes.go index 7defa1b..e7846ed 100644 --- a/backend/routes/member/routes.go +++ b/backend/routes/member/routes.go @@ -3,7 +3,7 @@ package member import ( "github.com/go-chi/chi/v5" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/server" ) type Server struct { @@ -19,6 +19,7 @@ func Mount(srv *server.Server, r chi.Router) { // user-scoped member lookup (including custom urls) r.Get("/users/{userRef}/members/{memberRef}", server.WrapHandler(s.getUserMember)) + r.With(server.MustAuth).Get("/users/@me/members/{memberRef}", server.WrapHandler(s.getMeMember)) r.Route("/members", func(r chi.Router) { // any member by ID @@ -28,5 +29,8 @@ func Mount(srv *server.Server, r chi.Router) { r.With(server.MustAuth).Post("/", server.WrapHandler(s.createMember)) r.With(server.MustAuth).Patch("/{memberRef}", server.WrapHandler(s.patchMember)) r.With(server.MustAuth).Delete("/{memberRef}", server.WrapHandler(s.deleteMember)) + + // reroll member SID + r.With(server.MustAuth).Get("/{memberRef}/reroll", server.WrapHandler(s.rerollMemberSID)) }) } diff --git a/backend/routes/meta/meta.go b/backend/routes/meta/meta.go index 609d691..ebae428 100644 --- a/backend/routes/meta/meta.go +++ b/backend/routes/meta/meta.go @@ -4,8 +4,7 @@ import ( "net/http" "os" - "codeberg.org/u1f320/pronouns.cc/backend/server" - "emperror.dev/errors" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) @@ -21,31 +20,34 @@ func Mount(srv *server.Server, r chi.Router) { } type MetaResponse struct { - GitRepository string `json:"git_repository"` - GitCommit string `json:"git_commit"` - Users int64 `json:"users"` - Members int64 `json:"members"` - RequireInvite bool `json:"require_invite"` + GitRepository string `json:"git_repository"` + GitCommit string `json:"git_commit"` + Users MetaUsers `json:"users"` + Members int64 `json:"members"` + RequireInvite bool `json:"require_invite"` +} + +type MetaUsers struct { + Total int64 `json:"total"` + ActiveMonth int64 `json:"active_month"` + ActiveWeek int64 `json:"active_week"` + ActiveDay int64 `json:"active_day"` } 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) - if err != nil { - return errors.Wrap(err, "querying user count") - } - - err = s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM members").Scan(&numMembers) - if err != nil { - return errors.Wrap(err, "querying user count") - } + numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx) render.JSON(w, r, MetaResponse{ GitRepository: server.Repository, GitCommit: server.Revision, - Users: numUsers, + Users: MetaUsers{ + Total: numUsers, + ActiveMonth: activeMonth, + ActiveWeek: activeWeek, + ActiveDay: activeDay, + }, Members: numMembers, RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", }) diff --git a/backend/routes/mod/create_report.go b/backend/routes/mod/create_report.go index a516bc0..c9b6377 100644 --- a/backend/routes/mod/create_report.go +++ b/backend/routes/mod/create_report.go @@ -3,9 +3,9 @@ package mod import ( "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" diff --git a/backend/routes/mod/get_reports.go b/backend/routes/mod/get_reports.go index 1aad2e8..3a61904 100644 --- a/backend/routes/mod/get_reports.go +++ b/backend/routes/mod/get_reports.go @@ -4,8 +4,8 @@ import ( "net/http" "strconv" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" diff --git a/backend/routes/mod/resolve_report.go b/backend/routes/mod/resolve_report.go index 28f327e..77e4b8d 100644 --- a/backend/routes/mod/resolve_report.go +++ b/backend/routes/mod/resolve_report.go @@ -4,9 +4,9 @@ import ( "net/http" "strconv" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" diff --git a/backend/routes/mod/routes.go b/backend/routes/mod/routes.go index 52ff0aa..aaed170 100644 --- a/backend/routes/mod/routes.go +++ b/backend/routes/mod/routes.go @@ -3,9 +3,10 @@ package mod import ( "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/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/routes/mod/warnings.go b/backend/routes/mod/warnings.go index d97fd6c..755a3ff 100644 --- a/backend/routes/mod/warnings.go +++ b/backend/routes/mod/warnings.go @@ -4,9 +4,9 @@ import ( "net/http" "strconv" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" @@ -44,7 +44,7 @@ func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error) ctx := r.Context() claims, _ := server.ClaimsFromContext(ctx) - if !claims.APIToken { + if claims.APIToken { return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} } diff --git a/backend/routes/user/delete_user.go b/backend/routes/user/delete_user.go index 49a2143..1523978 100644 --- a/backend/routes/user/delete_user.go +++ b/backend/routes/user/delete_user.go @@ -3,7 +3,7 @@ package user import ( "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" ) diff --git a/backend/routes/user/export.go b/backend/routes/user/export.go index 4748258..2dde011 100644 --- a/backend/routes/user/export.go +++ b/backend/routes/user/export.go @@ -4,9 +4,9 @@ import ( "net/http" "time" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/go-chi/render" ) diff --git a/backend/routes/user/flags.go b/backend/routes/user/flags.go new file mode 100644 index 0000000..899a675 --- /dev/null +++ b/backend/routes/user/flags.go @@ -0,0 +1,239 @@ +package user + +import ( + "fmt" + "net/http" + "strings" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/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 { + 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"} + } else if err == db.ErrFileTooLarge { + return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"} + } + 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 +} + +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 +} + +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/get_user.go b/backend/routes/user/get_user.go index b8e3833..f6a9aae 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -2,39 +2,53 @@ package user import ( "net/http" + "time" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/rs/xid" ) 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"` + SID string `json:"sid"` + 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"` + Flags []db.UserFlag `json:"flags"` + Badges db.Badge `json:"badges"` } type GetMeResponse struct { GetUserResponse - MaxInvites int `json:"max_invites"` - IsAdmin bool `json:"is_admin"` - ListPrivate bool `json:"list_private"` + CreatedAt time.Time `json:"created_at"` + + MaxInvites int `json:"max_invites"` + IsAdmin bool `json:"is_admin"` + ListPrivate bool `json:"list_private"` + LastSIDReroll time.Time `json:"last_sid_reroll"` Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + 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"` @@ -42,6 +56,7 @@ type GetMeResponse struct { type PartialMember struct { ID xid.ID `json:"id"` + SID string `json:"sid"` Name string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` @@ -51,24 +66,32 @@ 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, - 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, + SID: u.SID, + 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, + Flags: flags, + } + + if u.IsAdmin { + resp.Badges |= db.BadgeAdmin } resp.Members = make([]PartialMember, len(members)) for i := range members { resp.Members[i] = PartialMember{ ID: members[i].ID, + SID: members[i].SID, Name: members[i].Name, DisplayName: members[i].DisplayName, Bio: members[i].Bio, @@ -82,56 +105,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 { @@ -149,6 +145,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) @@ -158,7 +160,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 } @@ -184,13 +186,25 @@ 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, ListPrivate: u.ListPrivate, + LastSIDReroll: u.LastSIDReroll, Discord: u.Discord, 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 01a32c2..716dcca 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -3,26 +3,31 @@ package user import ( "fmt" "net/http" + "time" - "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" + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/google/uuid" + "github.com/rs/xid" ) 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:"name"` + 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"` + Flags *[]xid.ID `json:"flags"` } // patchUser parses a PatchUserRequest and updates the user with the given ID. @@ -57,7 +62,9 @@ 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 && + req.Flags == nil { return server.APIError{ Code: server.ErrBadRequest, Details: "Data must not be empty", @@ -103,15 +110,46 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } - if err := validateSlicePtr("name", req.Names); err != nil { + // validate flag length + if req.Flags != nil { + if len(*req.Flags) > db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags), + } + } + } + + // 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 { + 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} + } + } + } + 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); err != nil { + if err := validateSlicePtr("pronoun", req.Pronouns, customPreferences); err != nil { return *err } - if err := validateSlicePtr("field", req.Fields); err != nil { + if err := validateSlicePtr("field", req.Fields, customPreferences); err != nil { return *err } @@ -186,7 +224,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 @@ -228,6 +266,26 @@ 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 { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + err = tx.Commit(ctx) if err != nil { log.Errorf("committing transaction: %v", err) @@ -243,14 +301,26 @@ 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, + LastSIDReroll: u.LastSIDReroll, Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: fediInstance, @@ -259,12 +329,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 } @@ -284,7 +354,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), @@ -294,3 +364,31 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError { return nil } + +func (s *Server) rerollUserSID(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"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting existing user") + } + + if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) { + return server.APIError{Code: server.ErrRerollingTooQuickly} + } + + newID, err := s.DB.RerollUserSID(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "updating user SID") + } + + u.SID = newID + render.JSON(w, r, dbUserToResponse(u, nil, nil, nil)) + return nil +} diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go index 974fa55..a609e9a 100644 --- a/backend/routes/user/routes.go +++ b/backend/routes/user/routes.go @@ -3,7 +3,7 @@ package user import ( "os" - "codeberg.org/u1f320/pronouns.cc/backend/server" + "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" ) @@ -29,6 +29,13 @@ 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/{flagID}", server.WrapHandler(s.patchUserFlag)) + r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag)) + + r.Get("/@me/reroll", server.WrapHandler(s.rerollUserSID)) }) }) } diff --git a/backend/server/auth.go b/backend/server/auth.go index 915dc77..636b35e 100644 --- a/backend/server/auth.go +++ b/backend/server/auth.go @@ -5,8 +5,8 @@ import ( "net/http" "strings" - "codeberg.org/u1f320/pronouns.cc/backend/log" - "codeberg.org/u1f320/pronouns.cc/backend/server/auth" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server/auth" "github.com/go-chi/render" ) diff --git a/backend/server/auth/auth.go b/backend/server/auth/auth.go index e9d2bfb..1a124b0 100644 --- a/backend/server/auth/auth.go +++ b/backend/server/auth/auth.go @@ -6,7 +6,8 @@ import ( "os" "time" - "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "emperror.dev/errors" "github.com/golang-jwt/jwt/v4" "github.com/rs/xid" @@ -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, diff --git a/backend/server/errors.go b/backend/server/errors.go index 0b0ae3d..b4b8b07 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -4,7 +4,7 @@ import ( "fmt" "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "github.com/go-chi/render" ) @@ -97,10 +97,13 @@ 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 - ErrMemberListPrivate = 2002 + ErrUserNotFound = 2001 + ErrMemberListPrivate = 2002 + ErrFlagLimitReached = 2003 + ErrRerollingTooQuickly = 2004 // Member-related error codes ErrMemberNotFound = 3001 @@ -141,9 +144,12 @@ 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.", + ErrUserNotFound: "User not found", + ErrMemberListPrivate: "This user's member list is private", + ErrFlagLimitReached: "Maximum number of pride flags reached", + ErrRerollingTooQuickly: "You can only reroll one short ID per hour.", ErrMemberNotFound: "Member not found", ErrMemberLimitReached: "Member limit reached", @@ -181,9 +187,12 @@ var errCodeStatuses = map[int]int{ ErrAlreadyLinked: http.StatusBadRequest, ErrNotLinked: http.StatusBadRequest, ErrLastProvider: http.StatusBadRequest, + ErrInvalidCaptcha: http.StatusBadRequest, - ErrUserNotFound: http.StatusNotFound, - ErrMemberListPrivate: http.StatusForbidden, + ErrUserNotFound: http.StatusNotFound, + ErrMemberListPrivate: http.StatusForbidden, + ErrFlagLimitReached: http.StatusBadRequest, + ErrRerollingTooQuickly: http.StatusForbidden, ErrMemberNotFound: http.StatusNotFound, ErrMemberLimitReached: http.StatusBadRequest, diff --git a/backend/server/server.go b/backend/server/server.go index 70e9725..399ead0 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -6,13 +6,15 @@ import ( "strconv" "time" - "codeberg.org/u1f320/pronouns.cc/backend/db" - "codeberg.org/u1f320/pronouns.cc/backend/server/auth" - "codeberg.org/u1f320/pronouns.cc/backend/server/rate" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server/auth" + "codeberg.org/pronounscc/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" + chiprometheus "github.com/toshi0607/chi-prometheus" ) // Revision is the git commit, filled at build time @@ -22,7 +24,7 @@ var ( ) // Repository is the URL of the git repository -const Repository = "https://codeberg.org/u1f320/pronouns.cc" +const Repository = "https://codeberg.org/pronounscc/pronouns.cc" type Server struct { Router *chi.Mux @@ -48,6 +50,22 @@ 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://*"}, + // Allow all methods normally used by the API + AllowedMethods: []string{"HEAD", "GET", "POST", "PATCH", "DELETE"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: false, + MaxAge: 300, + })) + + // enable request latency tracking + os.Setenv(chiprometheus.EnvChiPrometheusLatencyBuckets, "10,25,50,100,300,500,1000,5000") + prom := chiprometheus.New("pronouns.cc") + s.Router.Use(prom.Handler) + prom.MustRegisterDefault() + // enable authentication for all routes (but don't require it) s.Router.Use(s.maybeAuth) @@ -97,12 +115,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/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 9a2daa0..9afe530 100644 --- a/docs/production.md +++ b/docs/production.md @@ -6,12 +6,11 @@ You might have to change paths and ports, but they should work fine as-is. ## Building pronouns.cc ```bash -git clone https://codeberg.org/u1f320/pronouns.cc.git pronouns +git clone https://codeberg.org/pronounscc/pronouns.cc.git pronouns cd pronouns +git checkout stable make all -# if required fonts have not been downloaded yet -./download-fonts.sh # if running for the first time ./pronouns database migrate ``` @@ -23,7 +22,7 @@ one in the repository root (for the backend) and one in the frontend directory. ### Backend keys -- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `scripts/genkey`. +- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `go run -v . generate key` (or `./pronouns generate key` after building). - `DATABASE_URL`: the URL for the PostgreSQL database. - `REDIS`: the URL for the Redis database. - `PORT` (int): the port the backend will listen on. @@ -45,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 @@ -62,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`) diff --git a/download-fonts.sh b/download-fonts.sh deleted file mode 100755 index 58d81e0..0000000 --- a/download-fonts.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -curl "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=400" -o frontend/static/fonts/FiraGO-400.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=400i" -o frontend/static/fonts/FiraGO-400i.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=700" -o frontend/static/fonts/FiraGO-700.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=700i" -o frontend/static/fonts/FiraGO-700i.woff diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..1463658 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,15 @@ +# Base of frontend URLs +PUBLIC_BASE_URL=http://localhost:5173 + +# Base of media URLs, required for avatars, pride flags, and data exports +# If using the provided nginx reverse proxy config, use `$PUBLIC_BASE_URL/media` +PUBLIC_MEDIA_URL= + +# Base of shortened profile URLs (leave empty to disable) +PUBLIC_SHORT_BASE= + +# hCaptcha configuration (leave empty to disable) +PUBLIC_HCAPTCHA_SITEKEY= + +# Sentry configuration (unused in dev, required in production) +PRIVATE_SENTRY_DSN= diff --git a/.prettierrc b/frontend/.prettierrc similarity index 100% rename from .prettierrc rename to frontend/.prettierrc diff --git a/frontend/icons.js b/frontend/icons.js new file mode 100644 index 0000000..6b0e67f --- /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.ts`); + +writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`); + +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/package.json b/frontend/package.json index 6bcead5..80b8720 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", @@ -36,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", @@ -44,6 +46,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..67c96b7 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -1,83 +1,111 @@ -lockfileVersion: 5.4 - -specifiers: - '@popperjs/core': ^2.11.7 - '@sentry/node': ^7.46.0 - '@sveltejs/adapter-auto': ^2.0.0 - '@sveltejs/adapter-node': ^1.2.3 - '@sveltejs/kit': ^1.15.0 - '@types/luxon': ^3.2.2 - '@types/markdown-it': ^12.2.3 - '@types/node': ^18.15.11 - '@types/sanitize-html': ^2.9.0 - '@typescript-eslint/eslint-plugin': ^5.57.1 - '@typescript-eslint/parser': ^5.57.1 - base64-arraybuffer: ^1.0.2 - bootstrap: 5.3.0-alpha1 - bootstrap-icons: ^1.10.4 - eslint: ^8.37.0 - eslint-config-prettier: ^8.8.0 - eslint-plugin-svelte3: ^4.0.0 - jose: ^4.13.1 - luxon: ^3.3.0 - markdown-it: ^13.0.1 - prettier: ^2.8.7 - prettier-plugin-svelte: ^2.10.0 - sanitize-html: ^2.10.0 - svelte: ^3.58.0 - svelte-check: ^3.1.4 - sveltestrap: ^5.10.0 - tslib: ^2.5.0 - typescript: ^4.9.5 - vite: ^4.2.1 - vite-plugin-markdown: ^2.1.0 +lockfileVersion: '6.0' dependencies: - '@popperjs/core': 2.11.7 - '@sentry/node': 7.46.0 - base64-arraybuffer: 1.0.2 - bootstrap: 5.3.0-alpha1_@popperjs+core@2.11.7 - bootstrap-icons: 1.10.4 - jose: 4.13.1 - luxon: 3.3.0 - markdown-it: 13.0.1 - sanitize-html: 2.10.0 + '@fontsource/firago': + specifier: ^4.5.3 + version: 4.5.3 + '@popperjs/core': + specifier: ^2.11.7 + version: 2.11.7 + '@sentry/node': + specifier: ^7.46.0 + version: 7.46.0 + base64-arraybuffer: + specifier: ^1.0.2 + version: 1.0.2 + bootstrap: + specifier: 5.3.0-alpha1 + version: 5.3.0-alpha1(@popperjs/core@2.11.7) + bootstrap-icons: + specifier: ^1.10.4 + version: 1.10.4 + jose: + specifier: ^4.13.1 + version: 4.13.1 + luxon: + specifier: ^3.3.0 + version: 3.3.0 + markdown-it: + specifier: ^13.0.1 + version: 13.0.1 + pretty-bytes: + specifier: ^6.1.0 + version: 6.1.0 + sanitize-html: + specifier: ^2.10.0 + version: 2.10.0 devDependencies: - '@sveltejs/adapter-auto': 2.0.0_@sveltejs+kit@1.15.0 - '@sveltejs/adapter-node': 1.2.3_@sveltejs+kit@1.15.0 - '@sveltejs/kit': 1.15.0_svelte@3.58.0+vite@4.2.1 - '@types/luxon': 3.2.2 - '@types/markdown-it': 12.2.3 - '@types/node': 18.15.11 - '@types/sanitize-html': 2.9.0 - '@typescript-eslint/eslint-plugin': 5.57.1_iify4w3mi7rbbu6mxzspkpx4b4 - '@typescript-eslint/parser': 5.57.1_ip5up2nocltd47wbnuyybe5dxu - eslint: 8.37.0 - eslint-config-prettier: 8.8.0_eslint@8.37.0 - eslint-plugin-svelte3: 4.0.0_4gllgxcu6gmiyy5rrmqexpx7de - prettier: 2.8.7 - prettier-plugin-svelte: 2.10.0_ur5pqdgn24bclu6l6i7qojopk4 - svelte: 3.58.0 - svelte-check: 3.1.4_svelte@3.58.0 - sveltestrap: 5.10.0_svelte@3.58.0 - tslib: 2.5.0 - typescript: 4.9.5 - vite: 4.2.1_@types+node@18.15.11 - vite-plugin-markdown: 2.1.0_vite@4.2.1 + '@sveltejs/adapter-auto': + specifier: ^2.0.0 + version: 2.0.0(@sveltejs/kit@1.15.0) + '@sveltejs/adapter-node': + specifier: ^1.2.3 + version: 1.2.3(@sveltejs/kit@1.15.0) + '@sveltejs/kit': + specifier: ^1.15.0 + version: 1.15.0(svelte@3.58.0)(vite@4.2.1) + '@types/luxon': + specifier: ^3.2.2 + version: 3.2.2 + '@types/markdown-it': + specifier: ^12.2.3 + version: 12.2.3 + '@types/node': + specifier: ^18.15.11 + version: 18.15.11 + '@types/sanitize-html': + specifier: ^2.9.0 + version: 2.9.0 + '@typescript-eslint/eslint-plugin': + specifier: ^5.57.1 + version: 5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.37.0)(typescript@4.9.5) + '@typescript-eslint/parser': + specifier: ^5.57.1 + version: 5.57.1(eslint@8.37.0)(typescript@4.9.5) + eslint: + specifier: ^8.37.0 + version: 8.37.0 + eslint-config-prettier: + specifier: ^8.8.0 + version: 8.8.0(eslint@8.37.0) + eslint-plugin-svelte3: + specifier: ^4.0.0 + version: 4.0.0(eslint@8.37.0)(svelte@3.58.0) + prettier: + specifier: ^2.8.7 + version: 2.8.7 + prettier-plugin-svelte: + specifier: ^2.10.0 + version: 2.10.0(prettier@2.8.7)(svelte@3.58.0) + svelte: + specifier: ^3.58.0 + version: 3.58.0 + svelte-check: + specifier: ^3.1.4 + version: 3.1.4(svelte@3.58.0) + svelte-hcaptcha: + specifier: ^0.1.1 + version: 0.1.1 + sveltestrap: + specifier: ^5.10.0 + version: 5.10.0(svelte@3.58.0) + tslib: + specifier: ^2.5.0 + version: 2.5.0 + typescript: + specifier: ^4.9.5 + version: 4.9.5 + vite: + specifier: ^4.2.1 + version: 4.2.1(@types/node@18.15.11) + vite-plugin-markdown: + specifier: ^2.1.0 + version: 2.1.0(vite@4.2.1) packages: - /@esbuild/android-arm/0.17.15: - resolution: {integrity: sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm64/0.17.15: + /@esbuild/android-arm64@0.17.15: resolution: {integrity: sha512-0kOB6Y7Br3KDVgHeg8PRcvfLkq+AccreK///B4Z6fNZGr/tNHX0z2VywCc7PTeWp+bPvjA5WMvNXltHw5QjAIA==} engines: {node: '>=12'} cpu: [arm64] @@ -86,7 +114,16 @@ packages: dev: true optional: true - /@esbuild/android-x64/0.17.15: + /@esbuild/android-arm@0.17.15: + resolution: {integrity: sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.17.15: resolution: {integrity: sha512-MzDqnNajQZ63YkaUWVl9uuhcWyEyh69HGpMIrf+acR4otMkfLJ4sUCxqwbCyPGicE9dVlrysI3lMcDBjGiBBcQ==} engines: {node: '>=12'} cpu: [x64] @@ -95,7 +132,7 @@ packages: dev: true optional: true - /@esbuild/darwin-arm64/0.17.15: + /@esbuild/darwin-arm64@0.17.15: resolution: {integrity: sha512-7siLjBc88Z4+6qkMDxPT2juf2e8SJxmsbNVKFY2ifWCDT72v5YJz9arlvBw5oB4W/e61H1+HDB/jnu8nNg0rLA==} engines: {node: '>=12'} cpu: [arm64] @@ -104,7 +141,7 @@ packages: dev: true optional: true - /@esbuild/darwin-x64/0.17.15: + /@esbuild/darwin-x64@0.17.15: resolution: {integrity: sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==} engines: {node: '>=12'} cpu: [x64] @@ -113,7 +150,7 @@ packages: dev: true optional: true - /@esbuild/freebsd-arm64/0.17.15: + /@esbuild/freebsd-arm64@0.17.15: resolution: {integrity: sha512-Xk9xMDjBVG6CfgoqlVczHAdJnCs0/oeFOspFap5NkYAmRCT2qTn1vJWA2f419iMtsHSLm+O8B6SLV/HlY5cYKg==} engines: {node: '>=12'} cpu: [arm64] @@ -122,7 +159,7 @@ packages: dev: true optional: true - /@esbuild/freebsd-x64/0.17.15: + /@esbuild/freebsd-x64@0.17.15: resolution: {integrity: sha512-3TWAnnEOdclvb2pnfsTWtdwthPfOz7qAfcwDLcfZyGJwm1SRZIMOeB5FODVhnM93mFSPsHB9b/PmxNNbSnd0RQ==} engines: {node: '>=12'} cpu: [x64] @@ -131,16 +168,7 @@ packages: dev: true optional: true - /@esbuild/linux-arm/0.17.15: - resolution: {integrity: sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm64/0.17.15: + /@esbuild/linux-arm64@0.17.15: resolution: {integrity: sha512-T0MVnYw9KT6b83/SqyznTs/3Jg2ODWrZfNccg11XjDehIved2oQfrX/wVuev9N936BpMRaTR9I1J0tdGgUgpJA==} engines: {node: '>=12'} cpu: [arm64] @@ -149,7 +177,16 @@ packages: dev: true optional: true - /@esbuild/linux-ia32/0.17.15: + /@esbuild/linux-arm@0.17.15: + resolution: {integrity: sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.17.15: resolution: {integrity: sha512-wp02sHs015T23zsQtU4Cj57WiteiuASHlD7rXjKUyAGYzlOKDAjqK6bk5dMi2QEl/KVOcsjwL36kD+WW7vJt8Q==} engines: {node: '>=12'} cpu: [ia32] @@ -158,7 +195,7 @@ packages: dev: true optional: true - /@esbuild/linux-loong64/0.17.15: + /@esbuild/linux-loong64@0.17.15: resolution: {integrity: sha512-k7FsUJjGGSxwnBmMh8d7IbObWu+sF/qbwc+xKZkBe/lTAF16RqxRCnNHA7QTd3oS2AfGBAnHlXL67shV5bBThQ==} engines: {node: '>=12'} cpu: [loong64] @@ -167,7 +204,7 @@ packages: dev: true optional: true - /@esbuild/linux-mips64el/0.17.15: + /@esbuild/linux-mips64el@0.17.15: resolution: {integrity: sha512-ZLWk6czDdog+Q9kE/Jfbilu24vEe/iW/Sj2d8EVsmiixQ1rM2RKH2n36qfxK4e8tVcaXkvuV3mU5zTZviE+NVQ==} engines: {node: '>=12'} cpu: [mips64el] @@ -176,7 +213,7 @@ packages: dev: true optional: true - /@esbuild/linux-ppc64/0.17.15: + /@esbuild/linux-ppc64@0.17.15: resolution: {integrity: sha512-mY6dPkIRAiFHRsGfOYZC8Q9rmr8vOBZBme0/j15zFUKM99d4ILY4WpOC7i/LqoY+RE7KaMaSfvY8CqjJtuO4xg==} engines: {node: '>=12'} cpu: [ppc64] @@ -185,7 +222,7 @@ packages: dev: true optional: true - /@esbuild/linux-riscv64/0.17.15: + /@esbuild/linux-riscv64@0.17.15: resolution: {integrity: sha512-EcyUtxffdDtWjjwIH8sKzpDRLcVtqANooMNASO59y+xmqqRYBBM7xVLQhqF7nksIbm2yHABptoioS9RAbVMWVA==} engines: {node: '>=12'} cpu: [riscv64] @@ -194,7 +231,7 @@ packages: dev: true optional: true - /@esbuild/linux-s390x/0.17.15: + /@esbuild/linux-s390x@0.17.15: resolution: {integrity: sha512-BuS6Jx/ezxFuHxgsfvz7T4g4YlVrmCmg7UAwboeyNNg0OzNzKsIZXpr3Sb/ZREDXWgt48RO4UQRDBxJN3B9Rbg==} engines: {node: '>=12'} cpu: [s390x] @@ -203,7 +240,7 @@ packages: dev: true optional: true - /@esbuild/linux-x64/0.17.15: + /@esbuild/linux-x64@0.17.15: resolution: {integrity: sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg==} engines: {node: '>=12'} cpu: [x64] @@ -212,7 +249,7 @@ packages: dev: true optional: true - /@esbuild/netbsd-x64/0.17.15: + /@esbuild/netbsd-x64@0.17.15: resolution: {integrity: sha512-R6fKjtUysYGym6uXf6qyNephVUQAGtf3n2RCsOST/neIwPqRWcnc3ogcielOd6pT+J0RDR1RGcy0ZY7d3uHVLA==} engines: {node: '>=12'} cpu: [x64] @@ -221,7 +258,7 @@ packages: dev: true optional: true - /@esbuild/openbsd-x64/0.17.15: + /@esbuild/openbsd-x64@0.17.15: resolution: {integrity: sha512-mVD4PGc26b8PI60QaPUltYKeSX0wxuy0AltC+WCTFwvKCq2+OgLP4+fFd+hZXzO2xW1HPKcytZBdjqL6FQFa7w==} engines: {node: '>=12'} cpu: [x64] @@ -230,7 +267,7 @@ packages: dev: true optional: true - /@esbuild/sunos-x64/0.17.15: + /@esbuild/sunos-x64@0.17.15: resolution: {integrity: sha512-U6tYPovOkw3459t2CBwGcFYfFRjivcJJc1WC8Q3funIwX8x4fP+R6xL/QuTPNGOblbq/EUDxj9GU+dWKX0oWlQ==} engines: {node: '>=12'} cpu: [x64] @@ -239,7 +276,7 @@ packages: dev: true optional: true - /@esbuild/win32-arm64/0.17.15: + /@esbuild/win32-arm64@0.17.15: resolution: {integrity: sha512-W+Z5F++wgKAleDABemiyXVnzXgvRFs+GVKThSI+mGgleLWluv0D7Diz4oQpgdpNzh4i2nNDzQtWbjJiqutRp6Q==} engines: {node: '>=12'} cpu: [arm64] @@ -248,7 +285,7 @@ packages: dev: true optional: true - /@esbuild/win32-ia32/0.17.15: + /@esbuild/win32-ia32@0.17.15: resolution: {integrity: sha512-Muz/+uGgheShKGqSVS1KsHtCyEzcdOn/W/Xbh6H91Etm+wiIfwZaBn1W58MeGtfI8WA961YMHFYTthBdQs4t+w==} engines: {node: '>=12'} cpu: [ia32] @@ -257,7 +294,7 @@ packages: dev: true optional: true - /@esbuild/win32-x64/0.17.15: + /@esbuild/win32-x64@0.17.15: resolution: {integrity: sha512-DjDa9ywLUUmjhV2Y9wUTIF+1XsmuFGvZoCmOWkli1XcNAh5t25cc7fgsCx4Zi/Uurep3TTLyDiKATgGEg61pkA==} engines: {node: '>=12'} cpu: [x64] @@ -266,7 +303,7 @@ packages: dev: true optional: true - /@eslint-community/eslint-utils/4.4.0_eslint@8.37.0: + /@eslint-community/eslint-utils@4.4.0(eslint@8.37.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -276,12 +313,12 @@ packages: eslint-visitor-keys: 3.4.0 dev: true - /@eslint-community/regexpp/4.5.0: + /@eslint-community/regexpp@4.5.0: resolution: {integrity: sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} dev: true - /@eslint/eslintrc/2.0.2: + /@eslint/eslintrc@2.0.2: resolution: {integrity: sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: @@ -298,12 +335,16 @@ packages: - supports-color dev: true - /@eslint/js/8.37.0: + /@eslint/js@8.37.0: resolution: {integrity: sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@humanwhocodes/config-array/0.11.8: + /@fontsource/firago@4.5.3: + resolution: {integrity: sha512-Kn6FBi6MGNPYwzVKUtgyWGUj0zV18El263ieZi8A7TM2LPKsfzON5ENeKfjyTKfYErkjg9ktwiNP3SLsEOSBNQ==} + dev: false + + /@humanwhocodes/config-array@0.11.8: resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} engines: {node: '>=10.10.0'} dependencies: @@ -314,32 +355,32 @@ packages: - supports-color dev: true - /@humanwhocodes/module-importer/1.0.1: + /@humanwhocodes/module-importer@1.0.1: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} dev: true - /@humanwhocodes/object-schema/1.2.1: + /@humanwhocodes/object-schema@1.2.1: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true - /@jridgewell/resolve-uri/3.1.0: + /@jridgewell/resolve-uri@3.1.0: resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} engines: {node: '>=6.0.0'} dev: true - /@jridgewell/sourcemap-codec/1.4.14: + /@jridgewell/sourcemap-codec@1.4.14: resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} dev: true - /@jridgewell/trace-mapping/0.3.17: + /@jridgewell/trace-mapping@0.3.17: resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==} dependencies: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.14 dev: true - /@nodelib/fs.scandir/2.1.5: + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} dependencies: @@ -347,12 +388,12 @@ packages: run-parallel: 1.2.0 dev: true - /@nodelib/fs.stat/2.0.5: + /@nodelib/fs.stat@2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} dev: true - /@nodelib/fs.walk/1.2.8: + /@nodelib/fs.walk@1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} dependencies: @@ -360,14 +401,14 @@ packages: fastq: 1.15.0 dev: true - /@polka/url/1.0.0-next.21: + /@polka/url@1.0.0-next.21: resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} dev: true - /@popperjs/core/2.11.7: + /@popperjs/core@2.11.7: resolution: {integrity: sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==} - /@rollup/plugin-commonjs/24.0.1_rollup@3.20.2: + /@rollup/plugin-commonjs@24.0.1(rollup@3.20.2): resolution: {integrity: sha512-15LsiWRZk4eOGqvrJyu3z3DaBu5BhXIMeWnijSRvd8irrrg9SHpQ1pH+BUK4H6Z9wL9yOxZJMTLU+Au86XHxow==} engines: {node: '>=14.0.0'} peerDependencies: @@ -376,7 +417,7 @@ packages: rollup: optional: true dependencies: - '@rollup/pluginutils': 5.0.2_rollup@3.20.2 + '@rollup/pluginutils': 5.0.2(rollup@3.20.2) commondir: 1.0.1 estree-walker: 2.0.2 glob: 8.1.0 @@ -385,7 +426,7 @@ packages: rollup: 3.20.2 dev: true - /@rollup/plugin-json/6.0.0_rollup@3.20.2: + /@rollup/plugin-json@6.0.0(rollup@3.20.2): resolution: {integrity: sha512-i/4C5Jrdr1XUarRhVu27EEwjt4GObltD7c+MkCIpO2QIbojw8MUs+CCTqOphQi3Qtg1FLmYt+l+6YeoIf51J7w==} engines: {node: '>=14.0.0'} peerDependencies: @@ -394,11 +435,11 @@ packages: rollup: optional: true dependencies: - '@rollup/pluginutils': 5.0.2_rollup@3.20.2 + '@rollup/pluginutils': 5.0.2(rollup@3.20.2) rollup: 3.20.2 dev: true - /@rollup/plugin-node-resolve/15.0.1_rollup@3.20.2: + /@rollup/plugin-node-resolve@15.0.1(rollup@3.20.2): resolution: {integrity: sha512-ReY88T7JhJjeRVbfCyNj+NXAG3IIsVMsX9b5/9jC98dRP8/yxlZdz7mHZbHk5zHr24wZZICS5AcXsFZAXYUQEg==} engines: {node: '>=14.0.0'} peerDependencies: @@ -407,7 +448,7 @@ packages: rollup: optional: true dependencies: - '@rollup/pluginutils': 5.0.2_rollup@3.20.2 + '@rollup/pluginutils': 5.0.2(rollup@3.20.2) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-builtin-module: 3.2.1 @@ -416,7 +457,7 @@ packages: rollup: 3.20.2 dev: true - /@rollup/pluginutils/5.0.2_rollup@3.20.2: + /@rollup/pluginutils@5.0.2(rollup@3.20.2): resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -431,7 +472,7 @@ packages: rollup: 3.20.2 dev: true - /@sentry-internal/tracing/7.46.0: + /@sentry-internal/tracing@7.46.0: resolution: {integrity: sha512-KYoppa7PPL8Er7bdPoxTNUfIY804JL7hhOEomQHYD22rLynwQ4AaLm3YEY75QWwcGb0B7ZDMV+tSumW7Rxuwuw==} engines: {node: '>=8'} dependencies: @@ -441,7 +482,7 @@ packages: tslib: 1.14.1 dev: false - /@sentry/core/7.46.0: + /@sentry/core@7.46.0: resolution: {integrity: sha512-BnNHGh/ZTztqQedFko7vb2u6yLs/kWesOQNivav32ZbsEpVCjcmG1gOJXh2YmGIvj3jXOC9a4xfIuh+lYFcA6A==} engines: {node: '>=8'} dependencies: @@ -450,7 +491,7 @@ packages: tslib: 1.14.1 dev: false - /@sentry/node/7.46.0: + /@sentry/node@7.46.0: resolution: {integrity: sha512-+GrgJMCye2WXGarRiU5IJHCK27xg7xbPc2XjGojBKbBoZfqxVAWbXEK4bnBQgRGP1pCmrU/M6ZhVgR3dP580xA==} engines: {node: '>=8'} dependencies: @@ -466,12 +507,12 @@ packages: - supports-color dev: false - /@sentry/types/7.46.0: + /@sentry/types@7.46.0: resolution: {integrity: sha512-2FMEMgt2h6u7AoELhNhu9L54GAh67KKfK2pJ1kEXJHmWxM9FSCkizjLs/t+49xtY7jEXr8qYq8bV967VfDPQ9g==} engines: {node: '>=8'} dev: false - /@sentry/utils/7.46.0: + /@sentry/utils@7.46.0: resolution: {integrity: sha512-elRezDAF84guMG0OVIIZEWm6wUpgbda4HGks98CFnPsrnMm3N1bdBI9XdlxYLtf+ir5KsGR5YlEIf/a0kRUwAQ==} engines: {node: '>=8'} dependencies: @@ -479,28 +520,28 @@ packages: tslib: 1.14.1 dev: false - /@sveltejs/adapter-auto/2.0.0_@sveltejs+kit@1.15.0: + /@sveltejs/adapter-auto@2.0.0(@sveltejs/kit@1.15.0): resolution: {integrity: sha512-b+gkHFZgD771kgV3aO4avHFd7y1zhmMYy9i6xOK7m/rwmwaRO8gnF5zBc0Rgca80B2PMU1bKNxyBTHA14OzUAQ==} peerDependencies: '@sveltejs/kit': ^1.0.0 dependencies: - '@sveltejs/kit': 1.15.0_svelte@3.58.0+vite@4.2.1 + '@sveltejs/kit': 1.15.0(svelte@3.58.0)(vite@4.2.1) import-meta-resolve: 2.2.2 dev: true - /@sveltejs/adapter-node/1.2.3_@sveltejs+kit@1.15.0: + /@sveltejs/adapter-node@1.2.3(@sveltejs/kit@1.15.0): resolution: {integrity: sha512-Fv6NyVpVWYA63KRaV6dDjcU8ytcWFiUr0siJOoHl+oWy5WHNEuRiJOUdiZzYbZo8MmvFaCoxHkTgPrVQhpqaRA==} peerDependencies: '@sveltejs/kit': ^1.0.0 dependencies: - '@rollup/plugin-commonjs': 24.0.1_rollup@3.20.2 - '@rollup/plugin-json': 6.0.0_rollup@3.20.2 - '@rollup/plugin-node-resolve': 15.0.1_rollup@3.20.2 - '@sveltejs/kit': 1.15.0_svelte@3.58.0+vite@4.2.1 + '@rollup/plugin-commonjs': 24.0.1(rollup@3.20.2) + '@rollup/plugin-json': 6.0.0(rollup@3.20.2) + '@rollup/plugin-node-resolve': 15.0.1(rollup@3.20.2) + '@sveltejs/kit': 1.15.0(svelte@3.58.0)(vite@4.2.1) rollup: 3.20.2 dev: true - /@sveltejs/kit/1.15.0_svelte@3.58.0+vite@4.2.1: + /@sveltejs/kit@1.15.0(svelte@3.58.0)(vite@4.2.1): resolution: {integrity: sha512-fvDsW9msxWjDU/j9wwLlxEZ6cpXQYcmcQHq7neJMqibMEl39gI1ztVymGnYqM8KLqZXwNmhKtLu8EPheukKtXQ==} engines: {node: ^16.14 || >=18} hasBin: true @@ -509,7 +550,7 @@ packages: svelte: ^3.54.0 vite: ^4.0.0 dependencies: - '@sveltejs/vite-plugin-svelte': 2.0.4_svelte@3.58.0+vite@4.2.1 + '@sveltejs/vite-plugin-svelte': 2.0.4(svelte@3.58.0)(vite@4.2.1) '@types/cookie': 0.5.1 cookie: 0.5.0 devalue: 4.3.0 @@ -523,12 +564,12 @@ packages: svelte: 3.58.0 tiny-glob: 0.2.9 undici: 5.21.0 - vite: 4.2.1_@types+node@18.15.11 + vite: 4.2.1(@types/node@18.15.11) transitivePeerDependencies: - supports-color dev: true - /@sveltejs/vite-plugin-svelte/2.0.4_svelte@3.58.0+vite@4.2.1: + /@sveltejs/vite-plugin-svelte@2.0.4(svelte@3.58.0)(vite@4.2.1): resolution: {integrity: sha512-pjqhW00KwK2uzDGEr+yJBwut+D+4XfJO/+bHHdHzPRXn9+1Jeq5JcFHyrUiYaXgHtyhX0RsllCTm4ssAx4ZY7Q==} engines: {node: ^14.18.0 || >= 16} peerDependencies: @@ -540,67 +581,67 @@ packages: kleur: 4.1.5 magic-string: 0.30.0 svelte: 3.58.0 - svelte-hmr: 0.15.1_svelte@3.58.0 - vite: 4.2.1_@types+node@18.15.11 - vitefu: 0.2.4_vite@4.2.1 + svelte-hmr: 0.15.1(svelte@3.58.0) + vite: 4.2.1(@types/node@18.15.11) + vitefu: 0.2.4(vite@4.2.1) transitivePeerDependencies: - supports-color dev: true - /@types/cookie/0.5.1: + /@types/cookie@0.5.1: resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} dev: true - /@types/estree/1.0.0: + /@types/estree@1.0.0: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} dev: true - /@types/json-schema/7.0.11: + /@types/json-schema@7.0.11: resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} dev: true - /@types/linkify-it/3.0.2: + /@types/linkify-it@3.0.2: resolution: {integrity: sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==} dev: true - /@types/luxon/3.2.2: + /@types/luxon@3.2.2: resolution: {integrity: sha512-CuF9hIlsxGpJO4EztrW23/q1L9ctQfb5JM9mnLCJhhA8z81K2b4LTVjQYySXWhFV5SMyUsPYH/IcvvXDCKwa2g==} dev: true - /@types/markdown-it/12.2.3: + /@types/markdown-it@12.2.3: resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} dependencies: '@types/linkify-it': 3.0.2 '@types/mdurl': 1.0.2 dev: true - /@types/mdurl/1.0.2: + /@types/mdurl@1.0.2: resolution: {integrity: sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==} dev: true - /@types/node/18.15.11: + /@types/node@18.15.11: resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==} dev: true - /@types/pug/2.0.6: + /@types/pug@2.0.6: resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} dev: true - /@types/resolve/1.20.2: + /@types/resolve@1.20.2: resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} dev: true - /@types/sanitize-html/2.9.0: + /@types/sanitize-html@2.9.0: resolution: {integrity: sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==} dependencies: htmlparser2: 8.0.2 dev: true - /@types/semver/7.3.13: + /@types/semver@7.3.13: resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} dev: true - /@typescript-eslint/eslint-plugin/5.57.1_iify4w3mi7rbbu6mxzspkpx4b4: + /@typescript-eslint/eslint-plugin@5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.37.0)(typescript@4.9.5): resolution: {integrity: sha512-1MeobQkQ9tztuleT3v72XmY0XuKXVXusAhryoLuU5YZ+mXoYKZP9SQ7Flulh1NX4DTjpGTc2b/eMu4u7M7dhnQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -612,23 +653,23 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.5.0 - '@typescript-eslint/parser': 5.57.1_ip5up2nocltd47wbnuyybe5dxu + '@typescript-eslint/parser': 5.57.1(eslint@8.37.0)(typescript@4.9.5) '@typescript-eslint/scope-manager': 5.57.1 - '@typescript-eslint/type-utils': 5.57.1_ip5up2nocltd47wbnuyybe5dxu - '@typescript-eslint/utils': 5.57.1_ip5up2nocltd47wbnuyybe5dxu + '@typescript-eslint/type-utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5) + '@typescript-eslint/utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5) debug: 4.3.4 eslint: 8.37.0 grapheme-splitter: 1.0.4 ignore: 5.2.4 natural-compare-lite: 1.4.0 semver: 7.3.8 - tsutils: 3.21.0_typescript@4.9.5 + tsutils: 3.21.0(typescript@4.9.5) typescript: 4.9.5 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser/5.57.1_ip5up2nocltd47wbnuyybe5dxu: + /@typescript-eslint/parser@5.57.1(eslint@8.37.0)(typescript@4.9.5): resolution: {integrity: sha512-hlA0BLeVSA/wBPKdPGxoVr9Pp6GutGoY380FEhbVi0Ph4WNe8kLvqIRx76RSQt1lynZKfrXKs0/XeEk4zZycuA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -640,7 +681,7 @@ packages: dependencies: '@typescript-eslint/scope-manager': 5.57.1 '@typescript-eslint/types': 5.57.1 - '@typescript-eslint/typescript-estree': 5.57.1_typescript@4.9.5 + '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5) debug: 4.3.4 eslint: 8.37.0 typescript: 4.9.5 @@ -648,7 +689,7 @@ packages: - supports-color dev: true - /@typescript-eslint/scope-manager/5.57.1: + /@typescript-eslint/scope-manager@5.57.1: resolution: {integrity: sha512-N/RrBwEUKMIYxSKl0oDK5sFVHd6VI7p9K5MyUlVYAY6dyNb/wHUqndkTd3XhpGlXgnQsBkRZuu4f9kAHghvgPw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: @@ -656,7 +697,7 @@ packages: '@typescript-eslint/visitor-keys': 5.57.1 dev: true - /@typescript-eslint/type-utils/5.57.1_ip5up2nocltd47wbnuyybe5dxu: + /@typescript-eslint/type-utils@5.57.1(eslint@8.37.0)(typescript@4.9.5): resolution: {integrity: sha512-/RIPQyx60Pt6ga86hKXesXkJ2WOS4UemFrmmq/7eOyiYjYv/MUSHPlkhU6k9T9W1ytnTJueqASW+wOmW4KrViw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -666,22 +707,22 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.57.1_typescript@4.9.5 - '@typescript-eslint/utils': 5.57.1_ip5up2nocltd47wbnuyybe5dxu + '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5) + '@typescript-eslint/utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5) debug: 4.3.4 eslint: 8.37.0 - tsutils: 3.21.0_typescript@4.9.5 + tsutils: 3.21.0(typescript@4.9.5) typescript: 4.9.5 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/types/5.57.1: + /@typescript-eslint/types@5.57.1: resolution: {integrity: sha512-bSs4LOgyV3bJ08F5RDqO2KXqg3WAdwHCu06zOqcQ6vqbTJizyBhuh1o1ImC69X4bV2g1OJxbH71PJqiO7Y1RuA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/typescript-estree/5.57.1_typescript@4.9.5: + /@typescript-eslint/typescript-estree@5.57.1(typescript@4.9.5): resolution: {integrity: sha512-A2MZqD8gNT0qHKbk2wRspg7cHbCDCk2tcqt6ScCFLr5Ru8cn+TCfM786DjPhqwseiS+PrYwcXht5ztpEQ6TFTw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -696,24 +737,24 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.3.8 - tsutils: 3.21.0_typescript@4.9.5 + tsutils: 3.21.0(typescript@4.9.5) typescript: 4.9.5 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils/5.57.1_ip5up2nocltd47wbnuyybe5dxu: + /@typescript-eslint/utils@5.57.1(eslint@8.37.0)(typescript@4.9.5): resolution: {integrity: sha512-kN6vzzf9NkEtawECqze6v99LtmDiUJCVpvieTFA1uL7/jDghiJGubGZ5csicYHU1Xoqb3oH/R5cN5df6W41Nfg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: - '@eslint-community/eslint-utils': 4.4.0_eslint@8.37.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.37.0) '@types/json-schema': 7.0.11 '@types/semver': 7.3.13 '@typescript-eslint/scope-manager': 5.57.1 '@typescript-eslint/types': 5.57.1 - '@typescript-eslint/typescript-estree': 5.57.1_typescript@4.9.5 + '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5) eslint: 8.37.0 eslint-scope: 5.1.1 semver: 7.3.8 @@ -722,7 +763,7 @@ packages: - typescript dev: true - /@typescript-eslint/visitor-keys/5.57.1: + /@typescript-eslint/visitor-keys@5.57.1: resolution: {integrity: sha512-RjQrAniDU0CEk5r7iphkm731zKlFiUjvcBS2yHAg8WWqFMCaCrD0rKEVOMUyMMcbGPZ0bPp56srkGWrgfZqLRA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: @@ -730,7 +771,7 @@ packages: eslint-visitor-keys: 3.4.0 dev: true - /acorn-jsx/5.3.2_acorn@8.8.2: + /acorn-jsx@5.3.2(acorn@8.8.2): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -738,13 +779,13 @@ packages: acorn: 8.8.2 dev: true - /acorn/8.8.2: + /acorn@8.8.2: resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} engines: {node: '>=0.4.0'} hasBin: true dev: true - /agent-base/6.0.2: + /agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: @@ -753,7 +794,7 @@ packages: - supports-color dev: false - /ajv/6.12.6: + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: fast-deep-equal: 3.1.3 @@ -762,19 +803,19 @@ packages: uri-js: 4.4.1 dev: true - /ansi-regex/5.0.1: + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} dev: true - /ansi-styles/4.3.0: + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} dependencies: color-convert: 2.0.1 dev: true - /anymatch/3.1.3: + /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} dependencies: @@ -782,39 +823,39 @@ packages: picomatch: 2.3.1 dev: true - /argparse/1.0.10: + /argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} dependencies: sprintf-js: 1.0.3 dev: true - /argparse/2.0.1: + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - /array-union/2.1.0: + /array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} dev: true - /balanced-match/1.0.2: + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true - /base64-arraybuffer/1.0.2: + /base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} dev: false - /binary-extensions/2.2.0: + /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} dev: true - /bootstrap-icons/1.10.4: + /bootstrap-icons@1.10.4: resolution: {integrity: sha512-eI3HyIUmpGKRiRv15FCZccV+2sreGE2NnmH8mtxV/nPOzQVu0sPEj8HhF1MwjJ31IhjF0rgMvtYOX5VqIzcb/A==} dev: false - /bootstrap/5.3.0-alpha1_@popperjs+core@2.11.7: + /bootstrap@5.3.0-alpha1(@popperjs/core@2.11.7): resolution: {integrity: sha512-ABZpKK4ObS3kKlIqH+ZVDqoy5t/bhFG0oHTAzByUdon7YIom0lpCeTqRniDzJmbtcWkNe800VVPBiJgxSYTYew==} peerDependencies: '@popperjs/core': ^2.11.6 @@ -822,48 +863,48 @@ packages: '@popperjs/core': 2.11.7 dev: false - /brace-expansion/1.1.11: + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 dev: true - /brace-expansion/2.0.1: + /brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} dependencies: balanced-match: 1.0.2 dev: true - /braces/3.0.2: + /braces@3.0.2: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} engines: {node: '>=8'} dependencies: fill-range: 7.0.1 dev: true - /buffer-crc32/0.2.13: + /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: true - /builtin-modules/3.3.0: + /builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} dev: true - /busboy/1.6.0: + /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} dependencies: streamsearch: 1.1.0 dev: true - /callsites/3.1.0: + /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} dev: true - /chalk/4.1.2: + /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} dependencies: @@ -871,7 +912,7 @@ packages: supports-color: 7.2.0 dev: true - /chokidar/3.5.3: + /chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} dependencies: @@ -886,36 +927,36 @@ packages: fsevents: 2.3.2 dev: true - /color-convert/2.0.1: + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} dependencies: color-name: 1.1.4 dev: true - /color-name/1.1.4: + /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true - /commondir/1.0.1: + /commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} dev: true - /concat-map/0.0.1: + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true - /cookie/0.4.2: + /cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} dev: false - /cookie/0.5.0: + /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} dev: true - /cross-spawn/7.0.3: + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} dependencies: @@ -924,7 +965,7 @@ packages: which: 2.0.2 dev: true - /debug/4.3.4: + /debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} peerDependencies: @@ -935,38 +976,38 @@ packages: dependencies: ms: 2.1.2 - /deep-is/0.1.4: + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true - /deepmerge/4.3.1: + /deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - /detect-indent/6.1.0: + /detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} dev: true - /devalue/4.3.0: + /devalue@4.3.0: resolution: {integrity: sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==} dev: true - /dir-glob/3.0.1: + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} dependencies: path-type: 4.0.0 dev: true - /doctrine/3.0.0: + /doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} dependencies: esutils: 2.0.3 dev: true - /dom-serializer/1.4.1: + /dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} dependencies: domelementtype: 2.3.0 @@ -974,30 +1015,30 @@ packages: entities: 2.2.0 dev: true - /dom-serializer/2.0.0: + /dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 entities: 4.4.0 - /domelementtype/2.3.0: + /domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - /domhandler/4.3.1: + /domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} dependencies: domelementtype: 2.3.0 dev: true - /domhandler/5.0.3: + /domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} dependencies: domelementtype: 2.3.0 - /domutils/2.8.0: + /domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} dependencies: dom-serializer: 1.4.1 @@ -1005,35 +1046,35 @@ packages: domhandler: 4.3.1 dev: true - /domutils/3.0.1: + /domutils@3.0.1: resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} dependencies: dom-serializer: 2.0.0 domelementtype: 2.3.0 domhandler: 5.0.3 - /entities/2.1.0: + /entities@2.1.0: resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} dev: true - /entities/2.2.0: + /entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} dev: true - /entities/3.0.1: + /entities@3.0.1: resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==} engines: {node: '>=0.12'} dev: false - /entities/4.4.0: + /entities@4.4.0: resolution: {integrity: sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==} engines: {node: '>=0.12'} - /es6-promise/3.3.1: + /es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} dev: true - /esbuild/0.17.15: + /esbuild@0.17.15: resolution: {integrity: sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw==} engines: {node: '>=12'} hasBin: true @@ -1063,11 +1104,11 @@ packages: '@esbuild/win32-x64': 0.17.15 dev: true - /escape-string-regexp/4.0.0: + /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - /eslint-config-prettier/8.8.0_eslint@8.37.0: + /eslint-config-prettier@8.8.0(eslint@8.37.0): resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} hasBin: true peerDependencies: @@ -1076,7 +1117,7 @@ packages: eslint: 8.37.0 dev: true - /eslint-plugin-svelte3/4.0.0_4gllgxcu6gmiyy5rrmqexpx7de: + /eslint-plugin-svelte3@4.0.0(eslint@8.37.0)(svelte@3.58.0): resolution: {integrity: sha512-OIx9lgaNzD02+MDFNLw0GEUbuovNcglg+wnd/UY0fbZmlQSz7GlQiQ1f+yX0XvC07XPcDOnFcichqI3xCwp71g==} peerDependencies: eslint: '>=8.0.0' @@ -1086,7 +1127,7 @@ packages: svelte: 3.58.0 dev: true - /eslint-scope/5.1.1: + /eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} dependencies: @@ -1094,7 +1135,7 @@ packages: estraverse: 4.3.0 dev: true - /eslint-scope/7.1.1: + /eslint-scope@7.1.1: resolution: {integrity: sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: @@ -1102,17 +1143,17 @@ packages: estraverse: 5.3.0 dev: true - /eslint-visitor-keys/3.4.0: + /eslint-visitor-keys@3.4.0: resolution: {integrity: sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint/8.37.0: + /eslint@8.37.0: resolution: {integrity: sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true dependencies: - '@eslint-community/eslint-utils': 4.4.0_eslint@8.37.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.37.0) '@eslint-community/regexpp': 4.5.0 '@eslint/eslintrc': 2.0.2 '@eslint/js': 8.37.0 @@ -1156,63 +1197,63 @@ packages: - supports-color dev: true - /esm-env/1.0.0: + /esm-env@1.0.0: resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} dev: true - /espree/9.5.1: + /espree@9.5.1: resolution: {integrity: sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: acorn: 8.8.2 - acorn-jsx: 5.3.2_acorn@8.8.2 + acorn-jsx: 5.3.2(acorn@8.8.2) eslint-visitor-keys: 3.4.0 dev: true - /esprima/4.0.1: + /esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true dev: true - /esquery/1.5.0: + /esquery@1.5.0: resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} engines: {node: '>=0.10'} dependencies: estraverse: 5.3.0 dev: true - /esrecurse/4.3.0: + /esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} dependencies: estraverse: 5.3.0 dev: true - /estraverse/4.3.0: + /estraverse@4.3.0: resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} engines: {node: '>=4.0'} dev: true - /estraverse/5.3.0: + /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} dev: true - /estree-walker/2.0.2: + /estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} dev: true - /esutils/2.0.3: + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} dev: true - /fast-deep-equal/3.1.3: + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true - /fast-glob/3.2.12: + /fast-glob@3.2.12: resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} engines: {node: '>=8.6.0'} dependencies: @@ -1223,35 +1264,35 @@ packages: micromatch: 4.0.5 dev: true - /fast-json-stable-stringify/2.1.0: + /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true - /fast-levenshtein/2.0.6: + /fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true - /fastq/1.15.0: + /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 dev: true - /file-entry-cache/6.0.1: + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} dependencies: flat-cache: 3.0.4 dev: true - /fill-range/7.0.1: + /fill-range@7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 dev: true - /find-up/5.0.0: + /find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} dependencies: @@ -1259,7 +1300,7 @@ packages: path-exists: 4.0.0 dev: true - /flat-cache/3.0.4: + /flat-cache@3.0.4: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} engines: {node: ^10.12.0 || >=12.0.0} dependencies: @@ -1267,21 +1308,21 @@ packages: rimraf: 3.0.2 dev: true - /flatted/3.2.7: + /flatted@3.2.7: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} dev: true - /front-matter/4.0.2: + /front-matter@4.0.2: resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==} dependencies: js-yaml: 3.14.1 dev: true - /fs.realpath/1.0.0: + /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true - /fsevents/2.3.2: + /fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] @@ -1289,25 +1330,25 @@ packages: dev: true optional: true - /function-bind/1.1.1: + /function-bind@1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} dev: true - /glob-parent/5.1.2: + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} dependencies: is-glob: 4.0.3 dev: true - /glob-parent/6.0.2: + /glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} dependencies: is-glob: 4.0.3 dev: true - /glob/7.2.3: + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} dependencies: fs.realpath: 1.0.0 @@ -1318,7 +1359,7 @@ packages: path-is-absolute: 1.0.1 dev: true - /glob/8.1.0: + /glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} dependencies: @@ -1329,18 +1370,18 @@ packages: once: 1.4.0 dev: true - /globals/13.20.0: + /globals@13.20.0: resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} engines: {node: '>=8'} dependencies: type-fest: 0.20.2 dev: true - /globalyzer/0.1.0: + /globalyzer@0.1.0: resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} dev: true - /globby/11.1.0: + /globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} dependencies: @@ -1352,31 +1393,31 @@ packages: slash: 3.0.0 dev: true - /globrex/0.1.2: + /globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} dev: true - /graceful-fs/4.2.11: + /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true - /grapheme-splitter/1.0.4: + /grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} dev: true - /has-flag/4.0.0: + /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} dev: true - /has/1.0.3: + /has@1.0.3: resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} engines: {node: '>= 0.4.0'} dependencies: function-bind: 1.1.1 dev: true - /htmlparser2/6.1.0: + /htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} dependencies: domelementtype: 2.3.0 @@ -1385,7 +1426,7 @@ packages: entities: 2.2.0 dev: true - /htmlparser2/8.0.2: + /htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} dependencies: domelementtype: 2.3.0 @@ -1393,7 +1434,7 @@ packages: domutils: 3.0.1 entities: 4.4.0 - /https-proxy-agent/5.0.1: + /https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} dependencies: @@ -1403,12 +1444,12 @@ packages: - supports-color dev: false - /ignore/5.2.4: + /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} dev: true - /import-fresh/3.3.0: + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} dependencies: @@ -1416,96 +1457,96 @@ packages: resolve-from: 4.0.0 dev: true - /import-meta-resolve/2.2.2: + /import-meta-resolve@2.2.2: resolution: {integrity: sha512-f8KcQ1D80V7RnqVm+/lirO9zkOxjGxhaTC1IPrBGd3MEfNgmNG67tSUO9gTi2F3Blr2Az6g1vocaxzkVnWl9MA==} dev: true - /imurmurhash/0.1.4: + /imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} dev: true - /inflight/1.0.6: + /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} dependencies: once: 1.4.0 wrappy: 1.0.2 dev: true - /inherits/2.0.4: + /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: true - /is-binary-path/2.1.0: + /is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} dependencies: binary-extensions: 2.2.0 dev: true - /is-builtin-module/3.2.1: + /is-builtin-module@3.2.1: resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} engines: {node: '>=6'} dependencies: builtin-modules: 3.3.0 dev: true - /is-core-module/2.11.0: + /is-core-module@2.11.0: resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==} dependencies: has: 1.0.3 dev: true - /is-extglob/2.1.1: + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} dev: true - /is-glob/4.0.3: + /is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 dev: true - /is-module/1.0.0: + /is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} dev: true - /is-number/7.0.0: + /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} dev: true - /is-path-inside/3.0.3: + /is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} dev: true - /is-plain-object/5.0.0: + /is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} dev: false - /is-reference/1.2.1: + /is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} dependencies: '@types/estree': 1.0.0 dev: true - /isexe/2.0.0: + /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true - /jose/4.13.1: + /jose@4.13.1: resolution: {integrity: sha512-MSJQC5vXco5Br38mzaQKiq9mwt7lwj2eXpgpRyQYNHYt2lq1PjkWa7DLXX0WVcQLE9HhMh3jPiufS7fhJf+CLQ==} dev: false - /js-sdsl/4.4.0: + /js-sdsl@4.4.0: resolution: {integrity: sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==} dev: true - /js-yaml/3.14.1: + /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true dependencies: @@ -1513,27 +1554,27 @@ packages: esprima: 4.0.1 dev: true - /js-yaml/4.1.0: + /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true dependencies: argparse: 2.0.1 dev: true - /json-schema-traverse/0.4.1: + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true - /json-stable-stringify-without-jsonify/1.0.1: + /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true - /kleur/4.1.5: + /kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} dev: true - /levn/0.4.1: + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} dependencies: @@ -1541,60 +1582,60 @@ packages: type-check: 0.4.0 dev: true - /linkify-it/3.0.3: + /linkify-it@3.0.3: resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} dependencies: uc.micro: 1.0.6 dev: true - /linkify-it/4.0.1: + /linkify-it@4.0.1: resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==} dependencies: uc.micro: 1.0.6 dev: false - /locate-path/6.0.0: + /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} dependencies: p-locate: 5.0.0 dev: true - /lodash.merge/4.6.2: + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true - /lru-cache/6.0.0: + /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} dependencies: yallist: 4.0.0 dev: true - /lru_map/0.3.3: + /lru_map@0.3.3: resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} dev: false - /luxon/3.3.0: + /luxon@3.3.0: resolution: {integrity: sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==} engines: {node: '>=12'} dev: false - /magic-string/0.27.0: + /magic-string@0.27.0: resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} engines: {node: '>=12'} dependencies: '@jridgewell/sourcemap-codec': 1.4.14 dev: true - /magic-string/0.30.0: + /magic-string@0.30.0: resolution: {integrity: sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==} engines: {node: '>=12'} dependencies: '@jridgewell/sourcemap-codec': 1.4.14 dev: true - /markdown-it/12.3.2: + /markdown-it@12.3.2: resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} hasBin: true dependencies: @@ -1605,7 +1646,7 @@ packages: uc.micro: 1.0.6 dev: true - /markdown-it/13.0.1: + /markdown-it@13.0.1: resolution: {integrity: sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==} hasBin: true dependencies: @@ -1616,15 +1657,15 @@ packages: uc.micro: 1.0.6 dev: false - /mdurl/1.0.1: + /mdurl@1.0.1: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} - /merge2/1.4.1: + /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} dev: true - /micromatch/4.0.5: + /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} dependencies: @@ -1632,79 +1673,79 @@ packages: picomatch: 2.3.1 dev: true - /mime/3.0.0: + /mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true dev: true - /min-indent/1.0.1: + /min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} dev: true - /minimatch/3.1.2: + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 dev: true - /minimatch/5.1.6: + /minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} dependencies: brace-expansion: 2.0.1 dev: true - /minimist/1.2.8: + /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: true - /mkdirp/0.5.6: + /mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true dependencies: minimist: 1.2.8 dev: true - /mri/1.2.0: + /mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} dev: true - /mrmime/1.0.1: + /mrmime@1.0.1: resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} engines: {node: '>=10'} dev: true - /ms/2.1.2: + /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - /nanoid/3.3.6: + /nanoid@3.3.6: resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - /natural-compare-lite/1.4.0: + /natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} dev: true - /natural-compare/1.4.0: + /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /normalize-path/3.0.0: + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} dev: true - /once/1.4.0: + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: wrappy: 1.0.2 dev: true - /optionator/0.9.1: + /optionator@0.9.1: resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} engines: {node: '>= 0.8.0'} dependencies: @@ -1716,64 +1757,64 @@ packages: word-wrap: 1.2.3 dev: true - /p-limit/3.1.0: + /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} dependencies: yocto-queue: 0.1.0 dev: true - /p-locate/5.0.0: + /p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} dependencies: p-limit: 3.1.0 dev: true - /parent-module/1.0.1: + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} dependencies: callsites: 3.1.0 dev: true - /parse-srcset/1.0.2: + /parse-srcset@1.0.2: resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} dev: false - /path-exists/4.0.0: + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} dev: true - /path-is-absolute/1.0.1: + /path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} dev: true - /path-key/3.1.1: + /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} dev: true - /path-parse/1.0.7: + /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true - /path-type/4.0.0: + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} dev: true - /picocolors/1.0.0: + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - /picomatch/2.3.1: + /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} dev: true - /postcss/8.4.21: + /postcss@8.4.21: resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} engines: {node: ^10 || ^12 || >=14} dependencies: @@ -1781,12 +1822,12 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 - /prelude-ls/1.2.1: + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} dev: true - /prettier-plugin-svelte/2.10.0_ur5pqdgn24bclu6l6i7qojopk4: + /prettier-plugin-svelte@2.10.0(prettier@2.8.7)(svelte@3.58.0): resolution: {integrity: sha512-GXMY6t86thctyCvQq+jqElO+MKdB09BkL3hexyGP3Oi8XLKRFaJP1ud/xlWCZ9ZIa2BxHka32zhHfcuU+XsRQg==} peerDependencies: prettier: ^1.16.4 || ^2.0.0 @@ -1796,34 +1837,39 @@ packages: svelte: 3.58.0 dev: true - /prettier/2.8.7: + /prettier@2.8.7: resolution: {integrity: sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==} engines: {node: '>=10.13.0'} hasBin: true dev: true - /punycode/2.3.0: + /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'} dev: true - /queue-microtask/1.2.3: + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true - /readdirp/3.6.0: + /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} dependencies: picomatch: 2.3.1 dev: true - /resolve-from/4.0.0: + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} dev: true - /resolve/1.22.1: + /resolve@1.22.1: resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} hasBin: true dependencies: @@ -1832,26 +1878,26 @@ packages: supports-preserve-symlinks-flag: 1.0.0 dev: true - /reusify/1.0.4: + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} dev: true - /rimraf/2.7.1: + /rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} hasBin: true dependencies: glob: 7.2.3 dev: true - /rimraf/3.0.2: + /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} hasBin: true dependencies: glob: 7.2.3 dev: true - /rollup/3.20.2: + /rollup@3.20.2: resolution: {integrity: sha512-3zwkBQl7Ai7MFYQE0y1MeQ15+9jsi7XxfrqwTb/9EK8D9C9+//EBR4M+CuA1KODRaNbFez/lWxA5vhEGZp4MUg==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true @@ -1859,20 +1905,20 @@ packages: fsevents: 2.3.2 dev: true - /run-parallel/1.2.0: + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 dev: true - /sade/1.8.1: + /sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} dependencies: mri: 1.2.0 dev: true - /sander/0.5.1: + /sander@0.5.1: resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} dependencies: es6-promise: 3.3.1 @@ -1881,7 +1927,7 @@ packages: rimraf: 2.7.1 dev: true - /sanitize-html/2.10.0: + /sanitize-html@2.10.0: resolution: {integrity: sha512-JqdovUd81dG4k87vZt6uA6YhDfWkUGruUu/aPmXLxXi45gZExnt9Bnw/qeQU8oGf82vPyaE0vO4aH0PbobB9JQ==} dependencies: deepmerge: 4.3.1 @@ -1892,7 +1938,7 @@ packages: postcss: 8.4.21 dev: false - /semver/7.3.8: + /semver@7.3.8: resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==} engines: {node: '>=10'} hasBin: true @@ -1900,23 +1946,23 @@ packages: lru-cache: 6.0.0 dev: true - /set-cookie-parser/2.6.0: + /set-cookie-parser@2.6.0: resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} dev: true - /shebang-command/2.0.0: + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} dependencies: shebang-regex: 3.0.0 dev: true - /shebang-regex/3.0.0: + /shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} dev: true - /sirv/2.0.2: + /sirv@2.0.2: resolution: {integrity: sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==} engines: {node: '>= 10'} dependencies: @@ -1925,12 +1971,12 @@ packages: totalist: 3.0.1 dev: true - /slash/3.0.0: + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} dev: true - /sorcery/0.11.0: + /sorcery@0.11.0: resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==} hasBin: true dependencies: @@ -1940,51 +1986,51 @@ packages: sander: 0.5.1 dev: true - /source-map-js/1.0.2: + /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} - /sprintf-js/1.0.3: + /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true - /streamsearch/1.1.0: + /streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} dev: true - /strip-ansi/6.0.1: + /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} dependencies: ansi-regex: 5.0.1 dev: true - /strip-indent/3.0.0: + /strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} dependencies: min-indent: 1.0.1 dev: true - /strip-json-comments/3.1.1: + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} dev: true - /supports-color/7.2.0: + /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} dependencies: has-flag: 4.0.0 dev: true - /supports-preserve-symlinks-flag/1.0.0: + /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} dev: true - /svelte-check/3.1.4_svelte@3.58.0: + /svelte-check@3.1.4(svelte@3.58.0): resolution: {integrity: sha512-25Lb46ZS4IK/XpBMe4IBMrtYf23V8alqBX+szXoccb7uM0D2Wqq5rMRzYBONZnFVuU1bQG3R50lyIT5eRewv2g==} hasBin: true peerDependencies: @@ -1997,7 +2043,7 @@ packages: picocolors: 1.0.0 sade: 1.8.1 svelte: 3.58.0 - svelte-preprocess: 5.0.3_aswnnyxiuwffgztcffmr7c7eny + svelte-preprocess: 5.0.3(svelte@3.58.0)(typescript@4.9.5) typescript: 4.9.5 transitivePeerDependencies: - '@babel/core' @@ -2011,7 +2057,11 @@ packages: - sugarss dev: true - /svelte-hmr/0.15.1_svelte@3.58.0: + /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} peerDependencies: @@ -2020,7 +2070,7 @@ packages: svelte: 3.58.0 dev: true - /svelte-preprocess/5.0.3_aswnnyxiuwffgztcffmr7c7eny: + /svelte-preprocess@5.0.3(svelte@3.58.0)(typescript@4.9.5): resolution: {integrity: sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==} engines: {node: '>= 14.10.0'} requiresBuild: true @@ -2067,12 +2117,12 @@ packages: typescript: 4.9.5 dev: true - /svelte/3.58.0: + /svelte@3.58.0: resolution: {integrity: sha512-brIBNNB76mXFmU/Kerm4wFnkskBbluBDCjx/8TcpYRb298Yh2dztS2kQ6bhtjMcvUhd5ynClfwpz5h2gnzdQ1A==} engines: {node: '>= 8'} dev: true - /sveltestrap/5.10.0_svelte@3.58.0: + /sveltestrap@5.10.0(svelte@3.58.0): resolution: {integrity: sha512-k6Ob+6G2AMYvBidXHBKM9W28fJqFHbmosqCe/NC8pv6TV7K+v47Yw+zmnLWkjqCzzmjkSLkL48SrHZrlWc9mYQ==} peerDependencies: svelte: ^3.29.0 @@ -2081,37 +2131,37 @@ packages: svelte: 3.58.0 dev: true - /text-table/0.2.0: + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true - /tiny-glob/0.2.9: + /tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} dependencies: globalyzer: 0.1.0 globrex: 0.1.2 dev: true - /to-regex-range/5.0.1: + /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 dev: true - /totalist/3.0.1: + /totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} dev: true - /tslib/1.14.1: + /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - /tslib/2.5.0: + /tslib@2.5.0: resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} dev: true - /tsutils/3.21.0_typescript@4.9.5: + /tsutils@3.21.0(typescript@4.9.5): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: @@ -2121,41 +2171,41 @@ packages: typescript: 4.9.5 dev: true - /type-check/0.4.0: + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} dependencies: prelude-ls: 1.2.1 dev: true - /type-fest/0.20.2: + /type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} dev: true - /typescript/4.9.5: + /typescript@4.9.5: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} hasBin: true dev: true - /uc.micro/1.0.6: + /uc.micro@1.0.6: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} - /undici/5.21.0: + /undici@5.21.0: resolution: {integrity: sha512-HOjK8l6a57b2ZGXOcUsI5NLfoTrfmbOl90ixJDl0AEFG4wgHNDQxtZy15/ZQp7HhjkpaGlp/eneMgtsu1dIlUA==} engines: {node: '>=12.18'} dependencies: busboy: 1.6.0 dev: true - /uri-js/4.4.1: + /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: punycode: 2.3.0 dev: true - /vite-plugin-markdown/2.1.0_vite@4.2.1: + /vite-plugin-markdown@2.1.0(vite@4.2.1): resolution: {integrity: sha512-eWLlrWzYZXEX3/HaXZo/KLjRpO72IUhbgaoFrbwB07ueXi6QfwqrgdZQfUcXTSofJCkN7GhErMC1K1RTAE0gGQ==} peerDependencies: vite: ^2.0.0 || ^3.0.0 @@ -2163,10 +2213,10 @@ packages: front-matter: 4.0.2 htmlparser2: 6.1.0 markdown-it: 12.3.2 - vite: 4.2.1_@types+node@18.15.11 + vite: 4.2.1(@types/node@18.15.11) dev: true - /vite/4.2.1_@types+node@18.15.11: + /vite@4.2.1(@types/node@18.15.11): resolution: {integrity: sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -2200,7 +2250,7 @@ packages: fsevents: 2.3.2 dev: true - /vitefu/0.2.4_vite@4.2.1: + /vitefu@0.2.4(vite@4.2.1): resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} peerDependencies: vite: ^3.0.0 || ^4.0.0 @@ -2208,10 +2258,10 @@ packages: vite: optional: true dependencies: - vite: 4.2.1_@types+node@18.15.11 + vite: 4.2.1(@types/node@18.15.11) dev: true - /which/2.0.2: + /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true @@ -2219,20 +2269,20 @@ packages: isexe: 2.0.0 dev: true - /word-wrap/1.2.3: + /word-wrap@1.2.3: resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} engines: {node: '>=0.10.0'} dev: true - /wrappy/1.0.2: + /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true - /yallist/4.0.0: + /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true - /yocto-queue/0.1.0: + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true 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/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%
+ diff --git a/frontend/src/error.html b/frontend/src/error.html new file mode 100644 index 0000000..40fc538 --- /dev/null +++ b/frontend/src/error.html @@ -0,0 +1,73 @@ + + + + + + + Internal error occurred + + + +
+ +

Internal error occurred

+

An internal error has occurred. Don't worry, it's (probably) not your fault.

+

+ If this is the first time this is happening, try reloading the page. Otherwise, check the + status page for updates. +

+
+

+ Status: %sveltekit.status%
+ Error message: %sveltekit.error.message% +

+ + 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; 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 diff --git a/frontend/src/lib/api/default_preferences.ts b/frontend/src/lib/api/default_preferences.ts new file mode 100644 index 0000000..0d564d2 --- /dev/null +++ b/frontend/src/lib/api/default_preferences.ts @@ -0,0 +1,48 @@ +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: "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 133b609..61c5c92 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -1,31 +1,61 @@ -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; export const MAX_DESCRIPTION_LENGTH = 1000; export interface User { id: string; + sid: string; name: string; display_name: string | null; bio: string | null; avatar: string | null; links: string[]; member_title: string | null; + badges: number; names: FieldEntry[]; pronouns: Pronoun[]; members: PartialMember[]; fields: Field[]; + flags: PrideFlag[]; + 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 { + created_at: string; max_invites: number; + is_admin: boolean; discord: string | null; 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; list_private: boolean; + last_sid_reroll: string; } export interface Field { @@ -35,26 +65,18 @@ export interface Field { export interface FieldEntry { value: string; - status: WordStatus; + status: string; } export interface Pronoun { pronouns: string; display_text: string | null; - status: WordStatus; -} - -export enum WordStatus { - Unknown = "", - Favourite = "favourite", - Okay = "okay", - Jokingly = "jokingly", - FriendsOnly = "friends_only", - Avoid = "avoid", + status: string; } export interface PartialMember { id: string; + sid: string; name: string; display_name: string | null; bio: string | null; @@ -66,6 +88,7 @@ export interface PartialMember { export interface Member extends PartialMember { fields: Field[]; + flags: PrideFlag[]; user: MemberPartialUser; unlisted?: boolean; @@ -76,6 +99,14 @@ export interface MemberPartialUser { name: string; display_name: string | null; avatar: string | null; + custom_preferences: CustomPreferences; +} + +export interface PrideFlag { + id: string; + hash: string; + name: string; + description: string | null; } export interface Invite { @@ -135,6 +166,7 @@ export enum ErrorCode { AlreadyLinked = 1014, NotLinked = 1015, LastProvider = 1016, + InvalidCaptcha = 1017, UserNotFound = 2001, @@ -159,8 +191,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`, ]; }; @@ -168,11 +200,13 @@ 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`, ]; }; +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/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; + } +}; diff --git a/frontend/src/lib/api/regex.ts b/frontend/src/lib/api/regex.ts index a8f30e6..45db77c 100644 --- a/frontend/src/lib/api/regex.ts +++ b/frontend/src/lib/api/regex.ts @@ -1,2 +1,2 @@ -export const memberNameRegex = /^[^@\\?!#/\\\\[\]"'$%&()+<=>^|~`,]{1,100}$/; +export const memberNameRegex = /^[^@\\?!#/\\\\[\]"\\{\\}'$%&()+<=>^|~`,]{1,100}$/; export const usernameRegex = /^[\w-.]{2,40}$/; diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts index b7ba0c5..5a343f8 100644 --- a/frontend/src/lib/api/responses.ts +++ b/frontend/src/lib/api/responses.ts @@ -8,13 +8,22 @@ export interface SignupResponse { export interface MetaResponse { git_repository: string; git_commit: string; - users: number; + users: MetaUsers; members: number; require_invite: boolean; } +export interface MetaUsers { + total: number; + active_month: number; + active_week: number; + active_day: number; +} + export interface UrlsResponse { - discord: string; + discord?: string; + tumblr?: string; + google?: string; } export interface ExportResponse { 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/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte index cfbb617..c131a4d 100644 --- a/frontend/src/lib/components/IconButton.svelte +++ b/frontend/src/lib/components/IconButton.svelte @@ -4,10 +4,12 @@ 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; + export let outline: boolean | undefined = undefined; + export let border = true; export let click: ((e: MouseEvent) => void) | undefined = undefined; export let href: string | undefined = undefined; @@ -23,6 +25,8 @@ {active} {disabled} {href} + {outline} + class={border ? undefined : "border-0"} aria-label={tooltip} on:click={click} bind:inner={button} diff --git a/frontend/src/lib/components/PartialMemberCard.svelte b/frontend/src/lib/components/PartialMemberCard.svelte index 29fcf22..4f57712 100644 --- a/frontend/src/lib/components/PartialMemberCard.svelte +++ b/frontend/src/lib/components/PartialMemberCard.svelte @@ -1,15 +1,27 @@
@@ -37,6 +51,10 @@

{member.display_name ?? member.name} + {#if member.unlisted === true} + + This member is hidden + {/if} {#if pronouns}
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/lib/components/StatusIcon.svelte b/frontend/src/lib/components/StatusIcon.svelte index ca92c9e..824c894 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..25e109b 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/lib/store.ts b/frontend/src/lib/store.ts index 492b0c7..2f88900 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -6,9 +6,11 @@ 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; export const themeStore = writable(initialThemeValue); + +export const CURRENT_CHANGELOG = "0.5.3"; diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte index 52d0058..21dc4d0 100644 --- a/frontend/src/routes/+error.svelte +++ b/frontend/src/routes/+error.svelte @@ -15,7 +15,7 @@

An internal error occurred. Please try again later.

If this error keeps happening, please file a bug report with an explanation of what you did to cause the error. diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index d26a80c..1af3ac9 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -1,6 +1,5 @@ -import { error } from "@sveltejs/kit"; +import { building } from "$app/environment"; import type { LayoutServerLoad } from "./$types"; -import type { APIError } from "$lib/api/entities"; import { apiFetch } from "$lib/api/fetch"; import type { MetaResponse } from "$lib/api/responses"; @@ -8,6 +7,24 @@ export const load = (async () => { try { return await apiFetch("/meta", {}); } catch (e) { - throw error(500, (e as APIError).message); + console.warn("error fetching meta endpoint:", e); + + if (building) { + // just return an empty object--this only affects the three static pages, nothing else, so it's fine + return { + git_repository: "", + git_commit: "", + users: { + total: 0, + active_month: 0, + active_week: 0, + active_day: 0, + }, + members: 0, + require_invite: false, + }; + } else { + throw e; + } } }) satisfies LayoutServerLoad; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 9ef1a8a..e3040c1 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,5 +1,9 @@

@@ -39,16 +45,28 @@
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index fbf9a95..f5e1d10 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -49,7 +49,7 @@

pronouns.cc is currently in beta. There might be issues and some functionality is not available or unfinished. Issue reports and pull requests - in the repository are welcome!

@@ -67,7 +67,7 @@

Open source

pronouns.cc is - open source, and licensed under the GNU Affero General Public License. Feel free to contribute!

diff --git a/frontend/src/routes/@[username]/+error.svelte b/frontend/src/routes/@[username]/+error.svelte index 9d5d297..8e0cbbc 100644 --- a/frontend/src/routes/@[username]/+error.svelte +++ b/frontend/src/routes/@[username]/+error.svelte @@ -12,7 +12,7 @@

An internal error occurred. Please try again later.

If this error keeps happening, please file a bug report with an explanation of what you did to cause the error. diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index 5c6ea6c..a181ee7 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -1,12 +1,15 @@

@@ -106,15 +151,22 @@
+ {#if data.flags && data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if}
{#if data.display_name}
-

{data.display_name}

+

{data.display_name}

@{data.name}

{:else} -

@{data.name}

+

@{data.name}

{/if} {#if profileEmpty && $userStore?.id === data.id}
@@ -140,13 +192,24 @@
{/if}
+ {#if data.flags && !data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if}
{#if data.names.length > 0}

Names

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

Pronouns

    {#each data.pronouns as pronouns} -
  • +
  • + +
  • {/each}
{/if} {#each data.fields as field}
- +
{/each}
- {#if $userStore && $userStore.id !== data.id} -
- +
+
+ + + {#if PUBLIC_SHORT_BASE} + + {/if} + {#if $userStore && $userStore.id !== data.id} + + {/if} +
- {/if} +
+
{#if data.members.length > 0 || ($userStore && $userStore.id === data.id)}
@@ -200,11 +284,24 @@
{#if data.members.length > 0} -
+
{#each memberSlice as member} {/each}
+ {#if totalPages > 1} +
+ + + + + +
+ {/if} {:else}

@@ -225,7 +322,7 @@

Your members must have distinct names. Member names must be 100 characters long at most, and cannot contain the following characters: @ ? - ! # / \ [ ] " ' $ % & ( ) + < = > ^ | ~ ` and , + ! # / \ [ ] " ' $ % & ( ) { } + < = > ^ | ~ ` and ,

{#if newMemberError} diff --git a/frontend/src/routes/@[username]/ProfileFlag.svelte b/frontend/src/routes/@[username]/ProfileFlag.svelte new file mode 100644 index 0000000..3ef4c63 --- /dev/null +++ b/frontend/src/routes/@[username]/ProfileFlag.svelte @@ -0,0 +1,22 @@ + + + + {flag.description ?? flag.name} + {flag.description + {flag.name} + + + diff --git a/frontend/src/routes/@[username]/ReportButton.svelte b/frontend/src/routes/@[username]/ReportButton.svelte index f33f5c0..79dbc82 100644 --- a/frontend/src/routes/@[username]/ReportButton.svelte +++ b/frontend/src/routes/@[username]/ReportButton.svelte @@ -28,11 +28,9 @@ }; -
- -
+ @@ -47,6 +45,9 @@ terms of service. +
+ Note that we cannot take action on any reports that cannot be proven with just + the reported profile and any external pages linked on it.

diff --git a/frontend/src/routes/@[username]/[memberName]/+page.svelte b/frontend/src/routes/@[username]/[memberName]/+page.svelte index c6ca432..504448a 100644 --- a/frontend/src/routes/@[username]/[memberName]/+page.svelte +++ b/frontend/src/routes/@[username]/[memberName]/+page.svelte @@ -2,25 +2,43 @@ 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 { PUBLIC_BASE_URL } from "$env/static/public"; + import { Alert, Button, Icon, InputGroup } from "sveltestrap"; + import { + memberAvatars, + pronounDisplay, + type CustomPreferences, + type FieldEntry, + type Pronoun, + } from "$lib/api/entities"; + import { PUBLIC_BASE_URL, PUBLIC_SHORT_BASE } 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"; + import { addToast } from "$lib/toast"; + import ProfileFlag from "../ProfileFlag.svelte"; + import IconButton from "$lib/components/IconButton.svelte"; 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 = @@ -28,6 +46,18 @@ 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 }); + }; + + const copyShortURL = async () => { + const url = `${PUBLIC_SHORT_BASE}/${data.sid}`; + await navigator.clipboard.writeText(url); + addToast({ body: "Copied the short link to your clipboard!", duration: 2000 }); + };
@@ -47,6 +77,13 @@
+ {#if data.flags && data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if}

{data.display_name ?? data.name}

@@ -75,13 +112,24 @@
{/if}
+ {#if data.flags && !data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if}
{#if data.names.length > 0}

Names

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

Pronouns

    {#each data.pronouns as pronouns} -
  • +
  • + +
  • {/each}
{/if} {#each data.fields as field}
- +
{/each}
- {#if $userStore && $userStore.id !== data.user.id} -
- +
+
+ + + {#if PUBLIC_SHORT_BASE} + + {/if} + {#if $userStore && $userStore.id !== data.user.id} + + {/if} +
- {/if} +
+
diff --git a/frontend/src/routes/@[username]/badges/Admin.svelte b/frontend/src/routes/@[username]/badges/Admin.svelte new file mode 100644 index 0000000..0c979f7 --- /dev/null +++ b/frontend/src/routes/@[username]/badges/Admin.svelte @@ -0,0 +1,22 @@ + + +This user is an admin + +
+ +
+ + diff --git a/frontend/src/routes/@[username]/badges/Badges.svelte b/frontend/src/routes/@[username]/badges/Badges.svelte new file mode 100644 index 0000000..5b61c9c --- /dev/null +++ b/frontend/src/routes/@[username]/badges/Badges.svelte @@ -0,0 +1,20 @@ + + +
+ {#if isAdmin} + + {/if} +
+ + diff --git a/frontend/src/routes/auth/login/+page.svelte b/frontend/src/routes/auth/login/+page.svelte index 339db36..528f7c0 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; @@ -60,12 +59,16 @@
- - Fediverse logo Log in with the Fediverse - - - Log in with Discord - + Log in with the Fediverse + {#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/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 @@ { + 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; + require_captcha: 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..3e3722c --- /dev/null +++ b/frontend/src/routes/auth/login/google/+page.svelte @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/routes/auth/login/mastodon/[instance]/+page.server.ts b/frontend/src/routes/auth/login/mastodon/[instance]/+page.server.ts index f4e3c1c..377983d 100644 --- a/frontend/src/routes/auth/login/mastodon/[instance]/+page.server.ts +++ b/frontend/src/routes/auth/login/mastodon/[instance]/+page.server.ts @@ -30,6 +30,7 @@ interface CallbackResponse { fediverse?: string; ticket?: string; require_invite: boolean; + require_captcha: boolean; is_deleted: boolean; deleted_at?: string; diff --git a/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte b/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte index a192f05..4241bba 100644 --- a/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte +++ b/frontend/src/routes/auth/login/mastodon/[instance]/+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/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 new file mode 100644 index 0000000..c045030 --- /dev/null +++ b/frontend/src/routes/auth/login/tumblr/+page.server.ts @@ -0,0 +1,39 @@ +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; + require_captcha: 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..1316f33 --- /dev/null +++ b/frontend/src/routes/auth/login/tumblr/+page.svelte @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/routes/edit/EditableField.svelte b/frontend/src/routes/edit/EditableField.svelte index 446fe71..1c3f229 100644 --- a/frontend/src/routes/edit/EditableField.svelte +++ b/frontend/src/routes/edit/EditableField.svelte @@ -1,19 +1,20 @@

@@ -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..946851e 100644 --- a/frontend/src/routes/edit/EditablePronouns.svelte +++ b/frontend/src/routes/edit/EditablePronouns.svelte @@ -1,8 +1,8 @@
@@ -75,31 +54,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..1f4aa61 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/FlagButton.svelte b/frontend/src/routes/edit/FlagButton.svelte new file mode 100644 index 0000000..19c8fbd --- /dev/null +++ b/frontend/src/routes/edit/FlagButton.svelte @@ -0,0 +1,26 @@ + + +{tooltip} + + + diff --git a/frontend/src/routes/edit/member/[id]/+page.svelte b/frontend/src/routes/edit/member/[id]/+page.svelte index f60b935..1ab1819 100644 --- a/frontend/src/routes/edit/member/[id]/+page.svelte +++ b/frontend/src/routes/edit/member/[id]/+page.svelte @@ -3,12 +3,12 @@ import { MAX_DESCRIPTION_LENGTH, memberAvatars, - WordStatus, type APIError, type Field, type FieldEntry, type Member, type Pronoun, + type PrideFlag, } from "$lib/api/entities"; import FallbackImage from "$lib/components/FallbackImage.svelte"; import { @@ -28,18 +28,22 @@ CardHeader, Alert, } from "sveltestrap"; + import { DateTime } from "luxon"; import { encode } from "base64-arraybuffer"; + import prettyBytes from "pretty-bytes"; + import { PUBLIC_SHORT_BASE } from "$env/static/public"; import { apiFetchClient, fastFetchClient } from "$lib/api/fetch"; import IconButton from "$lib/components/IconButton.svelte"; import EditableField from "../../EditableField.svelte"; import EditableName from "../../EditableName.svelte"; import EditablePronouns from "../../EditablePronouns.svelte"; import ErrorAlert from "$lib/components/ErrorAlert.svelte"; - import type { PageData } from "./$types"; + import type { PageData, Snapshot } from "./$types"; import { addToast, delToast } from "$lib/toast"; import { memberNameRegex } from "$lib/api/regex"; import { charCount, renderMarkdown } from "$lib/utils"; import MarkdownHelp from "../../MarkdownHelp.svelte"; + import FlagButton from "../../FlagButton.svelte"; const MAX_AVATAR_BYTES = 1_000_000; @@ -59,6 +63,7 @@ let names: FieldEntry[] = window.structuredClone(data.member.names); let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns); let fields: Field[] = window.structuredClone(data.member.fields); + let flags: PrideFlag[] = window.structuredClone(data.member.flags); let unlisted: boolean = data.member.unlisted || false; let memberNameValid = true; @@ -71,6 +76,18 @@ let newPronouns = ""; let newLink = ""; + let flagSearch = ""; + let filteredFlags: PrideFlag[]; + $: filteredFlags = filterFlags(flagSearch, data.flags); + + const filterFlags = (search: string, flags: PrideFlag[]) => { + return ( + search + ? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) + : flags + ).slice(0, 25); + }; + let modified = false; $: modified = isModified( @@ -82,6 +99,7 @@ names, pronouns, fields, + flags, avatar, unlisted, ); @@ -96,6 +114,7 @@ names: FieldEntry[], pronouns: Pronoun[], fields: Field[], + flags: PrideFlag[], avatar: string | null, unlisted: boolean, ) => { @@ -104,6 +123,7 @@ if (display_name !== member.display_name) return true; if (!linksEqual(links, member.links)) return true; if (!fieldsEqual(fields, member.fields)) return true; + if (!flagsEqual(flags, member.flags)) return true; if (!namesEqual(names, member.names)) return true; if (!pronounsEqual(pronouns, member.pronouns)) return true; if (avatar !== null) return true; @@ -147,9 +167,22 @@ return arr1.every((_, i) => arr1[i] === arr2[i]); }; + const flagsEqual = (arr1: PrideFlag[], arr2: PrideFlag[]) => { + if (arr1.length !== arr2.length) return false; + return arr1.every((_, i) => arr1[i].id === arr2[i].id); + }; + 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); @@ -192,10 +225,41 @@ 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 moveFlag = (index: number, up: boolean) => { + if (up && index == 0) return; + if (!up && index == flags.length - 1) return; + + const newIndex = up ? index - 1 : index + 1; + + const temp = flags[index]; + flags[index] = flags[newIndex]; + flags[newIndex] = temp; + }; + + const addFlag = (flag: PrideFlag) => { + flags = [...flags, flag]; + }; + + const removeFlag = (index: number) => { + flags.splice(index, 1); + flags = [...flags]; + }; + const addName = (event: Event) => { event.preventDefault(); - names = [...names, { value: newName, status: WordStatus.Okay }]; + names = [...names, { value: newName, status: "okay" }]; newName = ""; }; @@ -209,14 +273,11 @@ { pronouns: fullSet.pronouns.join("/"), display_text: fullSet.display || null, - status: WordStatus.Okay, + status: "okay", }, ]; } else { - pronouns = [ - ...pronouns, - { pronouns: newPronouns, display_text: null, status: WordStatus.Okay }, - ]; + pronouns = [...pronouns, { pronouns: newPronouns, display_text: null, status: "okay" }]; } newPronouns = ""; }; @@ -265,6 +326,7 @@ names, pronouns, fields, + flags: flags.map((flag) => flag.id), unlisted, }); @@ -300,7 +362,7 @@ $: deleteModalPronoun = updateModalPronoun(pronouns); const updateModalPronoun = (pronouns: Pronoun[]) => { - const filtered = pronouns.filter((entry) => entry.status === WordStatus.Favourite); + const filtered = pronouns.filter((entry) => entry.status === "favourite"); if (filtered.length < 1) return "the member's"; const split = filtered[0].pronouns.split("/"); @@ -312,6 +374,78 @@ const toggleDeleteOpen = () => (deleteOpen = !deleteOpen); let deleteName = ""; let deleteError: APIError | null = null; + + const now = DateTime.now().toLocal(); + let canRerollSid: boolean; + $: canRerollSid = + now.diff(DateTime.fromISO(data.user.last_sid_reroll).toLocal(), "hours").hours >= 1; + + const rerollSid = async () => { + try { + const resp = await apiFetchClient(`/members/${data.member.id}/reroll`); + addToast({ header: "Success", body: "Rerolled short ID!" }); + error = null; + data.member.sid = resp.sid; + } catch (e) { + error = e as APIError; + } + }; + + const copyShortURL = async () => { + const url = `${PUBLIC_SHORT_BASE}/${data.member.sid}`; + await navigator.clipboard.writeText(url); + addToast({ body: "Copied the short link to your clipboard!", duration: 2000 }); + }; + + interface SnapshotData { + bio: string; + name: string; + display_name: string; + links: string[]; + names: FieldEntry[]; + pronouns: Pronoun[]; + fields: Field[]; + flags: PrideFlag[]; + unlisted: boolean; + + avatar: string | null; + newName: string; + newPronouns: string; + newLink: string; + } + + export const snapshot: Snapshot = { + capture: () => ({ + bio, + name, + display_name, + links, + names, + pronouns, + fields, + flags, + unlisted, + avatar, + newName, + newPronouns, + newLink, + }), + restore: (value) => { + bio = value.bio; + name = value.name; + display_name = value.display_name; + links = value.links; + names = value.names; + pronouns = value.pronouns; + fields = value.fields; + flags = value.flags; + unlisted = value.unlisted; + avatar = value.avatar; + newName = value.newName; + newPronouns = value.newPronouns; + newLink = value.newLink; + }, + }; @@ -430,6 +564,7 @@ moveName(index, true)} moveDown={() => moveName(index, false)} remove={() => removeName(index)} @@ -469,6 +604,7 @@ {#each pronouns as _, index} movePronoun(index, true)} moveDown={() => movePronoun(index, false)} remove={() => removePronoun(index)} @@ -510,6 +646,7 @@ {#each fields as _, index} removeField(index)} moveField={(up) => moveField(index, up)} /> @@ -522,10 +659,88 @@
+ +
+ {#each flags as _, index} + + moveFlag(index, true)} + /> + moveFlag(index, false)} + /> + removeFlag(index)} + /> + + {/each} +
+
+
+
+ +
+ {#each filteredFlags as flag (flag.id)} + addFlag(flag)} + /> + {:else} + {#if data.flags.length === 0} + You haven't uploaded any flags yet. + {:else} + There are no flags matching your search {flagSearch}. + {/if} + {/each} +
+
+
+ + {#if data.flags.length === 0} +

Why can't I see any flags?

+

+ There are thousands of pride flags, and it would be impossible to bundle all of them + by default. Many labels also have multiple different flags that are favoured by + different people. Because of this, there are no flags available by default--instead, + you can upload flags in your settings. Your main profile + and your member profiles can all have different flags. +

+ {:else} + To upload and delete flags, go to your settings. + {/if} +
+
+
+
{#each links as _, index}
+ moveLink(index, true)} + /> + moveLink(index, false)} + /> Hide from member list

+ {#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. @@ -557,6 +779,30 @@

+ {#if PUBLIC_SHORT_BASE} +
+

+ Current short ID: {data.member.sid} + + + + +
+ + + This ID is used in prns.cc links. You can reroll one short ID every hour (shared + between your main profile and all members) by pressing the button above. + +

+
+ {/if}
diff --git a/frontend/src/routes/edit/member/[id]/+page.ts b/frontend/src/routes/edit/member/[id]/+page.ts index b695c60..a84b94d 100644 --- a/frontend/src/routes/edit/member/[id]/+page.ts +++ b/frontend/src/routes/edit/member/[id]/+page.ts @@ -1,4 +1,4 @@ -import type { MeUser, APIError, Member, PronounsJson } from "$lib/api/entities"; +import type { PrideFlag, MeUser, APIError, Member, PronounsJson } from "$lib/api/entities"; import { apiFetchClient } from "$lib/api/fetch"; import { error } from "@sveltejs/kit"; @@ -11,11 +11,13 @@ export const load = async ({ params }) => { try { const user = await apiFetchClient(`/users/@me`); const member = await apiFetchClient(`/members/${params.id}`); + const flags = await apiFetchClient("/users/@me/flags"); return { user, member, pronouns: pronouns.autocomplete, + flags, }; } catch (e) { throw error((e as APIError).code, (e as APIError).message); diff --git a/frontend/src/routes/edit/profile/+page.svelte b/frontend/src/routes/edit/profile/+page.svelte index a1be3fd..da01fe3 100644 --- a/frontend/src/routes/edit/profile/+page.svelte +++ b/frontend/src/routes/edit/profile/+page.svelte @@ -2,12 +2,14 @@ import { MAX_DESCRIPTION_LENGTH, userAvatars, - WordStatus, type APIError, type Field, type FieldEntry, type MeUser, type Pronoun, + PreferenceSize, + type CustomPreferences, + type PrideFlag, } from "$lib/api/entities"; import FallbackImage from "$lib/components/FallbackImage.svelte"; import { userStore } from "$lib/store"; @@ -26,16 +28,21 @@ TabPane, } from "sveltestrap"; import { encode } from "base64-arraybuffer"; + import { DateTime } from "luxon"; import { apiFetchClient } from "$lib/api/fetch"; + import { PUBLIC_SHORT_BASE } from "$env/static/public"; import IconButton from "$lib/components/IconButton.svelte"; import EditableField from "../EditableField.svelte"; import EditableName from "../EditableName.svelte"; import EditablePronouns from "../EditablePronouns.svelte"; import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import { addToast, delToast } from "$lib/toast"; - import type { PageData } from "./$types"; + import type { PageData, Snapshot } from "./$types"; import { charCount, renderMarkdown } from "$lib/utils"; import MarkdownHelp from "../MarkdownHelp.svelte"; + import prettyBytes from "pretty-bytes"; + import CustomPreference from "./CustomPreference.svelte"; + import FlagButton from "../FlagButton.svelte"; const MAX_AVATAR_BYTES = 1_000_000; @@ -50,7 +57,9 @@ let names: FieldEntry[] = window.structuredClone(data.user.names); let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns); let fields: Field[] = window.structuredClone(data.user.fields); + let flags: PrideFlag[] = window.structuredClone(data.user.flags); 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; @@ -59,6 +68,21 @@ let newPronouns = ""; let newLink = ""; + let flagSearch = ""; + let filteredFlags: PrideFlag[]; + $: filteredFlags = filterFlags(flagSearch, data.flags); + + const filterFlags = (search: string, flags: PrideFlag[]) => { + return ( + search + ? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) + : flags + ).slice(0, 25); + }; + + let preferenceIds: string[]; + $: preferenceIds = Object.keys(custom_preferences); + let modified = false; $: modified = isModified( @@ -69,9 +93,11 @@ names, pronouns, fields, + flags, avatar, member_title, list_private, + custom_preferences, ); $: getAvatar(avatar_files).then((b64) => (avatar = b64)); @@ -83,17 +109,21 @@ names: FieldEntry[], pronouns: Pronoun[], fields: Field[], + flags: PrideFlag[], 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; if (member_title !== (user.member_title || "")) return true; if (!linksEqual(links, user.links)) return true; if (!fieldsEqual(fields, user.fields)) return true; + if (!flagsEqual(flags, user.flags)) 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; @@ -135,9 +165,39 @@ return arr1.every((_, i) => arr1[i] === arr2[i]); }; + const flagsEqual = (arr1: PrideFlag[], arr2: PrideFlag[]) => { + if (arr1.length !== arr2.length) return false; + return arr1.every((_, i) => arr1[i].id === arr2[i].id); + }; + + 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; + 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) 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); @@ -181,10 +241,41 @@ 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 moveFlag = (index: number, up: boolean) => { + if (up && index == 0) return; + if (!up && index == flags.length - 1) return; + + const newIndex = up ? index - 1 : index + 1; + + const temp = flags[index]; + flags[index] = flags[newIndex]; + flags[newIndex] = temp; + }; + + const addFlag = (flag: PrideFlag) => { + flags = [...flags, flag]; + }; + + const removeFlag = (index: number) => { + flags.splice(index, 1); + flags = [...flags]; + }; + const addName = (event: Event) => { event.preventDefault(); - names = [...names, { value: newName, status: WordStatus.Okay }]; + names = [...names, { value: newName, status: "okay" }]; newName = ""; }; @@ -198,14 +289,11 @@ { pronouns: fullSet.pronouns.join("/"), display_text: fullSet.display || null, - status: WordStatus.Okay, + status: "okay", }, ]; } else { - pronouns = [ - ...pronouns, - { pronouns: newPronouns, display_text: null, status: WordStatus.Okay }, - ]; + pronouns = [...pronouns, { pronouns: newPronouns, display_text: null, status: "okay" }]; } newPronouns = ""; }; @@ -217,6 +305,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]; @@ -237,6 +338,11 @@ fields = [...fields]; }; + const removePreference = (id: string) => { + delete custom_preferences[id]; + custom_preferences = custom_preferences; + }; + const updateUser = async () => { const toastId = addToast({ header: "Saving changes", @@ -255,9 +361,12 @@ fields, member_title, list_private, + custom_preferences, + flags: flags.map((flag) => flag.id), }); data.user = resp; + custom_preferences = resp.custom_preferences; userStore.set(resp); localStorage.setItem("pronouns-user", JSON.stringify(resp)); @@ -271,6 +380,81 @@ delToast(toastId); } }; + + const now = DateTime.now().toLocal(); + let canRerollSid: boolean; + $: canRerollSid = + now.diff(DateTime.fromISO(data.user.last_sid_reroll).toLocal(), "hours").hours >= 1; + + const rerollSid = async () => { + try { + const resp = await apiFetchClient("/users/@me/reroll"); + addToast({ header: "Success", body: "Rerolled short ID!" }); + error = null; + data.user.sid = resp.sid; + } catch (e) { + error = e as APIError; + } + }; + + const copyShortURL = async () => { + const url = `${PUBLIC_SHORT_BASE}/${data.user.sid}`; + await navigator.clipboard.writeText(url); + addToast({ body: "Copied the short link to your clipboard!", duration: 2000 }); + }; + + interface SnapshotData { + bio: string; + display_name: string; + member_title: string; + links: string[]; + names: FieldEntry[]; + pronouns: Pronoun[]; + fields: Field[]; + flags: PrideFlag[]; + list_private: boolean; + custom_preferences: CustomPreferences; + + avatar: string | null; + newName: string; + newPronouns: string; + newLink: string; + } + + export const snapshot: Snapshot = { + capture: () => ({ + bio, + display_name, + member_title, + links, + names, + pronouns, + fields, + flags, + list_private, + custom_preferences, + avatar, + newName, + newPronouns, + newLink, + }), + restore: (value) => { + bio = value.bio; + display_name = value.display_name; + member_title = value.member_title; + links = value.links; + names = value.names; + pronouns = value.pronouns; + fields = value.fields; + flags = value.flags; + list_private = value.list_private; + custom_preferences = value.custom_preferences; + avatar = value.avatar; + newName = value.newName; + newPronouns = value.newPronouns; + newLink = value.newLink; + }, + }; @@ -358,6 +542,7 @@ moveName(index, true)} moveDown={() => moveName(index, false)} remove={() => removeName(index)} @@ -397,6 +582,7 @@ {#each pronouns as _, index} movePronoun(index, true)} moveDown={() => movePronoun(index, false)} remove={() => removePronoun(index)} @@ -438,6 +624,7 @@ {#each fields as _, index} removeField(index)} moveField={(up) => moveField(index, up)} /> @@ -450,10 +637,88 @@
+ +
+ {#each flags as _, index} + + moveFlag(index, true)} + /> + moveFlag(index, false)} + /> + removeFlag(index)} + /> + + {/each} +
+
+
+
+ +
+ {#each filteredFlags as flag (flag.id)} + addFlag(flag)} + /> + {:else} + {#if data.flags.length === 0} + You haven't uploaded any flags yet. + {:else} + There are no flags matching your search {flagSearch}. + {/if} + {/each} +
+
+
+ + {#if data.flags.length === 0} +

Why can't I see any flags?

+

+ There are thousands of pride flags, and it would be impossible to bundle all of them + by default. Many labels also have multiple different flags that are favoured by + different people. Because of this, there are no flags available by default--instead, + you can upload flags in your settings. Your main profile + and your member profiles can all have different flags. +

+ {:else} + To upload and delete flags, go to your settings. + {/if} +
+
+
+
{#each links as _, index}
+ moveLink(index, true)} + /> + moveLink(index, false)} + />
- +
@@ -480,6 +745,29 @@ will be used.

+ {#if PUBLIC_SHORT_BASE} +
+

+ Current short ID: {data.user.sid} + + + + +
+ + + This ID is used in prns.cc links. You can reroll one short ID every hour (shared + between your main profile and all members) by pressing the button above. + +

+ {/if}
@@ -501,5 +789,18 @@

+
+

+ Preferences +

+ {#each preferenceIds as id} + removePreference(id)} + /> + {/each} +
diff --git a/frontend/src/routes/edit/profile/+page.ts b/frontend/src/routes/edit/profile/+page.ts index 1054016..894eb83 100644 --- a/frontend/src/routes/edit/profile/+page.ts +++ b/frontend/src/routes/edit/profile/+page.ts @@ -1,4 +1,4 @@ -import type { APIError, MeUser, PronounsJson } from "$lib/api/entities"; +import type { PrideFlag, APIError, MeUser, PronounsJson } from "$lib/api/entities"; import { apiFetchClient } from "$lib/api/fetch"; import { error } from "@sveltejs/kit"; @@ -10,10 +10,12 @@ export const ssr = false; export const load = async () => { try { const user = await apiFetchClient(`/users/@me`); + const flags = await apiFetchClient("/users/@me/flags"); return { user, pronouns: pronouns.autocomplete, + flags, }; } catch (e) { throw error((e as APIError).code, (e as APIError).message); diff --git a/frontend/src/routes/edit/profile/CustomPreference.svelte b/frontend/src/routes/edit/profile/CustomPreference.svelte new file mode 100644 index 0000000..cd68110 --- /dev/null +++ b/frontend/src/routes/edit/profile/CustomPreference.svelte @@ -0,0 +1,62 @@ + + + + + + Change text size + + + + + + (preference.size = PreferenceSize.Large)}>Large + (preference.size = PreferenceSize.Normal)}>Medium + (preference.size = PreferenceSize.Small)}>Small + + + + + + diff --git a/frontend/src/routes/edit/profile/IconPicker.svelte b/frontend/src/routes/edit/profile/IconPicker.svelte new file mode 100644 index 0000000..eef2ddc --- /dev/null +++ b/frontend/src/routes/edit/profile/IconPicker.svelte @@ -0,0 +1,71 @@ + + + + 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} +
+
+ + diff --git a/frontend/src/routes/main.css b/frontend/src/routes/main.css index c1a4c91..6610c12 100644 --- a/frontend/src/routes/main.css +++ b/frontend/src/routes/main.css @@ -1,37 +1,3 @@ -@font-face { - font-family: "FiraGO"; - font-display: swap; - font-style: normal; - font-weight: 400; - src: url("/fonts/FiraGO-400.woff") format("woff"); -} - -@font-face { - font-family: "FiraGO"; - font-display: swap; - font-style: normal; - font-weight: 400; - src: url("/fonts/FiraGO-400i.woff") format("woff"); - font-style: italic; -} - -@font-face { - font-family: "FiraGO"; - font-display: swap; - font-style: normal; - font-weight: 700; - src: url("/fonts/FiraGO-700.woff") format("woff"); -} - -@font-face { - font-family: "FiraGO"; - font-display: swap; - font-style: normal; - font-weight: 700; - src: url("/fonts/FiraGO-700i.woff") format("woff"); - font-style: italic; -} - body { font-family: "FiraGO", sans-serif; } diff --git a/frontend/src/routes/nav/Logo.svelte b/frontend/src/routes/nav/Logo.svelte index 09dec40..9371e6c 100644 --- a/frontend/src/routes/nav/Logo.svelte +++ b/frontend/src/routes/nav/Logo.svelte @@ -1,8 +1,8 @@ @@ -10,7 +10,7 @@ - + diff --git a/frontend/src/routes/nav/Navigation.svelte b/frontend/src/routes/nav/Navigation.svelte index 2b2c33b..8052181 100644 --- a/frontend/src/routes/nav/Navigation.svelte +++ b/frontend/src/routes/nav/Navigation.svelte @@ -1,4 +1,5 @@ dev + {:else} + beta {/if} @@ -126,10 +132,15 @@ + + diff --git a/frontend/src/routes/page/about/about.md b/frontend/src/routes/page/about/about.md index 9a1e59e..f9cac68 100644 --- a/frontend/src/routes/page/about/about.md +++ b/frontend/src/routes/page/about/about.md @@ -24,7 +24,7 @@ GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -pronouns.cc's source code is available [on Codeberg](https://codeberg.org/u1f320/pronouns.cc). +pronouns.cc's source code is available [on Codeberg](https://codeberg.org/pronounscc/pronouns.cc). # Support pronouns.cc 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..0eadc6c --- /dev/null +++ b/frontend/src/routes/page/changelog/changelog.md @@ -0,0 +1,50 @@ +# Changelog + +## 0.5.3 - June 3rd 2023 + +- Added a link shortener at **prns.cc**! There is now an additional button on all user and member pages to copy a shorter link. + The IDs used here can be rerolled on the edit profile page. +- Fixed the member list breaking if you have too many private members. + +## 0.5.0 - May 29th 2023 + +- **Added pride flags!** + These will show up below avatars and are completely customizable: + you can upload up to one hundred different flags and pick which ones will show up on which profiles. +- `.` and `..` are no longer valid member names, as these break member pages. Any other number of periods is still allowed. +- Added Plausible analytics. +- Added some extra info to the [settings page](/settings). +- Added [API documentation](/api). + +## 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. +- 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!** + 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. 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 === "") { 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; 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/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index 8e254bd..fc225ee 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} +
+ + + Authentication + {#if data.invitesEnabled} - Tokens + API tokens Log out
-
+
diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index a112ef4..c28872e 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -1,14 +1,31 @@ + + 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 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) + +
+

Your profile

@@ -110,8 +155,9 @@ {/if}
- -

+

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

@@ -150,16 +196,38 @@ ID {data.user.id} + + Account created at + {DateTime.fromISO(data.user.created_at) + .toLocal() + .toLocaleString(DateTime.DATETIME_MED)} + Members {data.user.members.length}/{MAX_MEMBERS} + + Member list hidden? + {data.user.list_private ? "Yes" : "No"} + + + Custom preferences + {Object.keys(data.user.custom_preferences).length}/{MAX_FIELDS} + {#if data.invitesEnabled} Invites {data.invites.length}/{data.user.max_invites} {/if} + {#if data.user.is_admin} + + Admin? + Yes + + {/if}
diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte index eddd309..7c10704 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, data.user.google] .map((entry) => (entry === null ? 0 : 1)) .reduce((prev, current) => prev + current) >= 2; @@ -38,6 +38,12 @@ let discordUnlinkModalOpen = false; let toggleDiscordUnlinkModal = () => (discordUnlinkModalOpen = !discordUnlinkModalOpen); + let tumblrUnlinkModalOpen = false; + let toggleTumblrUnlinkModal = () => (tumblrUnlinkModalOpen = !tumblrUnlinkModalOpen); + + let googleUnlinkModalOpen = false; + let toggleGoogleUnlinkModal = () => (googleUnlinkModalOpen = !googleUnlinkModalOpen); + const fediLogin = async () => { fediDisabled = true; try { @@ -74,6 +80,28 @@ 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; + } + }; + + 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; + } + };
@@ -104,28 +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} - - -
+
+
+
+ {/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} + + {:else if data.urls.tumblr} + + {/if} + + +
+ {/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} + + {:else if data.urls.google} + + {/if} + + +
+ {/if} @@ -185,5 +263,49 @@ + + + +

+ 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} +
+ + + + +
+ + + +

+ 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/frontend/src/routes/settings/export/+page.svelte b/frontend/src/routes/settings/export/+page.svelte index d33e177..31f87e7 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:

-

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

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 (flag.id)} + + {: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. +

+

+ +