diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 5bfc986..30ba552 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -137,6 +137,13 @@ func (s *Server) Router() *gin.Engine { authed.PATCH("/photos/:id", photosHandler.Rename) authed.DELETE("/photos/:id", photosHandler.Delete) authed.GET("/photos/:id/download", photosHandler.Download) + authed.POST("/photos/:id/restore", photosHandler.Restore) + authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete) + + authed.POST("/photo-batch/delete", photosHandler.DeleteBatch) + authed.POST("/photo-batch/move", photosHandler.MoveBatch) + authed.POST("/photo-batch/copy", photosHandler.CopyBatch) + authed.GET("/trash", photosHandler.Trash) authed.POST("/photos/:id/likes", socialHandler.AddLike) authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 0ff50b2..d4df5de 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -78,6 +78,8 @@ type Photo struct { Longitude *float64 `json:"longitude,omitempty"` Caption string `json:"caption"` EXIF map[string]any `json:"exif,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + AlbumTitle string `json:"album_title,omitempty"` CreatedAt time.Time `json:"created_at"` Uploader PublicUser `json:"uploader"` LikesCount int `json:"likes_count"` diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index 5c90772..61dd588 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -133,3 +133,76 @@ func (h *Handler) Delete(c *gin.Context) { } c.Status(http.StatusNoContent) } + +func (h *Handler) PermanentDelete(c *gin.Context) { + if err := h.svc.PermanentDelete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil { + httpx.RespondError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *Handler) Restore(c *gin.Context) { + p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c)) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photo": p}) +} + +func (h *Handler) Trash(c *gin.Context) { + list, err := h.svc.Trash(c.Request.Context(), auth.UserID(c)) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photos": list}) +} + +type batchRequest struct { + IDs []string `json:"ids" binding:"required"` + AlbumID string `json:"album_id"` +} + +func (h *Handler) DeleteBatch(c *gin.Context) { + var req batchRequest + if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids are required")) + return + } + n, err := h.svc.DeleteBatch(c.Request.Context(), req.IDs, auth.UserID(c)) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": n}) +} + +func (h *Handler) MoveBatch(c *gin.Context) { + var req batchRequest + if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required")) + return + } + n, err := h.svc.MoveBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"moved": n}) +} + +func (h *Handler) CopyBatch(c *gin.Context) { + var req batchRequest + if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required")) + return + } + created, err := h.svc.CopyBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photos": created}) +} diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index e2c4184..1465c76 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -207,6 +207,9 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo, } return nil, err } + if p.DeletedAt != nil { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } if err := s.attachURLs(ctx, p); err != nil { return nil, err } @@ -230,6 +233,9 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot } return nil, err } + if p.DeletedAt != nil { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil { return nil, err } @@ -297,23 +303,192 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error { if err != nil { return err } - own := p.UploaderID == userID - if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) { + if !canDeletePhoto(role, p.UploaderID == userID) { return httpx.Forbidden("forbidden", "you cannot delete this photo") } - if err := s.store.Delete(ctx, photoID); err != nil { + if p.DeletedAt != nil { + return nil + } + if err := s.store.SetDeleted(ctx, photoID, true); err != nil { return err } - keys := []string{p.StorageKey} - if p.ThumbKey != "" { - keys = append(keys, p.ThumbKey) - } - if p.PreviewKey != "" { - keys = append(keys, p.PreviewKey) - } - _ = s.storage.Delete(ctx, keys...) + s.publishDeleted(p) + return nil +} + +func canDeletePhoto(role string, own bool) bool { + return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role)) +} + +func (s *Service) publishDeleted(p *models.Photo) { s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{ "id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID, }) +} + +// DeleteBatch moves the given photos to the trash. It skips photos the user cannot delete. +func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string) (int, error) { + deleted := 0 + for _, id := range ids { + if err := s.Delete(ctx, id, userID); err != nil { + continue + } + deleted++ + } + return deleted, nil +} + +// Restore returns a photo from the trash back to its album. +func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.Photo, error) { + p, err := s.store.GetByID(ctx, photoID, userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } + return nil, err + } + _, role, err := s.albums.Access(ctx, p.AlbumID, userID) + if err != nil { + return nil, err + } + if !canDeletePhoto(role, p.UploaderID == userID) { + return nil, httpx.Forbidden("forbidden", "you cannot restore this photo") + } + if p.DeletedAt == nil { + return p, nil + } + if err := s.store.SetDeleted(ctx, photoID, false); err != nil { + return nil, err + } + p.DeletedAt = nil + if err := s.attachURLs(ctx, p); err != nil { + return nil, err + } + s.hub.Publish(p.AlbumID, "photo.created", p) + return p, nil +} + +// PermanentDelete removes the photo row; stored objects are deleted only when no +// other album references the same storage key. +func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) error { + p, err := s.store.GetByID(ctx, photoID, userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return httpx.NotFound("photo_not_found", "photo not found") + } + 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 err := s.store.HardDelete(ctx, photoID); err != nil { + return err + } + if p.AlbumID != "" { + s.publishDeleted(p) + } + if n, err := s.store.StorageKeyRefCount(ctx, p.StorageKey); err == nil && n == 0 { + keys := []string{p.StorageKey} + if p.ThumbKey != "" { + keys = append(keys, p.ThumbKey) + } + if p.PreviewKey != "" { + keys = append(keys, p.PreviewKey) + } + _ = s.storage.Delete(ctx, keys...) + } return 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) + if err != nil { + return 0, err + } + if !roles.CanUpload(targetRole) { + return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album") + } + moved := 0 + for _, id := range ids { + p, err := s.store.GetByID(ctx, id, userID) + if err != nil || p.DeletedAt != nil { + continue + } + oldAlbum := p.AlbumID + if oldAlbum == targetAlbumID { + continue + } + _, srcRole, err := s.albums.Access(ctx, oldAlbum, userID) + if err != nil { + continue + } + if !canDeletePhoto(srcRole, p.UploaderID == userID) { + continue + } + if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil { + continue + } + p.AlbumID = targetAlbumID + s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{ + "id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID, + }) + if err := s.attachURLs(ctx, p); err == nil { + s.hub.Publish(targetAlbumID, "photo.created", p) + } + moved++ + } + return moved, nil +} + +// CopyBatch clones photos into another album (metadata only; stored objects shared). +func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlbumID string) ([]models.Photo, error) { + _, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID) + if err != nil { + return nil, err + } + if !roles.CanUpload(targetRole) { + return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album") + } + out := []models.Photo{} + for _, id := range ids { + src, err := s.store.GetByID(ctx, id, userID) + if err != nil || src.DeletedAt != nil { + continue + } + if _, _, err := s.albums.Access(ctx, src.AlbumID, userID); err != nil { + continue + } + newID := auth.NewID() + if err := s.store.Clone(ctx, src, targetAlbumID, userID, newID); err != nil { + continue + } + clone, err := s.store.GetByID(ctx, newID, userID) + if err != nil { + continue + } + if err := s.attachURLs(ctx, clone); err != nil { + continue + } + s.store.SetCoverIfEmpty(ctx, targetAlbumID, newID) + s.hub.Publish(targetAlbumID, "photo.created", clone) + out = append(out, *clone) + } + return out, nil +} + +// Trash lists soft-deleted photos from all albums the user belongs to. +func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, error) { + out, err := s.store.ListTrash(ctx, userID) + if err != nil { + return nil, err + } + for i := range out { + _ = s.attachURLs(ctx, &out[i]) + } + return out, nil +} diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index bc4dec8..2bcf76b 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "shared-album/internal/models" @@ -28,7 +29,7 @@ const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key, (SELECT count(*) FROM likes l WHERE l.photo_id = p.id), (SELECT count(*) FROM comments c WHERE c.photo_id = p.id), EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2), - COALESCE(p.exif, '{}'::jsonb)::text` + COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at` func scanPhoto(row pgx.Row) (*models.Photo, error) { var p models.Photo @@ -38,7 +39,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) { &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, - &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText) + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -103,6 +104,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) `SELECT `+photoColumns+` FROM photos p JOIN users u ON u.id = p.uploader_id WHERE p.album_id = $1 + AND p.deleted_at IS NULL AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3)) AND ($4::timestamptz IS NULL OR p.created_at > $4) ORDER BY p.created_at DESC, p.id DESC @@ -122,7 +124,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, - &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText); err != nil { + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt); err != nil { return nil, err } setExif(&p, exifText) @@ -131,7 +133,62 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) return out, rows.Err() } -func (s *Store) Delete(ctx context.Context, id string) error { +func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error { + var tag pgconn.CommandTag + var err error + if deleted { + tag, err = s.pool.Exec(ctx, + `UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id) + } else { + tag, err = s.pool.Exec(ctx, + `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id) + } + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error { + tag, err := s.pool.Exec(ctx, + `UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Clone creates a new photo row that references the same stored objects as src. +func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error { + exifJSON, err := json.Marshal(src.EXIF) + if err != nil || len(exifJSON) == 0 { + exifJSON = []byte("{}") + } + _, err = s.pool.Exec(ctx, + `INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key, + original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`, + id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey), + src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType, + src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON)) + return err +} + +// StorageKeyRefCount counts photo rows (any state) referencing the stored object. +func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) { + var n int + err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n) + return n, err +} + +// HardDelete permanently removes the photo row. +func (s *Store) HardDelete(ctx context.Context, id string) error { tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id) if err != nil { return err @@ -142,6 +199,49 @@ func (s *Store) Delete(ctx context.Context, id string) error { return nil } +// ListTrash returns soft-deleted photos from albums the user belongs to. +func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) { + rows, err := s.pool.Query(ctx, + `SELECT `+photoColumns+`, a.title + FROM photos p + JOIN users u ON u.id = p.uploader_id + JOIN albums a ON a.id = p.album_id + WHERE p.deleted_at 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)) + ORDER BY p.deleted_at DESC + LIMIT 200`, + userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.Photo{} + for rows.Next() { + var p models.Photo + var exifText string + if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, + &p.ThumbKey, &p.PreviewKey, + &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, + &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, + &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle); err != nil { + return nil, err + } + setExif(&p, exifText) + out = append(out, p) + } + return out, rows.Err() +} + +func nullIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} + func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) { _, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, diff --git a/backend/migrations/000005_photo_trash.down.sql b/backend/migrations/000005_photo_trash.down.sql new file mode 100644 index 0000000..efa7285 --- /dev/null +++ b/backend/migrations/000005_photo_trash.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS photos_deleted_idx; +ALTER TABLE photos DROP COLUMN IF EXISTS deleted_at; diff --git a/backend/migrations/000005_photo_trash.up.sql b/backend/migrations/000005_photo_trash.up.sql new file mode 100644 index 0000000..0e2a655 --- /dev/null +++ b/backend/migrations/000005_photo_trash.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE photos ADD COLUMN IF NOT EXISTS deleted_at timestamptz; +CREATE INDEX IF NOT EXISTS photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7d45bc8..1244686 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,6 @@ import { goto } from '$app/navigation'; import { auth, type Tokens, type User } from './stores/auth.svelte'; -import type { Preferences } from './types'; +import type { Photo, Preferences } from './types'; export class ApiError extends Error { status: number; @@ -105,3 +105,21 @@ export const authApi = { body: preferences }) }; + +export const photosApi = { + deleteBatch: (ids: string[]) => + api<{ deleted: number }>('/photo-batch/delete', { method: 'POST', body: { ids } }), + moveBatch: (ids: string[], albumId: string) => + api<{ moved: number }>('/photo-batch/move', { + method: 'POST', + body: { ids, album_id: albumId } + }), + copyBatch: (ids: string[], albumId: string) => + api<{ photos: Photo[] }>('/photo-batch/copy', { + method: 'POST', + body: { ids, album_id: albumId } + }), + trash: () => api<{ photos: Photo[] }>('/trash'), + restore: (id: string) => api<{ photo: Photo }>(`/photos/${id}/restore`, { method: 'POST' }), + permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }) +}; diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index 6a705f5..5d549c9 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -1,14 +1,17 @@ +{#snippet selectionActions()} +
Действия
+ +Альбом
+ {#if albums.list.length === 0} +Нет доступных альбомов.
+ {:else} +{error}
{/if} +