package photos import ( "bytes" "context" "errors" "fmt" "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) id := auth.NewID() ext := strings.ToLower(filepath.Ext(in.Filename)) if ext == "" { ext = ".bin" } storageKey := fmt.Sprintf("albums/%s/original/%s%s", in.AlbumID, id, ext) if err := s.storage.Put(ctx, storageKey, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil { return nil, err } p := &models.Photo{ ID: id, AlbumID: in.AlbumID, UploaderID: in.UserID, StorageKey: storageKey, 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, } if err := s.store.Create(ctx, p); err != nil { return nil, err } data := in.Data go s.processVariants(p.ID, in.AlbumID, data) if err := s.attachURLs(ctx, p); err != nil { return nil, err } return p, nil } func (s *Service) processVariants(photoID, albumID string, data []byte) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() thumb, preview, err := buildVariants(data) if err != nil { return } thumbKey := fmt.Sprintf("albums/%s/thumb/%s.webp", albumID, photoID) previewKey := fmt.Sprintf("albums/%s/preview/%s.webp", albumID, photoID) if err := s.storage.Put(ctx, thumbKey, bytes.NewReader(thumb), int64(len(thumb)), "image/webp"); err != nil { return } if err := s.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/webp"); err != nil { return } if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil { return } s.store.SetCoverIfEmpty(ctx, albumID, photoID) if p, err := s.store.GetByID(ctx, photoID, ""); err == nil { if err := s.attachURLs(ctx, p); err == nil { s.hub.Publish(albumID, "photo.created", p) } } } 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 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 _, _, 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 } own := p.UploaderID == userID if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) { return httpx.Forbidden("forbidden", "you cannot delete this photo") } if err := s.store.Delete(ctx, photoID); err != nil { return err } keys := []string{p.StorageKey} if p.ThumbKey != "" { keys = append(keys, p.ThumbKey) } if p.PreviewKey != "" { keys = append(keys, p.PreviewKey) } _ = s.storage.Delete(ctx, keys...) s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{ "id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID, }) return nil }