From c69c777fc8596e0b83c2c045e662e0126bb5f9a4 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 10 May 2023 00:45:31 +0200 Subject: [PATCH] feat: GET /users/@me/flags, POST /users/@me/flags --- backend/db/flags.go | 226 ++++++++++++++++++++++++++++ backend/routes/user/flags.go | 121 +++++++++++++++ backend/routes/user/routes.go | 5 + backend/server/errors.go | 5 +- scripts/migrate/017_pride_flags.sql | 24 +++ 5 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 backend/db/flags.go create mode 100644 backend/routes/user/flags.go create mode 100644 scripts/migrate/017_pride_flags.sql diff --git a/backend/db/flags.go b/backend/db/flags.go new file mode 100644 index 0000000..cc2195a --- /dev/null +++ b/backend/db/flags.go @@ -0,0 +1,226 @@ +package db + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "strings" + + "emperror.dev/errors" + "github.com/davidbyttow/govips/v2/vips" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/minio/minio-go/v7" + "github.com/rs/xid" +) + +type PrideFlag struct { + ID xid.ID `json:"id"` + UserID xid.ID `json:"-"` + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +type UserFlag struct { + ID int64 `json:"-"` + UserID xid.ID `json:"-"` + FlagID xid.ID `json:"id"` + + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +type MemberFlag struct { + ID int64 `json:"-"` + MemberID xid.ID `json:"-"` + FlagID xid.ID `json:"id"` + + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +const ( + MaxPrideFlags = 100 + MaxPrideFlagTitleLength = 100 + MaxPrideFlagDescLength = 200 +) + +func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) { + sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("id").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) { + sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description"). + From("user_flags AS u"). + Where("u.user_id = $1"). + Join("pride_flags AS f ON u.flag_id = f.id"). + OrderBy("u.id ASC"). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) MemberFlags(ctx context.Context, userID xid.ID) (fs []MemberFlag, err error) { + sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description"). + From("member_flags AS m"). + Where("m.member_id = $1"). + Join("pride_flags AS f ON m.flag_id = f.id"). + OrderBy("m.id ASC"). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) { + description := &desc + if desc == "" { + description = nil + } + + sql, args, err := sq.Insert("pride_flags"). + SetMap(map[string]any{ + "id": xid.New(), + "hash": "", + "user_id": userID.String(), + "name": name, + "description": description, + }).Suffix("RETURNING *").ToSql() + if err != nil { + return f, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, tx, &f, sql, args...) + if err != nil { + return f, errors.Wrap(err, "executing query") + } + return f, nil +} + +func (db *DB) EditFlag(ctx context.Context, tx pgx.Tx, flagID xid.ID, name, desc, hash *string) (f PrideFlag, err error) { + b := sq.Update("pride_flags"). + Where("id = ?", flagID) + if name != nil { + b = b.Set("name", *name) + } + if desc != nil { + if *desc == "" { + b = b.Set("description", nil) + } else { + b = b.Set("description", *desc) + } + } + if hash != nil { + b = b.Set("hash", *hash) + } + + sql, args, err := b.Suffix("RETURNING *").ToSql() + if err != nil { + return f, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &f, sql, args...) + if err != nil { + return f, errors.Wrap(err, "executing query") + } + return f, nil +} + +func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) (hash string, err error) { + hasher := sha256.New() + _, err = hasher.Write(flag.Bytes()) + if err != nil { + return "", errors.Wrap(err, "hashing flag") + } + hash = hex.EncodeToString(hasher.Sum(nil)) + + _, err = db.minio.PutObject(ctx, db.minioBucket, "/flags/"+hash+".webp", flag, -1, minio.PutObjectOptions{ + ContentType: "image/webp", + }) + if err != nil { + return "", errors.Wrap(err, "uploading flag") + } + + return hash, nil +} + +func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error { + err := db.minio.RemoveObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) + if err != nil { + return errors.Wrap(err, "deleting flag") + } + + return nil +} + +func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.ReadCloser, error) { + obj, err := db.minio.GetObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) + if err != nil { + return nil, errors.Wrap(err, "getting object") + } + return obj, nil +} + +// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result. +func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) { + defer vips.ShutdownThread() + + data = strings.TrimSpace(data) + if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") { + return nil, ErrInvalidDataURI + } + split := strings.Split(data, ",") + + rawData, err := base64.StdEncoding.DecodeString(split[1]) + if err != nil { + return nil, errors.Wrap(err, "invalid base64 data") + } + + image, err := vips.LoadImageFromBuffer(rawData, nil) + if err != nil { + return nil, errors.Wrap(err, "decoding image") + } + + err = image.ThumbnailWithSize(256, 256, vips.InterestingNone, vips.SizeBoth) + if err != nil { + return nil, errors.Wrap(err, "resizing image") + } + + webpExport := vips.NewWebpExportParams() + webpExport.Lossless = true + webpB, _, err := image.ExportWebp(webpExport) + if err != nil { + return nil, errors.Wrap(err, "exporting webp image") + } + webpOut = bytes.NewBuffer(webpB) + + return webpOut, nil +} diff --git a/backend/routes/user/flags.go b/backend/routes/user/flags.go new file mode 100644 index 0000000..2402137 --- /dev/null +++ b/backend/routes/user/flags.go @@ -0,0 +1,121 @@ +package user + +import ( + "fmt" + "net/http" + "strings" + + "codeberg.org/u1f320/pronouns.cc/backend/common" + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + flags, err := s.DB.AccountFlags(ctx, claims.UserID) + if err != nil { + return errors.Wrapf(err, "getting flags for account %v", claims.UserID) + } + + render.JSON(w, r, flags) + return nil +} + +type postUserFlagRequest struct { + Flag string `json:"flag"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + flags, err := s.DB.AccountFlags(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting current user flags") + } + if len(flags) >= db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrFlagLimitReached, + } + } + + var req postUserFlagRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // remove whitespace from all fields + req.Name = strings.TrimSpace(req.Name) + req.Description = strings.TrimSpace(req.Description) + + if s := common.StringLength(&req.Name); s > db.MaxPrideFlagTitleLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s), + } + } + if s := common.StringLength(&req.Description); s > db.MaxPrideFlagDescLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s), + } + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "starting transaction") + } + defer tx.Rollback(ctx) + + flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description) + if err != nil { + log.Errorf("creating flag: %v", err) + return errors.Wrap(err, "creating flag") + } + + webp, err := s.DB.ConvertFlag(req.Flag) + if err != nil { + if err == db.ErrInvalidDataURI { + return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"} + } + return errors.Wrap(err, "converting flag") + } + + hash, err := s.DB.WriteFlag(ctx, flag.ID, webp) + if err != nil { + return errors.Wrap(err, "writing flag") + } + + flag, err = s.DB.EditFlag(ctx, tx, flag.ID, nil, nil, &hash) + if err != nil { + return errors.Wrap(err, "setting hash for flag") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, flag) + return nil +} + +func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error { + return nil +} + +func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error { + return nil +} diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go index 974fa55..dbd943d 100644 --- a/backend/routes/user/routes.go +++ b/backend/routes/user/routes.go @@ -29,6 +29,11 @@ func Mount(srv *server.Server, r chi.Router) { r.Get("/@me/export/start", server.WrapHandler(s.startExport)) r.Get("/@me/export", server.WrapHandler(s.getExport)) + + r.Get("/@me/flags", server.WrapHandler(s.getUserFlags)) + r.Post("/@me/flags", server.WrapHandler(s.postUserFlag)) + r.Patch("/@me/flags", server.WrapHandler(s.patchUserFlag)) + r.Delete("/@me/flags", server.WrapHandler(s.deleteUserFlag)) }) }) } diff --git a/backend/server/errors.go b/backend/server/errors.go index 18bec03..deb901b 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -102,6 +102,7 @@ const ( // User-related error codes ErrUserNotFound = 2001 ErrMemberListPrivate = 2002 + ErrFlagLimitReached = 2003 // Member-related error codes ErrMemberNotFound = 3001 @@ -145,7 +146,8 @@ var errCodeMessages = map[int]string{ ErrInvalidCaptcha: "Invalid or missing captcha response", ErrUserNotFound: "User not found", - ErrMemberListPrivate: "This user's member list is private.", + ErrMemberListPrivate: "This user's member list is private", + ErrFlagLimitReached: "Maximum number of pride flags reached", ErrMemberNotFound: "Member not found", ErrMemberLimitReached: "Member limit reached", @@ -187,6 +189,7 @@ var errCodeStatuses = map[int]int{ ErrUserNotFound: http.StatusNotFound, ErrMemberListPrivate: http.StatusForbidden, + ErrFlagLimitReached: http.StatusBadRequest, ErrMemberNotFound: http.StatusNotFound, ErrMemberLimitReached: http.StatusBadRequest, diff --git a/scripts/migrate/017_pride_flags.sql b/scripts/migrate/017_pride_flags.sql new file mode 100644 index 0000000..9c9b622 --- /dev/null +++ b/scripts/migrate/017_pride_flags.sql @@ -0,0 +1,24 @@ +-- +migrate Up + +-- 2023-05-09: Add pride flags +-- Hashes are a separate table so we can deduplicate flags. + +create table pride_flags ( + id text primary key, + user_id text not null references users (id) on delete cascade, + hash text not null, + name text not null, + description text +); + +create table user_flags ( + id bigint generated by default as identity primary key, + user_id text not null references users (id) on delete cascade, + flag_id text not null references pride_flags (id) on delete cascade +); + +create table member_flags ( + id bigint generated by default as identity primary key, + member_id text not null references members (id) on delete cascade, + flag_id text not null references pride_flags (id) on delete cascade +);