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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user