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

Settings

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

+

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

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

Pride flags ({data.flags.length})

+ +

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

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

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

+

+ + +

+

+ This name will be shown beside the flag. +

+

+ +