diff --git a/backend/db/avatars.go b/backend/db/avatars.go index 5fc7bc2..09b8a6b 100644 --- a/backend/db/avatars.go +++ b/backend/db/avatars.go @@ -3,7 +3,9 @@ package db import ( "bytes" "context" + "crypto/sha256" "encoding/base64" + "encoding/hex" "io" "os/exec" "strings" @@ -23,8 +25,8 @@ 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, + webp *bytes.Buffer, + jpg *bytes.Buffer, err error, ) { data = strings.TrimSpace(data) @@ -142,53 +144,59 @@ func (db *DB) ConvertAvatar(data string) ( } func (db *DB) WriteUserAvatar(ctx context.Context, - userID xid.ID, webp io.Reader, jpeg io.Reader, + userID xid.ID, webp *bytes.Buffer, jpeg *bytes.Buffer, ) ( - webpLocation string, - jpegLocation string, - err error, + hash string, err error, ) { - _, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+".webp", webp, -1, minio.PutObjectOptions{ + hasher := sha256.New() + _, err = hasher.Write(webp.Bytes()) + if err != nil { + return "", errors.Wrap(err, "hashing webp avatar") + } + hash = hex.EncodeToString(hasher.Sum(nil)) + + _, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ ContentType: "image/webp", }) if err != nil { - return "", "", errors.Wrap(err, "uploading webp avatar") + return "", errors.Wrap(err, "uploading webp avatar") } - _, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+".jpg", jpeg, -1, minio.PutObjectOptions{ + _, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ ContentType: "image/jpeg", }) if err != nil { - return "", "", errors.Wrap(err, "uploading jpeg avatar") + return "", errors.Wrap(err, "uploading jpeg avatar") } - return db.baseURL.JoinPath("/media/users/" + userID.String() + ".webp").String(), - db.baseURL.JoinPath("/media/users/" + userID.String() + ".jpg").String(), - nil + return hash, nil } func (db *DB) WriteMemberAvatar(ctx context.Context, - memberID xid.ID, webp io.Reader, jpeg io.Reader, + memberID xid.ID, webp *bytes.Buffer, jpeg *bytes.Buffer, ) ( - webpLocation string, - jpegLocation string, - err error, + hash string, err error, ) { - _, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+".webp", webp, -1, minio.PutObjectOptions{ + hasher := sha256.New() + _, err = hasher.Write(webp.Bytes()) + if err != nil { + return "", errors.Wrap(err, "hashing webp avatar") + } + hash = hex.EncodeToString(hasher.Sum(nil)) + + _, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ ContentType: "image/webp", }) if err != nil { - return "", "", errors.Wrap(err, "uploading webp avatar") + return "", errors.Wrap(err, "uploading webp avatar") } - _, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+".jpg", jpeg, -1, minio.PutObjectOptions{ + _, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ ContentType: "image/jpeg", }) if err != nil { - return "", "", errors.Wrap(err, "uploading jpeg avatar") + return "", errors.Wrap(err, "uploading jpeg avatar") } - return db.baseURL.JoinPath("/media/members/" + memberID.String() + ".webp").String(), - db.baseURL.JoinPath("/media/members/" + memberID.String() + ".jpg").String(), - nil + return hash, nil } diff --git a/backend/db/member.go b/backend/db/member.go index 2c54a13..0fe3d3c 100644 --- a/backend/db/member.go +++ b/backend/db/member.go @@ -21,7 +21,7 @@ type Member struct { Name string DisplayName *string Bio *string - AvatarURLs []string `db:"avatar_urls"` + Avatar *string Links []string Names []FieldEntry Pronouns []PronounEntry @@ -61,7 +61,7 @@ func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) ( // UserMembers returns all of a user's members, sorted by name. func (db *DB) UserMembers(ctx context.Context, userID xid.ID) (ms []Member, err error) { - sql, args, err := sq.Select("id", "user_id", "name", "display_name", "bio", "avatar_urls", "names", "pronouns"). + sql, args, err := sq.Select("id", "user_id", "name", "display_name", "bio", "avatar", "names", "pronouns"). From("members").Where("user_id = ?", userID). OrderBy("name", "id").ToSql() if err != nil { @@ -141,9 +141,9 @@ func (db *DB) UpdateMember( tx pgx.Tx, id xid.ID, name, displayName, bio *string, links *[]string, - avatarURLs []string, + avatar *string, ) (m Member, err error) { - if name == nil && displayName == nil && bio == nil && links == nil && avatarURLs == nil { + if name == nil && displayName == nil && bio == nil && links == nil && avatar == nil { // get member sql, args, err := sq.Select("*").From("members").Where("id = ?", id).ToSql() if err != nil { @@ -183,8 +183,12 @@ func (db *DB) UpdateMember( builder = builder.Set("links", *links) } - if avatarURLs != nil { - builder = builder.Set("avatar_urls", avatarURLs) + if avatar != nil { + if *avatar == "" { + builder = builder.Set("avatar", nil) + } else { + builder = builder.Set("avatar", avatar) + } } sql, args, err := builder.ToSql() diff --git a/backend/db/user.go b/backend/db/user.go index a62af5f..bfb268a 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -19,8 +19,8 @@ type User struct { DisplayName *string Bio *string - AvatarURLs []string `db:"avatar_urls"` - Links []string + Avatar *string + Links []string Names []FieldEntry Pronouns []PronounEntry @@ -208,9 +208,9 @@ func (db *DB) UpdateUser( tx pgx.Tx, id xid.ID, displayName, bio *string, links *[]string, - avatarURLs []string, + avatar *string, ) (u User, err error) { - if displayName == nil && bio == nil && links == nil && avatarURLs == nil { + if displayName == nil && bio == nil && links == nil && avatar == nil { sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql() if err != nil { return u, errors.Wrap(err, "building sql") @@ -243,8 +243,12 @@ func (db *DB) UpdateUser( builder = builder.Set("links", *links) } - if avatarURLs != nil { - builder = builder.Set("avatar_urls", avatarURLs) + if avatar != nil { + if *avatar == "" { + builder = builder.Set("avatar", nil) + } else { + builder = builder.Set("avatar", avatar) + } } sql, args, err := builder.ToSql() diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 8b781a2..4494693 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -24,7 +24,7 @@ type userResponse struct { Username string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` - AvatarURLs []string `json:"avatar_urls"` + Avatar *string `json:"avatar"` Links []string `json:"links"` Names []db.FieldEntry `json:"names"` Pronouns []db.PronounEntry `json:"pronouns"` @@ -40,7 +40,7 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { Username: u.Username, DisplayName: u.DisplayName, Bio: u.Bio, - AvatarURLs: db.NotNull(u.AvatarURLs), + Avatar: u.Avatar, Links: db.NotNull(u.Links), Names: db.NotNull(u.Names), Pronouns: db.NotNull(u.Pronouns), diff --git a/backend/routes/bot/bot.go b/backend/routes/bot/bot.go index 5c1aa8f..f7e49c4 100644 --- a/backend/routes/bot/bot.go +++ b/backend/routes/bot/bot.go @@ -23,6 +23,14 @@ type Bot struct { baseURL string } +func (bot *Bot) UserAvatarURL(u db.User) string { + if u.Avatar == nil { + return "" + } + + return bot.baseURL + "/media/users/" + u.ID.String() + "/" + *u.Avatar + ".webp" +} + func Mount(srv *server.Server, r chi.Router) { publicKey, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY")) if err != nil { @@ -97,8 +105,8 @@ func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discord } avatarURL := du.AvatarURL("") - if len(u.AvatarURLs) > 0 { - avatarURL = u.AvatarURLs[0] + if url := bot.UserAvatarURL(u); url != "" { + avatarURL = url } name := u.Username if u.DisplayName != nil { diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go index 50ff54e..4b59b1e 100644 --- a/backend/routes/member/create_member.go +++ b/backend/routes/member/create_member.go @@ -125,13 +125,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error return err } - webpURL, jpgURL, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) + hash, 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) + err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar) if err != nil { return errors.Wrap(err, "setting avatar urls in db") } diff --git a/backend/routes/member/get_member.go b/backend/routes/member/get_member.go index 6f675c7..7028b4f 100644 --- a/backend/routes/member/get_member.go +++ b/backend/routes/member/get_member.go @@ -16,7 +16,7 @@ type GetMemberResponse struct { Name string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` - AvatarURLs []string `json:"avatar_urls"` + Avatar *string `json:"avatar"` Links []string `json:"links"` Names []db.FieldEntry `json:"names"` @@ -32,7 +32,7 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field) GetMemberRespon Name: m.Name, DisplayName: m.DisplayName, Bio: m.Bio, - AvatarURLs: db.NotNull(m.AvatarURLs), + Avatar: m.Avatar, Links: db.NotNull(m.Links), Names: db.NotNull(m.Names), @@ -43,16 +43,16 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field) GetMemberRespon ID: u.ID, Username: u.Username, DisplayName: u.DisplayName, - AvatarURLs: db.NotNull(u.AvatarURLs), + Avatar: u.Avatar, }, } } type PartialUser struct { - ID xid.ID `json:"id"` - Username string `json:"name"` - DisplayName *string `json:"display_name"` - AvatarURLs []string `json:"avatar_urls"` + ID xid.ID `json:"id"` + Username string `json:"name"` + DisplayName *string `json:"display_name"` + Avatar *string `json:"avatar"` } func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { diff --git a/backend/routes/member/get_members.go b/backend/routes/member/get_members.go index ab791eb..6f90b24 100644 --- a/backend/routes/member/get_members.go +++ b/backend/routes/member/get_members.go @@ -15,7 +15,7 @@ type memberListResponse struct { Name string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` - AvatarURLs []string `json:"avatar_urls"` + Avatar *string `json:"avatar"` Links []string `json:"links"` Names []db.FieldEntry `json:"names"` Pronouns []db.PronounEntry `json:"pronouns"` @@ -25,13 +25,13 @@ 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: db.NotNull(ms[i].AvatarURLs), - Links: db.NotNull(ms[i].Links), - Names: db.NotNull(ms[i].Names), - Pronouns: db.NotNull(ms[i].Pronouns), + ID: ms[i].ID, + Name: ms[i].Name, + Bio: ms[i].Bio, + Avatar: ms[i].Avatar, + Links: db.NotNull(ms[i].Links), + Names: db.NotNull(ms[i].Names), + Pronouns: db.NotNull(ms[i].Pronouns), } } diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go index 10f706f..dcb941c 100644 --- a/backend/routes/member/patch_member.go +++ b/backend/routes/member/patch_member.go @@ -127,7 +127,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } // update avatar - var avatarURLs []string = nil + var avatarHash *string = nil if req.Avatar != nil { webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar) if err != nil { @@ -147,12 +147,12 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { return err } - webpURL, jpgURL, err := s.DB.WriteMemberAvatar(ctx, id, webp, jpg) + hash, err := s.DB.WriteMemberAvatar(ctx, id, webp, jpg) if err != nil { log.Errorf("uploading member avatar: %v", err) return err } - avatarURLs = []string{webpURL, jpgURL} + avatarHash = &hash } // start transaction @@ -163,7 +163,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } defer tx.Rollback(ctx) - m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Links, avatarURLs) + m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Links, avatarHash) if err != nil { switch errors.Cause(err) { case db.ErrNothingToUpdate: diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index ca3f699..6e9da05 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:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` - AvatarURLs []string `json:"avatar_urls"` + Avatar *string `json:"avatar"` Links []string `json:"links"` Names []db.FieldEntry `json:"names"` Pronouns []db.PronounEntry `json:"pronouns"` @@ -36,7 +36,7 @@ type PartialMember struct { Name string `json:"name"` DisplayName *string `json:"display_name"` Bio *string `json:"bio"` - AvatarURLs []string `json:"avatar_urls"` + Avatar *string `json:"avatar"` Links []string `json:"links"` Names []db.FieldEntry `json:"names"` Pronouns []db.PronounEntry `json:"pronouns"` @@ -48,7 +48,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser Username: u.Username, DisplayName: u.DisplayName, Bio: u.Bio, - AvatarURLs: db.NotNull(u.AvatarURLs), + Avatar: u.Avatar, Links: db.NotNull(u.Links), Names: db.NotNull(u.Names), Pronouns: db.NotNull(u.Pronouns), @@ -62,7 +62,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser Name: members[i].Name, DisplayName: members[i].DisplayName, Bio: members[i].Bio, - AvatarURLs: db.NotNull(members[i].AvatarURLs), + Avatar: members[i].Avatar, Links: db.NotNull(members[i].Links), Names: db.NotNull(members[i].Names), Pronouns: db.NotNull(members[i].Pronouns), diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 2ad0ac6..dd4f9da 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -101,7 +101,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } // update avatar - var avatarURLs []string = nil + var avatarHash *string = nil if req.Avatar != nil { webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar) if err != nil { @@ -121,12 +121,12 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { return err } - webpURL, jpgURL, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg) + hash, 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} + avatarHash = &hash } // start transaction @@ -152,7 +152,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } - u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.Links, avatarURLs) + u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.Links, avatarHash) if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { log.Errorf("updating user: %v", err) return err diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index dc4c2b5..399e7a4 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -1,9 +1,11 @@ +import { PUBLIC_BASE_URL } from "$env/static/public"; + export interface User { id: string; name: string; display_name: string | null; bio: string | null; - avatar_urls: string[]; + avatar: string | null; links: string[]; names: FieldEntry[]; @@ -47,7 +49,7 @@ export interface PartialMember { name: string; display_name: string | null; bio: string | null; - avatar_urls: string[]; + avatar: string | null; links: string[]; names: FieldEntry[]; pronouns: Pronoun[]; @@ -63,7 +65,7 @@ export interface MemberPartialUser { id: string; name: string; display_name: string | null; - avatar_urls: string[]; + avatar: string | null; } export interface APIError { @@ -98,3 +100,21 @@ export enum ErrorCode { RequestTooBig = 4001, } + +export const userAvatars = (user: User | MeUser | MemberPartialUser) => { + if (!user.avatar) return []; + + return [ + `${PUBLIC_BASE_URL}/media/users/${user.id}/${user.avatar}.webp`, + `${PUBLIC_BASE_URL}/media/users/${user.id}/${user.avatar}.webp`, + ]; +}; + +export const memberAvatars = (member: Member | PartialMember) => { + if (!member.avatar) return []; + + return [ + `${PUBLIC_BASE_URL}/media/members/${member.id}/${member.avatar}.webp`, + `${PUBLIC_BASE_URL}/media/members/${member.id}/${member.avatar}.webp`, + ]; +}; diff --git a/frontend/src/lib/components/PartialMemberCard.svelte b/frontend/src/lib/components/PartialMemberCard.svelte index 87d0bc7..7493b40 100644 --- a/frontend/src/lib/components/PartialMemberCard.svelte +++ b/frontend/src/lib/components/PartialMemberCard.svelte @@ -1,5 +1,5 @@