merge branch 'feature/flags'
This commit is contained in:
		
						commit
						e993d2a89e
					
				
					 29 changed files with 1322 additions and 73 deletions
				
			
		|  | @ -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) ( | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
							
								
								
									
										322
									
								
								backend/db/flags.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								backend/db/flags.go
									
										
									
									
									
										Normal 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 | ||||
| } | ||||
|  | @ -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 | ||||
| 			} | ||||
| 		} | ||||
|  |  | |||
|  | @ -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 | ||||
| 			} | ||||
| 		} | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
							
								
								
									
										239
									
								
								backend/routes/user/flags.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								backend/routes/user/flags.go
									
										
									
									
									
										Normal 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 | ||||
| } | ||||
|  | @ -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,57 +95,30 @@ 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) | ||||
| 		u, err = s.DB.User(ctx, 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 | ||||
| 			log.Errorf("getting user by ID: %v", 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() { | ||||
| 		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 | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if u.DeletedAt != nil { | ||||
| 		return server.APIError{Code: server.ErrUserNotFound} | ||||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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)) | ||||
| 		}) | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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`, | ||||
|  |  | |||
|  | @ -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<PartialMember[]>("/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); | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
|   }); | ||||
| </script> | ||||
| 
 | ||||
| <div class="container"> | ||||
|  | @ -140,6 +141,13 @@ | |||
|     <div class="row"> | ||||
|       <div class="col-md-4 text-center"> | ||||
|         <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 class="col-md"> | ||||
|         {#if data.display_name} | ||||
|  | @ -174,6 +182,13 @@ | |||
|         </div> | ||||
|       {/if} | ||||
|     </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"> | ||||
|       {#if data.names.length > 0} | ||||
|         <div class="col-md"> | ||||
|  |  | |||
							
								
								
									
										22
									
								
								frontend/src/routes/@[username]/ProfileFlag.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								frontend/src/routes/@[username]/ProfileFlag.svelte
									
										
									
									
									
										Normal 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> | ||||
|  | @ -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 @@ | |||
|     <div class="row"> | ||||
|       <div class="col-md-4 text-center"> | ||||
|         <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 class="col-md"> | ||||
|         <h2>{data.display_name ?? data.name}</h2> | ||||
|  | @ -97,6 +105,13 @@ | |||
|         </div> | ||||
|       {/if} | ||||
|     </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"> | ||||
|       {#if data.names.length > 0} | ||||
|         <div class="col-md"> | ||||
|  |  | |||
							
								
								
									
										26
									
								
								frontend/src/routes/edit/FlagButton.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/routes/edit/FlagButton.svelte
									
										
									
									
									
										Normal 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> | ||||
|  | @ -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 @@ | |||
|       </Button> | ||||
|     </div> | ||||
|   </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"> | ||||
|     <div class="mt-3"> | ||||
|       {#each links as _, index} | ||||
|  |  | |||
|  | @ -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<MeUser>(`/users/@me`); | ||||
|     const member = await apiFetchClient<Member>(`/members/${params.id}`); | ||||
|     const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags"); | ||||
| 
 | ||||
|     return { | ||||
|       user, | ||||
|       member, | ||||
|       pronouns: pronouns.autocomplete, | ||||
|       flags, | ||||
|     }; | ||||
|   } catch (e) { | ||||
|     throw error((e as APIError).code, (e as APIError).message); | ||||
|  |  | |||
|  | @ -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 @@ | |||
|       </Button> | ||||
|     </div> | ||||
|   </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"> | ||||
|     <div class="mt-3"> | ||||
|       {#each links as _, index} | ||||
|  |  | |||
|  | @ -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<MeUser>(`/users/@me`); | ||||
|     const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags"); | ||||
| 
 | ||||
|     return { | ||||
|       user, | ||||
|       pronouns: pronouns.autocomplete, | ||||
|       flags, | ||||
|     }; | ||||
|   } catch (e) { | ||||
|     throw error((e as APIError).code, (e as APIError).message); | ||||
|  |  | |||
|  | @ -42,20 +42,13 @@ | |||
| 
 | ||||
| <div class="grid"> | ||||
|   <div class="row"> | ||||
|     <div class="col-md-3 m-3"> | ||||
|     <div class="col-md-3 p-3"> | ||||
|       <h1>Settings</h1> | ||||
| 
 | ||||
|       <ListGroup> | ||||
|         <ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings"> | ||||
|           Your profile | ||||
|         </ListGroupItem> | ||||
|         <ListGroupItem | ||||
|           tag="a" | ||||
|           active={$page.url.pathname === "/settings/auth"} | ||||
|           href="/settings/auth" | ||||
|         > | ||||
|           Authentication | ||||
|         </ListGroupItem> | ||||
|         {#if hasHiddenMembers} | ||||
|           <ListGroupItem | ||||
|             tag="a" | ||||
|  | @ -65,6 +58,14 @@ | |||
|             Hidden members | ||||
|           </ListGroupItem> | ||||
|         {/if} | ||||
|         <ListGroupItem | ||||
|           tag="a" | ||||
|           active={$page.url.pathname === "/settings/flags"} | ||||
|           href="/settings/flags">Flags</ListGroupItem | ||||
|         > | ||||
|       </ListGroup> | ||||
|       <br /> | ||||
|       <ListGroup> | ||||
|         {#if data.invitesEnabled} | ||||
|           <ListGroupItem | ||||
|             tag="a" | ||||
|  | @ -101,7 +102,7 @@ | |||
|         <ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem> | ||||
|       </ListGroup> | ||||
|     </div> | ||||
|     <div class="col-md m-3"> | ||||
|     <div class="col-md p-3"> | ||||
|       <slot /> | ||||
|     </div> | ||||
|   </div> | ||||
|  |  | |||
|  | @ -155,8 +155,9 @@ | |||
|       {/if} | ||||
|     </div> | ||||
|     <div class="col-lg-4"> | ||||
|       <p class="text-center"> | ||||
|         <FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" /> | ||||
|       <p> | ||||
|         <br /> | ||||
|         To change your avatar, go to <a href="/edit/profile">edit profile</a>. | ||||
|       </p> | ||||
|     </div> | ||||
|  |  | |||
							
								
								
									
										180
									
								
								frontend/src/routes/settings/flags/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								frontend/src/routes/settings/flags/+page.svelte
									
										
									
									
									
										Normal 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> | ||||
							
								
								
									
										7
									
								
								frontend/src/routes/settings/flags/+page.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/routes/settings/flags/+page.ts
									
										
									
									
									
										Normal 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 }; | ||||
| }; | ||||
							
								
								
									
										84
									
								
								frontend/src/routes/settings/flags/Flag.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								frontend/src/routes/settings/flags/Flag.svelte
									
										
									
									
									
										Normal 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> | ||||
							
								
								
									
										
											BIN
										
									
								
								frontend/src/routes/settings/flags/unknown_flag.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/src/routes/settings/flags/unknown_flag.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.7 KiB | 
							
								
								
									
										24
									
								
								scripts/migrate/017_pride_flags.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								scripts/migrate/017_pride_flags.sql
									
										
									
									
									
										Normal 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 | ||||
| ); | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue