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/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 new file mode 100644 index 0000000..e722d16 --- /dev/null +++ b/backend/db/flags.go @@ -0,0 +1,322 @@ +package db + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "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" +) + +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 = 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)").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", + }) + 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 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..7e3409d 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", @@ -153,6 +155,16 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } } + // 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 } @@ -270,6 +282,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 +308,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, true)) + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) return nil } diff --git a/backend/routes/user/flags.go b/backend/routes/user/flags.go new file mode 100644 index 0000000..60219b9 --- /dev/null +++ b/backend/routes/user/flags.go @@ -0,0 +1,239 @@ +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/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 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..19ab3e1 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", @@ -106,6 +109,16 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } + // 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 { @@ -252,6 +265,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 +300,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, diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go index 974fa55..fd6e9a2 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/{flagID}", server.WrapHandler(s.patchUserFlag)) + r.Delete("/@me/flags/{flagID}", 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/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index acb2767..37bcd87 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -17,6 +17,7 @@ export interface User { pronouns: Pronoun[]; members: PartialMember[]; fields: Field[]; + flags: PrideFlag[]; custom_preferences: CustomPreferences; } @@ -83,6 +84,7 @@ export interface PartialMember { export interface Member extends PartialMember { fields: Field[]; + flags: PrideFlag[]; user: MemberPartialUser; unlisted?: boolean; @@ -96,6 +98,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 +201,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/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index 3bb8704..edb9aae 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -40,6 +40,7 @@ import StatusLine from "$lib/components/StatusLine.svelte"; import defaultPreferences from "$lib/api/default_preferences"; import { addToast } from "$lib/toast"; + import ProfileFlag from "./ProfileFlag.svelte"; export let data: PageData; @@ -117,16 +118,16 @@ onMount(async () => { if ($userStore && $userStore.id === data.id) { - console.log("User is current user, fetching members") + console.log("User is current user, fetching members"); try { const members = await apiFetchClient("/users/@me/members"); data.members = members; } catch (e) { // If it fails, we fail silently but log to console anyway - console.error("Fetching members:", e) + console.error("Fetching members:", e); } } - }) + });
@@ -140,6 +141,13 @@
+ {#if data.flags && data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if}
{#if data.display_name} @@ -174,6 +182,13 @@
{/if}
+ {#if data.flags && !data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if}
{#if data.names.length > 0}
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]/[memberName]/+page.svelte b/frontend/src/routes/@[username]/[memberName]/+page.svelte index 99146cb..b281905 100644 --- a/frontend/src/routes/@[username]/[memberName]/+page.svelte +++ b/frontend/src/routes/@[username]/[memberName]/+page.svelte @@ -20,6 +20,7 @@ import StatusLine from "$lib/components/StatusLine.svelte"; import defaultPreferences from "$lib/api/default_preferences"; import { addToast } from "$lib/toast"; + import ProfileFlag from "../ProfileFlag.svelte"; export let data: PageData; @@ -69,6 +70,13 @@
+ {#if data.flags && data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if}

{data.display_name ?? data.name}

@@ -97,6 +105,13 @@
{/if}
+ {#if data.flags && !data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if}
{#if data.names.length > 0}
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 34fc26d..7ba9080 100644 --- a/frontend/src/routes/edit/member/[id]/+page.svelte +++ b/frontend/src/routes/edit/member/[id]/+page.svelte @@ -8,6 +8,7 @@ type FieldEntry, type Member, type Pronoun, + type PrideFlag, } from "$lib/api/entities"; import FallbackImage from "$lib/components/FallbackImage.svelte"; import { @@ -40,6 +41,7 @@ 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 +61,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 +74,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 +97,7 @@ names, pronouns, fields, + flags, avatar, unlisted, ); @@ -96,6 +112,7 @@ names: FieldEntry[], pronouns: Pronoun[], fields: Field[], + flags: PrideFlag[], avatar: string | null, unlisted: boolean, ) => { @@ -104,6 +121,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,6 +165,11 @@ 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) { @@ -211,6 +234,26 @@ 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(); @@ -281,6 +324,7 @@ names, pronouns, fields, + flags: flags.map((flag) => flag.id), unlisted, }); @@ -541,6 +585,72 @@
+ +
+ {#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} 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 b6fda9f..f2ee796 100644 --- a/frontend/src/routes/edit/profile/+page.svelte +++ b/frontend/src/routes/edit/profile/+page.svelte @@ -9,6 +9,7 @@ type Pronoun, PreferenceSize, type CustomPreferences, + type PrideFlag, } from "$lib/api/entities"; import FallbackImage from "$lib/components/FallbackImage.svelte"; import { userStore } from "$lib/store"; @@ -39,6 +40,7 @@ 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; @@ -53,6 +55,7 @@ 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); @@ -63,6 +66,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 preferenceIds: string[]; $: preferenceIds = Object.keys(custom_preferences); @@ -76,6 +91,7 @@ names, pronouns, fields, + flags, avatar, member_title, list_private, @@ -91,6 +107,7 @@ names: FieldEntry[], pronouns: Pronoun[], fields: Field[], + flags: PrideFlag[], avatar: string | null, member_title: string, list_private: boolean, @@ -101,6 +118,7 @@ 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; @@ -145,6 +163,11 @@ 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; @@ -227,6 +250,26 @@ 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(); @@ -317,6 +360,7 @@ member_title, list_private, custom_preferences, + flags: flags.map((flag) => flag.id), }); data.user = resp; @@ -516,6 +560,72 @@
+ +
+ {#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} 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/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..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. +

+

+ +