Files
ds-galo/backend/internal/photos/service.go
T
iu 304cfedb75 feat: lightroom-style workspace UI with album covers
Backend:
- expose cover thumbnail URL on album responses (LEFT JOIN + presign)
- auto-assign cover only when empty; FK cover_photo_id -> photos ON DELETE SET NULL
- add photos_count / members_count to album responses; cover repo test

Frontend:
- split layouts into (app) shell (resizable rail + contextual panel) and (public)
- album rail + create-album dialog; album panel with cover picker / make-cover
- photo viewer route with filmstrip, replacing lightbox; share reuses it
- grid density slider, toolbar + drag-and-drop upload, dark graphite default

OpenSpec: add lightroom-workspace-ui change (proposal, design, specs, tasks)
2026-10-10 18:15:13 +03:00

285 lines
7.2 KiB
Go

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")
}
taken, lat, lon := 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: taken,
Latitude: lat,
Longitude: lon,
}
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) 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
}