diff --git a/README.md b/README.md index 43c5032..a916d67 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pronouns.cc -A work-in-progress site to share your pronouns and preferred terms. +A work-in-progress site to share your names, pronouns, and other preferred terms. ## Stack @@ -8,6 +8,7 @@ A work-in-progress site to share your pronouns and preferred terms. - Persistent data is stored in PostgreSQL - Temporary data is stored in Redis - The frontend is written in TypeScript with React, using [Next](https://nextjs.org/) for server-side rendering +- Avatars are stored in S3-compatible storage ([MinIO](https://github.com/minio/minio) for development) ## Development diff --git a/backend/db/avatars.go b/backend/db/avatars.go index acd3e34..adbccc2 100644 --- a/backend/db/avatars.go +++ b/backend/db/avatars.go @@ -164,3 +164,27 @@ func (db *DB) WriteUserAvatar(ctx context.Context, return webpInfo.Location, jpegInfo.Location, nil } + +func (db *DB) WriteMemberAvatar(ctx context.Context, + memberID xid.ID, webp io.Reader, jpeg io.Reader, +) ( + webpLocation string, + jpegLocation string, + err error, +) { + webpInfo, err := db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+".webp", webp, -1, minio.PutObjectOptions{ + ContentType: "image/webp", + }) + if err != nil { + return "", "", errors.Wrap(err, "uploading webp avatar") + } + + jpegInfo, err := db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+".jpg", jpeg, -1, minio.PutObjectOptions{ + ContentType: "image/jpeg", + }) + if err != nil { + return "", "", errors.Wrap(err, "uploading jpeg avatar") + } + + return webpInfo.Location, jpegInfo.Location, nil +} diff --git a/backend/db/member.go b/backend/db/member.go index 1cc300c..56b586e 100644 --- a/backend/db/member.go +++ b/backend/db/member.go @@ -5,10 +5,13 @@ import ( "emperror.dev/errors" "github.com/georgysavva/scany/pgxscan" + "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/rs/xid" ) +const MaxMemberCount = 500 + type Member struct { ID xid.ID UserID xid.ID @@ -18,7 +21,10 @@ type Member struct { Links []string } -const ErrMemberNotFound = errors.Sentinel("member not found") +const ( + ErrMemberNotFound = errors.Sentinel("member not found") + ErrMemberNameInUse = errors.Sentinel("member name already in use") +) func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) { sql, args, err := sq.Select("*").From("members").Where("id = ?", id).ToSql() @@ -71,3 +77,43 @@ func (db *DB) UserMembers(ctx context.Context, userID xid.ID) (ms []Member, err } return ms, nil } + +// CreateMember creates a member. +func (db *DB) CreateMember(ctx context.Context, tx pgx.Tx, userID xid.ID, name, bio string, links []string) (m Member, err error) { + sql, args, err := sq.Insert("members"). + Columns("user_id", "id", "name", "bio", "links"). + Values(userID, xid.New(), name, bio, links). + Suffix("RETURNING *").ToSql() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &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 query") + } + + return m, nil +} + +// MemberCount returns the number of members that the given user has. +func (db *DB) MemberCount(ctx context.Context, userID xid.ID) (n int64, err error) { + sql, args, err := sq.Select("count(id)").From("members").Where("user_id = ?", userID).ToSql() + if err != nil { + return 0, errors.Wrap(err, "building sql") + } + + err = db.QueryRow(ctx, sql, args...).Scan(&n) + if err != nil { + return 0, errors.Wrap(err, "executing query") + } + + return n, nil +} diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go index ba4363e..ad145ca 100644 --- a/backend/routes/member/create_member.go +++ b/backend/routes/member/create_member.go @@ -1,26 +1,44 @@ package member import ( - "context" + "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/render" ) type CreateMemberRequest struct { - Name string `json:"name"` - Bio *string `json:"bio"` - AvatarURL *string `json:"avatar_url"` - Links []string `json:"links"` - Names []db.Name `json:"names"` - Pronouns []db.Pronoun `json:"pronouns"` - Fields []db.Field `json:"fields"` + Name string `json:"name"` + Bio string `json:"bio"` + Avatar string `json:"avatar"` + Links []string `json:"links"` + Names []db.Name `json:"names"` + Pronouns []db.Pronoun `json:"pronouns"` + Fields []db.Field `json:"fields"` } func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error) { ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + memberCount, err := s.DB.MemberCount(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting member count") + } + if memberCount > db.MaxMemberCount { + return server.APIError{ + Code: server.ErrMemberLimitReached, + } + } var cmr CreateMemberRequest err = render.Decode(r, &cmr) @@ -32,8 +50,127 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error return server.APIError{Code: server.ErrBadRequest} } - ctx = context.WithValue(ctx, render.StatusCtxKey, 204) - render.NoContent(w, r) + // validate everything + if cmr.Name == "" { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "name may not be empty", + } + } + + if err := validateSlicePtr("name", &cmr.Names); err != nil { + return err + } + + if err := validateSlicePtr("pronoun", &cmr.Pronouns); err != nil { + return err + } + + if err := validateSlicePtr("field", &cmr.Fields); err != nil { + return err + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "starting transaction") + } + defer tx.Rollback(ctx) + + m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.Bio, cmr.Links) + if err != nil { + return err + } + + // set names, pronouns, fields + err = s.DB.SetMemberNames(ctx, tx, claims.UserID, cmr.Names) + if err != nil { + log.Errorf("setting names for user %v: %v", claims.UserID, err) + return err + } + err = s.DB.SetMemberPronouns(ctx, tx, claims.UserID, cmr.Pronouns) + if err != nil { + log.Errorf("setting pronouns for user %v: %v", claims.UserID, err) + return err + } + err = s.DB.SetMemberFields(ctx, tx, claims.UserID, cmr.Fields) + if err != nil { + log.Errorf("setting fields for user %v: %v", claims.UserID, err) + return err + } + + if cmr.Avatar != "" { + webp, jpg, err := s.DB.ConvertAvatar(cmr.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, m.ID, webp, jpg) + if err != nil { + log.Errorf("uploading member avatar: %v", err) + return err + } + + err = tx.QueryRow(ctx, "UPDATE members SET avatar_urls = $1 WHERE id = $2", []string{webpURL, jpgURL}, m.ID).Scan(&m.AvatarURLs) + if err != nil { + return errors.Wrap(err, "setting avatar urls in db") + } + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, dbMemberToMember(u, m, cmr.Names, cmr.Pronouns, cmr.Fields)) + return nil +} + +type validator interface { + Validate() string +} + +// validateSlicePtr validates a slice of validators. +// If the slice is nil, a nil error is returned (assuming that the field is not required) +func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError { + if slice == nil { + return nil + } + + max := db.MaxFields + if typ != "field" { + max = db.FieldEntriesLimit + } + + // max 25 fields + if len(*slice) > max { + return &server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)), + } + } + + // validate all fields + for i, pronouns := range *slice { + if s := pronouns.Validate(); s != "" { + return &server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("%s %d: %s", typ, i, s), + } + } + } return nil } diff --git a/backend/routes/member/get_members.go b/backend/routes/member/get_members.go index 561ae74..46a4e79 100644 --- a/backend/routes/member/get_members.go +++ b/backend/routes/member/get_members.go @@ -3,11 +3,36 @@ package member import ( "net/http" + "codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/rs/xid" ) +type memberListResponse struct { + ID xid.ID `json:"id"` + Name string `json:"name"` + Bio *string `json:"bio"` + AvatarURLs []string `json:"avatar_urls"` + Links []string `json:"links"` +} + +func membersToMemberList(ms []db.Member) []memberListResponse { + resps := make([]memberListResponse, len(ms)) + for i := range ms { + resps[i] = memberListResponse{ + ID: ms[i].ID, + Name: ms[i].Name, + Bio: ms[i].Bio, + AvatarURLs: ms[i].AvatarURLs, + Links: ms[i].Links, + } + } + + return resps +} + func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() @@ -23,7 +48,7 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error { return err } - render.JSON(w, r, ms) + render.JSON(w, r, membersToMemberList(ms)) return nil } @@ -36,6 +61,6 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error { return err } - render.JSON(w, r, ms) + render.JSON(w, r, membersToMemberList(ms)) return nil } diff --git a/scripts/migrate/001_init.sql b/scripts/migrate/001_init.sql index 7781393..ae20ee5 100644 --- a/scripts/migrate/001_init.sql +++ b/scripts/migrate/001_init.sql @@ -52,6 +52,8 @@ create table members ( links text[] ); +create unique index members_user_name_idx on members (user_id, lower(name)); + create table member_names ( member_id text not null references members (id) on delete cascade, id bigserial primary key, -- ID is used for sorting; when order changes, existing rows are deleted and new ones are created