From 69e5082e899b775a1bf531466f4d383c54647728 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 21 Nov 2022 17:01:51 +0100 Subject: [PATCH] feat(backend): PATCH /members/{id} route --- backend/db/member.go | 74 +++++++- backend/routes/member/patch_member.go | 237 ++++++++++++++++++++++++++ backend/routes/member/routes.go | 2 +- backend/server/errors.go | 3 + 4 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 backend/routes/member/patch_member.go diff --git a/backend/db/member.go b/backend/db/member.go index c11d74d..938bdf4 100644 --- a/backend/db/member.go +++ b/backend/db/member.go @@ -10,7 +10,10 @@ import ( "github.com/rs/xid" ) -const MaxMemberCount = 500 +const ( + MaxMemberCount = 500 + MaxMemberNameLength = 100 +) type Member struct { ID xid.ID @@ -118,3 +121,72 @@ func (db *DB) MemberCount(ctx context.Context, userID xid.ID) (n int64, err erro return n, nil } + +func (db *DB) UpdateMember( + ctx context.Context, + tx pgx.Tx, id xid.ID, + name, displayName, bio *string, + links *[]string, + avatarURLs []string, +) (m Member, err error) { + if name == nil && displayName == nil && bio == nil && links == nil && avatarURLs == nil { + return m, ErrNothingToUpdate + } + + builder := sq.Update("members").Where("id = ?", id) + if name != nil { + if *name == "" { + builder = builder.Set("name", nil) + } else { + builder = builder.Set("name", *displayName) + } + } + if displayName != nil { + if *displayName == "" { + builder = builder.Set("display_name", nil) + } else { + builder = builder.Set("display_name", *displayName) + } + } + if bio != nil { + if *bio == "" { + builder = builder.Set("bio", nil) + } else { + builder = builder.Set("bio", *bio) + } + } + if links != nil { + if len(*links) == 0 { + builder = builder.Set("links", nil) + } else { + builder = builder.Set("links", *links) + } + } + + if avatarURLs != nil { + if len(avatarURLs) == 0 { + builder = builder.Set("avatar_urls", nil) + } else { + builder = builder.Set("avatar_urls", avatarURLs) + } + } + + sql, args, err := builder.Suffix("RETURNING *").ToSql() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &m, sql, args...) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + if pge.Code == "23505" { + return m, ErrMemberNameInUse + } + } + + return m, errors.Wrap(err, "executing sql") + } + + return m, nil +} diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go new file mode 100644 index 0000000..1696eef --- /dev/null +++ b/backend/routes/member/patch_member.go @@ -0,0 +1,237 @@ +package member + +import ( + "fmt" + "net/http" + + "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" +) + +type PatchMemberRequest struct { + Name *string `json:"name"` + Bio *string `json:"bio"` + DisplayName *string `json:"display_name"` + Links *[]string `json:"links"` + Names *[]db.Name `json:"names"` + Pronouns *[]db.Pronoun `json:"pronouns"` + Fields *[]db.Field `json:"fields"` + Avatar *string `json:"avatar"` +} + +func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + id, err := xid.FromString(chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{Code: server.ErrMemberNotFound} + } + + m, err := s.DB.Member(ctx, id) + if err != nil { + if err == db.ErrMemberNotFound { + return server.APIError{Code: server.ErrMemberNotFound} + } + + return errors.Wrap(err, "getting member") + } + + if m.UserID != claims.UserID { + return server.APIError{Code: server.ErrNotOwnMember} + } + + var req PatchMemberRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // validate that *something* is set + if req.DisplayName == nil && + req.Name == nil && + req.Bio == nil && + req.Links == nil && + req.Fields == nil && + req.Names == nil && + req.Pronouns == nil && + req.Avatar == nil { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Data must not be empty", + } + } + + // validate display name/bio + if req.Name != nil && len(*req.Name) > db.MaxMemberNameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, len(*req.Name)), + } + } + if req.DisplayName != nil && len(*req.DisplayName) > db.MaxDisplayNameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, len(*req.DisplayName)), + } + } + if req.Bio != nil && len(*req.Bio) > db.MaxUserBioLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, len(*req.Bio)), + } + } + + // validate links + if req.Links != nil { + if len(*req.Links) > db.MaxUserLinksLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)), + } + } + + for i, link := range *req.Links { + if len(link) > db.MaxLinkLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)), + } + } + } + } + + if err := validateSlicePtr("name", req.Names); err != nil { + return err + } + + if err := validateSlicePtr("pronoun", req.Pronouns); err != nil { + return err + } + + if err := validateSlicePtr("field", req.Fields); err != nil { + return err + } + + // update avatar + var avatarURLs []string = nil + if req.Avatar != nil { + webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar) + if err != nil { + if err == db.ErrInvalidDataURI { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "invalid avatar data URI", + } + } else if err == db.ErrInvalidContentType { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "invalid avatar content type", + } + } + + log.Errorf("converting member avatar: %v", err) + return err + } + + webpURL, jpgURL, err := s.DB.WriteMemberAvatar(ctx, id, webp, jpg) + if err != nil { + log.Errorf("uploading member avatar: %v", err) + return err + } + avatarURLs = []string{webpURL, jpgURL} + } + + // start transaction + tx, err := s.DB.Begin(ctx) + if err != nil { + log.Errorf("creating transaction: %v", err) + return err + } + defer tx.Rollback(ctx) + + m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Links, avatarURLs) + if err != nil { + switch errors.Cause(err) { + case db.ErrNothingToUpdate: + case db.ErrMemberNameInUse: + return server.APIError{Code: server.ErrMemberNameInUse} + default: + log.Errorf("updating member: %v", err) + return errors.Wrap(err, "updating member in db") + } + + } + + var ( + names []db.Name + pronouns []db.Pronoun + fields []db.Field + ) + + if req.Names != nil { + err = s.DB.SetMemberNames(ctx, tx, id, *req.Names) + if err != nil { + log.Errorf("setting names for member %v: %v", id, err) + return err + } + names = *req.Names + } else { + names, err = s.DB.MemberNames(ctx, id) + if err != nil { + log.Errorf("getting names for member %v: %v", id, err) + return err + } + } + + if req.Pronouns != nil { + err = s.DB.SetMemberPronouns(ctx, tx, id, *req.Pronouns) + if err != nil { + log.Errorf("setting pronouns for member %v: %v", id, err) + return err + } + pronouns = *req.Pronouns + } else { + pronouns, err = s.DB.MemberPronouns(ctx, id) + if err != nil { + log.Errorf("getting fields for member %v: %v", id, err) + return err + } + } + + if req.Fields != nil { + err = s.DB.SetMemberFields(ctx, tx, id, *req.Fields) + if err != nil { + log.Errorf("setting fields for member %v: %v", id, err) + return err + } + fields = *req.Fields + } else { + fields, err = s.DB.MemberFields(ctx, id) + if err != nil { + log.Errorf("getting fields for member %v: %v", id, err) + return err + } + } + + err = tx.Commit(ctx) + if err != nil { + log.Errorf("committing transaction: %v", err) + return err + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + // echo the updated member back on success + render.JSON(w, r, dbMemberToMember(u, m, names, pronouns, fields)) + return nil +} diff --git a/backend/routes/member/routes.go b/backend/routes/member/routes.go index ff181b7..f0c12a2 100644 --- a/backend/routes/member/routes.go +++ b/backend/routes/member/routes.go @@ -25,7 +25,7 @@ func Mount(srv *server.Server, r chi.Router) { // create, edit, and delete members r.With(server.MustAuth).Post("/", server.WrapHandler(s.createMember)) - r.With(server.MustAuth).Patch("/{memberRef}", nil) + r.With(server.MustAuth).Patch("/{memberRef}", server.WrapHandler(s.patchMember)) r.With(server.MustAuth).Delete("/{memberRef}", nil) }) } diff --git a/backend/server/errors.go b/backend/server/errors.go index 6162b01..25da292 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -91,6 +91,7 @@ const ( ErrMemberNotFound = 3001 ErrMemberLimitReached = 3002 ErrMemberNameInUse = 3003 + ErrNotOwnMember = 3004 // General request error codes ErrRequestTooBig = 4001 @@ -120,6 +121,7 @@ var errCodeMessages = map[int]string{ ErrMemberNotFound: "Member not found", ErrMemberLimitReached: "Member limit reached", ErrMemberNameInUse: "Member name already in use", + ErrNotOwnMember: "Not your member", ErrRequestTooBig: "Request too big (max 2 MB)", } @@ -148,6 +150,7 @@ var errCodeStatuses = map[int]int{ ErrMemberNotFound: http.StatusNotFound, ErrMemberLimitReached: http.StatusBadRequest, ErrMemberNameInUse: http.StatusBadRequest, + ErrNotOwnMember: http.StatusForbidden, ErrRequestTooBig: http.StatusBadRequest, }