Add photo galleries API and frontend for public and admin viewing.

Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-07-25 17:32:23 +00:00
co-authored by Cursor
parent 4772b85f3d
commit c9a600b6d6
61 changed files with 6912 additions and 7 deletions
+99
View File
@@ -0,0 +1,99 @@
// Package auth validates the JWTs minted by backend/src/lib/auth.ts:
// HS256 with the shared JWT_SECRET, issuer "spanglish", audience
// "spanglish-app", plus the same DB-backed tokenVersion / account_status
// revocation check the backend's getAuthUser performs.
package auth
import (
"errors"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
const (
issuer = "spanglish"
audience = "spanglish-app"
)
var (
ErrNoToken = errors.New("no bearer token")
ErrInvalidToken = errors.New("invalid token")
)
// AdminRoles mirrors backend/src/routes/media.ts: photo administration is
// admin/organizer only (review decision #6).
var AdminRoles = []string{"admin", "organizer"}
type User struct {
ID string
Email string
Role string
}
func (u User) IsAdmin() bool {
for _, r := range AdminRoles {
if u.Role == r {
return true
}
}
return false
}
type claims struct {
Email string `json:"email"`
Role string `json:"role"`
TokenVersion *int `json:"tokenVersion"`
jwt.RegisteredClaims
}
type Verifier struct {
secret []byte
db *store.DB
}
func NewVerifier(secret string, db *store.DB) *Verifier {
return &Verifier{secret: []byte(secret), db: db}
}
// FromRequest returns the authenticated user, ErrNoToken when no
// Authorization header is present, or ErrInvalidToken for anything bad.
func (v *Verifier) FromRequest(r *http.Request) (User, error) {
header := r.Header.Get("Authorization")
if header == "" {
return User{}, ErrNoToken
}
raw, ok := strings.CutPrefix(header, "Bearer ")
if !ok {
return User{}, ErrInvalidToken
}
var c claims
_, err := jwt.ParseWithClaims(raw, &c, func(*jwt.Token) (any, error) { return v.secret, nil },
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithIssuer(issuer),
jwt.WithAudience(audience),
jwt.WithExpirationRequired(),
)
if err != nil || c.Subject == "" {
return User{}, ErrInvalidToken
}
// DB-backed revocation, same semantics as backend getAuthUser
// (backend/src/lib/auth.ts:320-327).
ua, err := v.db.GetUserAuth(r.Context(), c.Subject)
if err != nil {
return User{}, ErrInvalidToken
}
if ua.AccountStatus != "active" {
return User{}, ErrInvalidToken
}
if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion {
return User{}, ErrInvalidToken
}
// Role from the DB, not the token, so demotions apply immediately.
return User{ID: ua.ID, Email: c.Email, Role: ua.Role}, nil
}
+126
View File
@@ -0,0 +1,126 @@
// Package config loads photo-api configuration from the environment,
// following the backend's convention of a per-service .env file with
// ad-hoc defaults (backend/src/index.ts uses dotenv the same way).
package config
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Port int
DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE
DatabaseURL string
JWTSecret string
StoragePath string
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKeyID string
S3SecretKey string
S3ForcePathStyle bool
MaxUploadMB int
WorkerConcurrency int
FrontendURL string
HeicConverter string // optional explicit converter command; autodetected when empty
}
// S3Enabled mirrors backend/src/lib/storage.ts: S3 is active when both
// S3_ENDPOINT and S3_BUCKET are set.
func (c Config) S3Enabled() bool {
return c.S3Endpoint != "" && c.S3Bucket != ""
}
func Load() (Config, error) {
loadDotenv(".env")
cfg := Config{
Port: envInt("PORT", 3003),
DBType: strings.ToLower(env("DB_TYPE", "sqlite")),
DatabaseURL: env("DATABASE_URL", ""),
JWTSecret: env("JWT_SECRET", ""),
StoragePath: env("STORAGE_PATH", "./data/photos"),
S3Endpoint: env("S3_ENDPOINT", ""),
S3Region: env("S3_REGION", "auto"),
S3Bucket: env("S3_BUCKET", ""),
S3AccessKeyID: env("S3_ACCESS_KEY_ID", ""),
S3SecretKey: env("S3_SECRET_ACCESS_KEY", ""),
S3ForcePathStyle: envBool("S3_FORCE_PATH_STYLE", true),
MaxUploadMB: envInt("MAX_UPLOAD_MB", 50),
WorkerConcurrency: envInt("WORKER_CONCURRENCY", 2),
FrontendURL: strings.TrimRight(env("FRONTEND_URL", "http://localhost:3000"), "/"),
HeicConverter: env("HEIC_CONVERTER", ""),
}
if cfg.DBType != "postgres" && cfg.DBType != "sqlite" {
return cfg, fmt.Errorf("DB_TYPE must be postgres or sqlite, got %q", cfg.DBType)
}
if cfg.DatabaseURL == "" {
return cfg, fmt.Errorf("DATABASE_URL is required")
}
if cfg.JWTSecret == "" {
return cfg, fmt.Errorf("JWT_SECRET is required (must match backend/.env)")
}
return cfg, nil
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return def
}
// loadDotenv is a minimal KEY=VALUE loader (comments and blank lines
// ignored, optional surrounding quotes stripped, existing env wins).
func loadDotenv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] {
val = val[1 : len(val)-1]
}
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, val)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
package httpapi
import (
"net/http"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// accessDenial describes why a viewer may not see a gallery. Non-public
// galleries return 404 (not 403) so their existence is not probeable — the
// exception is ticket mode, whose 401/403 lets the frontend prompt login
// or explain the attendee requirement (its existence is already public via
// the event).
type accessDenial struct {
status int
msg string
}
// authorize is the single access-control decision point (PLAN.md §7); it is
// called from both the gallery-view handler and the file handler so every
// byte served re-checks.
func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, token string) *accessDenial {
if user != nil && user.IsAdmin() {
return nil
}
switch g.Visibility {
case store.VisibilityPublic:
return nil
case store.VisibilityLink:
if token != "" && token == g.ShareToken {
return nil
}
return &accessDenial{http.StatusNotFound, "Gallery not found"}
case store.VisibilityTicket:
// The share token is honored as an escape hatch (e.g. attendee +1s
// without accounts, at the admin's discretion).
if token != "" && token == g.ShareToken {
return nil
}
if user == nil {
return &accessDenial{http.StatusUnauthorized, "Authentication required"}
}
if g.EventID == "" {
return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"}
}
ok, err := s.db.HasPaidTicket(r.Context(), user.ID, g.EventID)
if err != nil || !ok {
return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"}
}
return nil
default: // private
return &accessDenial{http.StatusNotFound, "Gallery not found"}
}
}
+504
View File
@@ -0,0 +1,504 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/jpeg"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
)
const testSecret = "test-secret"
// Seed ids are UUID-formatted because the Postgres dialect uses uuid columns.
const (
uAdmin = "11111111-1111-1111-1111-111111111111"
uMember = "22222222-2222-2222-2222-222222222222"
uBuyer = "33333333-3333-3333-3333-333333333333"
uPending = "44444444-4444-4444-4444-444444444444"
uFree = "55555555-5555-5555-5555-555555555555"
evPaid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
evFree = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
)
type testEnv struct {
handler http.Handler
db *store.DB
worker *worker.Worker
}
// setup migrates a scratch DB (SQLite by default; Postgres when
// PHOTO_TEST_PG holds a connection URL), seeds the minimal slice of the
// main app schema that access.go reads, and returns a ready handler.
func setup(t *testing.T) *testEnv {
t.Helper()
dir := t.TempDir()
cfg := config.Config{
Port: 0,
DBType: "sqlite",
DatabaseURL: filepath.Join(dir, "test.db"),
JWTSecret: testSecret,
StoragePath: filepath.Join(dir, "photos"),
MaxUploadMB: 5,
WorkerConcurrency: 1,
FrontendURL: "http://localhost:3000",
}
if pgURL := os.Getenv("PHOTO_TEST_PG"); pgURL != "" {
cfg.DBType = "postgres"
cfg.DatabaseURL = pgURL
}
db, err := store.Open(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if cfg.DBType == "postgres" {
// The scratch Postgres DB persists across tests; start clean.
if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil {
t.Fatal(err)
}
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
idType := "text"
if cfg.DBType == "postgres" {
idType = "uuid"
}
seed := []string{
`CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`,
`CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`,
`CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`,
`CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`,
`INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`,
`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`,
`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`,
`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`,
`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`,
`INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`,
`INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`,
`INSERT INTO tickets VALUES ('t1','` + uBuyer + `','` + evPaid + `','confirmed')`,
`INSERT INTO payments VALUES ('p1','t1','paid')`,
`INSERT INTO tickets VALUES ('t2','` + uPending + `','` + evPaid + `','pending')`,
`INSERT INTO payments VALUES ('p2','t2','pending')`,
`INSERT INTO tickets VALUES ('t3','` + uFree + `','` + evFree + `','confirmed')`,
}
for _, q := range seed {
if _, err := db.Exec(q); err != nil {
t.Fatalf("seed %q: %v", q, err)
}
}
st, err := storage.New(cfg)
if err != nil {
t.Fatal(err)
}
wrk := worker.New(db, st, nil, cfg.StoragePath, 1)
srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk)
return &testEnv{handler: srv.Handler(), db: db, worker: wrk}
}
func makeToken(t *testing.T, sub, email, role string) string {
t.Helper()
tv := 0
claims := jwt.MapClaims{
"sub": sub, "email": email, "role": role, "tokenVersion": tv,
"iss": "spanglish", "aud": "spanglish-app",
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
}
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret))
if err != nil {
t.Fatal(err)
}
return s
}
func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
e.handler.ServeHTTP(w, req)
return w
}
func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
t.Helper()
var v T
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatalf("decode %s: %v", w.Body.String(), err)
}
return v
}
func testJPEG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 800, 600))
for x := 0; x < 800; x += 10 {
for y := 0; y < 600; y++ {
img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestHealth(t *testing.T) {
e := setup(t)
if w := e.request(t, "GET", "/api/photos/health", "", nil); w.Code != 200 {
t.Fatalf("health: %d", w.Code)
}
}
func TestAdminGate(t *testing.T) {
e := setup(t)
body := map[string]string{"title": "Test"}
if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 {
t.Fatalf("anon create: want 401, got %d", w.Code)
}
member := makeToken(t, uMember, "m@x.py", "user")
if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 {
t.Fatalf("member create: want 403, got %d", w.Code)
}
unknown := makeToken(t, newID(), "g@x.py", "admin")
if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 {
t.Fatalf("unknown-user token: want 401, got %d", w.Code)
}
}
type galleryResp struct {
Gallery galleryJSON `json:"gallery"`
Photos []photoJSON `json:"photos"`
}
func createGallery(t *testing.T, e *testEnv, admin string, body map[string]any) galleryJSON {
t.Helper()
w := e.request(t, "POST", "/api/photos/galleries", admin, body)
if w.Code != 201 {
t.Fatalf("create gallery: %d %s", w.Code, w.Body.String())
}
return decode[galleryResp](t, w).Gallery
}
func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte) photoJSON {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("files", "test photo.jpg")
fw.Write(file)
mw.Close()
req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+admin)
w := httptest.NewRecorder()
e.handler.ServeHTTP(w, req)
if w.Code != 201 {
t.Fatalf("upload: %d %s", w.Code, w.Body.String())
}
photos := decode[struct {
Photos []photoJSON `json:"photos"`
}](t, w).Photos
if len(photos) != 1 {
t.Fatalf("want 1 photo, got %d", len(photos))
}
return photos[0]
}
// processQueue runs the worker until the photo is ready or failed.
func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
e.worker.Run(ctx)
e.worker.Nudge()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
p, err := e.db.GetPhoto(ctx, photoID)
if err != nil {
t.Fatal(err)
}
if p.Status == "ready" || (p.Status == "failed" && p.Attempts > 0) {
return p
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("timeout waiting for processing")
return store.Photo{}
}
func TestUploadProcessAndServe(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"})
if g.Slug != "fiesta-de-junio" {
t.Fatalf("slug: %q", g.Slug)
}
// Public galleries share a clean URL: the token grants nothing there.
if g.ShareToken == "" {
t.Fatalf("share token missing: %+v", g)
}
if strings.Contains(g.ShareURL, "token=") {
t.Fatalf("public gallery share url must not carry a token: %s", g.ShareURL)
}
if !strings.HasSuffix(g.ShareURL, "/photos/"+g.Slug) {
t.Fatalf("public share url: %s", g.ShareURL)
}
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if p.Status != "queued" || p.ContentType != "image/jpeg" {
t.Fatalf("uploaded photo: %+v", p)
}
done := processQueue(t, e, p.ID)
if done.Status != "ready" {
t.Fatalf("processing failed: %s", done.LastError)
}
if done.Width != 800 || done.Height != 600 {
t.Fatalf("dimensions: %dx%d", done.Width, done.Height)
}
for _, variant := range []string{"thumb", "preview", "original"} {
w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/"+variant, "", nil)
if w.Code != 200 {
t.Fatalf("%s: %d", variant, w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("%s content type: %s", variant, ct)
}
}
w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil)
if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "test photo.jpg") {
t.Fatalf("original disposition: %q", cd)
}
// Bad upload is rejected by magic bytes.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("files", "notes.txt")
fw.Write([]byte("not an image at all"))
mw.Close()
req := httptest.NewRequest("POST", "/api/photos/galleries/"+g.ID+"/photos", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+admin)
rec := httptest.NewRecorder()
e.handler.ServeHTTP(rec, req)
if rec.Code != 415 {
t.Fatalf("text upload: want 415, got %d %s", rec.Code, rec.Body.String())
}
}
func TestAccessMatrix(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
member := makeToken(t, uMember, "m@x.py", "user")
buyer := makeToken(t, uBuyer, "b@x.py", "user")
pending := makeToken(t, uPending, "p@x.py", "user")
freeguy := makeToken(t, uFree, "f@x.py", "user")
get := func(slug, token, bearer string) int {
path := "/api/photos/public/galleries/" + slug
if token != "" {
path += "?token=" + token
}
return e.request(t, "GET", path, bearer, nil).Code
}
// private
priv := createGallery(t, e, admin, map[string]any{"title": "Privada", "visibility": "private"})
for name, code := range map[string]int{"anon": get(priv.Slug, "", ""), "member": get(priv.Slug, "", member),
"with-token": get(priv.Slug, priv.ShareToken, "")} {
if code != 404 {
t.Errorf("private/%s: want 404, got %d", name, code)
}
}
if got := get(priv.Slug, "", admin); got != 200 {
t.Errorf("private/admin: want 200, got %d", got)
}
// link
link := createGallery(t, e, admin, map[string]any{"title": "Enlace", "visibility": "link"})
if got := get(link.Slug, "", ""); got != 404 {
t.Errorf("link/anon: want 404, got %d", got)
}
if got := get(link.Slug, "wrong-token", ""); got != 404 {
t.Errorf("link/bad-token: want 404, got %d", got)
}
if got := get(link.Slug, link.ShareToken, ""); got != 200 {
t.Errorf("link/token: want 200, got %d", got)
}
// ticket, paid event
tick := createGallery(t, e, admin, map[string]any{"title": "Con Entrada", "visibility": "ticket", "eventId": evPaid})
if got := get(tick.Slug, "", ""); got != 401 {
t.Errorf("ticket/anon: want 401, got %d", got)
}
if got := get(tick.Slug, "", member); got != 403 {
t.Errorf("ticket/no-ticket: want 403, got %d", got)
}
if got := get(tick.Slug, "", pending); got != 403 {
t.Errorf("ticket/pending-payment: want 403, got %d", got)
}
if got := get(tick.Slug, "", buyer); got != 200 {
t.Errorf("ticket/paid: want 200, got %d", got)
}
if got := get(tick.Slug, tick.ShareToken, ""); got != 200 {
t.Errorf("ticket/share-token: want 200, got %d", got)
}
// ticket, free event: any confirmed ticket counts (review decision)
free := createGallery(t, e, admin, map[string]any{"title": "Evento Gratis", "visibility": "ticket", "eventId": evFree})
if got := get(free.Slug, "", freeguy); got != 200 {
t.Errorf("ticket/free-event-confirmed: want 200, got %d", got)
}
if got := get(free.Slug, "", member); got != 403 {
t.Errorf("ticket/free-event-no-ticket: want 403, got %d", got)
}
// public index lists only public galleries
pub := createGallery(t, e, admin, map[string]any{"title": "Publica", "visibility": "public"})
idx := decode[struct {
Galleries []galleryJSON `json:"galleries"`
}](t, e.request(t, "GET", "/api/photos/public/galleries", "", nil))
if len(idx.Galleries) != 1 || idx.Galleries[0].ID != pub.ID {
t.Errorf("public index: %+v", idx.Galleries)
}
for _, g := range idx.Galleries {
if g.ShareToken != "" {
t.Errorf("public index leaks share token")
}
}
}
func TestReorderAndVisibilityUpdate(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"})
jpg := testJPEG(t)
p1 := uploadPhoto(t, e, admin, g.ID, jpg)
p2 := uploadPhoto(t, e, admin, g.ID, jpg)
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin,
map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 {
t.Fatalf("reorder: %d %s", w.Code, w.Body.String())
}
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
if len(detail.Photos) != 2 || detail.Photos[0].ID != p2.ID {
t.Fatalf("order not applied: %+v", detail.Photos)
}
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin,
map[string]any{"photoIds": []string{p1.ID}}); w.Code != 400 {
t.Fatalf("partial reorder: want 400, got %d", w.Code)
}
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin,
map[string]any{"visibility": "banana"}); w.Code != 400 {
t.Fatalf("bad visibility: want 400, got %d", w.Code)
}
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin,
map[string]any{"visibility": "link"}); w.Code != 200 {
t.Fatalf("set link: %d %s", w.Code, w.Body.String())
}
if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 404 {
t.Fatalf("after switch to link, anon: want 404, got %d", w.Code)
}
}
func TestEventGalleryRoute(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
buyer := makeToken(t, uBuyer, "b@x.py", "user")
g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid})
// Event-linked galleries share via the event URL.
if !strings.Contains(g.ShareURL, "/events/fiesta/gallery?token="+g.ShareToken) {
t.Fatalf("share url should use event route: %s", g.ShareURL)
}
// /public/events/{slug}/gallery obeys the same access rules.
if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", "", nil); w.Code != 401 {
t.Fatalf("event gallery anon: want 401, got %d", w.Code)
}
w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", buyer, nil)
if w.Code != 200 {
t.Fatalf("event gallery buyer: want 200, got %d %s", w.Code, w.Body.String())
}
if got := decode[galleryResp](t, w).Gallery.ID; got != g.ID {
t.Fatalf("wrong gallery: %s != %s", got, g.ID)
}
if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery?token="+g.ShareToken, "", nil); w.Code != 200 {
t.Fatalf("event gallery share token: want 200, got %d", w.Code)
}
// Unknown event slug and event without a gallery are both 404.
if w := e.request(t, "GET", "/api/photos/public/events/nope/gallery", buyer, nil); w.Code != 404 {
t.Fatalf("unknown event: want 404, got %d", w.Code)
}
if w := e.request(t, "GET", "/api/photos/public/events/gratis/gallery", buyer, nil); w.Code != 404 {
t.Fatalf("event without gallery: want 404, got %d", w.Code)
}
// A standalone gallery keeps the /photos share URL; link mode carries
// the token, public mode does not.
solo := createGallery(t, e, admin, map[string]any{"title": "Sin Evento", "visibility": "link"})
if !strings.Contains(solo.ShareURL, "/photos/sin-evento?token=") {
t.Fatalf("standalone share url: %s", solo.ShareURL)
}
pubEvent := createGallery(t, e, admin, map[string]any{"title": "Publica Con Evento", "visibility": "public", "eventId": evFree})
if !strings.HasSuffix(pubEvent.ShareURL, "/events/gratis/gallery") {
t.Fatalf("public event gallery share url should be the clean event link: %s", pubEvent.ShareURL)
}
}
func TestSlugCollision(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
if a.Slug == b.Slug {
t.Fatalf("slug collision: %s", a.Slug)
}
if b.Slug != "misma-fiesta-2" {
t.Fatalf("second slug: %s", b.Slug)
}
if a.Visibility != "private" {
t.Fatalf("default visibility: %s", a.Visibility)
}
}
+153
View File
@@ -0,0 +1,153 @@
package httpapi
import (
"context"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// JSON shapes are camelCase like the backend's responses.
type eventJSON struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
TitleEs string `json:"titleEs,omitempty"`
StartDatetime string `json:"startDatetime"`
Status string `json:"status"`
}
type galleryJSON struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
TitleEs string `json:"titleEs,omitempty"`
Description string `json:"description,omitempty"`
DescriptionEs string `json:"descriptionEs,omitempty"`
EventID string `json:"eventId,omitempty"`
Visibility string `json:"visibility"`
CoverPhotoID string `json:"coverPhotoId,omitempty"`
PhotoCount int `json:"photoCount"`
CoverURL string `json:"coverUrl,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Event *eventJSON `json:"event,omitempty"`
// Admin-only fields:
ShareToken string `json:"shareToken,omitempty"`
ShareURL string `json:"shareUrl,omitempty"`
}
type photoURLs struct {
Thumb string `json:"thumb,omitempty"`
Preview string `json:"preview,omitempty"`
Original string `json:"original"`
}
type photoJSON struct {
ID string `json:"id"`
GalleryID string `json:"galleryId"`
Position int `json:"position"`
OriginalFilename string `json:"originalFilename,omitempty"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
TakenAt string `json:"takenAt,omitempty"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"` // admin only
CreatedAt string `json:"createdAt"`
URLs photoURLs `json:"urls"`
}
func isoTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
// fileURL builds the access-checked file endpoint URL; token is appended
// for link-mode viewers so the client can use URLs verbatim.
func fileURL(photoID, variant, token string) string {
u := "/api/photos/files/" + photoID + "/" + variant
if token != "" {
u += "?token=" + token
}
return u
}
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
out := photoJSON{
ID: p.ID,
GalleryID: p.GalleryID,
Position: p.Position,
OriginalFilename: p.OriginalFilename,
ContentType: p.ContentType,
SizeBytes: p.SizeBytes,
Width: p.Width,
Height: p.Height,
TakenAt: isoTime(p.TakenAt),
Status: p.Status,
CreatedAt: isoTime(p.CreatedAt),
URLs: photoURLs{Original: fileURL(p.ID, "original", token)},
}
if p.ThumbKey != "" {
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
}
if p.PreviewKey != "" {
out.URLs.Preview = fileURL(p.ID, "preview", token)
}
if admin {
out.LastError = p.LastError
}
return out
}
func (s *Server) galleryToJSON(ctx context.Context, g store.Gallery, token string, admin bool) galleryJSON {
out := galleryJSON{
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
TitleEs: g.TitleEs,
Description: g.Description,
DescriptionEs: g.DescriptionEs,
EventID: g.EventID,
Visibility: g.Visibility,
CoverPhotoID: g.CoverPhotoID,
PhotoCount: g.PhotoCount,
CreatedAt: isoTime(g.CreatedAt),
UpdatedAt: isoTime(g.UpdatedAt),
}
if coverID, _ := s.db.GalleryCoverKey(ctx, g); coverID != "" {
out.CoverURL = fileURL(coverID, "thumb", token)
}
if g.EventID != "" {
if ev, err := s.db.GetEventSummary(ctx, g.EventID); err == nil {
out.Event = &eventJSON{
ID: ev.ID,
Slug: ev.Slug,
Title: ev.Title,
TitleEs: ev.TitleEs,
StartDatetime: isoTime(ev.StartDatetime),
Status: ev.Status,
}
}
}
if admin {
out.ShareToken = g.ShareToken
// Event-linked galleries live under the event's URL; standalone
// galleries keep their own /photos/<slug> URL.
page := "/photos/" + g.Slug
if out.Event != nil {
page = "/events/" + out.Event.Slug + "/gallery"
}
out.ShareURL = s.cfg.FrontendURL + page
// The token only grants anything in link/ticket mode; public and
// private galleries get a clean URL.
if g.Visibility == store.VisibilityLink || g.Visibility == store.VisibilityTicket {
out.ShareURL += "?token=" + g.ShareToken
}
}
return out
}
+117
View File
@@ -0,0 +1,117 @@
package httpapi
import (
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
)
const presignExpiry = 15 * time.Minute
// serveFile delivers photo bytes after re-running the gallery access check.
// S3: 302 to a short-lived presigned URL; local: streamed directly.
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
variant := r.PathValue("variant")
if variant != "thumb" && variant != "preview" && variant != "original" {
writeError(w, http.StatusNotFound, "Not Found")
return
}
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
if err != nil {
writeStoreError(w, err, "Photo not found")
return
}
g, err := s.db.GetGallery(r.Context(), p.GalleryID)
if err != nil {
writeStoreError(w, err, "Photo not found")
return
}
user := s.optionalUser(r)
token := r.URL.Query().Get("token")
if denial := s.authorize(r, g, user, token); denial != nil {
writeError(w, denial.status, denial.msg)
return
}
isAdmin := user != nil && user.IsAdmin()
var key, contentType, downloadName string
switch variant {
case "thumb":
key, contentType = p.ThumbKey, "image/jpeg"
case "preview":
key, contentType = p.PreviewKey, "image/jpeg"
case "original":
key, contentType = p.OriginalKey, p.ContentType
downloadName = p.OriginalFilename
if downloadName == "" {
downloadName = p.ID + extForContentType(p.ContentType)
}
}
if key == "" {
// Variant not generated yet (photo still processing).
writeError(w, http.StatusNotFound, "Not ready")
return
}
// Non-ready originals stay admin-only so uploads that fail processing
// never leak to viewers.
if p.Status != "ready" && !isAdmin {
writeError(w, http.StatusNotFound, "Not ready")
return
}
if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil {
http.Redirect(w, r, url, http.StatusFound)
return
} else if !errors.Is(err, storage.ErrNoPresign) {
log.Printf("Error: presign %s: %v", key, err)
writeError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
reader, size, err := s.storage.Open(r.Context(), key)
if err != nil {
if os.IsNotExist(err) {
writeError(w, http.StatusNotFound, "Not Found")
return
}
log.Printf("Error: open %s: %v", key, err)
writeError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
defer reader.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("X-Content-Type-Options", "nosniff")
// Keys are immutable (new upload = new key), so private caching is safe
// even though access is checked per request.
w.Header().Set("Cache-Control", "private, max-age=86400")
if downloadName != "" {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName))
}
if _, err := io.Copy(w, reader); err != nil {
log.Printf("stream %s: %v", key, err)
}
}
func extForContentType(ct string) string {
switch ct {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/heic":
return ".heic"
}
return ""
}
+225
View File
@@ -0,0 +1,225 @@
package httpapi
import (
"log"
"net/http"
"strconv"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
func validVisibility(v string) bool {
switch v {
case store.VisibilityPublic, store.VisibilityPrivate, store.VisibilityLink, store.VisibilityTicket:
return true
}
return false
}
type createGalleryBody struct {
Title string `json:"title"`
TitleEs string `json:"titleEs"`
Description string `json:"description"`
DescriptionEs string `json:"descriptionEs"`
EventID string `json:"eventId"`
Visibility string `json:"visibility"`
}
func (s *Server) createGallery(w http.ResponseWriter, r *http.Request, user auth.User) {
var body createGalleryBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
if body.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if body.Visibility == "" {
body.Visibility = store.VisibilityPrivate
}
if !validVisibility(body.Visibility) {
writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket")
return
}
if body.EventID != "" {
if _, err := s.db.GetEventSummary(r.Context(), body.EventID); err != nil {
writeStoreError(w, err, "Event not found")
return
}
}
slug, err := s.uniqueSlug(r.Context(), body.Title)
if err != nil {
writeStoreError(w, err, "")
return
}
now := time.Now()
g := store.Gallery{
ID: newID(),
Slug: slug,
Title: body.Title,
TitleEs: body.TitleEs,
Description: body.Description,
DescriptionEs: body.DescriptionEs,
EventID: body.EventID,
Visibility: body.Visibility,
ShareToken: newShareToken(),
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.db.CreateGallery(r.Context(), g); err != nil {
writeStoreError(w, err, "")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
})
}
func (s *Server) listGalleries(w http.ResponseWriter, r *http.Request, _ auth.User) {
limit := queryInt(r, "limit", 100)
offset := queryInt(r, "offset", 0)
galleries, err := s.db.ListGalleries(r.Context(), r.URL.Query().Get("eventId"), limit, offset)
if err != nil {
writeStoreError(w, err, "")
return
}
out := make([]galleryJSON, 0, len(galleries))
for _, g := range galleries {
out = append(out, s.galleryToJSON(r.Context(), g, "", true))
}
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
}
func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
g, err := s.db.GetGallery(r.Context(), r.PathValue("id"))
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
photos, err := s.db.ListPhotos(r.Context(), g.ID, false)
if err != nil {
writeStoreError(w, err, "")
return
}
out := make([]photoJSON, 0, len(photos))
for _, p := range photos {
out = append(out, s.photoToJSON(p, "", true))
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
"photos": out,
})
}
type updateGalleryBody struct {
Title *string `json:"title"`
TitleEs *string `json:"titleEs"`
Description *string `json:"description"`
DescriptionEs *string `json:"descriptionEs"`
EventID *string `json:"eventId"`
Visibility *string `json:"visibility"`
CoverPhotoID *string `json:"coverPhotoId"`
}
func (s *Server) updateGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
id := r.PathValue("id")
var body updateGalleryBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
if body.Title != nil && *body.Title == "" {
writeError(w, http.StatusBadRequest, "title cannot be empty")
return
}
if body.Visibility != nil && !validVisibility(*body.Visibility) {
writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket")
return
}
if body.EventID != nil && *body.EventID != "" {
if _, err := s.db.GetEventSummary(r.Context(), *body.EventID); err != nil {
writeStoreError(w, err, "Event not found")
return
}
}
if body.CoverPhotoID != nil && *body.CoverPhotoID != "" {
p, err := s.db.GetPhoto(r.Context(), *body.CoverPhotoID)
if err != nil || p.GalleryID != id {
writeError(w, http.StatusBadRequest, "coverPhotoId must be a photo of this gallery")
return
}
}
err := s.db.UpdateGallery(r.Context(), id, store.GalleryUpdate{
Title: body.Title,
TitleEs: body.TitleEs,
Description: body.Description,
DescriptionEs: body.DescriptionEs,
EventID: body.EventID,
Visibility: body.Visibility,
CoverPhotoID: body.CoverPhotoID,
})
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
g, err := s.db.GetGallery(r.Context(), id)
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
})
}
func (s *Server) deleteGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
id := r.PathValue("id")
keys, err := s.db.PhotoKeys(r.Context(), id)
if err != nil {
writeStoreError(w, err, "")
return
}
if err := s.db.DeleteGallery(r.Context(), id); err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
// Object cleanup is best-effort after the rows are gone; orphaned
// objects are harmless (unreachable) and logged for manual sweep.
for _, key := range keys {
if err := s.storage.Delete(r.Context(), key); err != nil {
log.Printf("delete object %s: %v", key, err)
}
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Gallery deleted"})
}
func (s *Server) rotateShareToken(w http.ResponseWriter, r *http.Request, _ auth.User) {
id := r.PathValue("id")
if err := s.db.RotateShareToken(r.Context(), id, newShareToken()); err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
g, err := s.db.GetGallery(r.Context(), id)
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
})
}
func queryInt(r *http.Request, key string, def int) int {
if v := r.URL.Query().Get(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
}
return def
}
+36
View File
@@ -0,0 +1,36 @@
package httpapi
import (
"net/http"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
)
// requireAdmin mirrors the backend's requireAuth(['admin','organizer']):
// 401 {"error":"Unauthorized"} without a valid token, 403
// {"error":"Forbidden"} for valid non-admin users.
func (s *Server) requireAdmin(next func(http.ResponseWriter, *http.Request, auth.User)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := s.verifier.FromRequest(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
if !user.IsAdmin() {
writeError(w, http.StatusForbidden, "Forbidden")
return
}
next(w, r, user)
}
}
// optionalUser returns the authenticated user or nil; an invalid token is
// treated as anonymous rather than an error, matching how public backend
// routes behave.
func (s *Server) optionalUser(r *http.Request) *auth.User {
user, err := s.verifier.FromRequest(r)
if err != nil {
return nil
}
return &user
}
+242
View File
@@ -0,0 +1,242 @@
package httpapi
import (
"bytes"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// uploadPhotos accepts multipart form data with one or more "files" parts.
// Each file is sniffed by magic bytes (client filename/Content-Type are
// untrusted, same policy as /api/media/upload), stored as the untouched
// original, and queued for variant processing.
func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
galleryID := r.PathValue("id")
g, err := s.db.GetGallery(r.Context(), galleryID)
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
maxFile := int64(s.cfg.MaxUploadMB) << 20
// Generous request ceiling; nginx enforces its own client_max_body_size.
r.Body = http.MaxBytesReader(w, r.Body, 40*maxFile)
mr, err := r.MultipartReader()
if err != nil {
writeError(w, http.StatusBadRequest, "Expected multipart/form-data")
return
}
position, err := s.db.NextPosition(r.Context(), g.ID)
if err != nil {
writeStoreError(w, err, "")
return
}
var created []photoJSON
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
writeError(w, http.StatusBadRequest, "Malformed multipart body")
return
}
if part.FormName() != "files" && part.FormName() != "file" {
part.Close()
continue
}
photo, uploadErr := s.saveUpload(r, g, part, position, maxFile)
part.Close()
if uploadErr != nil {
// One bad file fails the request explicitly rather than silently
// skipping it; the admin UI uploads files individually.
writeError(w, uploadErr.status, uploadErr.msg)
return
}
created = append(created, s.photoToJSON(photo, "", true))
position++
}
if len(created) == 0 {
writeError(w, http.StatusBadRequest, "No file provided")
return
}
s.worker.Nudge()
writeJSON(w, http.StatusCreated, map[string]any{"photos": created})
}
type uploadError struct {
status int
msg string
}
func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, *uploadError) {
head := make([]byte, 16)
n, err := io.ReadFull(part, head)
if err != nil && err != io.ErrUnexpectedEOF {
return store.Photo{}, &uploadError{http.StatusBadRequest, "Could not read file"}
}
head = head[:n]
contentType, ext, ok, reason := imaging.Sniff(head)
if !ok {
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, reason}
}
if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil {
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType,
"HEIC uploads need an image converter on the server (install libvips-tools); please upload JPEG instead"}
}
// Spool to a temp file to learn the size before handing to storage
// (S3 wants a length; local rename wants a file anyway).
tmp, err := os.CreateTemp(s.cfg.StoragePath, ".incoming-*")
if err != nil {
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
defer os.Remove(tmp.Name())
defer tmp.Close()
size, err := io.Copy(tmp, io.MultiReader(bytes.NewReader(head), io.LimitReader(part, maxFile+1)))
if err != nil {
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
if size > maxFile {
return store.Photo{}, &uploadError{http.StatusRequestEntityTooLarge,
fmt.Sprintf("File exceeds the %d MB limit", s.cfg.MaxUploadMB)}
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
photoID := newID()
key := fmt.Sprintf("galleries/%s/orig/%s%s", g.ID, photoID, ext)
if err := s.storage.Put(r.Context(), key, tmp, size, contentType); err != nil {
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
now := time.Now()
photo := store.Photo{
ID: photoID,
GalleryID: g.ID,
Position: position,
OriginalKey: key,
OriginalFilename: sanitizeFilename(part.FileName()),
ContentType: contentType,
SizeBytes: size,
Status: "queued",
NextAttemptAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.db.InsertPhoto(r.Context(), photo); err != nil {
s.storage.Delete(r.Context(), key)
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
return photo, nil
}
type reorderBody struct {
PhotoIDs []string `json:"photoIds"`
}
func (s *Server) reorderPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
galleryID := r.PathValue("id")
var body reorderBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
existing, err := s.db.ListPhotos(r.Context(), galleryID, false)
if err != nil {
writeStoreError(w, err, "")
return
}
if len(existing) == 0 {
writeStoreError(w, store.ErrNotFound, "Gallery not found or empty")
return
}
current := map[string]bool{}
for _, p := range existing {
current[p.ID] = true
}
if len(body.PhotoIDs) != len(existing) {
writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once")
return
}
seen := map[string]bool{}
for _, id := range body.PhotoIDs {
if !current[id] || seen[id] {
writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once")
return
}
seen[id] = true
}
if err := s.db.ReorderPhotos(r.Context(), galleryID, body.PhotoIDs); err != nil {
writeStoreError(w, err, "")
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Order updated"})
}
func (s *Server) deletePhoto(w http.ResponseWriter, r *http.Request, _ auth.User) {
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
if err != nil {
writeStoreError(w, err, "Photo not found")
return
}
if err := s.db.DeletePhoto(r.Context(), p.ID); err != nil {
writeStoreError(w, err, "Photo not found")
return
}
for _, key := range []string{p.OriginalKey, p.ThumbKey, p.PreviewKey} {
if key == "" {
continue
}
if err := s.storage.Delete(r.Context(), key); err != nil {
log.Printf("delete object %s: %v", key, err)
}
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Photo deleted"})
}
func (s *Server) retryPhoto(w http.ResponseWriter, r *http.Request, _ auth.User) {
id := r.PathValue("photoId")
if err := s.db.RequeuePhoto(r.Context(), id); err != nil {
writeStoreError(w, err, "Photo not found or not failed")
return
}
s.worker.Nudge()
p, err := s.db.GetPhoto(r.Context(), id)
if err != nil {
writeStoreError(w, err, "Photo not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"photo": s.photoToJSON(p, "", true)})
}
func sanitizeFilename(name string) string {
name = filepath.Base(name)
if name == "." || name == "/" {
return ""
}
if len(name) > 200 {
name = name[len(name)-200:]
}
return name
}
+79
View File
@@ -0,0 +1,79 @@
package httpapi
import (
"net/http"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// listPublicGalleries is the public index: only visibility='public'
// galleries ever appear here.
func (s *Server) listPublicGalleries(w http.ResponseWriter, r *http.Request) {
galleries, err := s.db.ListPublicGalleries(r.Context())
if err != nil {
writeStoreError(w, err, "")
return
}
out := make([]galleryJSON, 0, len(galleries))
for _, g := range galleries {
out = append(out, s.galleryToJSON(r.Context(), g, "", false))
}
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
}
// getPublicGallery serves a single gallery to any authorized viewer.
// ?token= carries the share token for link/ticket modes.
func (s *Server) getPublicGallery(w http.ResponseWriter, r *http.Request) {
g, err := s.db.GetGalleryBySlug(r.Context(), r.PathValue("slug"))
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
s.respondGalleryView(w, r, g)
}
// getEventGallery serves the newest gallery linked to an event, backing the
// /events/{slug}/gallery public page. Same access rules as by-slug.
func (s *Server) getEventGallery(w http.ResponseWriter, r *http.Request) {
ev, err := s.db.GetEventSummaryBySlug(r.Context(), r.PathValue("eventSlug"))
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
g, err := s.db.GetGalleryByEventID(r.Context(), ev.ID)
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
s.respondGalleryView(w, r, g)
}
func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g store.Gallery) {
user := s.optionalUser(r)
token := r.URL.Query().Get("token")
if denial := s.authorize(r, g, user, token); denial != nil {
writeError(w, denial.status, denial.msg)
return
}
// Only pass the token through to file URLs when it was the granting
// credential, so it doesn't leak into public/ticket responses.
urlToken := ""
if token != "" && token == g.ShareToken {
urlToken = token
}
photos, err := s.db.ListPhotos(r.Context(), g.ID, true)
if err != nil {
writeStoreError(w, err, "")
return
}
out := make([]photoJSON, 0, len(photos))
for _, p := range photos {
out = append(out, s.photoToJSON(p, urlToken, false))
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, urlToken, false),
"photos": out,
})
}
+39
View File
@@ -0,0 +1,39 @@
package httpapi
import (
"encoding/json"
"log"
"net/http"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("write response: %v", err)
}
}
// writeError follows the backend's error shape: {"error": string}.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// writeStoreError maps store errors: ErrNotFound -> 404, else 500 with the
// backend's generic message (details go to the log, not the client).
func writeStoreError(w http.ResponseWriter, err error, notFoundMsg string) {
if err == store.ErrNotFound {
writeError(w, http.StatusNotFound, notFoundMsg)
return
}
log.Printf("Error: %v", err)
writeError(w, http.StatusInternalServerError, "Internal Server Error")
}
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
+90
View File
@@ -0,0 +1,90 @@
// Package httpapi exposes the photo-api HTTP surface under /api/photos,
// following the backend's route and response conventions (resource-keyed
// success bodies, {"error": string} failures, Bearer auth).
package httpapi
import (
"log"
"net/http"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
)
type Server struct {
cfg config.Config
db *store.DB
storage storage.Storage
verifier *auth.Verifier
worker *worker.Worker
}
func New(cfg config.Config, db *store.DB, st storage.Storage, verifier *auth.Verifier, w *worker.Worker) *Server {
return &Server{cfg: cfg, db: db, storage: st, verifier: verifier, worker: w}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
health := func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
mux.HandleFunc("GET /health", health)
mux.HandleFunc("GET /api/photos/health", health)
// Admin surface: role admin|organizer only (mirrors /api/media).
mux.HandleFunc("POST /api/photos/galleries", s.requireAdmin(s.createGallery))
mux.HandleFunc("GET /api/photos/galleries", s.requireAdmin(s.listGalleries))
mux.HandleFunc("GET /api/photos/galleries/{id}", s.requireAdmin(s.getGallery))
mux.HandleFunc("PATCH /api/photos/galleries/{id}", s.requireAdmin(s.updateGallery))
mux.HandleFunc("DELETE /api/photos/galleries/{id}", s.requireAdmin(s.deleteGallery))
mux.HandleFunc("POST /api/photos/galleries/{id}/photos", s.requireAdmin(s.uploadPhotos))
mux.HandleFunc("PATCH /api/photos/galleries/{id}/order", s.requireAdmin(s.reorderPhotos))
mux.HandleFunc("POST /api/photos/galleries/{id}/share-token", s.requireAdmin(s.rotateShareToken))
mux.HandleFunc("DELETE /api/photos/photos/{photoId}", s.requireAdmin(s.deletePhoto))
mux.HandleFunc("POST /api/photos/photos/{photoId}/retry", s.requireAdmin(s.retryPhoto))
// Viewer surface: optional auth, checked per gallery visibility.
mux.HandleFunc("GET /api/photos/public/galleries", s.listPublicGalleries)
mux.HandleFunc("GET /api/photos/public/galleries/{slug}", s.getPublicGallery)
mux.HandleFunc("GET /api/photos/public/events/{eventSlug}/gallery", s.getEventGallery)
mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile)
return s.withCommon(mux)
}
// withCommon adds request logging and a same-spirit CORS allowance as the
// backend's cors() (origin = FRONTEND_URL); in production nginx serves
// same-origin so this mostly matters in development.
func (s *Server) withCommon(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && origin == s.cfg.FrontendURL {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
+72
View File
@@ -0,0 +1,72 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"strings"
"unicode"
"github.com/google/uuid"
)
// slugify follows the spirit of backend/src/lib/slugify.ts: lowercase,
// ASCII-fold common Spanish characters, dashes for everything else.
func slugify(s string) string {
replacer := strings.NewReplacer(
"á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u", "ü", "u", "ñ", "n",
"Á", "a", "É", "e", "Í", "i", "Ó", "o", "Ú", "u", "Ü", "u", "Ñ", "n")
s = replacer.Replace(strings.ToLower(strings.TrimSpace(s)))
var b strings.Builder
prevDash := true // avoids a leading dash
for _, r := range s {
switch {
case unicode.IsLetter(r) && r < 128, unicode.IsDigit(r):
b.WriteRune(r)
prevDash = false
default:
if !prevDash {
b.WriteByte('-')
prevDash = true
}
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 140 {
out = strings.Trim(out[:140], "-")
}
if out == "" {
out = "gallery"
}
return out
}
// uniqueSlug appends -2, -3, ... until the slug is free.
func (s *Server) uniqueSlug(ctx context.Context, title string) (string, error) {
base := slugify(title)
slug := base
for i := 2; ; i++ {
exists, err := s.db.SlugExists(ctx, slug)
if err != nil {
return "", err
}
if !exists {
return slug, nil
}
slug = fmt.Sprintf("%s-%d", base, i)
}
}
func newID() string {
return uuid.NewString()
}
// newShareToken returns 32 random bytes, base64url (43 chars, no padding).
func newShareToken() string {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
panic(err) // crypto/rand failure is unrecoverable
}
return base64.RawURLEncoding.EncodeToString(buf)
}
+60
View File
@@ -0,0 +1,60 @@
package imaging
import (
"fmt"
"os/exec"
)
// HEIC cannot be decoded in pure Go (HEVC), so conversion shells out to a
// host-installed CLI. The Go binary itself stays cgo-free; the converter is
// a runtime dependency (Debian: libvips-tools or libheif-examples).
type HeicConverter struct {
kind string // "vips" | "heif-convert" | "custom"
path string
}
// DetectHeicConverter honors an explicit HEIC_CONVERTER command, else
// autodetects vips then heif-convert. Returns nil when none is available;
// uploads of HEIC are then rejected with a clear message.
func DetectHeicConverter(explicit string) *HeicConverter {
if explicit != "" {
if p, err := exec.LookPath(explicit); err == nil {
return &HeicConverter{kind: "custom", path: p}
}
return nil
}
if p, err := exec.LookPath("vips"); err == nil {
return &HeicConverter{kind: "vips", path: p}
}
if p, err := exec.LookPath("heif-convert"); err == nil {
return &HeicConverter{kind: "heif-convert", path: p}
}
return nil
}
func (h *HeicConverter) Name() string { return h.path }
// ToJPEG converts src (a .heic file) to a JPEG at dst.
func (h *HeicConverter) ToJPEG(src, dst string) error {
var cmd *exec.Cmd
switch h.kind {
case "vips":
cmd = exec.Command(h.path, "copy", src, dst+"[Q=95]")
case "heif-convert":
cmd = exec.Command(h.path, "-q", "95", src, dst)
default:
// custom converter contract: <cmd> <src> <dst>
cmd = exec.Command(h.path, src, dst)
}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("heic conversion failed: %v: %s", err, truncate(string(out), 300))
}
return nil
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
+159
View File
@@ -0,0 +1,159 @@
// Package imaging produces the two derived variants from an original:
// thumb (512px long edge, JPEG q78) for the grid and preview (2048px long
// edge, JPEG q85) for the lightbox. Originals are never modified.
// Re-encoding strips EXIF (including GPS) from variants; orientation is
// applied to pixels first.
package imaging
import (
"fmt"
"image"
"image/jpeg"
"io"
"os"
"time"
_ "image/gif"
_ "image/png"
"github.com/rwcarlsen/goexif/exif"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
const (
ThumbLongEdge = 512
ThumbQuality = 78
PreviewLongEdge = 2048
PreviewQuality = 85
)
type Result struct {
Width int // of the original, after orientation
Height int
TakenAt time.Time // zero when EXIF has no usable timestamp
}
// ProcessFile decodes the image at path (JPEG/PNG/GIF/WebP — HEIC must be
// converted to JPEG first), applies EXIF orientation, and writes both
// variants as JPEG to thumbPath and previewPath.
func ProcessFile(path, thumbPath, previewPath string) (Result, error) {
f, err := os.Open(path)
if err != nil {
return Result{}, err
}
orientation, takenAt := readExif(f)
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
return Result{}, err
}
img, _, err := image.Decode(f)
f.Close()
if err != nil {
return Result{}, fmt.Errorf("decode: %w", err)
}
img = applyOrientation(img, orientation)
bounds := img.Bounds()
res := Result{Width: bounds.Dx(), Height: bounds.Dy(), TakenAt: takenAt}
if err := writeVariant(img, previewPath, PreviewLongEdge, PreviewQuality); err != nil {
return res, fmt.Errorf("preview: %w", err)
}
if err := writeVariant(img, thumbPath, ThumbLongEdge, ThumbQuality); err != nil {
return res, fmt.Errorf("thumb: %w", err)
}
return res, nil
}
func writeVariant(img image.Image, path string, longEdge, quality int) error {
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, resize(img, longEdge), &jpeg.Options{Quality: quality})
}
// resize scales so the long edge is at most longEdge, never upscaling.
func resize(img image.Image, longEdge int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
long := max(w, h)
if long <= longEdge {
return img
}
scale := float64(longEdge) / float64(long)
nw, nh := max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
func readExif(r io.Reader) (orientation int, takenAt time.Time) {
orientation = 1
x, err := exif.Decode(r)
if err != nil {
return orientation, takenAt
}
if tag, err := x.Get(exif.Orientation); err == nil {
if v, err := tag.Int(0); err == nil && v >= 1 && v <= 8 {
orientation = v
}
}
if t, err := x.DateTime(); err == nil {
takenAt = t
}
return orientation, takenAt
}
// applyOrientation bakes the EXIF orientation into pixels (values 2-8 per
// the EXIF spec; 1 is identity).
func applyOrientation(img image.Image, orientation int) image.Image {
switch orientation {
case 2:
return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, y })
case 3:
return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, h - 1 - y })
case 4:
return transform(img, func(x, y, w, h int) (int, int) { return x, h - 1 - y })
case 5:
return transformSwap(img, func(x, y, w, h int) (int, int) { return y, x })
case 6:
return transformSwap(img, func(x, y, w, h int) (int, int) { return y, h - 1 - x })
case 7:
return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, h - 1 - x })
case 8:
return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, x })
default:
return img
}
}
// transform maps destination (x,y) to source coordinates, same dimensions.
func transform(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dst := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
sx, sy := srcAt(x, y, w, h)
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
}
}
return dst
}
// transformSwap is for orientations that swap width and height.
func transformSwap(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dst := image.NewRGBA(image.Rect(0, 0, h, w))
for y := 0; y < w; y++ {
for x := 0; x < h; x++ {
sx, sy := srcAt(x, y, w, h)
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
}
}
return dst
}
+30
View File
@@ -0,0 +1,30 @@
package imaging
import "bytes"
// Sniff identifies an image by magic bytes, ignoring the client-supplied
// filename and Content-Type — same policy as backend/src/routes/media.ts
// detectImageType. Returns the canonical content type and extension, or
// ok=false with a human-readable reason.
func Sniff(head []byte) (contentType, ext string, ok bool, reason string) {
switch {
case len(head) >= 3 && bytes.Equal(head[:3], []byte{0xFF, 0xD8, 0xFF}):
return "image/jpeg", ".jpg", true, ""
case len(head) >= 8 && bytes.Equal(head[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
return "image/png", ".png", true, ""
case len(head) >= 6 && (bytes.Equal(head[:6], []byte("GIF87a")) || bytes.Equal(head[:6], []byte("GIF89a"))):
return "image/gif", ".gif", true, ""
case len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WEBP")):
return "image/webp", ".webp", true, ""
}
if len(head) >= 12 && bytes.Equal(head[4:8], []byte("ftyp")) {
brand := string(head[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
return "image/heic", ".heic", true, ""
case "avif", "avis":
return "", "", false, "AVIF is not supported, please upload JPEG, PNG, WebP, GIF or HEIC"
}
}
return "", "", false, "unsupported file type, please upload JPEG, PNG, WebP, GIF or HEIC"
}
+91
View File
@@ -0,0 +1,91 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// local stores objects under root (STORAGE_PATH, default ./data/photos).
// Deliberately disjoint from backend/uploads; nothing here is ever served
// statically — all reads go through the access-checked files handler.
type local struct {
root string
}
func newLocal(root string) (*local, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create storage dir %s: %w", root, err)
}
return &local{root: root}, nil
}
// path validates the key stays inside root (keys are generated internally,
// but defense-in-depth costs one Clean call).
func (l *local) path(key string) (string, error) {
clean := filepath.Clean("/" + key)
if strings.Contains(clean, "..") {
return "", fmt.Errorf("invalid key %q", key)
}
return filepath.Join(l.root, clean), nil
}
func (l *local) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) error {
dst, err := l.path(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, r); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func (l *local) Open(_ context.Context, key string) (io.ReadCloser, int64, error) {
p, err := l.path(key)
if err != nil {
return nil, 0, err
}
f, err := os.Open(p)
if err != nil {
return nil, 0, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, info.Size(), nil
}
func (l *local) Delete(_ context.Context, key string) error {
p, err := l.path(key)
if err != nil {
return err
}
err = os.Remove(p)
if os.IsNotExist(err) {
return nil
}
return err
}
func (l *local) PresignGet(context.Context, string, string, string, time.Duration) (string, error) {
return "", ErrNoPresign
}
+92
View File
@@ -0,0 +1,92 @@
package storage
import (
"context"
"fmt"
"io"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
)
// s3Store targets AWS S3 or path-style compatibles (Garage/MinIO), same as
// the backend's S3 backend. The bucket is never public: downloads use
// short-lived presigned GETs so visibility rules keep holding on S3.
type s3Store struct {
client *s3.Client
presign *s3.PresignClient
bucket string
}
func newS3(cfg config.Config) (*s3Store, error) {
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(cfg.S3Region),
awsconfig.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(cfg.S3AccessKeyID, cfg.S3SecretKey, "")),
)
if err != nil {
return nil, fmt.Errorf("s3 config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(cfg.S3Endpoint)
o.UsePathStyle = cfg.S3ForcePathStyle
})
return &s3Store{
client: client,
presign: s3.NewPresignClient(client),
bucket: cfg.S3Bucket,
}, nil
}
func (s *s3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: r,
ContentLength: aws.Int64(size),
ContentType: aws.String(contentType),
})
return err
}
func (s *s3Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, 0, err
}
return out.Body, aws.ToInt64(out.ContentLength), nil
}
func (s *s3Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}
func (s *s3Store) PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error) {
in := &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}
if contentType != "" {
in.ResponseContentType = aws.String(contentType)
}
if downloadFilename != "" {
in.ResponseContentDisposition = aws.String(fmt.Sprintf("attachment; filename=%q", downloadFilename))
}
req, err := s.presign.PresignGetObject(ctx, in, s3.WithPresignExpires(expiry))
if err != nil {
return "", err
}
return req.URL, nil
}
+32
View File
@@ -0,0 +1,32 @@
// Package storage abstracts photo object storage. Selection follows the
// backend's convention (backend/src/lib/storage.ts): S3 when S3_ENDPOINT
// and S3_BUCKET are both set, local disk otherwise. Keys are identical on
// both backends: galleries/<galleryID>/<variantDir>/<photoID>.<ext>.
package storage
import (
"context"
"errors"
"io"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
)
// ErrNoPresign is returned by backends that cannot presign (local disk);
// callers then stream the object through the API instead.
var ErrNoPresign = errors.New("presigned URLs not supported")
type Storage interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
Open(ctx context.Context, key string) (io.ReadCloser, int64, error)
Delete(ctx context.Context, key string) error
PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error)
}
func New(cfg config.Config) (Storage, error) {
if cfg.S3Enabled() {
return newS3(cfg)
}
return newLocal(cfg.StoragePath)
}
+113
View File
@@ -0,0 +1,113 @@
package store
// Every query against Drizzle-owned tables (users, events, tickets,
// payments) lives in this file so the read-coupling surface stays small
// and auditable. Column semantics follow backend/src/db/schema.ts.
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
type UserAuth struct {
ID string
Role string
TokenVersion int
AccountStatus string
}
func (db *DB) GetUserAuth(ctx context.Context, userID string) (UserAuth, error) {
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT id, role, token_version, account_status FROM users WHERE id = ?"), userID)
var id, role, tv, status any
if err := row.Scan(&id, &role, &tv, &status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return UserAuth{}, ErrNotFound
}
return UserAuth{}, err
}
return UserAuth{
ID: asString(id),
Role: asString(role),
TokenVersion: int(asInt(tv)),
AccountStatus: asString(status),
}, nil
}
// HasPaidTicket implements the review decision: a confirmed/checked-in
// ticket whose payment is 'paid', or any confirmed/checked-in ticket when
// the event is free (price = 0).
func (db *DB) HasPaidTicket(ctx context.Context, userID, eventID string) (bool, error) {
var v any
err := db.QueryRowContext(ctx, db.Rebind(`
SELECT 1
FROM tickets t
JOIN events e ON e.id = t.event_id
LEFT JOIN payments p ON p.ticket_id = t.id
WHERE t.user_id = ? AND t.event_id = ?
AND t.status IN ('confirmed','checked_in')
AND (p.status = 'paid' OR e.price = 0)
LIMIT 1`), userID, eventID).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
type EventSummary struct {
ID string
Slug string
Title string
TitleEs string
StartDatetime time.Time
Status string
}
func (db *DB) GetEventSummary(ctx context.Context, eventID string) (EventSummary, error) {
return db.getEventWhere(ctx, "id = ?", eventID)
}
// GetEventSummaryBySlug resolves the /events/{slug}/gallery public route.
func (db *DB) GetEventSummaryBySlug(ctx context.Context, slug string) (EventSummary, error) {
return db.getEventWhere(ctx, "slug = ?", slug)
}
func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSummary, error) {
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT id, slug, title, title_es, start_datetime, status FROM events WHERE "+where), arg)
var id, slug, title, titleEs, start, status any
if err := row.Scan(&id, &slug, &title, &titleEs, &start, &status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return EventSummary{}, ErrNotFound
}
return EventSummary{}, err
}
return EventSummary{
ID: asString(id),
Slug: asString(slug),
Title: asString(title),
TitleEs: asString(titleEs),
StartDatetime: asTime(start),
Status: asString(status),
}, nil
}
// CheckMainSchema fails fast at startup if the columns this service reads
// from Drizzle-owned tables have been renamed or dropped.
func (db *DB) CheckMainSchema(ctx context.Context) error {
checks := []string{
"SELECT id, role, token_version, account_status FROM users WHERE 1 = 0",
"SELECT id, slug, title, title_es, start_datetime, status, price FROM events WHERE 1 = 0",
"SELECT id, user_id, event_id, status FROM tickets WHERE 1 = 0",
"SELECT id, ticket_id, status FROM payments WHERE 1 = 0",
}
for _, q := range checks {
if _, err := db.ExecContext(ctx, q); err != nil {
return fmt.Errorf("main app schema check failed (%s): %w", q, err)
}
}
return nil
}
+122
View File
@@ -0,0 +1,122 @@
// Package store owns all database access. Photo tables use the photos_
// prefix and are migrated by this service alone; the handful of reads
// against Drizzle-owned tables (users, events, tickets, payments) are
// confined to access.go.
package store
import (
"database/sql"
"fmt"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
)
const (
Postgres = "postgres"
SQLite = "sqlite"
// timeLayout is a fixed-width UTC format so SQLite text timestamps
// sort correctly; the backend stores ISO strings in SQLite the same way.
timeLayout = "2006-01-02T15:04:05.000Z"
)
type DB struct {
*sql.DB
Type string
}
func Open(cfg config.Config) (*DB, error) {
switch cfg.DBType {
case Postgres:
sqlDB, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
sqlDB.SetMaxOpenConns(10)
return &DB{DB: sqlDB, Type: Postgres}, nil
case SQLite:
dsn := "file:" + strings.TrimPrefix(cfg.DatabaseURL, "file:") +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// One writer at a time keeps the shared file friendly to the backend.
sqlDB.SetMaxOpenConns(1)
return &DB{DB: sqlDB, Type: SQLite}, nil
default:
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
}
}
// Rebind converts ?-style placeholders to $n for Postgres. Queries in this
// package never contain literal question marks in strings.
func (db *DB) Rebind(query string) string {
if db.Type != Postgres {
return query
}
var b strings.Builder
n := 0
for _, r := range query {
if r == '?' {
n++
fmt.Fprintf(&b, "$%d", n)
} else {
b.WriteRune(r)
}
}
return b.String()
}
// TimeArg converts a time for binding: time.Time for Postgres (timestamptz
// columns), fixed-layout UTC text for SQLite (text columns).
func (db *DB) TimeArg(t time.Time) any {
if db.Type == Postgres {
return t.UTC()
}
return t.UTC().Format(timeLayout)
}
// asString normalizes scanned values across drivers.
func asString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case []byte:
return string(x)
case time.Time:
return x.UTC().Format(timeLayout)
default:
return fmt.Sprintf("%v", x)
}
}
// asTime parses a scanned timestamp from either driver; zero time on failure.
func asTime(v any) time.Time {
switch x := v.(type) {
case time.Time:
return x.UTC()
case string:
return parseTime(x)
case []byte:
return parseTime(string(x))
default:
return time.Time{}
}
}
func parseTime(s string) time.Time {
for _, layout := range []string{timeLayout, time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
+289
View File
@@ -0,0 +1,289 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
var ErrNotFound = errors.New("not found")
const (
VisibilityPublic = "public"
VisibilityPrivate = "private"
VisibilityLink = "link"
VisibilityTicket = "ticket"
)
type Gallery struct {
ID string
Slug string
Title string
TitleEs string
Description string
DescriptionEs string
EventID string
Visibility string
ShareToken string
CoverPhotoID string
CreatedBy string
CreatedAt time.Time
UpdatedAt time.Time
PhotoCount int
}
const galleryColumns = `g.id, g.slug, g.title, g.title_es, g.description, g.description_es,
g.event_id, g.visibility, g.share_token, g.cover_photo_id, g.created_by, g.created_at, g.updated_at`
type scanner interface{ Scan(...any) error }
func scanGallery(s scanner, withCount bool) (Gallery, error) {
var v [13]any
dest := make([]any, 0, 14)
for i := range v {
dest = append(dest, &v[i])
}
var count any
if withCount {
dest = append(dest, &count)
}
if err := s.Scan(dest...); err != nil {
return Gallery{}, err
}
g := Gallery{
ID: asString(v[0]),
Slug: asString(v[1]),
Title: asString(v[2]),
TitleEs: asString(v[3]),
Description: asString(v[4]),
DescriptionEs: asString(v[5]),
EventID: asString(v[6]),
Visibility: asString(v[7]),
ShareToken: asString(v[8]),
CoverPhotoID: asString(v[9]),
CreatedBy: asString(v[10]),
CreatedAt: asTime(v[11]),
UpdatedAt: asTime(v[12]),
}
if withCount {
g.PhotoCount = int(asInt(count))
}
return g, nil
}
func (db *DB) CreateGallery(ctx context.Context, g Gallery) error {
_, err := db.ExecContext(ctx, db.Rebind(`
INSERT INTO photos_galleries
(id, slug, title, title_es, description, description_es, event_id, visibility, share_token, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
g.ID, g.Slug, g.Title, nullable(g.TitleEs), nullable(g.Description), nullable(g.DescriptionEs),
nullable(g.EventID), g.Visibility, g.ShareToken, nullable(g.CreatedBy),
db.TimeArg(g.CreatedAt), db.TimeArg(g.UpdatedAt))
return err
}
func (db *DB) GetGallery(ctx context.Context, id string) (Gallery, error) {
return db.getGalleryWhere(ctx, "g.id = ?", id)
}
func (db *DB) GetGalleryBySlug(ctx context.Context, slug string) (Gallery, error) {
return db.getGalleryWhere(ctx, "g.slug = ?", slug)
}
// GetGalleryByEventID returns the newest gallery linked to an event, for
// the /events/{slug}/gallery public route.
func (db *DB) GetGalleryByEventID(ctx context.Context, eventID string) (Gallery, error) {
galleries, err := db.queryGalleries(ctx, `
SELECT `+galleryColumns+`,
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
FROM photos_galleries g
WHERE g.event_id = ?
ORDER BY g.created_at DESC LIMIT 1`, eventID)
if err != nil {
return Gallery{}, err
}
if len(galleries) == 0 {
return Gallery{}, ErrNotFound
}
return galleries[0], nil
}
func (db *DB) getGalleryWhere(ctx context.Context, where string, arg any) (Gallery, error) {
row := db.QueryRowContext(ctx, db.Rebind(`
SELECT `+galleryColumns+`,
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
FROM photos_galleries g WHERE `+where), arg)
g, err := scanGallery(row, true)
if errors.Is(err, sql.ErrNoRows) {
return Gallery{}, ErrNotFound
}
return g, err
}
// ListGalleries returns every gallery (admin view). eventID filters when set.
func (db *DB) ListGalleries(ctx context.Context, eventID string, limit, offset int) ([]Gallery, error) {
q := `
SELECT ` + galleryColumns + `,
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
FROM photos_galleries g`
args := []any{}
if eventID != "" {
q += " WHERE g.event_id = ?"
args = append(args, eventID)
}
q += " ORDER BY g.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
return db.queryGalleries(ctx, q, args...)
}
// ListPublicGalleries returns visibility='public' galleries with at least
// their ready photo counts, newest first.
func (db *DB) ListPublicGalleries(ctx context.Context) ([]Gallery, error) {
return db.queryGalleries(ctx, `
SELECT `+galleryColumns+`,
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id AND p.status = 'ready') AS photo_count
FROM photos_galleries g
WHERE g.visibility = 'public'
ORDER BY g.created_at DESC`)
}
func (db *DB) queryGalleries(ctx context.Context, q string, args ...any) ([]Gallery, error) {
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
galleries := []Gallery{}
for rows.Next() {
g, err := scanGallery(rows, true)
if err != nil {
return nil, err
}
galleries = append(galleries, g)
}
return galleries, rows.Err()
}
type GalleryUpdate struct {
Title *string
TitleEs *string
Description *string
DescriptionEs *string
EventID *string // empty string clears the link
Visibility *string
CoverPhotoID *string
}
func (db *DB) UpdateGallery(ctx context.Context, id string, u GalleryUpdate) error {
set := ""
args := []any{}
add := func(col string, val any) {
if set != "" {
set += ", "
}
set += col + " = ?"
args = append(args, val)
}
if u.Title != nil {
add("title", *u.Title)
}
if u.TitleEs != nil {
add("title_es", nullable(*u.TitleEs))
}
if u.Description != nil {
add("description", nullable(*u.Description))
}
if u.DescriptionEs != nil {
add("description_es", nullable(*u.DescriptionEs))
}
if u.EventID != nil {
add("event_id", nullable(*u.EventID))
}
if u.Visibility != nil {
add("visibility", *u.Visibility)
}
if u.CoverPhotoID != nil {
add("cover_photo_id", nullable(*u.CoverPhotoID))
}
if set == "" {
return nil
}
add("updated_at", db.TimeArg(time.Now()))
args = append(args, id)
res, err := db.ExecContext(ctx, db.Rebind("UPDATE photos_galleries SET "+set+" WHERE id = ?"), args...)
if err != nil {
return err
}
return errIfNoRows(res)
}
func (db *DB) RotateShareToken(ctx context.Context, id, token string) error {
res, err := db.ExecContext(ctx,
db.Rebind("UPDATE photos_galleries SET share_token = ?, updated_at = ? WHERE id = ?"),
token, db.TimeArg(time.Now()), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
func (db *DB) DeleteGallery(ctx context.Context, id string) error {
res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_galleries WHERE id = ?"), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
func (db *DB) SlugExists(ctx context.Context, slug string) (bool, error) {
var v any
err := db.QueryRowContext(ctx, db.Rebind("SELECT 1 FROM photos_galleries WHERE slug = ?"), slug).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func errIfNoRows(res sql.Result) error {
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// nullable maps "" to NULL so optional text columns stay NULL not ”.
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
func asInt(v any) int64 {
switch x := v.(type) {
case int64:
return x
case int:
return int64(x)
case int32:
return int64(x)
case float64:
return int64(x)
case []byte:
var n int64
for _, c := range x {
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
default:
return 0
}
}
+115
View File
@@ -0,0 +1,115 @@
package store
import (
"context"
"fmt"
"log"
"sort"
"strconv"
"strings"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/migrations"
)
// advisoryLockKey is an arbitrary fixed key for pg_advisory_lock so two
// instances can't run migrations concurrently. SQLite relies on its file lock.
const advisoryLockKey = 792346801
// Migrate applies embedded migrations for the active dialect in version
// order, recording applied versions in photos_schema_migrations.
func (db *DB) Migrate(ctx context.Context) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
if db.Type == Postgres {
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", advisoryLockKey); err != nil {
return fmt.Errorf("advisory lock: %w", err)
}
defer conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", advisoryLockKey)
}
createVersions := "CREATE TABLE IF NOT EXISTS photos_schema_migrations (version integer PRIMARY KEY, applied_at text NOT NULL)"
if db.Type == Postgres {
createVersions = "CREATE TABLE IF NOT EXISTS photos_schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL)"
}
if _, err := conn.ExecContext(ctx, createVersions); err != nil {
return fmt.Errorf("create photos_schema_migrations: %w", err)
}
applied := map[int]bool{}
rows, err := conn.QueryContext(ctx, "SELECT version FROM photos_schema_migrations")
if err != nil {
return err
}
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
rows.Close()
return err
}
applied[v] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
suffix := ".sqlite.sql"
if db.Type == Postgres {
suffix = ".pg.sql"
}
entries, err := migrations.FS.ReadDir(".")
if err != nil {
return err
}
var names []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), suffix) {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
version, err := strconv.Atoi(strings.SplitN(name, "_", 2)[0])
if err != nil {
return fmt.Errorf("migration %s: name must start with a numeric version", name)
}
if applied[version] {
continue
}
body, err := migrations.FS.ReadFile(name)
if err != nil {
return err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return err
}
for _, stmt := range strings.Split(string(body), ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := tx.ExecContext(ctx, stmt); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s: %w", name, err)
}
}
if _, err := tx.ExecContext(ctx, db.Rebind("INSERT INTO photos_schema_migrations (version, applied_at) VALUES (?, ?)"),
version, db.TimeArg(time.Now())); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
log.Printf("applied migration %s", name)
}
return nil
}
+271
View File
@@ -0,0 +1,271 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
const maxAttempts = 5
type Photo struct {
ID string
GalleryID string
Position int
OriginalKey string
OriginalFilename string
ContentType string
SizeBytes int64
Width int
Height int
ThumbKey string
PreviewKey string
TakenAt time.Time
Status string
Attempts int
NextAttemptAt time.Time
LastError string
CreatedAt time.Time
UpdatedAt time.Time
}
const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type,
size_bytes, width, height, thumb_key, preview_key, taken_at, status, attempts, next_attempt_at,
last_error, created_at, updated_at`
func scanPhoto(s scanner) (Photo, error) {
var v [18]any
dest := make([]any, len(v))
for i := range v {
dest[i] = &v[i]
}
if err := s.Scan(dest...); err != nil {
return Photo{}, err
}
return Photo{
ID: asString(v[0]),
GalleryID: asString(v[1]),
Position: int(asInt(v[2])),
OriginalKey: asString(v[3]),
OriginalFilename: asString(v[4]),
ContentType: asString(v[5]),
SizeBytes: asInt(v[6]),
Width: int(asInt(v[7])),
Height: int(asInt(v[8])),
ThumbKey: asString(v[9]),
PreviewKey: asString(v[10]),
TakenAt: asTime(v[11]),
Status: asString(v[12]),
Attempts: int(asInt(v[13])),
NextAttemptAt: asTime(v[14]),
LastError: asString(v[15]),
CreatedAt: asTime(v[16]),
UpdatedAt: asTime(v[17]),
}, nil
}
func (db *DB) InsertPhoto(ctx context.Context, p Photo) error {
_, err := db.ExecContext(ctx, db.Rebind(`
INSERT INTO photos_photos
(id, gallery_id, position, original_key, original_filename, content_type, size_bytes,
status, attempts, next_attempt_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`),
p.ID, p.GalleryID, p.Position, p.OriginalKey, nullable(p.OriginalFilename), p.ContentType,
p.SizeBytes, db.TimeArg(p.NextAttemptAt), db.TimeArg(p.CreatedAt), db.TimeArg(p.UpdatedAt))
return err
}
func (db *DB) GetPhoto(ctx context.Context, id string) (Photo, error) {
row := db.QueryRowContext(ctx,
db.Rebind("SELECT "+photoColumns+" FROM photos_photos WHERE id = ?"), id)
p, err := scanPhoto(row)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
return p, err
}
// ListPhotos returns a gallery's photos in position order. When readyOnly is
// set (public callers), photos still processing or failed are omitted.
func (db *DB) ListPhotos(ctx context.Context, galleryID string, readyOnly bool) ([]Photo, error) {
q := "SELECT " + photoColumns + " FROM photos_photos WHERE gallery_id = ?"
if readyOnly {
q += " AND status = 'ready'"
}
q += " ORDER BY position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), galleryID)
if err != nil {
return nil, err
}
defer rows.Close()
photos := []Photo{}
for rows.Next() {
p, err := scanPhoto(rows)
if err != nil {
return nil, err
}
photos = append(photos, p)
}
return photos, rows.Err()
}
func (db *DB) NextPosition(ctx context.Context, galleryID string) (int, error) {
var v any
err := db.QueryRowContext(ctx,
db.Rebind("SELECT COALESCE(MAX(position), -1) + 1 FROM photos_photos WHERE gallery_id = ?"),
galleryID).Scan(&v)
return int(asInt(v)), err
}
// ReorderPhotos rewrites positions to match ids order, in one transaction.
// ids must be exactly the gallery's photo ids (validated by the handler).
func (db *DB) ReorderPhotos(ctx context.Context, galleryID string, ids []string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := db.Rebind("UPDATE photos_photos SET position = ?, updated_at = ? WHERE id = ? AND gallery_id = ?")
now := db.TimeArg(time.Now())
for i, id := range ids {
if _, err := tx.ExecContext(ctx, q, i, now, id, galleryID); err != nil {
return err
}
}
return tx.Commit()
}
func (db *DB) DeletePhoto(ctx context.Context, id string) error {
res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_photos WHERE id = ?"), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// PhotoKeys returns every storage key of a gallery, for object cleanup
// before the rows cascade away.
func (db *DB) PhotoKeys(ctx context.Context, galleryID string) ([]string, error) {
rows, err := db.QueryContext(ctx, db.Rebind(
"SELECT original_key, thumb_key, preview_key FROM photos_photos WHERE gallery_id = ?"), galleryID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var a, b, c any
if err := rows.Scan(&a, &b, &c); err != nil {
return nil, err
}
for _, k := range []string{asString(a), asString(b), asString(c)} {
if k != "" {
keys = append(keys, k)
}
}
}
return keys, rows.Err()
}
// ClaimNextPhoto picks the oldest due queued/failed photo and marks it
// processing. Optimistic claim (RowsAffected check) works identically on
// Postgres and SQLite; returns ErrNotFound when the queue is empty.
func (db *DB) ClaimNextPhoto(ctx context.Context) (Photo, error) {
now := time.Now()
var idRaw any
err := db.QueryRowContext(ctx, db.Rebind(`
SELECT id FROM photos_photos
WHERE status IN ('queued','failed') AND attempts < ? AND next_attempt_at <= ?
ORDER BY created_at LIMIT 1`),
maxAttempts, db.TimeArg(now)).Scan(&idRaw)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
if err != nil {
return Photo{}, err
}
id := asString(idRaw)
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'processing', attempts = attempts + 1, updated_at = ?
WHERE id = ? AND status IN ('queued','failed')`),
db.TimeArg(now), id)
if err != nil {
return Photo{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return Photo{}, ErrNotFound // lost the race; caller loops again
}
return db.GetPhoto(ctx, id)
}
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, width, height int, takenAt time.Time) error {
var takenArg any
if !takenAt.IsZero() {
takenArg = db.TimeArg(takenAt)
}
_, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos
SET status = 'ready', thumb_key = ?, preview_key = ?, width = ?, height = ?, taken_at = ?,
last_error = NULL, updated_at = ?
WHERE id = ?`),
thumbKey, previewKey, width, height, takenArg, db.TimeArg(time.Now()), id)
return err
}
func (db *DB) MarkPhotoFailed(ctx context.Context, id string, attempts int, cause error) error {
backoff := time.Duration(1<<min(attempts, 6)) * time.Minute
msg := fmt.Sprintf("%v", cause)
if len(msg) > 1000 {
msg = msg[:1000]
}
_, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'failed', last_error = ?, next_attempt_at = ?, updated_at = ?
WHERE id = ?`),
msg, db.TimeArg(time.Now().Add(backoff)), db.TimeArg(time.Now()), id)
return err
}
// RequeuePhoto resets a failed photo for a fresh round of attempts.
func (db *DB) RequeuePhoto(ctx context.Context, id string) error {
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'queued', attempts = 0, next_attempt_at = ?, updated_at = ?
WHERE id = ? AND status IN ('failed','queued')`),
db.TimeArg(time.Now()), db.TimeArg(time.Now()), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// RecoverStuckProcessing requeues photos left in 'processing' by a crash.
func (db *DB) RecoverStuckProcessing(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'queued', updated_at = ?
WHERE status = 'processing' AND updated_at < ?`),
db.TimeArg(time.Now()), db.TimeArg(time.Now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// GalleryCoverKeys returns thumb keys for cover selection: the explicit
// cover photo's thumb when set and ready, else the first ready photo's.
func (db *DB) GalleryCoverKey(ctx context.Context, g Gallery) (photoID, thumbKey string) {
if g.CoverPhotoID != "" {
if p, err := db.GetPhoto(ctx, g.CoverPhotoID); err == nil && p.Status == "ready" && p.GalleryID == g.ID {
return p.ID, p.ThumbKey
}
}
row := db.QueryRowContext(ctx, db.Rebind(`
SELECT id, thumb_key FROM photos_photos
WHERE gallery_id = ? AND status = 'ready'
ORDER BY position, created_at LIMIT 1`), g.ID)
var idRaw, keyRaw any
if err := row.Scan(&idRaw, &keyRaw); err != nil {
return "", ""
}
return asString(idRaw), asString(keyRaw)
}
+185
View File
@@ -0,0 +1,185 @@
// Package worker turns queued photos_photos rows into ready ones by
// generating the thumb/preview variants. The rows themselves are the queue
// (status/attempts/next_attempt_at) — no Redis, no jobs table. Claims are
// optimistic UPDATEs so they are safe on both Postgres and SQLite.
package worker
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
const (
pollInterval = 10 * time.Second
stuckAfter = 10 * time.Minute
)
type Worker struct {
db *store.DB
storage storage.Storage
heic *imaging.HeicConverter
tmpDir string
nudge chan struct{}
parallel int
}
func New(db *store.DB, st storage.Storage, heic *imaging.HeicConverter, tmpDir string, parallel int) *Worker {
if parallel < 1 {
parallel = 1
}
return &Worker{
db: db,
storage: st,
heic: heic,
tmpDir: tmpDir,
nudge: make(chan struct{}, 1),
parallel: parallel,
}
}
// Nudge wakes the worker after an upload so the poll interval is only a
// fallback.
func (w *Worker) Nudge() {
select {
case w.nudge <- struct{}{}:
default:
}
}
func (w *Worker) Run(ctx context.Context) {
if n, err := w.db.RecoverStuckProcessing(ctx, stuckAfter); err != nil {
log.Printf("worker: recover stuck: %v", err)
} else if n > 0 {
log.Printf("worker: requeued %d photos stuck in processing", n)
}
for i := 0; i < w.parallel; i++ {
go w.loop(ctx)
}
}
func (w *Worker) loop(ctx context.Context) {
for {
worked := w.drain(ctx)
if ctx.Err() != nil {
return
}
if worked {
continue
}
select {
case <-ctx.Done():
return
case <-w.nudge:
case <-time.After(pollInterval):
}
}
}
// drain processes until the queue is empty; returns whether anything ran.
func (w *Worker) drain(ctx context.Context) bool {
worked := false
for ctx.Err() == nil {
photo, err := w.db.ClaimNextPhoto(ctx)
if err == store.ErrNotFound {
return worked
}
if err != nil {
log.Printf("worker: claim: %v", err)
return worked
}
worked = true
if err := w.process(ctx, photo); err != nil {
log.Printf("worker: photo %s attempt %d failed: %v", photo.ID, photo.Attempts, err)
if dberr := w.db.MarkPhotoFailed(ctx, photo.ID, photo.Attempts, err); dberr != nil {
log.Printf("worker: mark failed: %v", dberr)
}
}
}
return worked
}
func (w *Worker) process(ctx context.Context, p store.Photo) error {
work, err := os.MkdirTemp(w.tmpDir, "photo-*")
if err != nil {
return err
}
defer os.RemoveAll(work)
// 1. Fetch the original to a local file.
src := filepath.Join(work, "original")
if err := w.download(ctx, p.OriginalKey, src); err != nil {
return fmt.Errorf("fetch original: %w", err)
}
// 2. HEIC → JPEG via the external converter before the pure-Go pipeline.
if p.ContentType == "image/heic" {
if w.heic == nil {
return fmt.Errorf("HEIC converter not installed on this host (install libvips-tools or libheif-examples)")
}
converted := filepath.Join(work, "converted.jpg")
if err := w.heic.ToJPEG(src, converted); err != nil {
return err
}
src = converted
}
// 3. Decode, orient, resize, encode.
thumbPath := filepath.Join(work, "thumb.jpg")
previewPath := filepath.Join(work, "preview.jpg")
res, err := imaging.ProcessFile(src, thumbPath, previewPath)
if err != nil {
return err
}
// 4. Store variants next to the original's key.
base := strings.TrimSuffix(filepath.Base(p.OriginalKey), filepath.Ext(p.OriginalKey))
prefix := fmt.Sprintf("galleries/%s", p.GalleryID)
thumbKey := fmt.Sprintf("%s/thumb/%s.jpg", prefix, base)
previewKey := fmt.Sprintf("%s/preview/%s.jpg", prefix, base)
if err := w.upload(ctx, thumbKey, thumbPath); err != nil {
return fmt.Errorf("store thumb: %w", err)
}
if err := w.upload(ctx, previewKey, previewPath); err != nil {
return fmt.Errorf("store preview: %w", err)
}
return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, res.Width, res.Height, res.TakenAt)
}
func (w *Worker) download(ctx context.Context, key, dst string) error {
r, _, err := w.storage.Open(ctx, key)
if err != nil {
return err
}
defer r.Close()
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, r)
return err
}
func (w *Worker) upload(ctx context.Context, key, src string) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
return w.storage.Put(ctx, key, f, info.Size(), "image/jpeg")
}