feat(backend): some member routes, half-broken avatar uploading

This commit is contained in:
Sam 2022-09-20 12:55:00 +02:00
parent 220e8fa71d
commit b48fc74042
17 changed files with 759 additions and 32 deletions

View file

@ -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,

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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,

View file

@ -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),
}