feat(photos): fix trash permanent delete and add empty trash
Permanently deleting a photo whose album was deleted failed because authorization resolved through the album. Fall back to uploader ownership when the photo is albumless. Add a bulk DELETE /trash that removes every trashed photo the caller may delete (uncapped listing) and returns the deleted count, with an «Очистить корзину» action on the trash screen.
This commit is contained in:
@@ -166,6 +166,7 @@ func (s *Server) Router() *gin.Engine {
|
||||
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
|
||||
authed.GET("/trash", photosHandler.Trash)
|
||||
authed.GET("/trash/count", photosHandler.TrashCount)
|
||||
authed.DELETE("/trash", photosHandler.EmptyTrash)
|
||||
|
||||
authed.POST("/photos/:id/likes", socialHandler.AddLike)
|
||||
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
|
||||
|
||||
@@ -39,11 +39,14 @@ func makePNG(t *testing.T) []byte {
|
||||
}
|
||||
|
||||
type stack struct {
|
||||
db *sql.DB
|
||||
photos *photos.Service
|
||||
albums *albums.Service
|
||||
auth *auth.Service
|
||||
hub *sse.Hub
|
||||
db *sql.DB
|
||||
photos *photos.Service
|
||||
albums *albums.Service
|
||||
auth *auth.Service
|
||||
hub *sse.Hub
|
||||
tm *auth.TokenManager
|
||||
repos store.Repos
|
||||
storage storage.Backend
|
||||
}
|
||||
|
||||
// openStack opens a migrated SQLite database at path and builds the services.
|
||||
@@ -70,10 +73,11 @@ func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
|
||||
if err != nil {
|
||||
t.Fatalf("fs: %v", err)
|
||||
}
|
||||
authSvc := auth.NewService(repos.Auth, auth.NewTokenManager("secret", 15*time.Minute, time.Hour))
|
||||
tm := auth.NewTokenManager("secret", 15*time.Minute, time.Hour)
|
||||
authSvc := auth.NewService(repos.Auth, tm)
|
||||
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob)
|
||||
photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub)
|
||||
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc, hub: hub}, func() { database.Close() }
|
||||
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc, hub: hub, tm: tm, repos: repos, storage: blob}, func() { database.Close() }
|
||||
}
|
||||
|
||||
func TestUploadSetsDefaultCover(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"galo/internal/albums"
|
||||
"galo/internal/app"
|
||||
"galo/internal/auth"
|
||||
"galo/internal/config"
|
||||
"galo/internal/photos"
|
||||
)
|
||||
|
||||
func TestPermanentDeleteAlbumlessPhoto(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
user, _, err := st.auth.Register(ctx, "albumless@test.local", "password123", "A")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Doomed"})
|
||||
if err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
|
||||
var objectID string
|
||||
if err := st.db.QueryRowContext(ctx,
|
||||
`SELECT object_id FROM photo_objects WHERE photo_id = $1 AND role = 'original'`, p.ID).Scan(&objectID); err != nil {
|
||||
t.Fatalf("original object: %v", err)
|
||||
}
|
||||
|
||||
// Deleting the album moves its photo to the trash with no album binding.
|
||||
if err := st.albums.Delete(ctx, album.ID, user.ID); err != nil {
|
||||
t.Fatalf("delete album: %v", err)
|
||||
}
|
||||
var albumID sql.NullString
|
||||
if err := st.db.QueryRowContext(ctx, `SELECT album_id FROM photos WHERE id = $1`, p.ID).Scan(&albumID); err != nil {
|
||||
t.Fatalf("read album_id: %v", err)
|
||||
}
|
||||
if albumID.Valid {
|
||||
t.Fatalf("photo still bound to album %q after album deletion", albumID.String)
|
||||
}
|
||||
|
||||
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
|
||||
t.Fatalf("permanent delete albumless photo: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM photos WHERE id = $1`, p.ID).Scan(&n); err != nil {
|
||||
t.Fatalf("count photos: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("photo row survived permanent delete")
|
||||
}
|
||||
var state string
|
||||
if err := st.db.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil {
|
||||
t.Fatalf("object row missing after delete: %v", err)
|
||||
}
|
||||
if state != photos.ObjectDeleting {
|
||||
t.Fatalf("object state = %q, want %q", state, photos.ObjectDeleting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTrashSkipsForeignPhotos(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
owner, _, err := st.auth.Register(ctx, "owner@test.local", "password123", "O")
|
||||
if err != nil {
|
||||
t.Fatalf("register owner: %v", err)
|
||||
}
|
||||
member, _, err := st.auth.Register(ctx, "member@test.local", "password123", "M")
|
||||
if err != nil {
|
||||
t.Fatalf("register member: %v", err)
|
||||
}
|
||||
album, err := st.albums.Create(ctx, owner.ID, albums.CreateInput{Title: "Shared"})
|
||||
if err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
if _, err := st.db.ExecContext(ctx,
|
||||
`INSERT INTO album_members (album_id, user_id, role) VALUES ($1, $2, 'member')`, album.ID, member.ID); err != nil {
|
||||
t.Fatalf("add member: %v", err)
|
||||
}
|
||||
|
||||
ownerPhoto, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: owner.ID, Filename: "o.png", Data: makePNG(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("upload owner photo: %v", err)
|
||||
}
|
||||
memberPhoto, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: member.ID, Filename: "m.png", Data: makePNG(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("upload member photo: %v", err)
|
||||
}
|
||||
if err := st.photos.Delete(ctx, ownerPhoto.ID, owner.ID); err != nil {
|
||||
t.Fatalf("trash owner photo: %v", err)
|
||||
}
|
||||
if err := st.photos.Delete(ctx, memberPhoto.ID, member.ID); err != nil {
|
||||
t.Fatalf("trash member photo: %v", err)
|
||||
}
|
||||
|
||||
if n, _ := st.photos.TrashCount(ctx, member.ID); n != 2 {
|
||||
t.Fatalf("member trash count = %d, want 2", n)
|
||||
}
|
||||
|
||||
deleted, err := st.photos.EmptyTrash(ctx, member.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("empty trash: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1 (only the member's own photo)", deleted)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM photos WHERE id = $1`, memberPhoto.ID).Scan(&n); err != nil {
|
||||
t.Fatalf("count member photo: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("member's own photo survived empty trash")
|
||||
}
|
||||
if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM photos WHERE id = $1`, ownerPhoto.ID).Scan(&n); err != nil {
|
||||
t.Fatalf("count owner photo: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("foreign photo was removed by emptier")
|
||||
}
|
||||
if c, _ := st.photos.TrashCount(ctx, member.ID); c != 1 {
|
||||
t.Fatalf("trash count after empty = %d, want 1 (the foreign photo)", c)
|
||||
}
|
||||
|
||||
// Nothing the caller may delete remains, so a second pass deletes nothing.
|
||||
if deleted, err := st.photos.EmptyTrash(ctx, member.ID); err != nil || deleted != 0 {
|
||||
t.Fatalf("second empty = %d, err=%v; want 0, nil", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTrashIdentitiesNotCapped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
user, _, err := st.auth.Register(ctx, "many@test.local", "password123", "M")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
tx, err := st.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
for i := 0; i < 205; i++ {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO photos (id, uploader_id, deleted_at) VALUES ($1, $2, $3)`,
|
||||
auth.NewID(), user.ID, "2026-01-01T00:00:00Z"); err != nil {
|
||||
t.Fatalf("insert trashed photo %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
items, err := st.repos.Photos.ListTrashIdentities(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list trash identities: %v", err)
|
||||
}
|
||||
if len(items) != 205 {
|
||||
t.Fatalf("got %d identities, want 205 (display cap leaked into purge)", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTrashReturnsZeroWhenEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
user, _, err := st.auth.Register(ctx, "empty@test.local", "password123", "E")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
deleted, err := st.photos.EmptyTrash(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("empty trash: %v", err)
|
||||
}
|
||||
if deleted != 0 {
|
||||
t.Fatalf("deleted = %d, want 0", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTrashHTTP(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
user, pair, err := st.auth.Register(ctx, "http@test.local", "password123", "H")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "HTTP"})
|
||||
if err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
if err := st.photos.Delete(ctx, p.ID, user.ID); err != nil {
|
||||
t.Fatalf("trash photo: %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
srv := app.New(&config.Config{}, st.repos, st.db.PingContext, st.tm, st.storage, st.hub)
|
||||
router := srv.Router()
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/trash", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Deleted int `json:"deleted"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body.Deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1", body.Deleted)
|
||||
}
|
||||
if n, _ := st.photos.TrashCount(ctx, user.ID); n != 0 {
|
||||
t.Fatalf("trash count after empty = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,15 @@ func (h *Handler) TrashCount(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"count": n})
|
||||
}
|
||||
|
||||
func (h *Handler) EmptyTrash(c *gin.Context) {
|
||||
n, err := h.svc.EmptyTrash(c.Request.Context(), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
|
||||
func (h *Handler) Tags(c *gin.Context) {
|
||||
tags, err := h.svc.Tags(c.Request.Context(), auth.UserID(c), c.Query("q"))
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,14 @@ import (
|
||||
"galo/internal/models"
|
||||
)
|
||||
|
||||
// TrashIdentity is the minimal fact set needed to authorize a trashed photo
|
||||
// during a bulk purge without loading the full photo.
|
||||
type TrashIdentity struct {
|
||||
ID string
|
||||
AlbumID string
|
||||
UploaderID string
|
||||
}
|
||||
|
||||
// Repository is the persistence contract for the photos domain, including the
|
||||
// object ledger, variant queue and garbage collection.
|
||||
type Repository interface {
|
||||
@@ -19,6 +27,9 @@ type Repository interface {
|
||||
Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error
|
||||
HardDelete(ctx context.Context, id string) error
|
||||
ListTrash(ctx context.Context, userID string) ([]models.Photo, error)
|
||||
// ListTrashIdentities returns every trashed photo accessible to the user
|
||||
// (no display cap) so a bulk purge can authorize and remove each one.
|
||||
ListTrashIdentities(ctx context.Context, userID string) ([]TrashIdentity, error)
|
||||
TrashCount(ctx context.Context, userID string) (int, error)
|
||||
RestoreToAlbum(ctx context.Context, id, albumID string) error
|
||||
AlbumExists(ctx context.Context, albumID string) (bool, error)
|
||||
|
||||
@@ -597,12 +597,19 @@ func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) e
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
if p.AlbumID == "" {
|
||||
// A photo whose album was deleted only has its uploader to vouch for it.
|
||||
if p.UploaderID != userID {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
}
|
||||
} else {
|
||||
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
}
|
||||
}
|
||||
objectIDs, err := s.store.PhotoObjectIDs(ctx, photoID)
|
||||
if err != nil {
|
||||
@@ -624,6 +631,23 @@ func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmptyTrash permanently deletes every trashed photo the user may remove,
|
||||
// skipping the rest, and returns how many were deleted.
|
||||
func (s *Service) EmptyTrash(ctx context.Context, userID string) (int, error) {
|
||||
items, err := s.store.ListTrashIdentities(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted := 0
|
||||
for _, it := range items {
|
||||
if err := s.PermanentDelete(ctx, it.ID, userID); err != nil {
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// MoveBatch moves photos to another album (metadata only; stored objects untouched).
|
||||
func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlbumID string) (int, error) {
|
||||
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||
|
||||
@@ -322,6 +322,34 @@ func (s *SQLiteStore) TrashCount(ctx context.Context, userID string) (int, error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) ListTrashIdentities(ctx context.Context, userID string) ([]TrashIdentity, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT p.id, COALESCE(p.album_id, ''), p.uploader_id
|
||||
FROM photos p
|
||||
LEFT JOIN albums a ON a.id = p.album_id
|
||||
WHERE p.deleted_at IS NOT NULL
|
||||
AND (
|
||||
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
||||
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
||||
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
||||
)
|
||||
ORDER BY p.deleted_at DESC`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TrashIdentity{}
|
||||
for rows.Next() {
|
||||
var it TrashIdentity
|
||||
if err := rows.Scan(&it.ID, &it.AlbumID, &it.UploaderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) RestoreToAlbum(ctx context.Context, id, albumID string) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
|
||||
|
||||
@@ -341,6 +341,36 @@ func (s *Store) TrashCount(ctx context.Context, userID string) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ListTrashIdentities returns every trashed photo accessible to the user
|
||||
// (no display cap) so a bulk purge can authorize and remove each one.
|
||||
func (s *Store) ListTrashIdentities(ctx context.Context, userID string) ([]TrashIdentity, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT p.id, COALESCE(p.album_id, ''), p.uploader_id
|
||||
FROM photos p
|
||||
LEFT JOIN albums a ON a.id = p.album_id
|
||||
WHERE p.deleted_at IS NOT NULL
|
||||
AND (
|
||||
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
||||
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
||||
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
||||
)
|
||||
ORDER BY p.deleted_at DESC`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TrashIdentity{}
|
||||
for rows.Next() {
|
||||
var it TrashIdentity
|
||||
if err := rows.Scan(&it.ID, &it.AlbumID, &it.UploaderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RestoreToAlbum reassigns a soft-deleted photo to an album and clears the trash flag.
|
||||
func (s *Store) RestoreToAlbum(ctx context.Context, id, albumID string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
|
||||
Reference in New Issue
Block a user