Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
285 lines
7.2 KiB
Go
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.SetCover(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
|
|
}
|