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:
iu
2026-10-11 20:54:54 +03:00
parent d5d3f55db8
commit 3b745fa737
16 changed files with 643 additions and 13 deletions
+9
View File
@@ -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 {
+11
View File
@@ -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)
+30 -6
View File
@@ -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)
+28
View File
@@ -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)
+30
View File
@@ -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,