Files
iu cd3d17c0f0 fix(web): правки применяются сразу и не сбрасывают вкладку
Вкладка «Редактирование» больше не переключается на «Сведения» при
обновлении того же фото: временное состояние панели сбрасывается только при
смене фото.

Правки теперь рендерятся без ожидания тика воркера: сервис будит воркер сразу
после постановки задачи, а уведомление о готовых вариантах приходит до
кодирования полноразмерной версии. Задержка между сохранением правок и
появлением отредактированной версии — около 0.25 с вместо нескольких секунд.
2026-10-20 23:11:57 +03:00

859 lines
24 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
// wakeEdit будит воркер сразу после постановки правок в очередь.
wakeEdit func()
}
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
}
}
// SetEditNotifier registers a callback that is invoked when an edit render is
// queued, so the worker picks the job up without waiting for the next tick.
func (s *Service) SetEditNotifier(fn func()) {
s.wakeEdit = fn
}
// 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)
}