feat(profile): add round avatar with upload and cropping
- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs, avatar service (EXIF-stripping WebP 128/512), public avatar read route - web: avatar upload/crop on profile, round avatar in header/members/comments/author - android: avatar upload/crop, round avatar on profile and albums header - openspec: add-user-avatar proposal/design/specs/tasks
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
assets "galo"
|
||||
"galo/internal/albums"
|
||||
"galo/internal/auth"
|
||||
"galo/internal/avatar"
|
||||
"galo/internal/config"
|
||||
"galo/internal/httpx"
|
||||
"galo/internal/members"
|
||||
@@ -38,6 +39,8 @@ type Server struct {
|
||||
|
||||
auth *auth.Service
|
||||
|
||||
avatar *avatar.Service
|
||||
|
||||
membersSvc *members.Service
|
||||
|
||||
photos *photos.Service
|
||||
@@ -71,6 +74,8 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
|
||||
photosSvc.SetTempDir(cfg.Video.TempDir)
|
||||
photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency)
|
||||
|
||||
avatarSvc := avatar.NewService(repos.Photos, repos.Auth, st)
|
||||
|
||||
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
|
||||
|
||||
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
|
||||
@@ -83,6 +88,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
|
||||
hub: hub,
|
||||
albums: albumsSvc,
|
||||
auth: authSvc,
|
||||
avatar: avatarSvc,
|
||||
membersSvc: membersSvc,
|
||||
photos: photosSvc,
|
||||
worker: photosWorker,
|
||||
@@ -106,6 +112,7 @@ func (s *Server) Router() *gin.Engine {
|
||||
|
||||
authHandler := auth.NewHandler(s.auth)
|
||||
albumsHandler := albums.NewHandler(s.albums)
|
||||
avatarHandler := avatar.NewHandler(s.avatar)
|
||||
membersHandler := members.NewHandler(s.membersSvc)
|
||||
photosHandler := photos.NewHandler(s.photos)
|
||||
socialHandler := social.NewHandler(s.social)
|
||||
@@ -116,6 +123,11 @@ func (s *Server) Router() *gin.Engine {
|
||||
api.GET("/health", s.health)
|
||||
api.GET("/objects/*key", s.serveObject)
|
||||
api.HEAD("/objects/*key", s.serveObject)
|
||||
// Avatars are read by <img> tags and image loaders, which cannot send an
|
||||
// Authorization header, so the read route is public. Upload and removal
|
||||
// stay behind authentication. The URL carries an opaque per-generation
|
||||
// version segment, so the path is not guessable from a user id alone.
|
||||
api.GET("/avatars/:userID/:version", avatarHandler.Serve)
|
||||
|
||||
api.POST("/auth/register", authHandler.Register)
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
@@ -134,6 +146,8 @@ func (s *Server) Router() *gin.Engine {
|
||||
|
||||
authed.GET("/auth/me", authHandler.Me)
|
||||
authed.PATCH("/auth/me", authHandler.UpdateMe)
|
||||
authed.POST("/auth/me/avatar", avatarHandler.Upload)
|
||||
authed.DELETE("/auth/me/avatar", avatarHandler.Remove)
|
||||
authed.POST("/auth/me/password", authHandler.ChangePassword)
|
||||
authed.POST("/auth/me/identifiers", authHandler.AddIdentifier)
|
||||
authed.DELETE("/auth/me", authHandler.DeleteMe)
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
assets "galo"
|
||||
"galo/internal/auth"
|
||||
"galo/internal/config"
|
||||
"galo/internal/db"
|
||||
"galo/internal/models"
|
||||
"galo/internal/photos"
|
||||
"galo/internal/sse"
|
||||
"galo/internal/storage"
|
||||
"galo/internal/store"
|
||||
)
|
||||
|
||||
// newAvatarServer builds a full router over SQLite and FS storage, plus a valid
|
||||
// access token for a freshly created user. It needs no external database.
|
||||
func newAvatarServer(t *testing.T) (*gin.Engine, string, string, store.Repos) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
|
||||
if err != nil {
|
||||
t.Fatalf("migrations fs: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "meta.db")
|
||||
if err := db.MigrateSQLite(path, migFS); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
database, err := db.OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
repos := store.NewSQLite(database)
|
||||
|
||||
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
blob, err := storage.NewFS(t.TempDir(), signer)
|
||||
if err != nil {
|
||||
t.Fatalf("fs: %v", err)
|
||||
}
|
||||
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
|
||||
srv := New(&config.Config{}, repos, func(context.Context) error { return nil }, tm, blob, sse.NewHub(1))
|
||||
|
||||
uid := auth.NewID()
|
||||
u := &models.User{ID: uid, Email: uid[:8] + "@test.local", PasswordHash: "x", Name: "Tester"}
|
||||
if err := repos.Auth.CreateUser(context.Background(), u); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
access, err := tm.GenerateAccess(uid, u.Email)
|
||||
if err != nil {
|
||||
t.Fatalf("access token: %v", err)
|
||||
}
|
||||
return srv.Router(), access, uid, repos
|
||||
}
|
||||
|
||||
func multipartFile(t *testing.T, field, filename string, data []byte) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile(field, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
t.Fatalf("write form file: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
return &buf, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func doAuthed(r *gin.Engine, method, target string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, target, body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func avatarURLOf(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
User models.SelfUser `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.User.AvatarURL
|
||||
}
|
||||
|
||||
func TestAvatarWritesRequireAuth(t *testing.T) {
|
||||
r, _, _, _ := newAvatarServer(t)
|
||||
img := makeTestPNG(t)
|
||||
|
||||
body, ct := multipartFile(t, "file", "a.png", img)
|
||||
if rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("upload without token = %d, want 401", rec.Code)
|
||||
}
|
||||
if rec := doAuthed(r, http.MethodDelete, "/api/auth/me/avatar", nil, "", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("delete without token = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarUploadServeReplaceRemove(t *testing.T) {
|
||||
r, token, _, _ := newAvatarServer(t)
|
||||
img := makeTestPNG(t)
|
||||
|
||||
// Upload.
|
||||
body, ct := multipartFile(t, "file", "a.png", img)
|
||||
rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
first := avatarURLOf(t, rec)
|
||||
if first == "" || !strings.HasPrefix(first, "/api/avatars/") {
|
||||
t.Fatalf("avatar_url = %q", first)
|
||||
}
|
||||
|
||||
// The image loader fetches without an Authorization header: the read route
|
||||
// is public and returns a WebP blob.
|
||||
rec = doAuthed(r, http.MethodGet, first, nil, "", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("serve = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.HasPrefix(rec.Body.Bytes(), []byte("RIFF")) || !bytes.Contains(rec.Body.Bytes()[:16], []byte("WEBP")) {
|
||||
t.Fatalf("served body is not WebP")
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Fatalf("cache-control = %q", cc)
|
||||
}
|
||||
|
||||
// A non-image upload is rejected and does not change the avatar.
|
||||
body, ct = multipartFile(t, "file", "a.txt", []byte("not an image"))
|
||||
rec = doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad image = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
// Replace changes the version segment so caches invalidate.
|
||||
body, ct = multipartFile(t, "file", "b.png", img)
|
||||
rec = doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("replace = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
second := avatarURLOf(t, rec)
|
||||
if second == "" || second == first {
|
||||
t.Fatalf("avatar_url did not change on replace: %q", second)
|
||||
}
|
||||
|
||||
// Remove clears the avatar and the served URL 404s.
|
||||
if rec := doAuthed(r, http.MethodDelete, "/api/auth/me/avatar", nil, "", token); rec.Code != http.StatusOK {
|
||||
t.Fatalf("remove = %d body=%s", rec.Code, rec.Body.String())
|
||||
} else if url := avatarURLOf(t, rec); url != "" {
|
||||
t.Fatalf("avatar_url not cleared: %q", url)
|
||||
}
|
||||
if rec := doAuthed(r, http.MethodGet, second, nil, "", token); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("serve after remove = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeAvatarMissingUser(t *testing.T) {
|
||||
r, token, _, _ := newAvatarServer(t)
|
||||
rec := doAuthed(r, http.MethodGet, "/api/avatars/does-not-exist/x", nil, "", token)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarAppearsInUserViews(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, token, uid, repos := newAvatarServer(t)
|
||||
|
||||
body, ct := multipartFile(t, "file", "a.png", makeTestPNG(t))
|
||||
rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
url := avatarURLOf(t, rec)
|
||||
|
||||
// The self profile carries the avatar.
|
||||
rec = doAuthed(r, http.MethodGet, "/api/auth/me", nil, "", token)
|
||||
if me := avatarURLOf(t, rec); me != url {
|
||||
t.Fatalf("auth/me avatar_url = %q, want %q", me, url)
|
||||
}
|
||||
|
||||
// Public representations carry it too: a member with an avatar exposes the
|
||||
// URL, a member without one omits it.
|
||||
album := &models.Album{ID: auth.NewID(), OwnerID: uid, Title: "A"}
|
||||
if err := repos.Albums.Create(ctx, album); err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
if err := repos.Members.Add(ctx, album.ID, uid, "editor", nil); err != nil {
|
||||
t.Fatalf("add owner member: %v", err)
|
||||
}
|
||||
other := &models.User{ID: auth.NewID(), Email: auth.NewID()[:8] + "@test.local", PasswordHash: "x", Name: "Other"}
|
||||
if err := repos.Auth.CreateUser(ctx, other); err != nil {
|
||||
t.Fatalf("create other user: %v", err)
|
||||
}
|
||||
if err := repos.Members.Add(ctx, album.ID, other.ID, "viewer", nil); err != nil {
|
||||
t.Fatalf("add other member: %v", err)
|
||||
}
|
||||
|
||||
rec = doAuthed(r, http.MethodGet, "/api/albums/"+album.ID+"/members", nil, "", token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("members = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var mb struct {
|
||||
Members []models.AlbumMember `json:"members"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &mb); err != nil {
|
||||
t.Fatalf("decode members: %v", err)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, m := range mb.Members {
|
||||
seen[m.User.ID] = m.User.AvatarURL
|
||||
}
|
||||
if seen[uid] != url {
|
||||
t.Fatalf("member avatar_url = %q, want %q", seen[uid], url)
|
||||
}
|
||||
if seen[other.ID] != "" {
|
||||
t.Fatalf("member without avatar has avatar_url = %q", seen[other.ID])
|
||||
}
|
||||
|
||||
// Comments carry the commenter's avatar too.
|
||||
photo := &models.Photo{ID: auth.NewID(), AlbumID: album.ID, UploaderID: uid, OriginalFilename: "p.jpg"}
|
||||
obj := &photos.Object{ID: auth.NewID(), StorageKey: "objects/" + auth.NewID(), State: photos.ObjectPending}
|
||||
if err := repos.Photos.CreateWithOriginal(ctx, photo, obj); err != nil {
|
||||
t.Fatalf("create photo: %v", err)
|
||||
}
|
||||
if err := repos.Photos.ActivateObject(ctx, obj.ID); err != nil {
|
||||
t.Fatalf("activate photo object: %v", err)
|
||||
}
|
||||
rec = doAuthed(r, http.MethodPost, "/api/photos/"+photo.ID+"/comments", bytes.NewBufferString(`{"text":"hi"}`), "application/json", token)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("comment = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doAuthed(r, http.MethodGet, "/api/photos/"+photo.ID+"/comments", nil, "", token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list comments = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var cb struct {
|
||||
Comments []models.Comment `json:"comments"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &cb); err != nil {
|
||||
t.Fatalf("decode comments: %v", err)
|
||||
}
|
||||
if len(cb.Comments) != 1 || cb.Comments[0].User.AvatarURL != url {
|
||||
t.Fatalf("comment avatar_url = %+v, want %q", cb.Comments, url)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user