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.
804 lines
22 KiB
Go
804 lines
22 KiB
Go
package photos
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"galo/internal/auth"
|
|
"galo/internal/httpx"
|
|
"galo/internal/models"
|
|
"galo/internal/roles"
|
|
"galo/internal/sse"
|
|
"galo/internal/storage"
|
|
)
|
|
|
|
type AlbumAccess interface {
|
|
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
|
|
}
|
|
|
|
type Service struct {
|
|
store Repository
|
|
albums AlbumAccess
|
|
storage storage.Backend
|
|
hub *sse.Hub
|
|
|
|
imageMax int64
|
|
videoMax int64
|
|
videoEnabled bool
|
|
tempDir string
|
|
}
|
|
|
|
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
|
|
return &Service{
|
|
store: store,
|
|
albums: albums,
|
|
storage: storage,
|
|
hub: hub,
|
|
imageMax: 100 << 20,
|
|
videoMax: 2 << 30,
|
|
videoEnabled: true,
|
|
}
|
|
}
|
|
|
|
// SetUploadLimits overrides the per-kind upload size limits.
|
|
func (s *Service) SetUploadLimits(imageMax, videoMax int64) {
|
|
if imageMax > 0 {
|
|
s.imageMax = imageMax
|
|
}
|
|
if videoMax > 0 {
|
|
s.videoMax = videoMax
|
|
}
|
|
}
|
|
|
|
// SetVideoEnabled controls whether video uploads are accepted. It is false when
|
|
// the ffmpeg/ffprobe toolchain is unavailable.
|
|
func (s *Service) SetVideoEnabled(enabled bool) { s.videoEnabled = enabled }
|
|
|
|
// SetTempDir sets where upload spool files are written (empty = system temp).
|
|
func (s *Service) SetTempDir(dir string) { s.tempDir = dir }
|
|
|
|
// MaxUploadSize returns the largest per-file limit across media kinds, for the
|
|
// request body cap.
|
|
func (s *Service) MaxUploadSize() int64 {
|
|
if s.videoMax > s.imageMax {
|
|
return s.videoMax
|
|
}
|
|
return s.imageMax
|
|
}
|
|
|
|
type UploadInput struct {
|
|
AlbumID string
|
|
UserID string
|
|
Filename string
|
|
MimeType string
|
|
// Reader is the upload stream. When nil, Data is used instead.
|
|
Reader io.Reader
|
|
// Size is the declared payload size when known (0 when unknown).
|
|
Size int64
|
|
// Data is an optional in-memory payload for callers that already hold it.
|
|
Data []byte
|
|
}
|
|
|
|
func (in UploadInput) reader() io.Reader {
|
|
if in.Reader != nil {
|
|
return in.Reader
|
|
}
|
|
return bytes.NewReader(in.Data)
|
|
}
|
|
|
|
func (in UploadInput) declaredSize() int64 {
|
|
if in.Size > 0 {
|
|
return in.Size
|
|
}
|
|
return int64(len(in.Data))
|
|
}
|
|
|
|
func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, error) {
|
|
_, role, err := s.albums.Access(ctx, in.AlbumID, in.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !roles.CanUpload(role) {
|
|
return nil, httpx.Forbidden("forbidden", "you do not have permission to upload photos")
|
|
}
|
|
|
|
br := bufio.NewReader(in.reader())
|
|
head, _ := br.Peek(512)
|
|
kind, mime := resolveMedia(in.MimeType, in.Filename, head)
|
|
if kind == "" {
|
|
return nil, httpx.BadRequest("invalid_file", "file is not an image or video")
|
|
}
|
|
if kind == KindVideo && !s.videoEnabled {
|
|
return nil, httpx.BadRequest("video_disabled", "video upload is not available")
|
|
}
|
|
limit := s.imageMax
|
|
if kind == KindVideo {
|
|
limit = s.videoMax
|
|
}
|
|
if sz := in.declaredSize(); sz > limit {
|
|
return nil, uploadTooLarge(kind, limit)
|
|
}
|
|
|
|
// Spool to a temporary file, computing size and checksum without holding the
|
|
// whole payload in memory. LimitReader enforces the cap even when the
|
|
// declared size is unknown.
|
|
tmp, err := os.CreateTemp(s.tempDir, "upload-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() { _ = os.Remove(tmpName) }()
|
|
|
|
hasher := sha256.New()
|
|
written, err := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(br, limit+1))
|
|
if err != nil {
|
|
_ = tmp.Close()
|
|
return nil, err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if written > limit {
|
|
return nil, uploadTooLarge(kind, limit)
|
|
}
|
|
|
|
var width, height int
|
|
var durationMS int64
|
|
attrs := map[string]any{}
|
|
var meta exifInfo
|
|
if kind == KindImage {
|
|
data, err := os.ReadFile(tmpName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
w, h, ok := inspect(data)
|
|
if !ok {
|
|
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
|
}
|
|
width, height = w, h
|
|
meta = extractEXIF(data)
|
|
attrs = meta.Attributes
|
|
if attrs == nil {
|
|
attrs = map[string]any{}
|
|
}
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(in.Filename))
|
|
if ext == "" {
|
|
ext = ".bin"
|
|
}
|
|
|
|
photoID := auth.NewID()
|
|
objectID := auth.NewID()
|
|
key := objectKey(objectID, RoleOriginal, ext)
|
|
|
|
p := &models.Photo{
|
|
ID: photoID,
|
|
AlbumID: in.AlbumID,
|
|
UploaderID: in.UserID,
|
|
StorageKey: key,
|
|
OriginalFilename: in.Filename,
|
|
Size: written,
|
|
Width: width,
|
|
Height: height,
|
|
Kind: kind,
|
|
DurationMS: durationMS,
|
|
MimeType: mime,
|
|
TakenAt: meta.TakenAt,
|
|
Latitude: meta.Latitude,
|
|
Longitude: meta.Longitude,
|
|
EXIF: attrs,
|
|
}
|
|
obj := &Object{
|
|
ID: objectID,
|
|
StorageKey: key,
|
|
MimeType: mime,
|
|
Size: written,
|
|
Width: width,
|
|
Height: height,
|
|
Checksum: hex.EncodeToString(hasher.Sum(nil)),
|
|
}
|
|
|
|
// The metadata layer is the ledger of record: the row is written first,
|
|
// then the bytes are stored, then the object is activated.
|
|
if err := s.store.CreateWithOriginal(ctx, p, obj); err != nil {
|
|
return nil, err
|
|
}
|
|
file, err := os.Open(tmpName)
|
|
if err != nil {
|
|
_ = s.store.DeleteUpload(ctx, photoID, objectID)
|
|
return nil, err
|
|
}
|
|
putErr := s.storage.Put(ctx, key, file, written, mime)
|
|
file.Close()
|
|
if putErr != nil {
|
|
_ = s.store.DeleteUpload(ctx, photoID, objectID)
|
|
return nil, putErr
|
|
}
|
|
if err := s.store.ActivateObject(ctx, objectID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The first photo uploaded to an album becomes its default cover.
|
|
s.store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)
|
|
|
|
if err := s.attachURLs(ctx, p); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// resolveMedia determines the media kind and effective MIME type of an upload.
|
|
// It prefers the declared MIME type, falls back to sniffing the leading bytes,
|
|
// then to the file extension.
|
|
func resolveMedia(declared, filename string, head []byte) (kind, outMime string) {
|
|
kind = kindFor(declared, filename)
|
|
if kind == "" && len(head) > 0 {
|
|
detected := http.DetectContentType(head)
|
|
kind = kindFor(detected, filename)
|
|
if declared == "" {
|
|
declared = detected
|
|
}
|
|
}
|
|
if kind == "" {
|
|
return "", ""
|
|
}
|
|
outMime = declared
|
|
if !strings.HasPrefix(outMime, kind+"/") {
|
|
if byExt := mime.TypeByExtension(filepath.Ext(filename)); byExt != "" {
|
|
outMime = byExt
|
|
}
|
|
}
|
|
if outMime == "" || !strings.HasPrefix(outMime, kind+"/") {
|
|
if kind == KindVideo {
|
|
outMime = "video/mp4"
|
|
} else {
|
|
outMime = "image/jpeg"
|
|
}
|
|
}
|
|
return kind, outMime
|
|
}
|
|
|
|
// kindFor classifies a MIME type or file extension as image or video.
|
|
func kindFor(mimeType, filename string) string {
|
|
switch {
|
|
case strings.HasPrefix(mimeType, "image/"):
|
|
return KindImage
|
|
case strings.HasPrefix(mimeType, "video/"):
|
|
return KindVideo
|
|
}
|
|
switch strings.ToLower(filepath.Ext(filename)) {
|
|
case ".mp4", ".m4v", ".mov", ".mkv", ".webm", ".avi", ".mpg", ".mpeg", ".3gp":
|
|
return KindVideo
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".tiff", ".tif", ".bmp", ".avif":
|
|
return KindImage
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func uploadTooLarge(kind string, limit int64) error {
|
|
return httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large",
|
|
fmt.Sprintf("file exceeds the maximum %s upload size (%s)", kind, humanSize(limit)))
|
|
}
|
|
|
|
func humanSize(n int64) string {
|
|
switch {
|
|
case n >= 1<<30:
|
|
return fmt.Sprintf("%d GB", n>>30)
|
|
case n >= 1<<20:
|
|
return fmt.Sprintf("%d MB", n>>20)
|
|
case n >= 1<<10:
|
|
return fmt.Sprintf("%d KB", n>>10)
|
|
default:
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
}
|
|
|
|
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
|
if p.StorageKey != "" {
|
|
url, err := s.storage.URL(ctx, p.StorageKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.OriginalURL = url
|
|
p.DownloadURL = url
|
|
}
|
|
if p.ThumbKey != "" {
|
|
if url, err := s.storage.URL(ctx, p.ThumbKey); err == nil {
|
|
p.ThumbURL = url
|
|
}
|
|
}
|
|
if p.MediumKey != "" {
|
|
if url, err := s.storage.URL(ctx, p.MediumKey); err == nil {
|
|
p.MediumURL = url
|
|
}
|
|
}
|
|
if p.PreviewKey != "" {
|
|
if url, err := s.storage.URL(ctx, p.PreviewKey); err == nil {
|
|
p.PreviewURL = url
|
|
}
|
|
}
|
|
if p.PlaybackKey != "" {
|
|
if url, err := s.storage.URL(ctx, p.PlaybackKey); err == nil {
|
|
p.PlaybackURL = url
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) List(ctx context.Context, albumID, userID, cursor string, since *time.Time, limit int) ([]models.Photo, string, error) {
|
|
if _, _, err := s.albums.Access(ctx, albumID, userID); err != nil {
|
|
return nil, "", err
|
|
}
|
|
out, err := s.store.List(ctx, ListParams{
|
|
AlbumID: albumID,
|
|
Viewer: userID,
|
|
Cursor: cursor,
|
|
Since: since,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
for i := range out {
|
|
_ = s.attachURLs(ctx, &out[i])
|
|
}
|
|
next := ""
|
|
if since == nil && len(out) == limit && limit > 0 {
|
|
next = out[len(out)-1].ID
|
|
}
|
|
return out, next, nil
|
|
}
|
|
|
|
// ListPublic lists photos for a share link (access already validated by token).
|
|
func (s *Service) ListPublic(ctx context.Context, albumID, cursor string, since *time.Time, limit int) ([]models.Photo, string, error) {
|
|
out, err := s.store.List(ctx, ListParams{
|
|
AlbumID: albumID,
|
|
Viewer: "",
|
|
Cursor: cursor,
|
|
Since: since,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
for i := range out {
|
|
_ = s.attachURLs(ctx, &out[i])
|
|
}
|
|
next := ""
|
|
if since == nil && len(out) == limit && limit > 0 {
|
|
next = out[len(out)-1].ID
|
|
}
|
|
return out, next, nil
|
|
}
|
|
|
|
// GetPublic returns a single photo for a share link.
|
|
func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo, error) {
|
|
p, err := s.store.GetByID(ctx, photoID, "")
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
|
}
|
|
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
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// DownloadPublic returns a signed original URL for a share link.
|
|
func (s *Service) DownloadPublic(ctx context.Context, photoID string) (string, error) {
|
|
p, err := s.GetPublic(ctx, photoID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return s.storage.URL(ctx, p.StorageKey)
|
|
}
|
|
|
|
func (s *Service) Get(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
|
|
}
|
|
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
|
|
}
|
|
if err := s.attachURLs(ctx, p); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (s *Service) Download(ctx context.Context, photoID, userID string) (string, error) {
|
|
p, err := s.Get(ctx, photoID, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
url, err := s.storage.URL(ctx, p.StorageKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return url, nil
|
|
}
|
|
|
|
func (s *Service) Rename(ctx context.Context, photoID, userID, filename 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
|
|
}
|
|
own := p.UploaderID == userID
|
|
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
|
|
return nil, httpx.Forbidden("forbidden", "you cannot rename this photo")
|
|
}
|
|
name := strings.TrimSpace(filename)
|
|
if name == "" {
|
|
return nil, httpx.BadRequest("invalid_filename", "filename is required")
|
|
}
|
|
if len(name) > 255 {
|
|
name = name[:255]
|
|
}
|
|
if err := s.store.UpdateFilename(ctx, photoID, name); err != nil {
|
|
return nil, err
|
|
}
|
|
p.OriginalFilename = name
|
|
if err := s.attachURLs(ctx, p); err != nil {
|
|
return nil, err
|
|
}
|
|
s.hub.Publish(p.AlbumID, "photo.updated", p)
|
|
return p, nil
|
|
}
|
|
|
|
func (s *Service) Delete(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 p.DeletedAt != nil {
|
|
return nil
|
|
}
|
|
if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
|
|
return err
|
|
}
|
|
// A trashed cover leaves the album without one.
|
|
s.store.ClearCoverIfMatches(ctx, p.AlbumID, photoID)
|
|
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.
|
|
// Restore returns a photo from the trash back to an album. When targetAlbumID is
|
|
// empty the photo goes back to its own album; if that album is gone, an
|
|
// album_required error is returned so the client can prompt for a target.
|
|
func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID 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
|
|
}
|
|
|
|
if targetAlbumID != "" {
|
|
_, role, err := s.albums.Access(ctx, targetAlbumID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !roles.CanUpload(role) {
|
|
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
|
}
|
|
if err := s.store.RestoreToAlbum(ctx, photoID, targetAlbumID); err != nil {
|
|
return nil, err
|
|
}
|
|
p.AlbumID = targetAlbumID
|
|
p.DeletedAt = nil
|
|
} else {
|
|
if p.AlbumID == "" {
|
|
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
|
|
}
|
|
exists, err := s.store.AlbumExists(ctx, p.AlbumID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
|
|
}
|
|
_, 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 {
|
|
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
|
|
}
|
|
if p.AlbumID != "" {
|
|
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
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
if err := s.store.HardDelete(ctx, photoID); err != nil {
|
|
return err
|
|
}
|
|
if p.AlbumID != "" {
|
|
s.publishDeleted(p)
|
|
}
|
|
// Mark objects that no longer have any binding for garbage collection.
|
|
// The background worker removes the blobs from storage.
|
|
for _, oid := range objectIDs {
|
|
if n, err := s.store.ObjectRefCount(ctx, oid); err == nil && n == 0 {
|
|
_ = s.store.MarkObjectDeleting(ctx, oid)
|
|
}
|
|
}
|
|
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)
|
|
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
|
|
}
|
|
// Moving a cover photo out of its album leaves the album without one.
|
|
s.store.ClearCoverIfMatches(ctx, oldAlbum, id)
|
|
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
|
|
}
|
|
|
|
// TrashCount returns the number of photos in the user's trash.
|
|
func (s *Service) TrashCount(ctx context.Context, userID string) (int, error) {
|
|
return s.store.TrashCount(ctx, userID)
|
|
}
|
|
|
|
// Tags lists the user's tags, optionally filtered by a search query.
|
|
func (s *Service) Tags(ctx context.Context, userID, q string) ([]models.Tag, error) {
|
|
return s.store.ListTags(ctx, userID, q)
|
|
}
|
|
|
|
func (s *Service) taggablePhoto(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
|
|
}
|
|
if p.DeletedAt != nil {
|
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
|
}
|
|
if p.AlbumID == "" {
|
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
|
}
|
|
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// AddTag attaches a tag (created on demand) to a photo and returns the refreshed photo.
|
|
func (s *Service) AddTag(ctx context.Context, photoID, userID, name string) (*models.Photo, error) {
|
|
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
tag, err := s.store.EnsureTag(ctx, userID, name)
|
|
if err != nil {
|
|
return nil, httpx.BadRequest("invalid_tag", "tag name is required")
|
|
}
|
|
if err := s.store.AttachTag(ctx, photoID, tag.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Get(ctx, photoID, userID)
|
|
}
|
|
|
|
// RemoveTag detaches one of the user's tags from a photo.
|
|
func (s *Service) RemoveTag(ctx context.Context, photoID, userID, tagID string) (*models.Photo, error) {
|
|
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
owned, err := s.store.TagOwnedBy(ctx, tagID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !owned {
|
|
return nil, httpx.NotFound("tag_not_found", "tag not found")
|
|
}
|
|
if err := s.store.DetachTag(ctx, photoID, tagID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Get(ctx, photoID, userID)
|
|
}
|