В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»: ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая с выбором канала. Правки сохраняются автоматически, переключатель «Сброс» удаляет параметры правок и отредактированную версию фото. Редактирование неразрушающее: параметры хранятся в метаданных фото, а отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb, edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется, скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают отредактированную версию, когда она отрендерена.
850 lines
23 KiB
Go
850 lines
23 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)
|
|
// NotifyAlbumChanged announces a change to the album's photo composition on
|
|
// the recipients' personal streams (album cards and counts update live).
|
|
NotifyAlbumChanged(ctx context.Context, albumID string)
|
|
// NotifyCoverReady announces the album when the photo that is its cover gains
|
|
// variant URLs: only then does the album object carry a cover link.
|
|
NotifyCoverReady(ctx context.Context, albumID, photoID string)
|
|
}
|
|
|
|
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 response and the published event carry the checksum the object ledger
|
|
// holds, so a client can match its local file against the stored bytes.
|
|
p.OriginalChecksum = obj.Checksum
|
|
|
|
// 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
|
|
}
|
|
// The photo appears in the album: subscribers insert it, and the album's
|
|
// recipients see the new count (and cover, if this was the first photo).
|
|
s.hub.Publish(in.AlbumID, "photo.created", p)
|
|
s.albums.NotifyAlbumChanged(ctx, in.AlbumID)
|
|
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
|
|
}
|
|
}
|
|
if p.EditKey != "" {
|
|
if url, err := s.storage.URL(ctx, p.EditKey); err == nil {
|
|
p.EditURL = url
|
|
}
|
|
}
|
|
if p.EditThumbKey != "" {
|
|
if url, err := s.storage.URL(ctx, p.EditThumbKey); err == nil {
|
|
p.EditThumbURL = url
|
|
}
|
|
}
|
|
if p.EditMediumKey != "" {
|
|
if url, err := s.storage.URL(ctx, p.EditMediumKey); err == nil {
|
|
p.EditMediumURL = url
|
|
}
|
|
}
|
|
if p.EditPreviewKey != "" {
|
|
if url, err := s.storage.URL(ctx, p.EditPreviewKey); err == nil {
|
|
p.EditPreviewURL = 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)
|
|
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
|
|
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)
|
|
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
|
|
}
|
|
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)
|
|
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
|
|
}
|
|
// 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
|
|
changed := map[string]struct{}{}
|
|
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)
|
|
}
|
|
changed[oldAlbum] = struct{}{}
|
|
changed[targetAlbumID] = struct{}{}
|
|
moved++
|
|
}
|
|
// One album.updated per affected album, not per photo.
|
|
for albumID := range changed {
|
|
s.albums.NotifyAlbumChanged(ctx, albumID)
|
|
}
|
|
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)
|
|
}
|
|
if len(out) > 0 {
|
|
s.albums.NotifyAlbumChanged(ctx, targetAlbumID)
|
|
}
|
|
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)
|
|
}
|