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)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ type Repository interface {
|
||||
AddIdentifier(ctx context.Context, userID, email, phone string) error
|
||||
DeleteUser(ctx context.Context, userID string) error
|
||||
UpdatePreferences(ctx context.Context, userID string, raw []byte) error
|
||||
// SetAvatarURL stores the stable URL of the user's avatar; an empty value
|
||||
// clears it.
|
||||
SetAvatarURL(ctx context.Context, userID, url string) error
|
||||
CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error
|
||||
GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error)
|
||||
RevokeRefreshToken(ctx context.Context, id string) error
|
||||
|
||||
@@ -88,6 +88,13 @@ func (s *SQLiteStore) UpdatePreferences(ctx context.Context, userID string, raw
|
||||
`UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw)))
|
||||
}
|
||||
|
||||
// SetAvatarURL stores the stable URL of the user's avatar. An empty value
|
||||
// clears the column.
|
||||
func (s *SQLiteStore) SetAvatarURL(ctx context.Context, userID, url string) error {
|
||||
return s.requireRow(s.db.ExecContext(ctx,
|
||||
`UPDATE users SET avatar_url = NULLIF($2, '') WHERE id = $1`, userID, url))
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) requireRow(res sql.Result, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -105,6 +105,14 @@ func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte
|
||||
return requireRow(tag, err)
|
||||
}
|
||||
|
||||
// SetAvatarURL stores the stable URL of the user's avatar. An empty value
|
||||
// clears the column.
|
||||
func (s *Store) SetAvatarURL(ctx context.Context, userID, url string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE users SET avatar_url = NULLIF($2, '') WHERE id = $1`, userID, url)
|
||||
return requireRow(tag, err)
|
||||
}
|
||||
|
||||
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package avatar manages a user's profile photo: it normalises an uploaded
|
||||
// image into square WebP variants and keeps them as journaled objects bound to
|
||||
// the user, so the object ledger's garbage collection preserves them.
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gen2brain/webp"
|
||||
)
|
||||
|
||||
// Canonical avatar sizes. The small variant serves lists and headers; the large
|
||||
// one serves the profile page.
|
||||
const (
|
||||
SizeLarge = 512
|
||||
SizeSmall = 128
|
||||
)
|
||||
|
||||
// maxUploadBytes caps an avatar upload. Avatars are small, so a generous
|
||||
// general image limit would let a client stream needlessly.
|
||||
const maxUploadBytes = 10 << 20
|
||||
|
||||
var errInvalidImage = errors.New("invalid image")
|
||||
|
||||
// normalize decodes an uploaded image and produces two square WebP variants.
|
||||
// Decoding honours the EXIF orientation; re-encoding discards all metadata
|
||||
// (including GPS), so the stored avatar carries none of the original's.
|
||||
func normalize(data []byte) (large, small []byte, err error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, nil, errInvalidImage
|
||||
}
|
||||
large, err = fillWebP(img, SizeLarge)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
small, err = fillWebP(img, SizeSmall)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return large, small, nil
|
||||
}
|
||||
|
||||
// fillWebP centre-crops the image to a square of the given size and encodes it
|
||||
// as WebP.
|
||||
func fillWebP(img image.Image, size int) ([]byte, error) {
|
||||
square := imaging.Fill(img, size, size, imaging.Center, imaging.Lanczos)
|
||||
var buf bytes.Buffer
|
||||
if err := webp.Encode(&buf, square, webp.Options{Quality: 82}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"galo/internal/auth"
|
||||
"galo/internal/httpx"
|
||||
"galo/internal/photos"
|
||||
)
|
||||
|
||||
// avatarCacheControl is safe to cache aggressively because the URL carries an
|
||||
// opaque per-generation version segment: replacing the avatar changes the URL.
|
||||
const avatarCacheControl = "private, max-age=31536000, immutable"
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
// Upload installs or replaces the authenticated user's avatar from a multipart
|
||||
// "file" field and returns the updated self profile.
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.svc.MaxUploadBytes())
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large",
|
||||
"avatar exceeds the maximum upload size"))
|
||||
return
|
||||
}
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "file is required"))
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
u, err := h.svc.SetAvatar(c.Request.Context(), auth.UserID(c), f)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": u.Self()})
|
||||
}
|
||||
|
||||
// Remove resets the avatar to the default placeholder and returns the updated
|
||||
// self profile.
|
||||
func (h *Handler) Remove(c *gin.Context) {
|
||||
u, err := h.svc.RemoveAvatar(c.Request.Context(), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": u.Self()})
|
||||
}
|
||||
|
||||
// Serve streams the user's avatar. The version segment only busts caches; the
|
||||
// current generation is always served.
|
||||
func (h *Handler) Serve(c *gin.Context) {
|
||||
large := c.Query("size") == "large"
|
||||
data, err := h.svc.AvatarImage(c.Request.Context(), c.Param("userID"), large)
|
||||
if err != nil {
|
||||
if errors.Is(err, photos.ErrObjectNotFound) {
|
||||
httpx.RespondError(c, httpx.NotFound("avatar_not_found", "avatar not found"))
|
||||
return
|
||||
}
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", avatarCacheControl)
|
||||
c.Data(http.StatusOK, "image/webp", data)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
|
||||
"github.com/gen2brain/webp"
|
||||
)
|
||||
|
||||
// jpegImage builds a non-square JPEG so the square centre crop is observable.
|
||||
func jpegImage(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for x := 0; x < w; x++ {
|
||||
for y := 0; y < h; y++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// withEXIFSegment inserts an APP1 segment carrying an "Exif\x00\x00" marker.
|
||||
// The payload stands in for real EXIF metadata (including GPS): the JPEG
|
||||
// decoder ignores it, and re-encoding must drop it.
|
||||
func withEXIFSegment(t *testing.T, jpegData []byte) []byte {
|
||||
t.Helper()
|
||||
payload := append([]byte("Exif\x00\x00"), 0x00, 0x00) // bogus TIFF header
|
||||
segLen := len(payload) + 2
|
||||
seg := []byte{0xFF, 0xE1, byte(segLen >> 8), byte(segLen & 0xFF)}
|
||||
seg = append(seg, payload...)
|
||||
if len(jpegData) < 2 {
|
||||
t.Fatalf("not a jpeg")
|
||||
}
|
||||
out := append([]byte{}, jpegData[:2]...)
|
||||
out = append(out, seg...)
|
||||
out = append(out, jpegData[2:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNormalizeSquaresAndStripsMetadata(t *testing.T) {
|
||||
source := withEXIFSegment(t, jpegImage(t, 800, 400))
|
||||
if !bytes.Contains(source, []byte("Exif")) {
|
||||
t.Fatalf("test setup: source has no EXIF marker")
|
||||
}
|
||||
|
||||
large, small, err := normalize(source)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := webp.DecodeConfig(bytes.NewReader(large))
|
||||
if err != nil {
|
||||
t.Fatalf("decode large: %v", err)
|
||||
}
|
||||
if cfg.Width != SizeLarge || cfg.Height != SizeLarge {
|
||||
t.Fatalf("large = %dx%d, want %dx%d", cfg.Width, cfg.Height, SizeLarge, SizeLarge)
|
||||
}
|
||||
cfg, err = webp.DecodeConfig(bytes.NewReader(small))
|
||||
if err != nil {
|
||||
t.Fatalf("decode small: %v", err)
|
||||
}
|
||||
if cfg.Width != SizeSmall || cfg.Height != SizeSmall {
|
||||
t.Fatalf("small = %dx%d, want %dx%d", cfg.Width, cfg.Height, SizeSmall, SizeSmall)
|
||||
}
|
||||
|
||||
// Re-encoding must discard the source metadata.
|
||||
for name, data := range map[string][]byte{"large": large, "small": small} {
|
||||
if bytes.Contains(data, []byte("Exif")) {
|
||||
t.Fatalf("%s WebP still contains an EXIF marker", name)
|
||||
}
|
||||
if _, err := webp.DecodeExif(bytes.NewReader(data)); !errors.Is(err, webp.ErrNoExif) {
|
||||
t.Fatalf("%s metadata not stripped: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsNonImage(t *testing.T) {
|
||||
if _, _, err := normalize([]byte("definitely not an image")); !errors.Is(err, errInvalidImage) {
|
||||
t.Fatalf("err = %v, want errInvalidImage", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"galo/internal/auth"
|
||||
"galo/internal/httpx"
|
||||
"galo/internal/models"
|
||||
"galo/internal/photos"
|
||||
"galo/internal/storage"
|
||||
)
|
||||
|
||||
// ObjectStore is the slice of the object ledger the avatar service depends on.
|
||||
// It is satisfied by the photos repository.
|
||||
type ObjectStore interface {
|
||||
CreateObject(ctx context.Context, o *photos.Object) error
|
||||
ActivateObject(ctx context.Context, id string) error
|
||||
DeleteObjectRow(ctx context.Context, id string) error
|
||||
BindUserAvatar(ctx context.Context, userID, objectID, role string) error
|
||||
UserAvatarObject(ctx context.Context, userID, role string) (*photos.Object, error)
|
||||
ClearUserAvatar(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
// UserStore reads the account and stores the avatar's stable URL.
|
||||
type UserStore interface {
|
||||
GetUserByID(ctx context.Context, id string) (*models.User, error)
|
||||
SetAvatarURL(ctx context.Context, userID, url string) error
|
||||
}
|
||||
|
||||
// Service installs, replaces and removes a user's avatar.
|
||||
type Service struct {
|
||||
objects ObjectStore
|
||||
users UserStore
|
||||
storage storage.Backend
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
func NewService(objects ObjectStore, users UserStore, st storage.Backend) *Service {
|
||||
return &Service{objects: objects, users: users, storage: st, maxBytes: maxUploadBytes}
|
||||
}
|
||||
|
||||
// SetMaxUploadBytes overrides the per-file avatar size limit.
|
||||
func (s *Service) SetMaxUploadBytes(n int64) {
|
||||
if n > 0 {
|
||||
s.maxBytes = n
|
||||
}
|
||||
}
|
||||
|
||||
// MaxUploadBytes returns the request body cap for an avatar upload.
|
||||
func (s *Service) MaxUploadBytes() int64 { return s.maxBytes }
|
||||
|
||||
// SetAvatar normalises the uploaded image, stores both variants as journaled
|
||||
// objects and rebinds them to the user. The previous avatar's blobs become
|
||||
// unreferenced and are reclaimed by garbage collection.
|
||||
func (s *Service) SetAvatar(ctx context.Context, userID string, r io.Reader) (*models.User, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, s.maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > s.maxBytes {
|
||||
return nil, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large",
|
||||
"avatar exceeds the maximum upload size")
|
||||
}
|
||||
large, small, err := normalize(data)
|
||||
if err != nil {
|
||||
return nil, httpx.BadRequest("invalid_image", "file is not a decodable image")
|
||||
}
|
||||
|
||||
largeObj, err := s.putObject(ctx, photos.RoleAvatar, SizeLarge, large)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
smallObj, err := s.putObject(ctx, photos.RoleAvatarSmall, SizeSmall, small)
|
||||
if err != nil {
|
||||
_ = s.objects.DeleteObjectRow(ctx, largeObj.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.objects.BindUserAvatar(ctx, userID, largeObj.ID, photos.RoleAvatar); err != nil {
|
||||
s.rollback(ctx, userID, false, largeObj, smallObj)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.objects.BindUserAvatar(ctx, userID, smallObj.ID, photos.RoleAvatarSmall); err != nil {
|
||||
s.rollback(ctx, userID, true, largeObj, smallObj)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.users.SetAvatarURL(ctx, userID, avatarURL(userID, largeObj.ID)); err != nil {
|
||||
s.rollback(ctx, userID, true, largeObj, smallObj)
|
||||
return nil, err
|
||||
}
|
||||
return s.users.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// RemoveAvatar clears the binding and the stored URL. The blobs are reclaimed
|
||||
// by garbage collection.
|
||||
func (s *Service) RemoveAvatar(ctx context.Context, userID string) (*models.User, error) {
|
||||
if err := s.objects.ClearUserAvatar(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.users.SetAvatarURL(ctx, userID, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.users.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// AvatarImage returns the stored bytes of the user's avatar for the requested
|
||||
// variant. It reports photos.ErrObjectNotFound when the user has no avatar.
|
||||
func (s *Service) AvatarImage(ctx context.Context, userID string, large bool) ([]byte, error) {
|
||||
role := photos.RoleAvatarSmall
|
||||
if large {
|
||||
role = photos.RoleAvatar
|
||||
}
|
||||
obj, err := s.objects.UserAvatarObject(ctx, userID, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.storage.Get(ctx, obj.StorageKey)
|
||||
}
|
||||
|
||||
// putObject records a pending object row, stores the bytes and activates it. A
|
||||
// failure after the row exists removes it so no orphan is left behind.
|
||||
func (s *Service) putObject(ctx context.Context, role string, size int, data []byte) (*photos.Object, error) {
|
||||
id := auth.NewID()
|
||||
key := photos.ObjectKey(id, role, ".webp")
|
||||
obj := &photos.Object{
|
||||
ID: id,
|
||||
StorageKey: key,
|
||||
MimeType: "image/webp",
|
||||
Size: int64(len(data)),
|
||||
Width: size,
|
||||
Height: size,
|
||||
Checksum: checksum(data),
|
||||
}
|
||||
if err := s.objects.CreateObject(ctx, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/webp"); err != nil {
|
||||
_ = s.objects.DeleteObjectRow(ctx, id)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.objects.ActivateObject(ctx, id); err != nil {
|
||||
_ = s.objects.DeleteObjectRow(ctx, id)
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// rollback discards freshly created objects after a failed install. When a new
|
||||
// binding was already written it is cleared first, so the object rows can be
|
||||
// removed without violating the binding's foreign key.
|
||||
func (s *Service) rollback(ctx context.Context, userID string, bound bool, objs ...*photos.Object) {
|
||||
if bound {
|
||||
_ = s.objects.ClearUserAvatar(ctx, userID)
|
||||
}
|
||||
for _, o := range objs {
|
||||
_ = s.objects.DeleteObjectRow(ctx, o.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func avatarURL(userID, version string) string {
|
||||
return "/api/avatars/" + userID + "/" + version
|
||||
}
|
||||
|
||||
func checksum(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
assets "galo"
|
||||
"galo/internal/auth"
|
||||
"galo/internal/db"
|
||||
"galo/internal/httpx"
|
||||
"galo/internal/models"
|
||||
"galo/internal/photos"
|
||||
"galo/internal/storage"
|
||||
"galo/internal/store"
|
||||
)
|
||||
|
||||
type stack struct {
|
||||
svc *Service
|
||||
repos store.Repos
|
||||
blob storage.Backend
|
||||
userID string
|
||||
}
|
||||
|
||||
func newStack(t *testing.T) *stack {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
|
||||
u := &models.User{ID: auth.NewID(), Email: auth.NewID()[:10] + "@test.local", PasswordHash: "x", Name: "Tester"}
|
||||
if err := repos.Auth.CreateUser(context.Background(), u); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
return &stack{svc: NewService(repos.Photos, repos.Auth, blob), repos: repos, blob: blob, userID: u.ID}
|
||||
}
|
||||
|
||||
func (s *stack) objectKeys(t *testing.T) int {
|
||||
t.Helper()
|
||||
keys, err := s.repos.Photos.AllStorageKeys(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("all storage keys: %v", err)
|
||||
}
|
||||
return len(keys)
|
||||
}
|
||||
|
||||
func errCode(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var appErr *httpx.AppError
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("expected AppError, got %v", err)
|
||||
}
|
||||
return appErr.Code
|
||||
}
|
||||
|
||||
func TestSetAvatarBindsBothVariants(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
before := st.objectKeys(t)
|
||||
|
||||
updated, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 400, 400)))
|
||||
if err != nil {
|
||||
t.Fatalf("set avatar: %v", err)
|
||||
}
|
||||
if updated.AvatarURL == "" || !bytes.Contains([]byte(updated.AvatarURL), []byte(st.userID)) {
|
||||
t.Fatalf("avatar_url not set: %q", updated.AvatarURL)
|
||||
}
|
||||
if st.objectKeys(t) != before+2 {
|
||||
t.Fatalf("object count = %d, want %d", st.objectKeys(t), before+2)
|
||||
}
|
||||
|
||||
large, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar)
|
||||
if err != nil || large.Width != SizeLarge || large.Height != SizeLarge {
|
||||
t.Fatalf("large variant: %v %+v", err, large)
|
||||
}
|
||||
small, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatarSmall)
|
||||
if err != nil || small.Width != SizeSmall || small.Height != SizeSmall {
|
||||
t.Fatalf("small variant: %v %+v", err, small)
|
||||
}
|
||||
|
||||
data, err := st.svc.AvatarImage(ctx, st.userID, false)
|
||||
if err != nil || len(data) == 0 {
|
||||
t.Fatalf("serve small: %v len=%d", err, len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAvatarReplacementReclaimsOldObjects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
|
||||
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 300, 300))); err != nil {
|
||||
t.Fatalf("first avatar: %v", err)
|
||||
}
|
||||
oldLarge, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar)
|
||||
oldSmall, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatarSmall)
|
||||
|
||||
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 200, 200))); err != nil {
|
||||
t.Fatalf("replace avatar: %v", err)
|
||||
}
|
||||
|
||||
claimed, err := st.repos.Photos.ClaimGarbage(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("claim garbage: %v", err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, o := range claimed {
|
||||
got[o.ID] = true
|
||||
}
|
||||
if !got[oldLarge.ID] || !got[oldSmall.ID] {
|
||||
t.Fatalf("superseded objects not collected: %+v", got)
|
||||
}
|
||||
|
||||
// The current avatar keeps its objects.
|
||||
newLarge, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar)
|
||||
if got[newLarge.ID] {
|
||||
t.Fatalf("current avatar object was collected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAvatarClearsAndReclaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
|
||||
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 100, 100))); err != nil {
|
||||
t.Fatalf("set avatar: %v", err)
|
||||
}
|
||||
large, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar)
|
||||
|
||||
updated, err := st.svc.RemoveAvatar(ctx, st.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("remove avatar: %v", err)
|
||||
}
|
||||
if updated.AvatarURL != "" {
|
||||
t.Fatalf("avatar_url not cleared: %q", updated.AvatarURL)
|
||||
}
|
||||
if _, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) {
|
||||
t.Fatalf("avatar still bound: %v", err)
|
||||
}
|
||||
claimed, _ := st.repos.Photos.ClaimGarbage(ctx, 10)
|
||||
found := false
|
||||
for _, o := range claimed {
|
||||
if o.ID == large.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("removed avatar object not collected: %+v", claimed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAvatarRejectsNonImage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
|
||||
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader([]byte("not an image"))); errCode(t, err) != "invalid_image" {
|
||||
t.Fatalf("code = %v, want invalid_image", err)
|
||||
}
|
||||
if st.objectKeys(t) != 0 {
|
||||
t.Fatalf("objects created for an invalid image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAvatarRejectsOversize(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
st.svc.SetMaxUploadBytes(1024)
|
||||
|
||||
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(make([]byte, 2048))); errCode(t, err) != "file_too_large" {
|
||||
t.Fatalf("code = %v, want file_too_large", err)
|
||||
}
|
||||
if st.objectKeys(t) != 0 {
|
||||
t.Fatalf("objects created for an oversize upload")
|
||||
}
|
||||
}
|
||||
|
||||
// failingStorage delegates to a real backend but fails every Put, to exercise
|
||||
// the rollback that must not leave orphaned object rows.
|
||||
type failingStorage struct {
|
||||
storage.Backend
|
||||
}
|
||||
|
||||
func (failingStorage) Put(context.Context, string, io.Reader, int64, string) error {
|
||||
return errors.New("put failed")
|
||||
}
|
||||
|
||||
func TestSetAvatarStorageFailureLeavesNoObjects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
st.svc = NewService(st.repos.Photos, st.repos.Auth, failingStorage{st.blob})
|
||||
|
||||
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 100, 100))); err == nil {
|
||||
t.Fatalf("expected storage failure")
|
||||
}
|
||||
if st.objectKeys(t) != 0 {
|
||||
t.Fatalf("orphaned object rows after storage failure")
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,28 @@ func TestPublicUserOmitsPhone(t *testing.T) {
|
||||
t.Fatalf("SelfUser phone not serialized: %s", self)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicUserCarriesAvatar(t *testing.T) {
|
||||
u := &User{ID: "u1", Name: "Tester", AvatarURL: "/api/avatars/u1/v123"}
|
||||
|
||||
pub, err := json.Marshal(u.Public())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal public: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(pub), `"avatar_url":"/api/avatars/u1/v123"`) {
|
||||
t.Fatalf("PublicUser avatar_url missing: %s", pub)
|
||||
}
|
||||
self, err := json.Marshal(u.Self())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal self: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(self), `"avatar_url":"/api/avatars/u1/v123"`) {
|
||||
t.Fatalf("SelfUser avatar_url missing: %s", self)
|
||||
}
|
||||
|
||||
// Without an avatar the field is omitted, so clients fall back to a placeholder.
|
||||
bare, _ := json.Marshal((&User{ID: "u2", Name: "Bare"}).Public())
|
||||
if strings.Contains(string(bare), "avatar_url") {
|
||||
t.Fatalf("empty avatar_url should be omitted: %s", bare)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,8 @@ func objectKey(objectID, role, ext string) string {
|
||||
}
|
||||
return fmt.Sprintf("objects/%s/%s%s", objectID, role, ext)
|
||||
}
|
||||
|
||||
// ObjectKey exposes the opaque storage-key format for callers outside the
|
||||
// photos package that need to create their own journaled objects (for example
|
||||
// user avatars). The key stays entity-free; only the caller knows the binding.
|
||||
func ObjectKey(objectID, role, ext string) string { return objectKey(objectID, role, ext) }
|
||||
|
||||
@@ -25,6 +25,13 @@ const (
|
||||
RolePlayback = "playback"
|
||||
)
|
||||
|
||||
// Avatar roles as bound to a user. The small variant serves lists and headers;
|
||||
// the large one serves the profile page.
|
||||
const (
|
||||
RoleAvatar = "avatar"
|
||||
RoleAvatarSmall = "avatar_small"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrObjectNotFound is returned when an object row does not exist.
|
||||
ErrObjectNotFound = errors.New("object not found")
|
||||
@@ -120,6 +127,60 @@ func (s *Store) BindObject(ctx context.Context, photoID, objectID, role string)
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// BindUserAvatar attaches a ready object as the user's avatar under the given
|
||||
// role. It mirrors BindObject: the object row is locked and refused unless it is
|
||||
// ready, so an object being garbage-collected can never be bound.
|
||||
func (s *Store) BindUserAvatar(ctx context.Context, userID, objectID, role string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var state string
|
||||
if err := tx.QueryRow(ctx, `SELECT state FROM objects WHERE id = $1 FOR UPDATE`, objectID).Scan(&state); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if state != ObjectReady {
|
||||
return ErrObjectNotReady
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO user_avatar_objects (user_id, object_id, role) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, role) DO UPDATE SET object_id = EXCLUDED.object_id`,
|
||||
userID, objectID, role); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// UserAvatarObject returns the object currently bound as the user's avatar for
|
||||
// the given role.
|
||||
func (s *Store) UserAvatarObject(ctx context.Context, userID, role string) (*Object, error) {
|
||||
var o Object
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at
|
||||
FROM user_avatar_objects uao JOIN objects o ON o.id = uao.object_id
|
||||
WHERE uao.user_id = $1 AND uao.role = $2`, userID, role).
|
||||
Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &o.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// ClearUserAvatar removes every avatar binding of the user. The blobs become
|
||||
// unreferenced and are reclaimed by garbage collection.
|
||||
func (s *Store) ClearUserAvatar(ctx context.Context, userID string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM user_avatar_objects WHERE user_id = $1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PhotoObjectIDs returns the object ids currently bound to a photo.
|
||||
func (s *Store) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID)
|
||||
@@ -199,6 +260,7 @@ func (s *Store) ClaimGarbage(ctx context.Context, limit int) ([]Object, error) {
|
||||
`SELECT o.id, o.storage_key FROM objects o
|
||||
WHERE o.state = ANY($1)
|
||||
AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM user_avatar_objects uao WHERE uao.object_id = o.id)
|
||||
ORDER BY o.created_at
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED`, []string{ObjectReady, ObjectDeleting}, limit)
|
||||
|
||||
@@ -49,6 +49,11 @@ type Repository interface {
|
||||
MarkObjectDeleting(ctx context.Context, id string) error
|
||||
DeleteObjectRow(ctx context.Context, id string) error
|
||||
BindObject(ctx context.Context, photoID, objectID, role string) error
|
||||
// BindUserAvatar, UserAvatarObject and ClearUserAvatar manage the object
|
||||
// binding that backs a user's profile avatar.
|
||||
BindUserAvatar(ctx context.Context, userID, objectID, role string) error
|
||||
UserAvatarObject(ctx context.Context, userID, role string) (*Object, error)
|
||||
ClearUserAvatar(ctx context.Context, userID string) error
|
||||
PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error)
|
||||
ObjectRefCount(ctx context.Context, objectID string) (int, error)
|
||||
OriginalObject(ctx context.Context, photoID string) (*Object, error)
|
||||
|
||||
@@ -89,6 +89,62 @@ func (s *SQLiteStore) BindObject(ctx context.Context, photoID, objectID, role st
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// BindUserAvatar attaches a ready object as the user's avatar under the given
|
||||
// role. The write lock of the immediate transaction serializes it against
|
||||
// garbage collection.
|
||||
func (s *SQLiteStore) BindUserAvatar(ctx context.Context, userID, objectID, role string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var state string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if state != ObjectReady {
|
||||
return ErrObjectNotReady
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO user_avatar_objects (user_id, object_id, role) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, role) DO UPDATE SET object_id = excluded.object_id`,
|
||||
userID, objectID, role); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UserAvatarObject returns the object currently bound as the user's avatar for
|
||||
// the given role.
|
||||
func (s *SQLiteStore) UserAvatarObject(ctx context.Context, userID, role string) (*Object, error) {
|
||||
var o Object
|
||||
var createdAt string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at
|
||||
FROM user_avatar_objects uao JOIN objects o ON o.id = uao.object_id
|
||||
WHERE uao.user_id = $1 AND uao.role = $2`, userID, role).
|
||||
Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &createdAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt = sqliteutil.ParseTime(createdAt)
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// ClearUserAvatar removes every avatar binding of the user. The blobs become
|
||||
// unreferenced and are reclaimed by garbage collection.
|
||||
func (s *SQLiteStore) ClearUserAvatar(ctx context.Context, userID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM user_avatar_objects WHERE user_id = $1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PhotoObjectIDs returns the object ids currently bound to a photo.
|
||||
func (s *SQLiteStore) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID)
|
||||
@@ -169,6 +225,7 @@ func (s *SQLiteStore) ClaimGarbage(ctx context.Context, limit int) ([]Object, er
|
||||
`SELECT o.id, o.storage_key FROM objects o
|
||||
WHERE o.state IN ('ready', 'deleting')
|
||||
AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM user_avatar_objects uao WHERE uao.object_id = o.id)
|
||||
ORDER BY o.created_at
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) {
|
||||
{"Social", testSocial},
|
||||
{"Photos", testPhotos},
|
||||
{"Objects", testObjects},
|
||||
{"UserAvatar", testUserAvatar},
|
||||
{"VariantJobKinds", testVariantJobKinds},
|
||||
{"Settings", testSettings},
|
||||
}
|
||||
@@ -648,6 +649,79 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
}
|
||||
}
|
||||
|
||||
func testUserAvatar(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
|
||||
obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||
if err := r.Photos.CreateObject(ctx, obj); err != nil {
|
||||
t.Fatalf("create avatar object: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotReady) {
|
||||
t.Fatalf("bind pending avatar err = %v, want ErrObjectNotReady", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil {
|
||||
t.Fatalf("activate avatar object: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); err != nil {
|
||||
t.Fatalf("bind avatar: %v", err)
|
||||
}
|
||||
|
||||
got, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar)
|
||||
if err != nil || got.ID != obj.ID || got.StorageKey != obj.StorageKey {
|
||||
t.Fatalf("avatar object: %v %+v", err, got)
|
||||
}
|
||||
// Roles are independent: no small variant was bound.
|
||||
if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatarSmall); !errors.Is(err, photos.ErrObjectNotFound) {
|
||||
t.Fatalf("unbound small role err = %v, want ErrObjectNotFound", err)
|
||||
}
|
||||
|
||||
// A bound avatar blob survives garbage collection.
|
||||
claimed, err := r.Photos.ClaimGarbage(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("claim garbage: %v", err)
|
||||
}
|
||||
if containsObject(claimed, obj.ID) {
|
||||
t.Fatalf("avatar object collected while bound: %+v", claimed)
|
||||
}
|
||||
|
||||
// Replacing the binding (a new generation) makes the old blob collectible.
|
||||
replacement := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||
if err := r.Photos.CreateObject(ctx, replacement); err != nil {
|
||||
t.Fatalf("create replacement: %v", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, replacement.ID); err != nil {
|
||||
t.Fatalf("activate replacement: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindUserAvatar(ctx, owner.ID, replacement.ID, photos.RoleAvatar); err != nil {
|
||||
t.Fatalf("rebind avatar: %v", err)
|
||||
}
|
||||
claimed, _ = r.Photos.ClaimGarbage(ctx, 10)
|
||||
if !containsObject(claimed, obj.ID) {
|
||||
t.Fatalf("superseded avatar object not collected: %+v", claimed)
|
||||
}
|
||||
|
||||
// Clearing removes every binding so the current blob is collected too.
|
||||
if err := r.Photos.ClearUserAvatar(ctx, owner.ID); err != nil {
|
||||
t.Fatalf("clear avatar: %v", err)
|
||||
}
|
||||
if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) {
|
||||
t.Fatalf("avatar still bound after clear: %v", err)
|
||||
}
|
||||
claimed, _ = r.Photos.ClaimGarbage(ctx, 10)
|
||||
if !containsObject(claimed, replacement.ID) {
|
||||
t.Fatalf("cleared avatar object not collected: %+v", claimed)
|
||||
}
|
||||
}
|
||||
|
||||
func containsObject(list []photos.Object, id string) bool {
|
||||
for _, o := range list {
|
||||
if o.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func testVariantJobKinds(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
a := seedAlbum(t, ctx, r, owner.ID)
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestSQLiteContract(t *testing.T) {
|
||||
Settings: settings.NewSQLiteStore(database),
|
||||
Reset: func(ctx context.Context) error {
|
||||
tables := []string{
|
||||
"variant_jobs", "photo_objects", "objects", "photo_tags", "tags",
|
||||
"variant_jobs", "photo_objects", "user_avatar_objects", "objects", "photo_tags", "tags",
|
||||
"refresh_tokens", "share_links", "notifications", "likes", "comments",
|
||||
"invites", "album_members", "photos", "albums", "users", "app_settings",
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_avatar_objects;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- A user's avatar is stored as journaled objects bound by role, mirroring
|
||||
-- photo_objects. Keeping the binding in the ledger means garbage collection
|
||||
-- preserves avatar blobs while they are referenced and reclaims them once the
|
||||
-- avatar is replaced or removed.
|
||||
CREATE TABLE IF NOT EXISTS user_avatar_objects (
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
object_id text NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
|
||||
role text NOT NULL CHECK (role IN ('avatar', 'avatar_small')),
|
||||
PRIMARY KEY (user_id, role)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_avatar_objects_object_idx ON user_avatar_objects (object_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_avatar_objects;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- A user's avatar is stored as journaled objects bound by role, mirroring
|
||||
-- photo_objects. Keeping the binding in the ledger means garbage collection
|
||||
-- preserves avatar blobs while they are referenced and reclaims them once the
|
||||
-- avatar is replaced or removed.
|
||||
CREATE TABLE user_avatar_objects (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
|
||||
role TEXT NOT NULL CHECK (role IN ('avatar', 'avatar_small')),
|
||||
PRIMARY KEY (user_id, role)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_avatar_objects_object_idx ON user_avatar_objects (object_id);
|
||||
Reference in New Issue
Block a user