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) }