Move physical file facts out of the photos table into an objects ledger:
- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix
Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.
OpenSpec change: media-object-layer
576 lines
16 KiB
Go
576 lines
16 KiB
Go
package photos
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"shared-album/internal/auth"
|
|
"shared-album/internal/httpx"
|
|
"shared-album/internal/models"
|
|
"shared-album/internal/roles"
|
|
"shared-album/internal/sse"
|
|
"shared-album/internal/storage"
|
|
)
|
|
|
|
type AlbumAccess interface {
|
|
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
|
|
}
|
|
|
|
type Service struct {
|
|
store *Store
|
|
albums AlbumAccess
|
|
storage *storage.Client
|
|
hub *sse.Hub
|
|
}
|
|
|
|
func NewService(store *Store, albums AlbumAccess, storage *storage.Client, hub *sse.Hub) *Service {
|
|
return &Service{store: store, albums: albums, storage: storage, hub: hub}
|
|
}
|
|
|
|
type UploadInput struct {
|
|
AlbumID string
|
|
UserID string
|
|
Filename string
|
|
MimeType string
|
|
Data []byte
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
mime := in.MimeType
|
|
if mime == "" {
|
|
mime = http.DetectContentType(in.Data)
|
|
}
|
|
if !strings.HasPrefix(mime, "image/") {
|
|
return nil, httpx.BadRequest("invalid_file", "file is not an image")
|
|
}
|
|
|
|
width, height, ok := inspect(in.Data)
|
|
if !ok {
|
|
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
|
}
|
|
meta := extractEXIF(in.Data)
|
|
|
|
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: int64(len(in.Data)),
|
|
Width: width,
|
|
Height: height,
|
|
MimeType: mime,
|
|
TakenAt: meta.TakenAt,
|
|
Latitude: meta.Latitude,
|
|
Longitude: meta.Longitude,
|
|
EXIF: meta.Attributes,
|
|
}
|
|
obj := &Object{
|
|
ID: objectID,
|
|
StorageKey: key,
|
|
MimeType: mime,
|
|
Size: int64(len(in.Data)),
|
|
Width: width,
|
|
Height: height,
|
|
Checksum: checksum(in.Data),
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if err := s.storage.Put(ctx, key, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil {
|
|
_ = s.store.DeleteUpload(ctx, photoID, objectID)
|
|
return nil, err
|
|
}
|
|
if err := s.store.ActivateObject(ctx, objectID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.attachURLs(ctx, p); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
|
if p.StorageKey != "" {
|
|
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.OriginalURL = url
|
|
p.DownloadURL = url
|
|
}
|
|
if p.ThumbKey != "" {
|
|
if url, err := s.storage.PresignGet(ctx, p.ThumbKey); err == nil {
|
|
p.ThumbURL = url
|
|
}
|
|
}
|
|
if p.PreviewKey != "" {
|
|
if url, err := s.storage.PresignGet(ctx, p.PreviewKey); err == nil {
|
|
p.PreviewURL = 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 presigned 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.PresignGet(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.PresignGet(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
|
|
}
|
|
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
|
|
}
|
|
_, 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|