merge branch 'feature/flags'

This commit is contained in:
Sam 2023-05-29 03:27:26 +02:00
commit e993d2a89e
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
29 changed files with 1322 additions and 73 deletions

View File

@ -19,6 +19,7 @@ import (
const ErrInvalidDataURI = errors.Sentinel("invalid data URI") const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type") 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. // ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
func (db *DB) ConvertAvatar(data string) ( func (db *DB) ConvertAvatar(data string) (

View File

@ -22,6 +22,11 @@ var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
const ErrNothingToUpdate = errors.Sentinel("nothing to update") const ErrNothingToUpdate = errors.Sentinel("nothing to update")
const (
uniqueViolation = "23505"
foreignKeyViolation = "23503"
)
type Execer interface { type Execer interface {
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error) Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
} }

322
backend/db/flags.go Normal file
View File

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

View File

@ -116,7 +116,7 @@ func (db *DB) CreateMember(
pge := &pgconn.PgError{} pge := &pgconn.PgError{}
if errors.As(err, &pge) { if errors.As(err, &pge) {
// unique constraint violation // unique constraint violation
if pge.Code == "23505" { if pge.Code == uniqueViolation {
return m, ErrMemberNameInUse return m, ErrMemberNameInUse
} }
} }
@ -223,7 +223,7 @@ func (db *DB) UpdateMember(
if err != nil { if err != nil {
pge := &pgconn.PgError{} pge := &pgconn.PgError{}
if errors.As(err, &pge) { if errors.As(err, &pge) {
if pge.Code == "23505" { if pge.Code == uniqueViolation {
return m, ErrMemberNameInUse return m, ErrMemberNameInUse
} }
} }

View File

@ -171,7 +171,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
pge := &pgconn.PgError{} pge := &pgconn.PgError{}
if errors.As(err, &pge) { if errors.As(err, &pge) {
// unique constraint violation // unique constraint violation
if pge.Code == "23505" { if pge.Code == uniqueViolation {
return u, ErrUsernameTaken return u, ErrUsernameTaken
} }
} }
@ -494,7 +494,7 @@ func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName
pge := &pgconn.PgError{} pge := &pgconn.PgError{}
if errors.As(err, &pge) { if errors.As(err, &pge) {
// unique constraint violation // unique constraint violation
if pge.Code == "23505" { if pge.Code == uniqueViolation {
return ErrUsernameTaken return ErrUsernameTaken
} }
} }

View File

@ -188,7 +188,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
return errors.Wrap(err, "committing transaction") 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 return nil
} }

View File

@ -23,13 +23,14 @@ type GetMemberResponse struct {
Names []db.FieldEntry `json:"names"` Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"` Pronouns []db.PronounEntry `json:"pronouns"`
Fields []db.Field `json:"fields"` Fields []db.Field `json:"fields"`
Flags []db.MemberFlag `json:"flags"`
User PartialUser `json:"user"` User PartialUser `json:"user"`
Unlisted *bool `json:"unlisted,omitempty"` 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{ r := GetMemberResponse{
ID: m.ID, ID: m.ID,
Name: m.Name, 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), Names: db.NotNull(m.Names),
Pronouns: db.NotNull(m.Pronouns), Pronouns: db.NotNull(m.Pronouns),
Fields: db.NotNull(fields), Fields: db.NotNull(fields),
Flags: flags,
User: PartialUser{ User: PartialUser{
ID: u.ID, ID: u.ID,
@ -102,7 +104,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
return err 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 return nil
} }
@ -137,7 +144,12 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
return err 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 return nil
} }

View File

@ -25,6 +25,7 @@ type PatchMemberRequest struct {
Fields *[]db.Field `json:"fields"` Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
Unlisted *bool `json:"unlisted"` Unlisted *bool `json:"unlisted"`
Flags *[]xid.ID `json:"flags"`
} }
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { 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.Fields == nil &&
req.Names == nil && req.Names == nil &&
req.Pronouns == nil && req.Pronouns == nil &&
req.Avatar == nil { req.Avatar == nil &&
req.Flags == nil {
return server.APIError{ return server.APIError{
Code: server.ErrBadRequest, Code: server.ErrBadRequest,
Details: "Data must not be empty", 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 { if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
return *err 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 // update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil { if err != nil {
@ -283,7 +308,14 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return 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 // 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 return nil
} }

View File

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

View File

@ -25,6 +25,7 @@ type GetUserResponse struct {
Members []PartialMember `json:"members"` Members []PartialMember `json:"members"`
Fields []db.Field `json:"fields"` Fields []db.Field `json:"fields"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"` CustomPreferences db.CustomPreferences `json:"custom_preferences"`
Flags []db.UserFlag `json:"flags"`
} }
type GetMeResponse struct { type GetMeResponse struct {
@ -61,7 +62,7 @@ type PartialMember struct {
Pronouns []db.PronounEntry `json:"pronouns"` 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{ resp := GetUserResponse{
ID: u.ID, ID: u.ID,
Username: u.Username, Username: u.Username,
@ -74,6 +75,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser
Pronouns: db.NotNull(u.Pronouns), Pronouns: db.NotNull(u.Pronouns),
Fields: db.NotNull(fields), Fields: db.NotNull(fields),
CustomPreferences: u.CustomPreferences, CustomPreferences: u.CustomPreferences,
Flags: flags,
} }
resp.Members = make([]PartialMember, len(members)) resp.Members = make([]PartialMember, len(members))
@ -93,56 +95,29 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser
return resp 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() ctx := r.Context()
userRef := chi.URLParamFromCtx(ctx, "userRef") userRef := chi.URLParamFromCtx(ctx, "userRef")
var u db.User
if id, err := xid.FromString(userRef); err == nil { if id, err := xid.FromString(userRef); err == nil {
u, err := s.DB.User(ctx, id) u, err = s.DB.User(ctx, id)
if err == nil { if err != nil {
if u.DeletedAt != nil { log.Errorf("getting user by ID: %v", err)
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
} }
// otherwise, we fall back to checking usernames
} }
u, err := s.DB.Username(ctx, userRef) if u.ID.IsNil() {
if err == db.ErrUserNotFound { u, err = s.DB.Username(ctx, userRef)
return server.APIError{ if err == db.ErrUserNotFound {
Code: server.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 { if u.DeletedAt != nil {
@ -160,6 +135,12 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
return err 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 var members []db.Member
if !u.ListPrivate || isSelf { if !u.ListPrivate || isSelf {
members, err = s.DB.UserMembers(ctx, u.ID, 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 return nil
} }
@ -195,8 +176,14 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
return err 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{ render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, members), GetUserResponse: dbUserToResponse(u, fields, members, flags),
CreatedAt: u.ID.Time(), CreatedAt: u.ID.Time(),
MaxInvites: u.MaxInvites, MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin, IsAdmin: u.IsAdmin,

View File

@ -11,6 +11,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/xid"
) )
type PatchUserRequest struct { type PatchUserRequest struct {
@ -25,6 +26,7 @@ type PatchUserRequest struct {
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
ListPrivate *bool `json:"list_private"` ListPrivate *bool `json:"list_private"`
CustomPreferences *db.CustomPreferences `json:"custom_preferences"` CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
Flags *[]xid.ID `json:"flags"`
} }
// patchUser parses a PatchUserRequest and updates the user with the given ID. // 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.Names == nil &&
req.Pronouns == nil && req.Pronouns == nil &&
req.Avatar == nil && req.Avatar == nil &&
req.CustomPreferences == nil { req.CustomPreferences == nil &&
req.Flags == nil {
return server.APIError{ return server.APIError{
Code: server.ErrBadRequest, Code: server.ErrBadRequest,
Details: "Data must not be empty", 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 // validate custom preferences
if req.CustomPreferences != nil { if req.CustomPreferences != nil {
if count := len(*req.CustomPreferences); count > db.MaxFields { 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 // update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil { 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 // echo the updated user back on success
render.JSON(w, r, GetMeResponse{ render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, nil), GetUserResponse: dbUserToResponse(u, fields, nil, flags),
MaxInvites: u.MaxInvites, MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin, IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate, ListPrivate: u.ListPrivate,

View File

@ -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/start", server.WrapHandler(s.startExport))
r.Get("/@me/export", server.WrapHandler(s.getExport)) 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))
}) })
}) })
} }

View File

@ -102,6 +102,7 @@ const (
// User-related error codes // User-related error codes
ErrUserNotFound = 2001 ErrUserNotFound = 2001
ErrMemberListPrivate = 2002 ErrMemberListPrivate = 2002
ErrFlagLimitReached = 2003
// Member-related error codes // Member-related error codes
ErrMemberNotFound = 3001 ErrMemberNotFound = 3001
@ -145,7 +146,8 @@ var errCodeMessages = map[int]string{
ErrInvalidCaptcha: "Invalid or missing captcha response", ErrInvalidCaptcha: "Invalid or missing captcha response",
ErrUserNotFound: "User not found", 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", ErrMemberNotFound: "Member not found",
ErrMemberLimitReached: "Member limit reached", ErrMemberLimitReached: "Member limit reached",
@ -187,6 +189,7 @@ var errCodeStatuses = map[int]int{
ErrUserNotFound: http.StatusNotFound, ErrUserNotFound: http.StatusNotFound,
ErrMemberListPrivate: http.StatusForbidden, ErrMemberListPrivate: http.StatusForbidden,
ErrFlagLimitReached: http.StatusBadRequest,
ErrMemberNotFound: http.StatusNotFound, ErrMemberNotFound: http.StatusNotFound,
ErrMemberLimitReached: http.StatusBadRequest, ErrMemberLimitReached: http.StatusBadRequest,

View File

@ -17,6 +17,7 @@ export interface User {
pronouns: Pronoun[]; pronouns: Pronoun[];
members: PartialMember[]; members: PartialMember[];
fields: Field[]; fields: Field[];
flags: PrideFlag[];
custom_preferences: CustomPreferences; custom_preferences: CustomPreferences;
} }
@ -83,6 +84,7 @@ export interface PartialMember {
export interface Member extends PartialMember { export interface Member extends PartialMember {
fields: Field[]; fields: Field[];
flags: PrideFlag[];
user: MemberPartialUser; user: MemberPartialUser;
unlisted?: boolean; unlisted?: boolean;
@ -96,6 +98,13 @@ export interface MemberPartialUser {
custom_preferences: CustomPreferences; custom_preferences: CustomPreferences;
} }
export interface PrideFlag {
id: string;
hash: string;
name: string;
description: string | null;
}
export interface Invite { export interface Invite {
code: string; code: string;
created: 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 = [ export const defaultAvatars = [
`${PUBLIC_BASE_URL}/default/512.webp`, `${PUBLIC_BASE_URL}/default/512.webp`,
`${PUBLIC_BASE_URL}/default/512.jpg`, `${PUBLIC_BASE_URL}/default/512.jpg`,

View File

@ -40,6 +40,7 @@
import StatusLine from "$lib/components/StatusLine.svelte"; import StatusLine from "$lib/components/StatusLine.svelte";
import defaultPreferences from "$lib/api/default_preferences"; import defaultPreferences from "$lib/api/default_preferences";
import { addToast } from "$lib/toast"; import { addToast } from "$lib/toast";
import ProfileFlag from "./ProfileFlag.svelte";
export let data: PageData; export let data: PageData;
@ -117,16 +118,16 @@
onMount(async () => { onMount(async () => {
if ($userStore && $userStore.id === data.id) { if ($userStore && $userStore.id === data.id) {
console.log("User is current user, fetching members") console.log("User is current user, fetching members");
try { try {
const members = await apiFetchClient<PartialMember[]>("/users/@me/members"); const members = await apiFetchClient<PartialMember[]>("/users/@me/members");
data.members = members; data.members = members;
} catch (e) { } catch (e) {
// If it fails, we fail silently but log to console anyway // If it fails, we fail silently but log to console anyway
console.error("Fetching members:", e) console.error("Fetching members:", e);
} }
} }
}) });
</script> </script>
<div class="container"> <div class="container">
@ -140,6 +141,13 @@
<div class="row"> <div class="row">
<div class="col-md-4 text-center"> <div class="col-md-4 text-center">
<FallbackImage width={200} urls={userAvatars(data)} alt="Avatar for @{data.name}" /> <FallbackImage width={200} urls={userAvatars(data)} alt="Avatar for @{data.name}" />
{#if data.flags && data.bio}
<div class="d-flex flex-wrap m-4">
{#each data.flags as flag}
<ProfileFlag {flag} />
{/each}
</div>
{/if}
</div> </div>
<div class="col-md"> <div class="col-md">
{#if data.display_name} {#if data.display_name}
@ -174,6 +182,13 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if data.flags && !data.bio}
<div class="d-flex flex-wrap m-4">
{#each data.flags as flag}
<ProfileFlag {flag} />
{/each}
</div>
{/if}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3"> <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#if data.names.length > 0} {#if data.names.length > 0}
<div class="col-md"> <div class="col-md">

View File

@ -0,0 +1,22 @@
<script lang="ts">
import { flagURL, type PrideFlag } from "$lib/api/entities";
import { Tooltip } from "sveltestrap";
export let flag: PrideFlag;
let elem: HTMLElement;
</script>
<span class="mx-2 my-1">
<Tooltip target={elem} aria-hidden placement="top">{flag.description ?? flag.name}</Tooltip>
<img bind:this={elem} class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} />
{flag.name}
</span>
<style>
.flag {
height: 1.5rem;
max-width: 200px;
border-radius: 3px;
}
</style>

View File

@ -20,6 +20,7 @@
import StatusLine from "$lib/components/StatusLine.svelte"; import StatusLine from "$lib/components/StatusLine.svelte";
import defaultPreferences from "$lib/api/default_preferences"; import defaultPreferences from "$lib/api/default_preferences";
import { addToast } from "$lib/toast"; import { addToast } from "$lib/toast";
import ProfileFlag from "../ProfileFlag.svelte";
export let data: PageData; export let data: PageData;
@ -69,6 +70,13 @@
<div class="row"> <div class="row">
<div class="col-md-4 text-center"> <div class="col-md-4 text-center">
<FallbackImage width={200} urls={memberAvatars(data)} alt="Avatar for @{data.name}" /> <FallbackImage width={200} urls={memberAvatars(data)} alt="Avatar for @{data.name}" />
{#if data.flags && data.bio}
<div class="d-flex flex-wrap m-4">
{#each data.flags as flag}
<ProfileFlag {flag} />
{/each}
</div>
{/if}
</div> </div>
<div class="col-md"> <div class="col-md">
<h2>{data.display_name ?? data.name}</h2> <h2>{data.display_name ?? data.name}</h2>
@ -97,6 +105,13 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if data.flags && !data.bio}
<div class="d-flex flex-wrap m-4">
{#each data.flags as flag}
<ProfileFlag {flag} />
{/each}
</div>
{/if}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3"> <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#if data.names.length > 0} {#if data.names.length > 0}
<div class="col-md"> <div class="col-md">

View File

@ -0,0 +1,26 @@
<script lang="ts">
import { flagURL, type PrideFlag } from "$lib/api/entities";
import { Button, Tooltip } from "sveltestrap";
export let flag: PrideFlag;
export let tooltip: string;
let className: string | null | undefined = undefined;
export { className as class };
let elem: HTMLElement;
</script>
<Tooltip target={elem} placement="top">{tooltip}</Tooltip>
<Button bind:inner={elem} class={className} on:click color="secondary" outline>
<img class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} />
{flag.name}
</Button>
<style>
.flag {
height: 1.5rem;
max-width: 200px;
border-radius: 3px;
margin-left: -5px;
}
</style>

View File

@ -8,6 +8,7 @@
type FieldEntry, type FieldEntry,
type Member, type Member,
type Pronoun, type Pronoun,
type PrideFlag,
} from "$lib/api/entities"; } from "$lib/api/entities";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import { import {
@ -40,6 +41,7 @@
import { memberNameRegex } from "$lib/api/regex"; import { memberNameRegex } from "$lib/api/regex";
import { charCount, renderMarkdown } from "$lib/utils"; import { charCount, renderMarkdown } from "$lib/utils";
import MarkdownHelp from "../../MarkdownHelp.svelte"; import MarkdownHelp from "../../MarkdownHelp.svelte";
import FlagButton from "../../FlagButton.svelte";
const MAX_AVATAR_BYTES = 1_000_000; const MAX_AVATAR_BYTES = 1_000_000;
@ -59,6 +61,7 @@
let names: FieldEntry[] = window.structuredClone(data.member.names); let names: FieldEntry[] = window.structuredClone(data.member.names);
let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns); let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns);
let fields: Field[] = window.structuredClone(data.member.fields); let fields: Field[] = window.structuredClone(data.member.fields);
let flags: PrideFlag[] = window.structuredClone(data.member.flags);
let unlisted: boolean = data.member.unlisted || false; let unlisted: boolean = data.member.unlisted || false;
let memberNameValid = true; let memberNameValid = true;
@ -71,6 +74,18 @@
let newPronouns = ""; let newPronouns = "";
let newLink = ""; 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; let modified = false;
$: modified = isModified( $: modified = isModified(
@ -82,6 +97,7 @@
names, names,
pronouns, pronouns,
fields, fields,
flags,
avatar, avatar,
unlisted, unlisted,
); );
@ -96,6 +112,7 @@
names: FieldEntry[], names: FieldEntry[],
pronouns: Pronoun[], pronouns: Pronoun[],
fields: Field[], fields: Field[],
flags: PrideFlag[],
avatar: string | null, avatar: string | null,
unlisted: boolean, unlisted: boolean,
) => { ) => {
@ -104,6 +121,7 @@
if (display_name !== member.display_name) return true; if (display_name !== member.display_name) return true;
if (!linksEqual(links, member.links)) return true; if (!linksEqual(links, member.links)) return true;
if (!fieldsEqual(fields, member.fields)) return true; if (!fieldsEqual(fields, member.fields)) return true;
if (!flagsEqual(flags, member.flags)) return true;
if (!namesEqual(names, member.names)) return true; if (!namesEqual(names, member.names)) return true;
if (!pronounsEqual(pronouns, member.pronouns)) return true; if (!pronounsEqual(pronouns, member.pronouns)) return true;
if (avatar !== null) return true; if (avatar !== null) return true;
@ -147,6 +165,11 @@
return arr1.every((_, i) => arr1[i] === arr2[i]); 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) => { const getAvatar = async (list: FileList | null) => {
if (!list || list.length === 0) return null; if (!list || list.length === 0) return null;
if (list[0].size > MAX_AVATAR_BYTES) { if (list[0].size > MAX_AVATAR_BYTES) {
@ -211,6 +234,26 @@
links[newIndex] = temp; 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) => { const addName = (event: Event) => {
event.preventDefault(); event.preventDefault();
@ -281,6 +324,7 @@
names, names,
pronouns, pronouns,
fields, fields,
flags: flags.map((flag) => flag.id),
unlisted, unlisted,
}); });
@ -541,6 +585,72 @@
</Button> </Button>
</div> </div>
</TabPane> </TabPane>
<TabPane tabId="flags" tab="Flags">
<div class="mt-3">
{#each flags as _, index}
<ButtonGroup class="m-1">
<IconButton
icon="chevron-left"
color="secondary"
tooltip="Move flag to the left"
click={() => moveFlag(index, true)}
/>
<IconButton
icon="chevron-right"
color="secondary"
tooltip="Move flag to the right"
click={() => moveFlag(index, false)}
/>
<FlagButton
flag={flags[index]}
tooltip="Remove this flag from your profile"
on:click={() => removeFlag(index)}
/>
</ButtonGroup>
{/each}
</div>
<hr />
<div class="row">
<div class="col-md">
<Input
placeholder="Filter flags"
bind:value={flagSearch}
disabled={data.flags.length === 0}
/>
<div class="p-2">
{#each filteredFlags as flag (flag.id)}
<FlagButton
{flag}
tooltip="Add this flag to your profile"
on:click={() => addFlag(flag)}
/>
{:else}
{#if data.flags.length === 0}
You haven't uploaded any flags yet.
{:else}
There are no flags matching your search <strong>{flagSearch}</strong>.
{/if}
{/each}
</div>
</div>
<div class="col-md">
<Alert color="secondary" fade={false}>
{#if data.flags.length === 0}
<p><strong>Why can't I see any flags?</strong></p>
<p>
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 <a href="/settings/flags">settings</a>. Your main profile
and your member profiles can all have different flags.
</p>
{:else}
To upload and delete flags, go to your <a href="/settings/flags">settings</a>.
{/if}
</Alert>
</div>
</div>
</TabPane>
<TabPane tabId="links" tab="Links"> <TabPane tabId="links" tab="Links">
<div class="mt-3"> <div class="mt-3">
{#each links as _, index} {#each links as _, index}

View File

@ -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 { apiFetchClient } from "$lib/api/fetch";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
@ -11,11 +11,13 @@ export const load = async ({ params }) => {
try { try {
const user = await apiFetchClient<MeUser>(`/users/@me`); const user = await apiFetchClient<MeUser>(`/users/@me`);
const member = await apiFetchClient<Member>(`/members/${params.id}`); const member = await apiFetchClient<Member>(`/members/${params.id}`);
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
return { return {
user, user,
member, member,
pronouns: pronouns.autocomplete, pronouns: pronouns.autocomplete,
flags,
}; };
} catch (e) { } catch (e) {
throw error((e as APIError).code, (e as APIError).message); throw error((e as APIError).code, (e as APIError).message);

View File

@ -9,6 +9,7 @@
type Pronoun, type Pronoun,
PreferenceSize, PreferenceSize,
type CustomPreferences, type CustomPreferences,
type PrideFlag,
} from "$lib/api/entities"; } from "$lib/api/entities";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import { userStore } from "$lib/store"; import { userStore } from "$lib/store";
@ -39,6 +40,7 @@
import MarkdownHelp from "../MarkdownHelp.svelte"; import MarkdownHelp from "../MarkdownHelp.svelte";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import CustomPreference from "./CustomPreference.svelte"; import CustomPreference from "./CustomPreference.svelte";
import FlagButton from "../FlagButton.svelte";
const MAX_AVATAR_BYTES = 1_000_000; const MAX_AVATAR_BYTES = 1_000_000;
@ -53,6 +55,7 @@
let names: FieldEntry[] = window.structuredClone(data.user.names); let names: FieldEntry[] = window.structuredClone(data.user.names);
let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns); let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns);
let fields: Field[] = window.structuredClone(data.user.fields); let fields: Field[] = window.structuredClone(data.user.fields);
let flags: PrideFlag[] = window.structuredClone(data.user.flags);
let list_private = data.user.list_private; let list_private = data.user.list_private;
let custom_preferences = window.structuredClone(data.user.custom_preferences); let custom_preferences = window.structuredClone(data.user.custom_preferences);
@ -63,6 +66,18 @@
let newPronouns = ""; let newPronouns = "";
let newLink = ""; 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[]; let preferenceIds: string[];
$: preferenceIds = Object.keys(custom_preferences); $: preferenceIds = Object.keys(custom_preferences);
@ -76,6 +91,7 @@
names, names,
pronouns, pronouns,
fields, fields,
flags,
avatar, avatar,
member_title, member_title,
list_private, list_private,
@ -91,6 +107,7 @@
names: FieldEntry[], names: FieldEntry[],
pronouns: Pronoun[], pronouns: Pronoun[],
fields: Field[], fields: Field[],
flags: PrideFlag[],
avatar: string | null, avatar: string | null,
member_title: string, member_title: string,
list_private: boolean, list_private: boolean,
@ -101,6 +118,7 @@
if (member_title !== (user.member_title || "")) return true; if (member_title !== (user.member_title || "")) return true;
if (!linksEqual(links, user.links)) return true; if (!linksEqual(links, user.links)) return true;
if (!fieldsEqual(fields, user.fields)) return true; if (!fieldsEqual(fields, user.fields)) return true;
if (!flagsEqual(flags, user.flags)) return true;
if (!namesEqual(names, user.names)) return true; if (!namesEqual(names, user.names)) return true;
if (!pronounsEqual(pronouns, user.pronouns)) return true; if (!pronounsEqual(pronouns, user.pronouns)) return true;
if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true; if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true;
@ -145,6 +163,11 @@
return arr1.every((_, i) => arr1[i] === arr2[i]); 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) => { const customPreferencesEqual = (obj1: CustomPreferences, obj2: CustomPreferences) => {
if (Object.keys(obj2).some((key) => !(key in obj1))) return false; if (Object.keys(obj2).some((key) => !(key in obj1))) return false;
@ -227,6 +250,26 @@
links[newIndex] = temp; 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) => { const addName = (event: Event) => {
event.preventDefault(); event.preventDefault();
@ -317,6 +360,7 @@
member_title, member_title,
list_private, list_private,
custom_preferences, custom_preferences,
flags: flags.map((flag) => flag.id),
}); });
data.user = resp; data.user = resp;
@ -516,6 +560,72 @@
</Button> </Button>
</div> </div>
</TabPane> </TabPane>
<TabPane tabId="flags" tab="Flags">
<div class="mt-3">
{#each flags as _, index}
<ButtonGroup class="m-1">
<IconButton
icon="chevron-left"
color="secondary"
tooltip="Move flag to the left"
click={() => moveFlag(index, true)}
/>
<IconButton
icon="chevron-right"
color="secondary"
tooltip="Move flag to the right"
click={() => moveFlag(index, false)}
/>
<FlagButton
flag={flags[index]}
tooltip="Remove this flag from your profile"
on:click={() => removeFlag(index)}
/>
</ButtonGroup>
{/each}
</div>
<hr />
<div class="row">
<div class="col-md">
<Input
placeholder="Filter flags"
bind:value={flagSearch}
disabled={data.flags.length === 0}
/>
<div class="p-2">
{#each filteredFlags as flag (flag.id)}
<FlagButton
{flag}
tooltip="Add this flag to your profile"
on:click={() => addFlag(flag)}
/>
{:else}
{#if data.flags.length === 0}
You haven't uploaded any flags yet.
{:else}
There are no flags matching your search <strong>{flagSearch}</strong>.
{/if}
{/each}
</div>
</div>
<div class="col-md">
<Alert color="secondary" fade={false}>
{#if data.flags.length === 0}
<p><strong>Why can't I see any flags?</strong></p>
<p>
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 <a href="/settings/flags">settings</a>. Your main profile
and your member profiles can all have different flags.
</p>
{:else}
To upload and delete flags, go to your <a href="/settings/flags">settings</a>.
{/if}
</Alert>
</div>
</div>
</TabPane>
<TabPane tabId="links" tab="Links"> <TabPane tabId="links" tab="Links">
<div class="mt-3"> <div class="mt-3">
{#each links as _, index} {#each links as _, index}

View File

@ -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 { apiFetchClient } from "$lib/api/fetch";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
@ -10,10 +10,12 @@ export const ssr = false;
export const load = async () => { export const load = async () => {
try { try {
const user = await apiFetchClient<MeUser>(`/users/@me`); const user = await apiFetchClient<MeUser>(`/users/@me`);
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
return { return {
user, user,
pronouns: pronouns.autocomplete, pronouns: pronouns.autocomplete,
flags,
}; };
} catch (e) { } catch (e) {
throw error((e as APIError).code, (e as APIError).message); throw error((e as APIError).code, (e as APIError).message);

View File

@ -42,20 +42,13 @@
<div class="grid"> <div class="grid">
<div class="row"> <div class="row">
<div class="col-md-3 m-3"> <div class="col-md-3 p-3">
<h1>Settings</h1> <h1>Settings</h1>
<ListGroup> <ListGroup>
<ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings"> <ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings">
Your profile Your profile
</ListGroupItem> </ListGroupItem>
<ListGroupItem
tag="a"
active={$page.url.pathname === "/settings/auth"}
href="/settings/auth"
>
Authentication
</ListGroupItem>
{#if hasHiddenMembers} {#if hasHiddenMembers}
<ListGroupItem <ListGroupItem
tag="a" tag="a"
@ -65,6 +58,14 @@
Hidden members Hidden members
</ListGroupItem> </ListGroupItem>
{/if} {/if}
<ListGroupItem
tag="a"
active={$page.url.pathname === "/settings/flags"}
href="/settings/flags">Flags</ListGroupItem
>
</ListGroup>
<br />
<ListGroup>
{#if data.invitesEnabled} {#if data.invitesEnabled}
<ListGroupItem <ListGroupItem
tag="a" tag="a"
@ -101,7 +102,7 @@
<ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem> <ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem>
</ListGroup> </ListGroup>
</div> </div>
<div class="col-md m-3"> <div class="col-md p-3">
<slot /> <slot />
</div> </div>
</div> </div>

View File

@ -155,8 +155,9 @@
{/if} {/if}
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" /> <p class="text-center">
<p> <FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
<br />
To change your avatar, go to <a href="/edit/profile">edit profile</a>. To change your avatar, go to <a href="/edit/profile">edit profile</a>.
</p> </p>
</div> </div>

View File

@ -0,0 +1,180 @@
<script lang="ts">
import type { APIError, PrideFlag } from "$lib/api/entities";
import { Button, Icon, Input, Modal, ModalBody, ModalFooter, ModalHeader } from "sveltestrap";
import type { PageData } from "./$types";
import Flag from "./Flag.svelte";
import prettyBytes from "pretty-bytes";
import { addToast } from "$lib/toast";
import { encode } from "base64-arraybuffer";
import unknownFlag from "./unknown_flag.png";
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
const MAX_FLAG_BYTES = 500_000;
export let data: PageData;
let search = "";
let error: APIError | null = null;
let filtered: PrideFlag[];
$: filtered = filterFlags(search, data.flags);
const filterFlags = (search: string, flags: PrideFlag[]) => {
return search
? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
: flags;
};
// NEW FLAG UPLOADING CODE
let modalOpen = false;
const toggleModal = () => (modalOpen = !modalOpen);
let canUpload: boolean;
$: canUpload = !!(newFlag && newName);
let newFlag: string | null;
let flagFiles: FileList | null;
$: getFlag(flagFiles).then((b64) => (newFlag = b64));
let newName = "";
let newDescription = "";
const getFlag = async (list: FileList | null) => {
if (!list || list.length === 0) return null;
if (list[0].size > MAX_FLAG_BYTES) {
addToast({
header: "Flag too large",
body: `This flag file is too large, please resize it (maximum is ${prettyBytes(
MAX_FLAG_BYTES,
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
});
return null;
}
const buffer = await list[0].arrayBuffer();
const base64 = encode(buffer);
const uri = `data:${list[0].type};base64,${base64}`;
return uri;
};
const uploadFlag = async () => {
try {
const resp = await apiFetchClient<PrideFlag>("/users/@me/flags", "POST", {
flag: newFlag,
name: newName,
description: newDescription || null,
});
error = null;
data.flags.push(resp);
data.flags.sort((a, b) => a.name.localeCompare(b.name));
data.flags = [...data.flags];
// reset flag
newFlag = null;
newName = "";
newDescription = "";
addToast({ header: "Uploaded flag", body: "Successfully uploaded flag!" });
toggleModal();
} catch (e) {
error = e as APIError;
}
};
// DELETE FLAG CODE
const deleteFlag = async (id: string) => {
try {
await fastFetchClient(`/users/@me/flags/${id}`, "DELETE");
error = null;
addToast({ header: "Deleted flag", body: "Successfully deleted flag!" });
data.flags = data.flags.filter((entry) => entry.id !== id);
} catch (e) {
error = e as APIError;
}
};
</script>
<h1>Pride flags ({data.flags.length})</h1>
<p>
You can upload pride flags to use on your profiles here. Flags you upload here will <em>not</em> automatically
show up on your profile.
</p>
<div class="input-group">
<Input placeholder="Filter flags" bind:value={search} disabled={data.flags.length === 0} />
<Button color="success" on:click={toggleModal}>
<Icon name="upload" aria-hidden /> Upload flag
</Button>
</div>
<div class="p-2">
{#each filtered as flag (flag.id)}
<Flag bind:flag {deleteFlag} />
{: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 <strong>{search}</strong>.
{/if}
{/each}
</div>
<Modal isOpen={modalOpen} toggle={toggleModal}>
<ModalHeader toggle={toggleModal}>Upload flag</ModalHeader>
<ModalBody>
{#if error}
<ErrorAlert {error} />
{/if}
<div class="d-flex align-items-center">
<img src={newFlag || unknownFlag} alt="New flag" class="flag m-1" />
<input
class="form-control"
id="flag-file"
type="file"
bind:files={flagFiles}
accept="image/png, image/jpeg, image/gif, image/webp, image/svg+xml"
/>
</div>
<p class="text-muted mt-2">
<Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be uploaded
as flags. The file cannot be larger than 512 kilobytes.
</p>
<p>
<label for="newName" class="form-label">Name</label>
<Input id="newName" bind:value={newName} />
</p>
<p class="text-muted">
<Icon name="info-circle-fill" aria-hidden /> This name will be shown beside the flag.
</p>
<p>
<label for="description" class="form-label">Description</label>
<textarea id="description" class="form-control" bind:value={newDescription} />
</p>
<p class="text-muted">
<Icon name="info-circle-fill" aria-hidden /> This text will be used as the alt text of the flag
image, and will also be shown on hover. Optional, but <strong>strongly recommended</strong> as
it improves accessibility.
</p>
</ModalBody>
<ModalFooter>
<Button disabled={!canUpload} color="success" on:click={() => uploadFlag()}>Upload flag</Button>
</ModalFooter>
</Modal>
<style>
.flag {
height: 2rem;
max-width: 200px;
border-radius: 3px;
}
textarea {
height: 100px;
}
</style>

View File

@ -0,0 +1,7 @@
import { apiFetchClient } from "$lib/api/fetch";
import type { PrideFlag } from "$lib/api/entities";
export const load = async () => {
const data = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
return { flags: data };
};

View File

@ -0,0 +1,84 @@
<script lang="ts">
import { flagURL, type APIError, type PrideFlag } from "$lib/api/entities";
import { apiFetchClient } from "$lib/api/fetch";
import { addToast } from "$lib/toast";
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader } from "sveltestrap";
export let flag: PrideFlag;
export let deleteFlag: (id: string) => Promise<void>;
let error: APIError | null = null;
let modalOpen = false;
const toggleModal = () => (modalOpen = !modalOpen);
let deleteModalOpen = false;
const toggleDeleteModal = () => (deleteModalOpen = !deleteModalOpen);
let name = flag.name;
let description = flag.description;
const updateFlag = async () => {
try {
const resp = await apiFetchClient<PrideFlag>(`/users/@me/flags/${flag.id}`, "PATCH", {
name,
description: description || null,
});
error = null;
flag = resp;
addToast({ header: "Updated flag", body: "Successfully updated flag!" });
toggleModal();
} catch (e) {
error = e as APIError;
}
};
</script>
<Button outline class="m-1" on:click={toggleModal}>
<img class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} />
{flag.name}
</Button>
<Modal isOpen={modalOpen} toggle={toggleModal}>
<ModalHeader toggle={toggleModal}>Edit {flag.name} flag</ModalHeader>
<ModalBody>
<p>
<label for="name" class="form-label">Name</label>
<Input id="name" bind:value={name} />
</p>
<p>
<label for="description" class="form-label">Description</label>
<textarea id="description" class="form-control" bind:value={description} />
</p>
</ModalBody>
<ModalFooter>
<Button color="danger" on:click={toggleDeleteModal}>Delete flag</Button>
<Button disabled={!name} color="success" on:click={() => updateFlag()}>Edit flag</Button>
</ModalFooter>
</Modal>
<Modal isOpen={deleteModalOpen} toggle={toggleDeleteModal}>
<ModalHeader toggle={toggleDeleteModal}>Delete {flag.name} flag</ModalHeader>
<ModalBody>
Are you sure you want to delete the {flag.name} flag? <strong>This cannot be undone!</strong>
</ModalBody>
<ModalFooter>
<Button color="danger" on:click={() => deleteFlag(flag.id)}>Delete flag</Button>
<Button color="secondary" on:click={toggleDeleteModal}>Cancel</Button>
</ModalFooter>
</Modal>
<style>
.flag {
height: 2rem;
max-width: 200px;
border-radius: 3px;
margin-left: -5px;
}
textarea {
height: 100px;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -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
);