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>
38 KiB
Photo API — Implementation Plan
Event photo galleries for the Spanglish platform, built as a standalone Go service in photo-api/.
Written 2026-07-24 after a read-only investigation of the repo.
Revised 2026-07-24 after review. Decisions from the review that amend the sections below:
- Both Postgres and SQLite are supported from day 1, mirroring the backend's
DB_TYPE/DATABASE_URLconvention. Consequence: the dedicatedphotosPostgres schema is dropped (SQLite has no schemas); instead every table carries aphotos_name prefix in both engines, which still cannot collide with any Drizzle-owned table name. Migrations are dual-dialect (*.pg.sql/*.sqlite.sql), SQLite driver ismodernc.org/sqlite(pure Go, keeps the cgo-free static binary), and queue claiming usesFOR UPDATE SKIP LOCKEDon Postgres and a plain claim-UPDATE on SQLite.- Deployment stays systemd-first with the Dockerfile as the secondary path (confirmed).
- Free events: any
confirmed/checked_inticket grants access when the event'sprice = 0; paid events still require apayments.status='paid'row (§7 SQL updated).- Admin nav keeps the name "Photos" alongside the existing "Gallery" item (confirmed).
- HEIC is supported, not rejected: uploads sniffed as HEIC are converted to JPEG for the variant pipeline by shelling out to a host-installed converter (
vipsfrom libvips-tools, falling back toheif-convert); the Go binary stays cgo-free and the original.heicis stored byte-identical for download. If no converter is installed, HEIC uploads fail with a clear 415 and a startup warning is logged.- Photo admin surface is
admin/organizeronly (confirmed).- Local disk and S3 both supported from day 1 (was already planned).
- The service has its own
photo-api/.env(was already planned).
0. Investigation summary (what the codebase actually is)
Database. Drizzle schema lives in one file, backend/src/db/schema.ts, with dual SQLite/Postgres table definitions selected at runtime by DB_TYPE (bottom-of-file conditional exports, lines ~716–738). drizzle.config.ts + npm run db:generate exist, but the migration that actually runs is the hand-written idempotent script backend/src/db/migrate.ts (npm run db:migrate), which is not run automatically at server start. The local backend/.env sets DB_TYPE=postgres with DATABASE_URL=postgresql://spanglish:...@localhost:5432/spanglish_db (driver: pg via drizzle-orm/node-postgres, everything in the public schema). Relevant tables:
events:id(uuid in PG),slug, bilingual title/description fields,start_datetime,status(draft|published|unlisted|cancelled|completed|archived),banner_url, …tickets:id,booking_id,user_id,event_id, attendee fields,status(pending|confirmed|cancelled|checked_in|on_hold),is_guest, …payments: one row per ticket (ticket_idFK),status(pending|pending_approval|paid|refunded|failed|cancelled|on_hold),provider(bancard|lightning|cash|bank_transfer|tpago),paid_at.
A ticket is "paid" when its payments row has status='paid' (set in backend/src/routes/payments.ts on admin approval/PATCH, and in backend/src/routes/lnbits.ts for Lightning), at which point the ticket itself is set to confirmed. "Paid" lives on the payment, not the ticket.
Auth. Stateless JWT, library jose, HS256 with the shared symmetric secret JWT_SECRET, issuer spanglish, audience spanglish-app, 1-day expiry (backend/src/lib/auth.ts, createToken lines 236–246). Payload: { sub: userId, email, role, tokenVersion, iat, exp }. Transport is Authorization: Bearer <token>; the frontend stores it in localStorage under spanglish-token (frontend/src/lib/api/client.ts). Roles live in a single users.role column: admin|organizer|staff|marketing|user; enforcement is requireAuth(roles?) (auth.ts:332), and media/admin routes use ['admin','organizer']. Revocation is DB-backed: users.token_version must match the token's tokenVersion claim, and account_status must be active. A Go service can validate tokens independently with JWT_SECRET + HS256 + iss/aud checks; honoring revocation additionally needs one indexed read of users. (There is a user_sessions table but it is a session list, not the auth mechanism.)
Backend conventions. Hono on Node (@hono/node-server), entry backend/src/index.ts. One Hono sub-router per domain in src/routes/*, mounted under /api/<domain>; shared helpers in src/lib/*; no controller/service layering. Validation: zod via @hono/zod-validator. Config: dotenv/config + ad-hoc process.env.* with inline defaults, no env schema. Responses: success is the resource keyed by name ({ media, url }, { contacts: [...] }) or { message }; errors are { error: string } with the status as the second arg; central app.onError → { error: 'Internal Server Error' } and app.notFound → { error: 'Not Found' }.
Existing media code (do not touch). backend/src/routes/media.ts (mounted at /api/media): POST /api/media/upload (auth ['admin','organizer'], Hono parseBody, magic-byte sniffing, UUID filename), plus GET/DELETE/list. Storage abstraction backend/src/lib/storage.ts: local ./uploads flat dir (served at /uploads/* from index.ts:1877) or S3 when S3_ENDPOINT + S3_BUCKET are set (S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_URL, S3_FORCE_PATH_STYLE). One generic media table (id, file_url, type, related_id, related_type, created_at). No image resizing anywhere (no sharp). Note: despite the feature name, there is no gallery table or backend gallery feature — frontend/src/app/admin/gallery/page.tsx is an admin page that uploads via /api/media with relatedType: 'gallery'. Event flyers/banners are just events.banner_url strings pointing at /uploads/....
Frontend. Next.js 14.2 App Router (frontend/src/app), React 18, TS 5.5. Admin shell + nav: frontend/src/app/admin/layout.tsx with a navigationWithRoles array (Heroicons); protection = middleware.ts checking a non-authoritative spanglish-auth=1 cookie + client-side useAuth() guard; the API is the real gate. API calls: fetchApi wrapper in src/lib/api/client.ts (API_BASE = NEXT_PUBLIC_API_URL || '', Bearer from localStorage); per-domain modules in src/lib/api/*. Same-origin /api/* and /uploads/* are rewritten to BACKEND_URL in next.config.js. Data fetching: server components with revalidate for public SEO pages handing off to *Client.tsx; plain useEffect in admin (no react-query). Tailwind 3.4 with custom theme (primary.yellow #FBB82B, brand.navy, Poppins, rounded-card 16px, shadow-card); custom UI primitives in src/components/ui/ (Button, Card, Input, Skeleton incl. ImageGridSkeleton); no shadcn. Custom i18n: src/i18n/ (en/es JSON) + LanguageContext with t(). No public gallery page exists today; there is a homepage carousel fed from public/images/carrousel/.
Infra. Real deployment is bare-metal systemd + host nginx, not Docker: deploy/spanglish-backend.service (node dist/index.js, port 3018, EnvironmentFile=backend/.env, hardened with ProtectSystem=strict + ReadWritePaths for uploads/data) and deploy/spanglish-frontend.service (next start -p 3019). Host nginx (deploy/front-end_nginx.conf, back-end_nginx.conf, spanglish_upstreams.conf) terminates TLS (Let's Encrypt) and routes /api and /uploads → backend :3018, everything else → frontend :3019; client_max_body_size 20m. deploy/docker-compose.scale.yml is a documented-as-non-turnkey alternative that references a backend/Dockerfile which does not exist. There is no CI at all (no .github/). Secrets come from gitignored per-service .env files referenced by EnvironmentFile=.
Where your description and the repo disagree (flagged, not worked around)
- "Shared Postgres" — the backend is dual-engine and defaults to SQLite. The local
backend/.envsaysDB_TYPE=postgres, so this plan assumes production is Postgres, but I cannot verify the production host's env from the repo. If production actually runs SQLite, the DB and ticket-check parts of this plan change materially (see Risks). - "Its own container" — nothing in this repo is deployed in a container; there are zero Dockerfiles and the compose file is explicitly a non-production starting point. The plan makes the systemd path primary (a static Go binary fits it perfectly) and includes a Dockerfile so the service can run as a container in the compose-scale setup.
- "backend/ already has gallery … logic" — there is no gallery model in the backend; only the generic
mediatable and an admin frontend page. The collision surface is smaller than described: it's the/api/mediaroutes, the/uploadspath/dir, and theS3_*/MEDIA_MAX_UPLOAD_MBenv names. This plan avoids all of them. - Ticket "paid" status lives on the
paymentstable, not the ticket — the check must join both.
1. Architecture
A single Go 1.26 binary (photo-api) exposing HTTP under the path prefix /api/photos, sitting behind the same host nginx, sharing the existing Postgres database but owning its own schema (photos), and validating the existing HS256 JWTs itself.
Why this shape:
- Same-origin path prefix instead of a new subdomain. The frontend already reaches the backend via same-origin
/api/*(nginx in prod,next.config.jsrewrites in dev). Adding one more-specific nginxlocation /api/photos/and one more-specific Next rewrite keepsfetchApiworking unchanged — no CORS, no new cert, no new env in the browser. Backend mounts (index.ts:1901-1916) include no/api/photosroute, so there is no collision. - Shared DB, own schema. The ticket-visibility feature requires reading
tickets/payments/users; that data lives inspanglish_db. Putting photo tables in aphotosschema in the same database gives one-query access checks, one backup story, and zero drizzle-kit interaction (drizzle introspects/manages only the tables it defines, all inpublic; it never touches other schemas). - Systemd-first deployment, mirroring
deploy/spanglish-backend.service: a newspanglish-photos.servicerunning a static binary on port 3020 (prod; 3002 in dev, following the 3018/3019 prod vs 3001/3000 dev split). - Stdlib
net/httpwith Go 1.22+ method/pattern routing (mux.HandleFunc("GET /api/photos/...")). The backend's Hono style — one router per domain, per-route middleware,{ error }JSON — maps cleanly onto this without importing a framework. Boring, zero-dep, matches "small service". - Same response conventions as Hono backend: resource-keyed success bodies (
{ gallery },{ galleries },{ photos }),{ error: "..." }failures,Authorization: Bearerauth, roles['admin','organizer']for admin surface — so the frontend treats it exactly like another backend domain module.
Request path: browser → nginx (location ^~ /api/photos/) → photo-api :3020 → Postgres (photos.* for its data, read-only use of public.users/tickets/payments/events for auth + access checks) → local disk or S3 for bytes.
2. Database: schema and migrations
Owned by the Go service. drizzle-kit never sees it. All photo tables carry a photos_ name prefix (photos_galleries, photos_photos, photos_schema_migrations) in the same database the backend uses — Postgres or SQLite per DB_TYPE — created and migrated by embedded, versioned, dual-dialect SQL files (migrations/NNNN_*.pg.sql + NNNN_*.sqlite.sql) applied by a ~60-line runner inside the binary (photo-api migrate subcommand). This matches the repo's existing convention — the backend also ignores drizzle-kit for application and runs a hand-written migration script manually at deploy time. The runner:
- on Postgres takes
pg_advisory_lockon a fixed key (safe if two instances start); SQLite's file lock covers the single-host case, - creates
photos_schema_migrations (version int primary key, applied_at), - applies each migration in a transaction, recording versions.
It is invoked by systemd as ExecStartPre=/…/photo-api migrate so deploys stay one-step, while Drizzle's tables are never touched. On SQLite the service opens the same database file as the backend (WAL is already enabled by the backend; photo-api sets busy_timeout and keeps write transactions short).
migrations/0001_init.pg.sql (the .sqlite.sql twin mirrors the backend's SQLite conventions: text ids generated in Go, text RFC3339 timestamps, integer booleans):
CREATE TABLE photos_galleries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug varchar(160) NOT NULL UNIQUE,
title varchar(200) NOT NULL,
title_es varchar(200),
description text,
description_es text,
event_id uuid, -- references public.events(id); soft reference, no FK (see note)
visibility varchar(10) NOT NULL DEFAULT 'private'
CHECK (visibility IN ('public','private','link','ticket')),
share_token varchar(64) NOT NULL UNIQUE, -- 32 random bytes, base64url; used only for 'link' mode
cover_photo_id uuid, -- set after photos exist; soft reference to photos_photos
created_by uuid, -- public.users(id); soft reference
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX photos_galleries_event_idx ON photos_galleries (event_id);
CREATE INDEX photos_galleries_visibility_idx ON photos_galleries (visibility);
CREATE TABLE photos_photos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gallery_id uuid NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
original_key text NOT NULL, -- storage key of the untouched original
original_filename text,
content_type varchar(50) NOT NULL,
size_bytes bigint NOT NULL,
width integer,
height integer,
thumb_key text, -- set when variant is ready
preview_key text,
taken_at timestamptz, -- from EXIF when present
status varchar(12) NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','processing','ready','failed')),
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position);
CREATE INDEX photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed');
No foreign keys into Drizzle-owned tables (events/users), deliberately: an FK from photos_* into a Drizzle-owned table would make future Drizzle migrations (drop/recreate, type changes) fail in confusing ways — exactly the "two tools fighting" scenario. Referential integrity for event_id is enforced in the handler (verify the event exists on create/update); a dangling event_id degrades gracefully (ticket-gated gallery just denies access; event card omitted).
The photo rows themselves are the processing queue (status/attempts/next_attempt_at) — no separate jobs table, no Redis. Queue claiming: FOR UPDATE SKIP LOCKED on Postgres, plain claim-UPDATE on SQLite (single host there anyway). Query text uses ? placeholders rebound to $n for Postgres by a small helper; ids are generated in Go (uuid), timestamps bound as Go time.Time (timestamptz on PG, RFC3339 text on SQLite, matching the backend's dual-schema style).
3. API surface
All routes under /api/photos. Auth = Authorization: Bearer <existing JWT>. "Admin" = role admin or organizer (mirrors backend/src/routes/media.ts). Errors are { error: string } with 400/401/403/404/413/500.
Admin (JWT, role admin|organizer)
| Method & path | Purpose | Response |
|---|---|---|
POST /api/photos/galleries |
Create gallery. Body: { title, titleEs?, description?, descriptionEs?, eventId?, visibility }. Slug generated from title (suffix on collision), share_token generated always. |
201 { gallery } |
GET /api/photos/galleries |
List all galleries (any visibility) with photo counts, cover thumb URL. Query: eventId?, limit/offset. |
{ galleries: [...] } |
GET /api/photos/galleries/:id |
Full gallery detail incl. photos in position order with per-variant URLs and processing status. | { gallery, photos: [...] } |
PATCH /api/photos/galleries/:id |
Update title/description/eventId/visibility/coverPhotoId. | { gallery } |
DELETE /api/photos/galleries/:id |
Delete gallery + photos + stored objects. | { message } |
POST /api/photos/galleries/:id/photos |
Multipart upload, field files (repeatable). Sniffs magic bytes (JPEG/PNG/WebP/GIF/AVIF; explicit 415 { error: "HEIC is not supported, please upload JPEG" } for HEIC). Stores original, inserts row status='queued', appends at end position. |
201 { photos: [...] } |
PATCH /api/photos/galleries/:id/order |
Body { photoIds: [uuid,...] } — full ordering; positions rewritten in one transaction. |
{ message } |
DELETE /api/photos/photos/:photoId |
Delete one photo + its objects; compacts positions. | { message } |
POST /api/photos/galleries/:id/share-token |
Rotate share token (invalidate old links). | { gallery } |
POST /api/photos/photos/:photoId/retry |
Re-queue a failed photo. |
{ photo } |
The share link the admin copies is a frontend URL: https://spanglishcommunity.com/photos/<slug>?token=<share_token>.
Viewer (public / optional JWT)
| Method & path | Purpose | Response |
|---|---|---|
GET /api/photos/public/galleries |
Public index: visibility='public' only, with cover thumb + count + linked-event summary. |
{ galleries: [...] } |
GET /api/photos/public/galleries/:slug?token= |
Single gallery incl. ready photos in order. Access check per matrix (§7); optional Bearer honored. |
{ gallery, photos: [...] } or 401/403/404 |
GET /api/photos/files/:photoId/:variant?token= |
The bytes. `variant ∈ thumb | preview |
GET /api/photos/health |
Liveness (also mapped at /health on the port for systemd checks). |
{ status: "ok" } |
Photo objects in responses carry ready-to-use URLs (/api/photos/files/<id>/thumb etc., with ?token= appended by the client when in link mode) so the frontend never constructs storage paths.
4. photo-api/ layout and module setup
photo-api/
go.mod # module github.com/Michilis/spanglish/photo-api
.env.example
Dockerfile # secondary path; multi-stage, static binary, distroless
cmd/photo-api/main.go # flag/subcommand: serve (default) | migrate
migrations/ # embedded SQL (embed.FS)
0001_init.sql
internal/config/config.go # env parsing with defaults, mirrors backend's ad-hoc style but centralized
internal/auth/auth.go # JWT verify (HS256, iss/aud), tokenVersion+account_status check, role helpers
internal/store/ # pgx queries: galleries.go, photos.go, access.go (ticket check), migrate.go
internal/storage/ # storage.go (interface), local.go, s3.go
internal/imaging/ # decode, EXIF orient, resize, encode JPEG
internal/worker/worker.go # variant-processing loop
internal/httpapi/ # router.go, middleware.go, galleries.go, photos_upload.go, files.go, public.go, respond.go
Plain go.mod, no go.work. There is exactly one Go module and no cross-module Go imports, so a workspace file adds nothing. Dependencies (each justified, all boring):
github.com/jackc/pgx/v5(via itsstdlibadapter) — canonical Postgres driver; advisory locks.modernc.org/sqlite— pure-Go SQLite driver (no cgo), forDB_TYPE=sqliteparity with the backend.github.com/golang-jwt/jwt/v5— HS256 verification of the existing tokens.github.com/aws/aws-sdk-go-v2(s3 + presign) — same SDK family the backend uses for S3; needed for the S3 storage backend and presigned URLs.golang.org/x/image— high-quality scaling (draw.CatmullRom).github.com/rwcarlsen/goexif— EXIF orientation +taken_at. (Tiny, stable, read-only.)
Monorepo integration. npm workspaces stay ["backend","frontend"] — a Go dir cannot be a workspace and nothing should pretend it is. Root package.json gets convenience scripts only:
"dev:photos": "cd photo-api && go run ./cmd/photo-api",
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
"test:photos": "cd photo-api && go test ./..."
and root dev adds the third concurrently entry. .gitignore additions: photo-api/bin/, photo-api/data/, (.env already covered). There is no CI in this repo, so nothing to wire; if CI is added later, path-filter Go steps on photo-api/**. Note: pnpm-lock.yaml sits untracked next to the committed package-lock.json — unrelated to this feature but worth resolving; scripts above are package-manager-agnostic.
5. Storage abstraction
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) // local streaming
Delete(ctx context.Context, key string) error
// PresignGet returns ("", ErrNoPresign) on local storage; handler then streams via Open.
PresignGet(ctx context.Context, key, downloadFilename string, expiry time.Duration) (string, error)
}
Key layout (both backends): galleries/<galleryID>/orig/<photoID>.<ext>, .../thumb/<photoID>.jpg, .../preview/<photoID>.jpg.
local.go— root dir fromSTORAGE_PATH(default./data/photos),0600files, atomic write via temp+rename. Notbackend/uploads— completely disjoint tree, and nothing is nginx-static-served: all reads go through the access-checked/api/photos/files/handler.s3.go— same selection convention asbackend/src/lib/storage.ts: S3 active whenS3_ENDPOINTandS3_BUCKETare both set; honorsS3_REGION,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY,S3_FORCE_PATH_STYLE(default true, Garage/MinIO-friendly). Serving uses presigned GETs so private galleries stay private even on S3 — the bucket is never public.
Env var names deliberately reuse the backend's vocabulary (S3_*, DATABASE_URL, JWT_SECRET, PORT) because each systemd service loads its own EnvironmentFile (photo-api/.env) — same conventions, zero collision. The one shared-by-value var is JWT_SECRET (must equal the backend's) and DATABASE_URL (same DB). Recommend a different S3_BUCKET (e.g. spanglish-photos) than any backend bucket. Full key list in §9.
6. Image processing
Library: pure Go for the pipeline, external CLI only for HEIC decode — stdlib image/jpeg|png|gif, golang.org/x/image (webp decode, CatmullRom scaler), goexif. Reasoning: the real deployment is a hardened systemd unit on a bare host; govips/bimg would add a libvips system dependency, cgo, and non-static binaries, complicating both the host install and the (secondary) container image for a service whose write volume is "admin uploads a batch after an event", not a hot path. Pure Go costs ~1–3 s/photo per variant on large JPEGs — fine for an async queue.
- HEIC (required per review): decoded by shelling out, since no viable pure-Go HEVC decoder exists. The worker converts HEIC→JPEG (quality 95, temp file in the service's data dir) via the first available of:
HEIC_CONVERTER(explicit path/command),vips copy(Debian packagelibvips-tools),heif-convert(packagelibheif-examples), then feeds the JPEG into the normal pure-Go pipeline. The Go binary itself stays cgo-free/static; the converter is a runtime host dependency (added to the systemd host setup and the Dockerfile — which therefore usesdebian:bookworm-slimrather than distroless). Missing converter ⇒ HEIC uploads get a clear 415 and startup logs a warning. Original.heicis stored byte-identical and served for download with its real content type. The sniffer recognizesftypbrandsheic|heix|hevc|hevx|mif1|msf1. - AVIF: accepted at upload only if decodable; stdlib has no AVIF decoder, so in practice AVIF is rejected with an explicit message (can ride the same converter escape hatch later if needed).
Variants (originals stored byte-identical, never touched):
| Variant | Long edge | Format | Quality | Purpose |
|---|---|---|---|---|
thumb |
512 px | JPEG | 78 | grid |
preview |
2048 px | JPEG | 85 | lightbox |
original |
— | as uploaded | — | download |
Re-encoding variants strips EXIF (including GPS — a privacy win for published pages); orientation is applied to pixels first. width/height/taken_at recorded on the row from the original.
Queue/retry: the photos.photos row is the job. Worker = single goroutine pool (size WORKER_CONCURRENCY, default 2) polling with SELECT ... WHERE status IN ('queued','failed') AND attempts < 5 AND next_attempt_at <= now() ORDER BY created_at LIMIT n FOR UPDATE SKIP LOCKED, plus an in-process channel nudge after each upload so the poll interval (10 s) is only a fallback. On error: attempts+1, exponential next_attempt_at, last_error; after 5 attempts stays failed and the admin UI shows a retry button (/photos/:id/retry). FOR UPDATE SKIP LOCKED makes multi-instance safe for free. Crash recovery: on startup, rows stuck in processing older than 10 min are re-queued.
7. Access control matrix
Caller types: anon (no/invalid JWT), member (valid JWT, any role), ticket-holder (member with paid ticket for the gallery's linked event), link-holder (anyone presenting the gallery's share_token), admin (JWT role admin|organizer).
| Visibility | anon | member | ticket-holder | link-holder | admin |
|---|---|---|---|---|---|
public |
✅ view/download | ✅ | ✅ | ✅ | ✅ + manage |
private |
❌ 404 | ❌ 404 | ❌ 404 | ❌ 404 (token ignored) | ✅ + manage |
link |
❌ 404 without token | ❌ 404 without token | ❌ 404 without token | ✅ with valid token | ✅ + manage |
ticket |
❌ 401 | ❌ 403 | ✅ | valid token also grants ✅ (link is a superset escape hatch; token exists on every gallery but is only honored for link and ticket modes) |
✅ + manage |
- Listing: only
publicgalleries ever appear in/api/photos/public/galleries.link/ticket/privateare unlisted; non-public misses return 404, not 403, so gallery existence isn't probeable (exception:ticketmode returns 401/403 so the frontend can prompt login/purchase — the gallery's existence is intentionally discoverable via its event page). - Enforcement lives in one function,
store.Authorize(ctx, gallery, caller, token), called in exactly two places: the gallery-view handler and the file handler — every byte served re-checks; there are no capability URLs except the explicit share token, and S3 presigned URLs are short-lived (15 min) so a leaked presign expires. - Ticket check (the
ticketrow +paymentsjoin, per how "paid" actually works here):
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) -- review decision: free events need only a confirmed ticket
LIMIT 1;
Chosen over the alternative (forwarding the user's JWT to the backend's existing GET /api/dashboard/tickets, backend/src/routes/dashboard.ts:98) because: we are already in the same database for our own tables, it is one indexed query instead of an HTTP hop + JSON parse, it has no runtime dependency on the backend being up, and it needs zero backend changes. The cost is read-coupling to four public columns (tickets.user_id/event_id/status, payments.ticket_id/status) — mitigated by confining every public.* query to internal/store/access.go and a startup sanity check that those columns exist (fail loud, not wrong).
- JWT verification in Go: HS256 +
iss=spanglish+aud=spanglish-app, then oneSELECT role, token_version, account_status FROM public.users WHERE id=$1to enforce the same revocation semantics asgetAuthUser(backend/src/lib/auth.ts:320-327). Skipping the DB check would silently weaken logout-everywhere/suspension for photo routes; since we're in the DB anyway, we match the backend exactly.
8. Frontend work
New files (all following existing patterns — fetchApi, useLanguage().t, Tailwind theme, ui/ primitives):
src/lib/api/photos.ts— API module (types + calls for every endpoint in §3), re-exported fromsrc/lib/api/index.ts.- Admin —
src/app/admin/photos/page.tsx: gallery list (cards: cover thumb, title, visibility badge, event, count; create-gallery modal).src/app/admin/photos/[id]/page.tsx: single gallery manager — multi-file upload (input + drag-drop, per-file progress,statuspolling for processing), thumbnail grid with drag-to-reorder (HTML5 DnD, no new dep; persists viaPATCH .../order), visibility selector, event linker (reuses events list API), copy-share-link button (navigator.clipboard,react-hot-toast), set-cover, delete, retry-failed. Modeled onadmin/gallery/page.tsx+admin/events/[id]/structurally. - Public —
src/app/(public)/photos/page.tsx: server component (fetch public index,revalidate: 300, metadata) →PhotosIndexClient.tsxgrid.src/app/(public)/photos/[slug]/page.tsx: server component fetching the gallery without token (public galleries get SSR/SEO; link/ticket galleries fall through) →GalleryClient.tsxwhich re-fetches client-side with?token=fromuseSearchParams()and the Bearer token, and renders: masonry-ish thumb grid (ImageGridSkeletonwhile loading), lightbox (new sharedsrc/components/Lightbox.tsx— full-screenfixed inset-0 bg-black/90, keyboard/swipe nav, preview variant, download button → original URL; pattern extracted from the inline modal inadmin/gallery/page.tsx), download-original per photo, ticket-mode gates (401 → login redirect with?redirect=, 403 → "this gallery is for attendees" message linking the event). - i18n: new
photos.*andadmin.nav.photoskeys insrc/i18n/locales/{en,es}.json.
Existing files changed (complete list):
| File | Change |
|---|---|
src/app/admin/layout.tsx |
one entry in navigationWithRoles: { name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin','organizer'] } (CameraIcon, since PhotoIcon is taken by the existing Gallery item) |
next.config.js |
prepend rewrite { source: '/api/photos/:path*', destination: PHOTO_API_URL + '/api/photos/:path*' } before the generic /api/:path* rule (rewrites are order-sensitive); PHOTO_API_URL default http://localhost:3003 |
src/lib/api/index.ts |
re-export photos module |
src/i18n/locales/en.json, es.json |
new keys |
src/app/sitemap.ts |
add /photos + public gallery slugs (nice-to-have, phase 7) |
Naming note: the admin nav will then contain both "Gallery" (existing homepage-media page at /admin/gallery) and "Photos" (new). Requirement says "Photos"; flagged in Questions since admins may find the pair confusing.
9. Deployment and config
New files in deploy/ (mirroring existing ones; no existing file is replaced — the two nginx confs get additive location blocks):
deploy/spanglish-photos.service— copy of the backend unit shape: userspanglish,ExecStartPre=…/photo-api/bin/photo-api migrate,ExecStart=…/photo-api/bin/photo-api,EnvironmentFile=…/photo-api/.env,PORT=3020, hardening incl.ReadWritePaths=…/photo-api/data.deploy/spanglish_upstreams.conf— addupstream spanglish_photos { server 127.0.0.1:3020; }.deploy/front-end_nginx.conf— add, abovelocation /api:location ^~ /api/photos/ { proxy_pass http://spanglish_photos; client_max_body_size 100m; proxy_request_buffering off; }(uploads are big; the vhost default is 20m). Same block indeploy/back-end_nginx.confif the api subdomain should serve it too.photo-api/Dockerfile— secondary/container path:golang:1.26build stage (CGO_ENABLED=0),gcr.io/distroless/staticruntime; and a commentedphotosservice example fordeploy/docker-compose.scale.yml(compose file itself untouched until that path is actually used).
photo-api/.env.example:
PORT=3003 # dev; systemd unit overrides to 3020
DB_TYPE=postgres # postgres | sqlite — must match the backend's engine
DATABASE_URL=postgresql://... # SAME database as backend (photos_* tables created by migrate);
# for sqlite: path to the backend's db file, e.g. ../backend/data/spanglish.db
JWT_SECRET= # MUST equal backend/.env JWT_SECRET
HEIC_CONVERTER= # optional; default autodetects `vips`, then `heif-convert`
STORAGE_PATH=./data/photos # local storage root (ignored when S3 is on)
S3_ENDPOINT= # + S3_BUCKET both set => S3 backend (same convention as backend)
S3_REGION=auto
S3_BUCKET= # use a photos-specific bucket
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=true
MAX_UPLOAD_MB=50 # per file
WORKER_CONCURRENCY=2
FRONTEND_URL=https://spanglishcommunity.com # for building share links
Frontend .env gains PHOTO_API_URL=http://localhost:3003 (dev; prod nginx routes directly so the rewrite is unused there, matching how /api works today).
10. Build sequence
- Scaffold + DB.
go.mod, config,cmd/photo-apiwithserve/migrate, migration runner +0001_init.sql,/health, rootpackage.jsonscripts. Test:photo-api migratecreatesphotos.*against the dev DB;psql \dnshows the schema; re-run is a no-op;drizzle-kit generatestill produces no diff. - Auth + gallery CRUD. JWT middleware (incl.
token_versioncheck), all/galleriesadmin endpoints. Test: curl with a real token from the running backend login; non-admin gets 403; CRUD round-trips. - Upload + local storage + worker. Multipart handler, sniffing,
LocalStorage, imaging pipeline, queue/retry,files/:id/:variantfor admins. Test: upload a batch of real photos incl. a portrait-orientation iPhone JPEG (EXIF rotation) and a deliberately corrupt file; variants appear; corrupt file lands infailedwithlast_error; retry works. - Access control + public endpoints.
Authorize, ticket-check query,public/galleries,public/galleries/:slug, token handling, presign-vs-stream in file handler. Test: matrix in §7 as a Go table test against seeded DB rows (paid/pending/refunded payments), plus curl for each caller type. - S3 backend.
S3Storage+ presigned GETs, verified against a local MinIO/Garage. Test: same upload/download flows withS3_*set; private gallery file URL fails without presign. - Admin frontend.
/admin/photospages, nav item, api module, i18n. Test: create → upload → reorder → visibility → copy link, in-browser against dev services. - Public frontend.
/photos,/photos/[slug], Lightbox, download, gated states, sitemap. Test: all four visibility modes in-browser, incl. link URL in an incognito window and ticket mode with a paid vs pending test user. - Deploy. systemd unit, nginx blocks,
.env.examples, Dockerfile, README section inphoto-api/. Test: on the host —systemctl start spanglish-photos, nginx reload, end-to-end over HTTPS.
11. Risks and open questions
Risks / could-not-determine:
- Production DB engine is unverifiable from the repo (local
backend/.envsaysDB_TYPE=postgres; prod env is gitignored). Mitigated by the review decision to support both engines — whichever prod runs, photo-api points at the same database with matchingDB_TYPE. On SQLite, two processes share one file: WAL is already on, photo-api addsbusy_timeoutand short write transactions, but write contention under heavy simultaneous booking + photo-processing load is a residual (small) risk. - Schema read-coupling. The ticket/user queries read five
publiccolumns Drizzle owns. A future rename breaks photo access checks at runtime; mitigated by the startup column sanity-check (fail fast) and by the queries living in one file. - Free events. If a PYG-0 event issues
confirmedtickets whose payment row is not'paid'(e.g. no payment orcash/pending), the paid-ticket join denies attendees. I could not confirm from the code what a free booking writes intopayments. May needOR (e.price = 0 AND t.status IN ('confirmed','checked_in')). - Guest attendees (
tickets.is_guest, tickets bought by someone else): access is keyed ontickets.user_id, so a +1 without their own account cannot passticketgating. Workaround exists (admin shares the link token), but it's a product gap to acknowledge. - HEIC depends on a host-installed converter (
vipsorheif-convert). If the prod host lacks the package, HEIC uploads 415 until it's installed; startup logs a warning so this is visible, and the deploy notes include theapt install libvips-toolsstep. - Nginx body size / timeouts for 100 MB batch uploads over spotty Paraguayan mobile connections;
proxy_request_buffering offplanned, but real-world testing is in phase 8. - 1-day JWT expiry with no working refresh flow (refresh tokens are minted but no endpoint consumes them) means long admin upload sessions can hit 401 mid-batch; the admin UI must surface that as "session expired, log in again" rather than a generic failure.
Questions — answered in review 2026-07-24 (see the revision note at the top): dual Postgres+SQLite support; systemd-first confirmed; free events count any confirmed ticket; nav name "Photos" kept; HEIC supported via converter shell-out; admin/organizer only; local disk and S3 both supported from day 1; the service keeps its own .env. The production engine check (and, if Postgres, connectivity for the spanglish role) is still on the user's side.