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