Files
ds-galo/backend/internal/photos/service.go
T

588 lines
16 KiB
Go

package photos
import (
"bytes"
"context"
"errors"
"net/http"
"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
}
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, 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
}
// 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
}
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
}
}
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
}
_, 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
}
// 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)
}