feat: GET /users/@me/flags, POST /users/@me/flags
This commit is contained in:
		
							parent
							
								
									7435604dab
								
							
						
					
					
						commit
						c69c777fc8
					
				
					 5 changed files with 380 additions and 1 deletions
				
			
		
							
								
								
									
										226
									
								
								backend/db/flags.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								backend/db/flags.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,226 @@ | |||
| package db | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"crypto/sha256" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/hex" | ||||
| 	"io" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"emperror.dev/errors" | ||||
| 	"github.com/davidbyttow/govips/v2/vips" | ||||
| 	"github.com/georgysavva/scany/v2/pgxscan" | ||||
| 	"github.com/jackc/pgx/v5" | ||||
| 	"github.com/minio/minio-go/v7" | ||||
| 	"github.com/rs/xid" | ||||
| ) | ||||
| 
 | ||||
| type PrideFlag struct { | ||||
| 	ID          xid.ID  `json:"id"` | ||||
| 	UserID      xid.ID  `json:"-"` | ||||
| 	Hash        string  `json:"hash"` | ||||
| 	Name        string  `json:"name"` | ||||
| 	Description *string `json:"description"` | ||||
| } | ||||
| 
 | ||||
| type UserFlag struct { | ||||
| 	ID     int64  `json:"-"` | ||||
| 	UserID xid.ID `json:"-"` | ||||
| 	FlagID xid.ID `json:"id"` | ||||
| 
 | ||||
| 	Hash        string  `json:"hash"` | ||||
| 	Name        string  `json:"name"` | ||||
| 	Description *string `json:"description"` | ||||
| } | ||||
| 
 | ||||
| type MemberFlag struct { | ||||
| 	ID       int64  `json:"-"` | ||||
| 	MemberID xid.ID `json:"-"` | ||||
| 	FlagID   xid.ID `json:"id"` | ||||
| 
 | ||||
| 	Hash        string  `json:"hash"` | ||||
| 	Name        string  `json:"name"` | ||||
| 	Description *string `json:"description"` | ||||
| } | ||||
| 
 | ||||
| const ( | ||||
| 	MaxPrideFlags           = 100 | ||||
| 	MaxPrideFlagTitleLength = 100 | ||||
| 	MaxPrideFlagDescLength  = 200 | ||||
| ) | ||||
| 
 | ||||
| func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) { | ||||
| 	sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("id").ToSql() | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "building query") | ||||
| 	} | ||||
| 
 | ||||
| 	err = pgxscan.Select(ctx, db, &fs, sql, args...) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 	return NotNull(fs), nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) { | ||||
| 	sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description"). | ||||
| 		From("user_flags AS u"). | ||||
| 		Where("u.user_id = $1"). | ||||
| 		Join("pride_flags AS f ON u.flag_id = f.id"). | ||||
| 		OrderBy("u.id ASC"). | ||||
| 		ToSql() | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "building query") | ||||
| 	} | ||||
| 
 | ||||
| 	err = pgxscan.Select(ctx, db, &fs, sql, args...) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 	return NotNull(fs), nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) MemberFlags(ctx context.Context, userID xid.ID) (fs []MemberFlag, err error) { | ||||
| 	sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description"). | ||||
| 		From("member_flags AS m"). | ||||
| 		Where("m.member_id = $1"). | ||||
| 		Join("pride_flags AS f ON m.flag_id = f.id"). | ||||
| 		OrderBy("m.id ASC"). | ||||
| 		ToSql() | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "building query") | ||||
| 	} | ||||
| 
 | ||||
| 	err = pgxscan.Select(ctx, db, &fs, sql, args...) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 	return NotNull(fs), nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) { | ||||
| 	description := &desc | ||||
| 	if desc == "" { | ||||
| 		description = nil | ||||
| 	} | ||||
| 
 | ||||
| 	sql, args, err := sq.Insert("pride_flags"). | ||||
| 		SetMap(map[string]any{ | ||||
| 			"id":          xid.New(), | ||||
| 			"hash":        "", | ||||
| 			"user_id":     userID.String(), | ||||
| 			"name":        name, | ||||
| 			"description": description, | ||||
| 		}).Suffix("RETURNING *").ToSql() | ||||
| 	if err != nil { | ||||
| 		return f, errors.Wrap(err, "building query") | ||||
| 	} | ||||
| 
 | ||||
| 	err = pgxscan.Get(ctx, tx, &f, sql, args...) | ||||
| 	if err != nil { | ||||
| 		return f, errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 	return f, nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) EditFlag(ctx context.Context, tx pgx.Tx, flagID xid.ID, name, desc, hash *string) (f PrideFlag, err error) { | ||||
| 	b := sq.Update("pride_flags"). | ||||
| 		Where("id = ?", flagID) | ||||
| 	if name != nil { | ||||
| 		b = b.Set("name", *name) | ||||
| 	} | ||||
| 	if desc != nil { | ||||
| 		if *desc == "" { | ||||
| 			b = b.Set("description", nil) | ||||
| 		} else { | ||||
| 			b = b.Set("description", *desc) | ||||
| 		} | ||||
| 	} | ||||
| 	if hash != nil { | ||||
| 		b = b.Set("hash", *hash) | ||||
| 	} | ||||
| 
 | ||||
| 	sql, args, err := b.Suffix("RETURNING *").ToSql() | ||||
| 	if err != nil { | ||||
| 		return f, errors.Wrap(err, "building sql") | ||||
| 	} | ||||
| 
 | ||||
| 	err = pgxscan.Get(ctx, tx, &f, sql, args...) | ||||
| 	if err != nil { | ||||
| 		return f, errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 	return f, nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) (hash string, err error) { | ||||
| 	hasher := sha256.New() | ||||
| 	_, err = hasher.Write(flag.Bytes()) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "hashing flag") | ||||
| 	} | ||||
| 	hash = hex.EncodeToString(hasher.Sum(nil)) | ||||
| 
 | ||||
| 	_, err = db.minio.PutObject(ctx, db.minioBucket, "/flags/"+hash+".webp", flag, -1, minio.PutObjectOptions{ | ||||
| 		ContentType: "image/webp", | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "uploading flag") | ||||
| 	} | ||||
| 
 | ||||
| 	return hash, nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error { | ||||
| 	err := db.minio.RemoveObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "deleting flag") | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.ReadCloser, error) { | ||||
| 	obj, err := db.minio.GetObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "getting object") | ||||
| 	} | ||||
| 	return obj, nil | ||||
| } | ||||
| 
 | ||||
| // ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result. | ||||
| func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) { | ||||
| 	defer vips.ShutdownThread() | ||||
| 
 | ||||
| 	data = strings.TrimSpace(data) | ||||
| 	if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") { | ||||
| 		return nil, ErrInvalidDataURI | ||||
| 	} | ||||
| 	split := strings.Split(data, ",") | ||||
| 
 | ||||
| 	rawData, err := base64.StdEncoding.DecodeString(split[1]) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "invalid base64 data") | ||||
| 	} | ||||
| 
 | ||||
| 	image, err := vips.LoadImageFromBuffer(rawData, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "decoding image") | ||||
| 	} | ||||
| 
 | ||||
| 	err = image.ThumbnailWithSize(256, 256, vips.InterestingNone, vips.SizeBoth) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "resizing image") | ||||
| 	} | ||||
| 
 | ||||
| 	webpExport := vips.NewWebpExportParams() | ||||
| 	webpExport.Lossless = true | ||||
| 	webpB, _, err := image.ExportWebp(webpExport) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "exporting webp image") | ||||
| 	} | ||||
| 	webpOut = bytes.NewBuffer(webpB) | ||||
| 
 | ||||
| 	return webpOut, nil | ||||
| } | ||||
							
								
								
									
										121
									
								
								backend/routes/user/flags.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								backend/routes/user/flags.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,121 @@ | |||
| package user | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/common" | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/db" | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/log" | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/server" | ||||
| 	"emperror.dev/errors" | ||||
| 	"github.com/go-chi/render" | ||||
| ) | ||||
| 
 | ||||
| func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error { | ||||
| 	ctx := r.Context() | ||||
| 	claims, _ := server.ClaimsFromContext(ctx) | ||||
| 
 | ||||
| 	flags, err := s.DB.AccountFlags(ctx, claims.UserID) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrapf(err, "getting flags for account %v", claims.UserID) | ||||
| 	} | ||||
| 
 | ||||
| 	render.JSON(w, r, flags) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type postUserFlagRequest struct { | ||||
| 	Flag        string `json:"flag"` | ||||
| 	Name        string `json:"name"` | ||||
| 	Description string `json:"description"` | ||||
| } | ||||
| 
 | ||||
| func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error { | ||||
| 	ctx := r.Context() | ||||
| 	claims, _ := server.ClaimsFromContext(ctx) | ||||
| 
 | ||||
| 	if !claims.TokenWrite { | ||||
| 		return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} | ||||
| 	} | ||||
| 
 | ||||
| 	flags, err := s.DB.AccountFlags(ctx, claims.UserID) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "getting current user flags") | ||||
| 	} | ||||
| 	if len(flags) >= db.MaxPrideFlags { | ||||
| 		return server.APIError{ | ||||
| 			Code: server.ErrFlagLimitReached, | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	var req postUserFlagRequest | ||||
| 	err = render.Decode(r, &req) | ||||
| 	if err != nil { | ||||
| 		return server.APIError{Code: server.ErrBadRequest} | ||||
| 	} | ||||
| 
 | ||||
| 	// remove whitespace from all fields | ||||
| 	req.Name = strings.TrimSpace(req.Name) | ||||
| 	req.Description = strings.TrimSpace(req.Description) | ||||
| 
 | ||||
| 	if s := common.StringLength(&req.Name); s > db.MaxPrideFlagTitleLength { | ||||
| 		return server.APIError{ | ||||
| 			Code:    server.ErrBadRequest, | ||||
| 			Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s), | ||||
| 		} | ||||
| 	} | ||||
| 	if s := common.StringLength(&req.Description); s > db.MaxPrideFlagDescLength { | ||||
| 		return server.APIError{ | ||||
| 			Code:    server.ErrBadRequest, | ||||
| 			Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s), | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	tx, err := s.DB.Begin(ctx) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "starting transaction") | ||||
| 	} | ||||
| 	defer tx.Rollback(ctx) | ||||
| 
 | ||||
| 	flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("creating flag: %v", err) | ||||
| 		return errors.Wrap(err, "creating flag") | ||||
| 	} | ||||
| 
 | ||||
| 	webp, err := s.DB.ConvertFlag(req.Flag) | ||||
| 	if err != nil { | ||||
| 		if err == db.ErrInvalidDataURI { | ||||
| 			return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"} | ||||
| 		} | ||||
| 		return errors.Wrap(err, "converting flag") | ||||
| 	} | ||||
| 
 | ||||
| 	hash, err := s.DB.WriteFlag(ctx, flag.ID, webp) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "writing flag") | ||||
| 	} | ||||
| 
 | ||||
| 	flag, err = s.DB.EditFlag(ctx, tx, flag.ID, nil, nil, &hash) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "setting hash for flag") | ||||
| 	} | ||||
| 
 | ||||
| 	err = tx.Commit(ctx) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "committing transaction") | ||||
| 	} | ||||
| 
 | ||||
| 	render.JSON(w, r, flag) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error { | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error { | ||||
| 	return nil | ||||
| } | ||||
|  | @ -29,6 +29,11 @@ func Mount(srv *server.Server, r chi.Router) { | |||
| 
 | ||||
| 			r.Get("/@me/export/start", server.WrapHandler(s.startExport)) | ||||
| 			r.Get("/@me/export", server.WrapHandler(s.getExport)) | ||||
| 
 | ||||
| 			r.Get("/@me/flags", server.WrapHandler(s.getUserFlags)) | ||||
| 			r.Post("/@me/flags", server.WrapHandler(s.postUserFlag)) | ||||
| 			r.Patch("/@me/flags", server.WrapHandler(s.patchUserFlag)) | ||||
| 			r.Delete("/@me/flags", server.WrapHandler(s.deleteUserFlag)) | ||||
| 		}) | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
							
								
								
									
										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