diff --git a/backend/db/avatars.go b/backend/db/avatars.go new file mode 100644 index 0000000..acd3e34 --- /dev/null +++ b/backend/db/avatars.go @@ -0,0 +1,166 @@ +package db + +import ( + "bytes" + "context" + "encoding/base64" + "io" + "os/exec" + "strings" + + "emperror.dev/errors" + "github.com/minio/minio-go/v7" + "github.com/rs/xid" +) + +var ( + webpArgs = []string{"-quality", "50", "webp:-"} + jpgArgs = []string{"-quality", "50", "jpg:-"} +) + +const ErrInvalidDataURI = errors.Sentinel("invalid data URI") +const ErrInvalidContentType = errors.Sentinel("invalid avatar content type") + +// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results. +func (db *DB) ConvertAvatar(data string) ( + webp io.Reader, + jpg io.Reader, + err error, +) { + data = strings.TrimSpace(data) + if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") { + return nil, nil, ErrInvalidDataURI + } + split := strings.Split(data, ",") + rest, b64 := split[0], split[1] + + rest = strings.Split(rest, ":")[1] + contentType := strings.Split(rest, ";")[0] + + var contentArg []string + switch contentType { + case "image/png": + contentArg = []string{"png:-"} + case "image/jpeg": + contentArg = []string{"jpg:-"} + case "image/gif": + contentArg = []string{"gif:-"} + case "image/webp": + contentArg = []string{"webp:-"} + default: + return nil, nil, ErrInvalidContentType + } + + rawData, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, nil, errors.Wrap(err, "invalid base64 data") + } + + // create webp convert command and get its pipes + webpConvert := exec.Command("convert", append(contentArg, webpArgs...)...) + stdIn, err := webpConvert.StdinPipe() + if err != nil { + return nil, nil, errors.Wrap(err, "getting webp stdin") + } + stdOut, err := webpConvert.StdoutPipe() + if err != nil { + return nil, nil, errors.Wrap(err, "getting webp stdout") + } + + // start webp command + err = webpConvert.Start() + if err != nil { + return nil, nil, errors.Wrap(err, "starting webp command") + } + + // write data + _, err = stdIn.Write(rawData) + if err != nil { + return nil, nil, errors.Wrap(err, "writing webp data") + } + err = stdIn.Close() + if err != nil { + return nil, nil, errors.Wrap(err, "closing webp stdin") + } + + // read webp output + webpBuffer := new(bytes.Buffer) + _, err = io.Copy(webpBuffer, stdOut) + if err != nil { + return nil, nil, errors.Wrap(err, "reading webp data") + } + webp = webpBuffer + + // finish webp command + err = webpConvert.Wait() + if err != nil { + return nil, nil, errors.Wrap(err, "running webp command") + } + + // create jpg convert command and get its pipes + jpgConvert := exec.Command("convert", append(contentArg, jpgArgs...)...) + stdIn, err = jpgConvert.StdinPipe() + if err != nil { + return nil, nil, errors.Wrap(err, "getting jpg stdin") + } + stdOut, err = jpgConvert.StdoutPipe() + if err != nil { + return nil, nil, errors.Wrap(err, "getting jpg stdout") + } + + // start jpg command + err = jpgConvert.Start() + if err != nil { + return nil, nil, errors.Wrap(err, "starting jpg command") + } + + // write data + _, err = stdIn.Write(rawData) + if err != nil { + return nil, nil, errors.Wrap(err, "writing jpg data") + } + err = stdIn.Close() + if err != nil { + return nil, nil, errors.Wrap(err, "closing jpg stdin") + } + + // read jpg output + jpgBuffer := new(bytes.Buffer) + _, err = io.Copy(jpgBuffer, stdOut) + if err != nil { + return nil, nil, errors.Wrap(err, "reading jpg data") + } + jpg = jpgBuffer + + // finish jpg command + err = jpgConvert.Wait() + if err != nil { + return nil, nil, errors.Wrap(err, "running jpg command") + } + + return webp, jpg, nil +} + +func (db *DB) WriteUserAvatar(ctx context.Context, + userID xid.ID, webp io.Reader, jpeg io.Reader, +) ( + webpLocation string, + jpegLocation string, + err error, +) { + webpInfo, err := db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.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, "/users/"+userID.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/db.go b/backend/db/db.go index f39e69b..9ea4159 100644 --- a/backend/db/db.go +++ b/backend/db/db.go @@ -10,6 +10,8 @@ import ( "github.com/Masterminds/squirrel" "github.com/jackc/pgx/v4/pgxpool" "github.com/mediocregopher/radix/v4" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" ) var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) @@ -20,22 +22,36 @@ type DB struct { *pgxpool.Pool Redis radix.Client + + minio *minio.Client + minioBucket string } -func New(dsn string) (*DB, error) { - pool, err := pgxpool.Connect(context.Background(), dsn) +func New() (*DB, error) { + pool, err := pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { - return nil, err + return nil, errors.Wrap(err, "creating postgres client") } redis, err := (&radix.PoolConfig{}).New(context.Background(), "tcp", os.Getenv("REDIS")) if err != nil { - return nil, err + return nil, errors.Wrap(err, "creating redis client") + } + + minioClient, err := minio.New(os.Getenv("MINIO_ENDPOINT"), &minio.Options{ + Creds: credentials.NewStaticV4(os.Getenv("MINIO_ACCESS_KEY_ID"), os.Getenv("MINIO_ACCESS_KEY_SECRET"), ""), + Secure: os.Getenv("MINIO_SSL") == "true", + }) + if err != nil { + return nil, errors.Wrap(err, "creating minio client") } db := &DB{ Pool: pool, Redis: redis, + + minio: minioClient, + minioBucket: os.Getenv("MINIO_BUCKET"), } return db, nil diff --git a/backend/db/field.go b/backend/db/field.go index 8330b94..d7d69ad 100644 --- a/backend/db/field.go +++ b/backend/db/field.go @@ -121,3 +121,51 @@ func (db *DB) SetUserFields(ctx context.Context, tx pgx.Tx, userID xid.ID, field } return nil } + +// MemberFields returns the fields associated with the given member ID. +func (db *DB) MemberFields(ctx context.Context, id xid.ID) (fs []Field, err error) { + sql, args, err := sq. + Select("id", "name", "favourite", "okay", "jokingly", "friends_only", "avoid"). + From("member_fields").Where("member_id = ?", id).OrderBy("id ASC").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Cause(err) + } + return fs, nil +} + +// SetMemberFields updates the fields for the given member. +func (db *DB) SetMemberFields(ctx context.Context, tx pgx.Tx, memberID xid.ID, fields []Field) (err error) { + sql, args, err := sq.Delete("member_fields").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 fields") + } + + _, err = tx.CopyFrom(ctx, + pgx.Identifier{"member_fields"}, + []string{"member_id", "name", "favourite", "okay", "jokingly", "friends_only", "avoid"}, + pgx.CopyFromSlice(len(fields), func(i int) ([]any, error) { + return []any{ + memberID, + fields[i].Name, + fields[i].Favourite, + fields[i].Okay, + fields[i].Jokingly, + fields[i].FriendsOnly, + fields[i].Avoid, + }, nil + })) + if err != nil { + return errors.Wrap(err, "inserting new fields") + } + return nil +} diff --git a/backend/db/member.go b/backend/db/member.go new file mode 100644 index 0000000..83a243a --- /dev/null +++ b/backend/db/member.go @@ -0,0 +1,73 @@ +package db + +import ( + "context" + + "emperror.dev/errors" + "github.com/georgysavva/scany/pgxscan" + "github.com/jackc/pgx/v4" + "github.com/rs/xid" +) + +type Member struct { + ID xid.ID + UserID xid.ID + Name string + Bio *string + AvatarURL *string + Links []string +} + +const ErrMemberNotFound = errors.Sentinel("member not found") + +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() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &m, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return m, ErrMemberNotFound + } + + return m, errors.Wrap(err, "retrieving member") + } + return m, nil +} + +func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) { + sql, args, err := sq.Select("*").From("members"). + Where("user_id = ? and (id = ? or name = ?)", userID, memberRef, memberRef).ToSql() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &m, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return m, ErrMemberNotFound + } + + return m, errors.Wrap(err, "retrieving member") + } + return m, nil +} + +func (db *DB) UserMembers(ctx context.Context, userID xid.ID) (ms []Member, err error) { + sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ms, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "retrieving members") + } + + if ms == nil { + ms = make([]Member, 0) + } + return ms, nil +} diff --git a/backend/db/names_pronouns.go b/backend/db/names_pronouns.go index 6b5e8a5..45fa146 100644 --- a/backend/db/names_pronouns.go +++ b/backend/db/names_pronouns.go @@ -170,3 +170,87 @@ func (db *DB) SetUserPronouns(ctx context.Context, tx pgx.Tx, userID xid.ID, nam } return nil } + +func (db *DB) MemberNames(ctx context.Context, memberID xid.ID) (ns []Name, err error) { + sql, args, err := sq.Select("id", "name", "status").From("member_names").Where("member_id = ?", memberID).OrderBy("id").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ns, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return ns, nil +} + +func (db *DB) MemberPronouns(ctx context.Context, memberID xid.ID) (ps []Pronoun, err error) { + sql, args, err := sq. + Select("id", "display_text", "pronouns", "status"). + From("member_pronouns").Where("member_id = ?", memberID). + OrderBy("id").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ps, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return ps, nil +} + +func (db *DB) SetMemberNames(ctx context.Context, tx pgx.Tx, memberID xid.ID, names []Name) (err error) { + sql, args, err := sq.Delete("member_names").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 names") + } + + _, err = tx.CopyFrom(ctx, + pgx.Identifier{"member_names"}, + []string{"member_id", "name", "status"}, + pgx.CopyFromSlice(len(names), func(i int) ([]any, error) { + return []any{ + memberID, + names[i].Name, + names[i].Status, + }, nil + })) + if err != nil { + return errors.Wrap(err, "inserting new names") + } + return nil +} + +func (db *DB) SetMemberPronouns(ctx context.Context, tx pgx.Tx, memberID xid.ID, names []Pronoun) (err error) { + sql, args, err := sq.Delete("member_pronouns").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 pronouns") + } + + _, err = tx.CopyFrom(ctx, + pgx.Identifier{"member_pronouns"}, + []string{"member_id", "pronouns", "display_text", "status"}, + pgx.CopyFromSlice(len(names), func(i int) ([]any, error) { + return []any{ + memberID, + names[i].Pronouns, + names[i].DisplayText, + names[i].Status, + }, nil + })) + if err != nil { + return errors.Wrap(err, "inserting new pronouns") + } + return nil +} diff --git a/backend/db/user.go b/backend/db/user.go index b2cd671..16f699e 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -19,7 +19,7 @@ type User struct { Bio *string AvatarSource *string - AvatarURL *string + AvatarURLs []string `db:"avatar_urls"` Links []string Discord *string @@ -103,12 +103,6 @@ func (u *User) UpdateFromDiscord(ctx context.Context, db pgxscan.Querier, du *di Where("id = ?", u.ID). Suffix("RETURNING *") - if u.AvatarSource == nil || *u.AvatarSource == "discord" { - builder = builder. - Set("avatar_source", "discord"). - Set("avatar_url", du.AvatarURL("1024")) - } - sql, args, err := builder.ToSql() if err != nil { return errors.Wrap(err, "building sql") @@ -160,6 +154,7 @@ func (db *DB) UpdateUser( tx pgx.Tx, id xid.ID, displayName, bio *string, links *[]string, + avatarURLs []string, ) (u User, err error) { if displayName == nil && bio == nil && links == nil { return u, ErrNothingToUpdate @@ -188,6 +183,14 @@ func (db *DB) UpdateUser( } } + 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 u, errors.Wrap(err, "building sql") diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 4de3d55..3864d8b 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -24,7 +24,7 @@ type userResponse struct { Username string `json:"username"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` - AvatarURL *string `json:"avatar_url"` + AvatarURLs []string `json:"avatar_urls"` Links []string `json:"links"` Discord *string `json:"discord"` @@ -37,7 +37,7 @@ func dbUserToUserResponse(u db.User) *userResponse { Username: u.Username, DisplayName: u.DisplayName, Bio: u.Bio, - AvatarURL: u.AvatarURL, + AvatarURLs: u.AvatarURLs, Links: u.Links, Discord: u.Discord, DiscordUsername: u.DiscordUsername, diff --git a/backend/routes/bot/bot.go b/backend/routes/bot/bot.go index 5d3b6aa..251d027 100644 --- a/backend/routes/bot/bot.go +++ b/backend/routes/bot/bot.go @@ -97,8 +97,8 @@ func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discord } avatarURL := du.AvatarURL("") - if u.AvatarURL != nil { - avatarURL = *u.AvatarURL + if len(u.AvatarURLs) > 0 { + avatarURL = u.AvatarURLs[0] } name := u.Username if u.DisplayName != nil { diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go new file mode 100644 index 0000000..ba4363e --- /dev/null +++ b/backend/routes/member/create_member.go @@ -0,0 +1,39 @@ +package member + +import ( + "context" + "net/http" + + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "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"` +} + +func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + + var cmr CreateMemberRequest + err = render.Decode(r, &cmr) + if err != nil { + if _, ok := err.(server.APIError); ok { + return err + } + + return server.APIError{Code: server.ErrBadRequest} + } + + ctx = context.WithValue(ctx, render.StatusCtxKey, 204) + render.NoContent(w, r) + + return nil +} diff --git a/backend/routes/member/get_member.go b/backend/routes/member/get_member.go new file mode 100644 index 0000000..a6c149d --- /dev/null +++ b/backend/routes/member/get_member.go @@ -0,0 +1,142 @@ +package member + +import ( + "context" + "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 GetMemberResponse struct { + ID xid.ID `json:"id"` + 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"` + + User PartialUser `json:"user"` +} + +func dbMemberToMember(u db.User, m db.Member, names []db.Name, pronouns []db.Pronoun, fields []db.Field) GetMemberResponse { + return GetMemberResponse{ + ID: m.ID, + Name: m.Name, + Bio: m.Bio, + AvatarURL: m.AvatarURL, + Links: m.Links, + + Names: names, + Pronouns: pronouns, + Fields: fields, + + User: PartialUser{ + ID: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + AvatarURLs: u.AvatarURLs, + }, + } +} + +type PartialUser struct { + ID xid.ID `json:"id"` + Username string `json:"username"` + DisplayName *string `json:"display_name"` + AvatarURLs []string `json:"avatar_urls"` +} + +func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + 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 { + return server.APIError{ + Code: server.ErrMemberNotFound, + } + } + + u, err := s.DB.User(ctx, m.UserID) + if err != nil { + return err + } + + names, err := s.DB.MemberNames(ctx, m.ID) + if err != nil { + return err + } + + pronouns, err := s.DB.MemberPronouns(ctx, m.ID) + if err != nil { + return err + } + + fields, err := s.DB.MemberFields(ctx, m.ID) + if err != nil { + return err + } + + render.JSON(w, r, dbMemberToMember(u, m, names, pronouns, fields)) + return nil +} + +func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + u, err := s.parseUser(ctx, chi.URLParam(r, "userRef")) + if err != nil { + return server.APIError{ + Code: server.ErrUserNotFound, + } + } + + m, err := s.DB.UserMember(ctx, u.ID, chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{ + Code: server.ErrMemberNotFound, + } + } + + names, err := s.DB.MemberNames(ctx, m.ID) + if err != nil { + return err + } + + pronouns, err := s.DB.MemberPronouns(ctx, m.ID) + if err != nil { + return err + } + + fields, err := s.DB.MemberFields(ctx, m.ID) + if err != nil { + return err + } + + render.JSON(w, r, dbMemberToMember(u, m, names, pronouns, fields)) + return nil +} + +func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) { + if id, err := xid.FromString(userRef); err != nil { + u, err := s.DB.User(ctx, id) + if err == nil { + return u, nil + } + } + + return s.DB.Username(ctx, userRef) +} diff --git a/backend/routes/member/get_members.go b/backend/routes/member/get_members.go new file mode 100644 index 0000000..561ae74 --- /dev/null +++ b/backend/routes/member/get_members.go @@ -0,0 +1,41 @@ +package member + +import ( + "net/http" + + "codeberg.org/u1f320/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + u, err := s.parseUser(ctx, chi.URLParam(r, "userRef")) + if err != nil { + return server.APIError{ + Code: server.ErrUserNotFound, + } + } + + ms, err := s.DB.UserMembers(ctx, u.ID) + if err != nil { + return err + } + + render.JSON(w, r, ms) + return nil +} + +func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + ms, err := s.DB.UserMembers(ctx, claims.UserID) + if err != nil { + return err + } + + render.JSON(w, r, ms) + return nil +} diff --git a/backend/routes/member/routes.go b/backend/routes/member/routes.go new file mode 100644 index 0000000..ff181b7 --- /dev/null +++ b/backend/routes/member/routes.go @@ -0,0 +1,31 @@ +package member + +import ( + "codeberg.org/u1f320/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" +) + +type Server struct { + *server.Server +} + +func Mount(srv *server.Server, r chi.Router) { + s := &Server{Server: srv} + + // member list + r.Get("/users/{userRef}/members", server.WrapHandler(s.getUserMembers)) + r.With(server.MustAuth).Get("/users/@me/members", server.WrapHandler(s.getMeMembers)) + + // user-scoped member lookup (including custom urls) + r.Get("/users/{userRef}/members/{memberRef}", server.WrapHandler(s.getUserMember)) + + r.Route("/members", func(r chi.Router) { + // any member by ID + r.Get("/{memberRef}", server.WrapHandler(s.getMember)) + + // 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).Delete("/{memberRef}", nil) + }) +} diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index f74efd4..e4e0d75 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -16,7 +16,7 @@ type GetUserResponse struct { Username string `json:"username"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` - AvatarURL *string `json:"avatar_url"` + AvatarURLs []string `json:"avatar_urls"` Links []string `json:"links"` Names []db.Name `json:"names"` Pronouns []db.Pronoun `json:"pronouns"` @@ -43,7 +43,7 @@ func dbUserToResponse(u db.User, fields []db.Field, names []db.Name, pronouns [] Username: u.Username, DisplayName: u.DisplayName, Bio: u.Bio, - AvatarURL: u.AvatarURL, + AvatarURLs: u.AvatarURLs, Links: u.Links, Names: names, Pronouns: pronouns, diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index bbc2c07..40fe4f4 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -18,6 +18,7 @@ type PatchUserRequest struct { Names *[]db.Name `json:"names"` Pronouns *[]db.Pronoun `json:"pronouns"` Fields *[]db.Field `json:"fields"` + Avatar *string `json:"avatar"` } // patchUser parses a PatchUserRequest and updates the user with the given ID. @@ -39,7 +40,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { req.Links == nil && req.Fields == nil && req.Names == nil && - req.Pronouns == nil { + req.Pronouns == nil && + req.Avatar == nil { return server.APIError{ Code: server.ErrBadRequest, Details: "Data must not be empty", @@ -91,6 +93,35 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { 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 user avatar: %v", err) + return err + } + + webpURL, jpgURL, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg) + if err != nil { + log.Errorf("uploading user avatar: %v", err) + return err + } + avatarURLs = []string{webpURL, jpgURL} + } + // start transaction tx, err := s.DB.Begin(ctx) if err != nil { @@ -99,7 +130,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } defer tx.Rollback(ctx) - u, err := s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.Links) + u, err := s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.Links, avatarURLs) if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { log.Errorf("updating user: %v", err) return err @@ -173,23 +204,28 @@ type validator interface { // 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) error { +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) > db.MaxFields { - return server.APIError{ + if len(*slice) > max { + return &server.APIError{ Code: server.ErrBadRequest, - Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, db.MaxFields, len(*slice)), + 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{ + return &server.APIError{ Code: server.ErrBadRequest, Details: fmt.Sprintf("%s %d: %s", typ, i, s), } diff --git a/go.mod b/go.mod index d84be58..e73e952 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/jackc/pgx/v4 v4.16.0 github.com/joho/godotenv v1.4.0 github.com/mediocregopher/radix/v4 v4.1.0 - github.com/rs/xid v1.2.1 + github.com/rs/xid v1.4.0 github.com/rubenv/sql-migrate v1.1.1 go.uber.org/zap v1.21.0 golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 @@ -23,8 +23,10 @@ require ( require ( github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect github.com/go-gorp/gorp/v3 v3.0.2 // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgio v1.0.0 // indirect @@ -33,16 +35,26 @@ require ( github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect github.com/jackc/pgtype v1.11.0 // indirect github.com/jackc/puddle v1.2.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.15.9 // indirect + github.com/klauspost/cpuid/v2 v2.1.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.37 // indirect + github.com/minio/sha256-simd v1.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect github.com/tilinna/clock v1.0.2 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect - golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect - golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect - golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf // indirect + golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect + golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.26.0 // indirect + gopkg.in/ini.v1 v1.66.6 // indirect ) diff --git a/go.sum b/go.sum index 1045ceb..faffad0 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -190,6 +192,8 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -290,6 +294,8 @@ github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXL github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= @@ -297,6 +303,12 @@ github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.1.0 h1:eyi1Ad2aNJMW95zcSbmGg7Cg6cq3ADwLpMAP96d8rF0= +github.com/klauspost/cpuid/v2 v2.1.0/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kortschak/utter v1.0.1/go.mod h1:vSmSjbyrlKjjsL71193LmzBOKgwePk9DH6uFaWHIInc= @@ -344,6 +356,12 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A github.com/mediocregopher/radix/v4 v4.1.0 h1:z96wBJkyK/hOrAV+qC8AXk0QsbwZEtx5+8ovjnXELuA= github.com/mediocregopher/radix/v4 v4.1.0/go.mod h1:ajchozX/6ELmydxWeWM6xCFHVpZ4+67LXHOTOVR0nCE= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.37 h1:aJvYMbtpVPSFBck6guyvOkxK03MycxDOCs49ZBuY5M8= +github.com/minio/minio-go/v7 v7.0.37/go.mod h1:nCrRzjoSUQh8hgKKtu3Y708OLvRLtuASMg2/nvmbarw= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.2/go.mod h1:6iaV0fGdElS6dPBx0EApTxHrcWvmJphyh2n8YBLPPZ4= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= @@ -356,8 +374,12 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -377,6 +399,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/rubenv/sql-migrate v1.1.1 h1:haR5Hn8hbW9/SpAICrXoZqXnywS7Q5WijwkQENPeNWY= @@ -394,6 +418,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= @@ -473,6 +499,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -547,6 +575,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -625,6 +655,10 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf h1:2ucpDCmfkl8Bd/FsLtiD653Wf96cW37s+iGx93zsu4k= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= @@ -818,6 +852,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI= +gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/scripts/migrate/001_init.sql b/scripts/migrate/001_init.sql index 721f1e8..be1830a 100644 --- a/scripts/migrate/001_init.sql +++ b/scripts/migrate/001_init.sql @@ -9,7 +9,7 @@ create table users ( bio text, avatar_source text, - avatar_url text, + avatar_urls text[], links text[], discord text unique, -- for Discord oauth @@ -21,7 +21,7 @@ create table user_names ( id bigserial primary key, -- ID is used for sorting; when order changes, existing rows are deleted and new ones are created name text not null, status int not null -) +); create table user_pronouns ( user_id text not null references users (id) on delete cascade, @@ -50,7 +50,7 @@ create table members ( bio text, avatar_url text, - links text + links text[] ); create table member_names ( @@ -58,7 +58,7 @@ create table member_names ( id bigserial primary key, -- ID is used for sorting; when order changes, existing rows are deleted and new ones are created name text not null, status int not null -) +); create table member_pronouns ( member_id text not null references members (id) on delete cascade,