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,43 @@
|
||||
# Spanglish photo-api configuration
|
||||
# Copy to .env — the service loads it on start (systemd also passes it via
|
||||
# EnvironmentFile). This service has its own .env; nothing is shared with
|
||||
# backend/.env except the VALUES of JWT_SECRET and DATABASE_URL.
|
||||
|
||||
# Port (dev default 3003; the systemd unit overrides to 3020 in production)
|
||||
PORT=3003
|
||||
|
||||
# Database — the SAME database the backend uses. photo-api owns only the
|
||||
# photos_* tables (created by `photo-api migrate`) and reads users/events/
|
||||
# tickets/payments for auth and access checks.
|
||||
# DB_TYPE must match the backend's engine: postgres | sqlite
|
||||
DB_TYPE=postgres
|
||||
DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db
|
||||
# For sqlite instead:
|
||||
# DB_TYPE=sqlite
|
||||
# DATABASE_URL=../backend/data/spanglish.db
|
||||
|
||||
# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the
|
||||
# same HS256 tokens the backend issues.
|
||||
JWT_SECRET=
|
||||
|
||||
# Public site origin, used to build share links and allow dev CORS
|
||||
FRONTEND_URL=https://spanglishcommunity.com
|
||||
|
||||
# Photo storage. Local disk by default; setting BOTH S3_ENDPOINT and
|
||||
# S3_BUCKET switches to S3 (same convention as the backend's media storage).
|
||||
# Use a photos-specific bucket — do not reuse the backend's media bucket.
|
||||
STORAGE_PATH=./data/photos
|
||||
#S3_ENDPOINT=
|
||||
#S3_REGION=auto
|
||||
#S3_BUCKET=spanglish-photos
|
||||
#S3_ACCESS_KEY_ID=
|
||||
#S3_SECRET_ACCESS_KEY=
|
||||
#S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Upload and processing tuning
|
||||
MAX_UPLOAD_MB=50
|
||||
WORKER_CONCURRENCY=2
|
||||
|
||||
# HEIC conversion shells out to a host-installed CLI (autodetects `vips`
|
||||
# then `heif-convert`; Debian: apt install libvips-tools). Set to override.
|
||||
#HEIC_CONVERTER=
|
||||
@@ -0,0 +1,25 @@
|
||||
# Secondary deployment path (the primary is bare-metal systemd, see
|
||||
# deploy/spanglish-photos.service). Built for the docker-compose "scale"
|
||||
# setup or any container host.
|
||||
#
|
||||
# docker build -t spanglish-photo-api photo-api/
|
||||
# docker run --env-file photo-api/.env -p 3020:3020 spanglish-photo-api
|
||||
|
||||
FROM golang:1.26 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /photo-api ./cmd/photo-api
|
||||
|
||||
# debian-slim rather than distroless: HEIC conversion needs the libvips CLI.
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libvips-tools ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /photo-api /usr/local/bin/photo-api
|
||||
RUN useradd -r -u 10001 photoapi && mkdir -p /data/photos && chown photoapi /data/photos
|
||||
USER photoapi
|
||||
ENV STORAGE_PATH=/data/photos
|
||||
EXPOSE 3020
|
||||
ENTRYPOINT ["/usr/local/bin/photo-api"]
|
||||
@@ -0,0 +1,23 @@
|
||||
.PHONY: start build test migrate clean help
|
||||
|
||||
BINARY := bin/photo-api
|
||||
CMD := ./cmd/photo-api
|
||||
|
||||
help: ## Show available targets
|
||||
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
|
||||
|
||||
start: ## Run the photo-api server (go run)
|
||||
go run $(CMD)
|
||||
|
||||
build: ## Build binary to bin/photo-api
|
||||
go build -o $(BINARY) $(CMD)
|
||||
|
||||
test: ## Run all Go tests
|
||||
go test ./...
|
||||
|
||||
migrate: ## Apply pending photos_* migrations
|
||||
go run $(CMD) migrate
|
||||
|
||||
clean: ## Remove built binary
|
||||
rm -rf bin
|
||||
@@ -0,0 +1,334 @@
|
||||
# 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:
|
||||
> 1. **Both Postgres and SQLite are supported from day 1**, mirroring the backend's `DB_TYPE`/`DATABASE_URL` convention. Consequence: the dedicated `photos` Postgres schema is dropped (SQLite has no schemas); instead every table carries a **`photos_` 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 is `modernc.org/sqlite` (pure Go, keeps the cgo-free static binary), and queue claiming uses `FOR UPDATE SKIP LOCKED` on Postgres and a plain claim-UPDATE on SQLite.
|
||||
> 2. Deployment stays **systemd-first with the Dockerfile as the secondary path** (confirmed).
|
||||
> 3. **Free events**: any `confirmed`/`checked_in` ticket grants access when the event's `price = 0`; paid events still require a `payments.status='paid'` row (§7 SQL updated).
|
||||
> 4. Admin nav keeps the name **"Photos"** alongside the existing "Gallery" item (confirmed).
|
||||
> 5. **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 (`vips` from libvips-tools, falling back to `heif-convert`); the Go binary stays cgo-free and the original `.heic` is stored byte-identical for download. If no converter is installed, HEIC uploads fail with a clear 415 and a startup warning is logged.
|
||||
> 6. Photo admin surface is **`admin`/`organizer` only** (confirmed).
|
||||
> 7. **Local disk and S3 both supported from day 1** (was already planned).
|
||||
> 8. 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_id` FK), `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)
|
||||
|
||||
1. **"Shared Postgres"** — the backend is dual-engine and *defaults* to SQLite. The local `backend/.env` says `DB_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).
|
||||
2. **"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.
|
||||
3. **"backend/ already has gallery … logic"** — there is no gallery model in the backend; only the generic `media` table and an admin frontend page. The collision surface is smaller than described: it's the `/api/media` routes, the `/uploads` path/dir, and the `S3_*`/`MEDIA_MAX_UPLOAD_MB` env names. This plan avoids all of them.
|
||||
4. **Ticket "paid" status** lives on the `payments` table, 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.js` rewrites in dev). Adding one more-specific nginx `location /api/photos/` and one more-specific Next rewrite keeps `fetchApi` working unchanged — no CORS, no new cert, no new env in the browser. Backend mounts (`index.ts:1901-1916`) include no `/api/photos` route, so there is no collision.
|
||||
- **Shared DB, own schema.** The ticket-visibility feature *requires* reading `tickets`/`payments`/`users`; that data lives in `spanglish_db`. Putting photo tables in a `photos` schema 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 in `public`; it never touches other schemas).
|
||||
- **Systemd-first deployment**, mirroring `deploy/spanglish-backend.service`: a new `spanglish-photos.service` running a static binary on port **3020** (prod; 3002 in dev, following the 3018/3019 prod vs 3001/3000 dev split).
|
||||
- **Stdlib `net/http`** with 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: Bearer` auth, 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_lock` on 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):
|
||||
|
||||
```sql
|
||||
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|original`. Same access check as its gallery. Local: streamed with long-lived `Cache-Control` (keyed URLs are stable); S3: 302 to a presigned GET (15 min). `original` adds `Content-Disposition: attachment; filename="<original_filename>"`. | image bytes / 302 |
|
||||
| `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 its `stdlib` adapter) — canonical Postgres driver; advisory locks.
|
||||
- `modernc.org/sqlite` — pure-Go SQLite driver (no cgo), for `DB_TYPE=sqlite` parity 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:
|
||||
|
||||
```json
|
||||
"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
|
||||
|
||||
```go
|
||||
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 from `STORAGE_PATH` (default `./data/photos`), `0600` files, atomic write via temp+rename. **Not** `backend/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 as `backend/src/lib/storage.ts`: S3 active when `S3_ENDPOINT` and `S3_BUCKET` are both set; honors `S3_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 package `libvips-tools`), `heif-convert` (package `libheif-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 uses `debian:bookworm-slim` rather than distroless). Missing converter ⇒ HEIC uploads get a clear 415 and startup logs a warning. Original `.heic` is stored byte-identical and served for download with its real content type. The sniffer recognizes `ftyp` brands `heic|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 `public` galleries ever appear in `/api/photos/public/galleries`. `link`/`ticket`/`private` are unlisted; non-public misses return **404, not 403**, so gallery existence isn't probeable (exception: `ticket` mode 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 `ticket` row + `payments` join, per how "paid" actually works here):
|
||||
|
||||
```sql
|
||||
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 one `SELECT role, token_version, account_status FROM public.users WHERE id=$1` to enforce the same revocation semantics as `getAuthUser` (`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 from `src/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, `status` polling for processing), thumbnail grid with drag-to-reorder (HTML5 DnD, no new dep; persists via `PATCH .../order`), visibility selector, event linker (reuses events list API), copy-share-link button (`navigator.clipboard`, `react-hot-toast`), set-cover, delete, retry-failed. Modeled on `admin/gallery/page.tsx` + `admin/events/[id]/` structurally.
|
||||
- **Public** — `src/app/(public)/photos/page.tsx`: server component (fetch public index, `revalidate: 300`, metadata) → `PhotosIndexClient.tsx` grid. `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.tsx` which re-fetches client-side with `?token=` from `useSearchParams()` and the Bearer token, and renders: masonry-ish thumb grid (`ImageGridSkeleton` while loading), lightbox (new shared `src/components/Lightbox.tsx` — full-screen `fixed inset-0 bg-black/90`, keyboard/swipe nav, preview variant, download button → original URL; pattern extracted from the inline modal in `admin/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.*` and `admin.nav.photos` keys in `src/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: user `spanglish`, `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` — add `upstream spanglish_photos { server 127.0.0.1:3020; }`.
|
||||
- `deploy/front-end_nginx.conf` — add, above `location /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 in `deploy/back-end_nginx.conf` if the api subdomain should serve it too.
|
||||
- `photo-api/Dockerfile` — secondary/container path: `golang:1.26` build stage (`CGO_ENABLED=0`), `gcr.io/distroless/static` runtime; and a commented `photos` service example for `deploy/docker-compose.scale.yml` (compose file itself untouched until that path is actually used).
|
||||
|
||||
`photo-api/.env.example`:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
1. **Scaffold + DB.** `go.mod`, config, `cmd/photo-api` with `serve`/`migrate`, migration runner + `0001_init.sql`, `/health`, root `package.json` scripts. *Test: `photo-api migrate` creates `photos.*` against the dev DB; `psql \dn` shows the schema; re-run is a no-op; `drizzle-kit generate` still produces no diff.*
|
||||
2. **Auth + gallery CRUD.** JWT middleware (incl. `token_version` check), all `/galleries` admin endpoints. *Test: curl with a real token from the running backend login; non-admin gets 403; CRUD round-trips.*
|
||||
3. **Upload + local storage + worker.** Multipart handler, sniffing, `LocalStorage`, imaging pipeline, queue/retry, `files/:id/:variant` for 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 in `failed` with `last_error`; retry works.*
|
||||
4. **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.*
|
||||
5. **S3 backend.** `S3Storage` + presigned GETs, verified against a local MinIO/Garage. *Test: same upload/download flows with `S3_*` set; private gallery file URL fails without presign.*
|
||||
6. **Admin frontend.** `/admin/photos` pages, nav item, api module, i18n. *Test: create → upload → reorder → visibility → copy link, in-browser against dev services.*
|
||||
7. **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.*
|
||||
8. **Deploy.** systemd unit, nginx blocks, `.env.example`s, Dockerfile, README section in `photo-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/.env` says `DB_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 matching `DB_TYPE`. On SQLite, two processes share one file: WAL is already on, photo-api adds `busy_timeout` and 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 `public` columns 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 `confirmed` tickets whose payment row is not `'paid'` (e.g. no payment or `cash`/`pending`), the paid-ticket join denies attendees. I could not confirm from the code what a free booking writes into `payments`. May need `OR (e.price = 0 AND t.status IN ('confirmed','checked_in'))`.
|
||||
- **Guest attendees** (`tickets.is_guest`, tickets bought by someone else): access is keyed on `tickets.user_id`, so a +1 without their own account cannot pass `ticket` gating. Workaround exists (admin shares the link token), but it's a product gap to acknowledge.
|
||||
- **HEIC depends on a host-installed converter** (`vips` or `heif-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 the `apt install libvips-tools` step.
|
||||
- **Nginx body size / timeouts** for 100 MB batch uploads over spotty Paraguayan mobile connections; `proxy_request_buffering off` planned, 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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# photo-api
|
||||
|
||||
Standalone Go service for event photo galleries: admins upload photos from
|
||||
past events, group them into galleries, and share them with the community.
|
||||
Lives in this monorepo but is its own module, binary, and deployable unit.
|
||||
Design/decisions: [PLAN.md](./PLAN.md).
|
||||
|
||||
## What it does
|
||||
|
||||
- Galleries with four visibility modes: `public` (listed on /photos),
|
||||
`private` (admins only), `link` (share-token URL), `ticket` (logged-in
|
||||
users with a confirmed ticket for the linked event — any confirmed ticket
|
||||
when the event is free).
|
||||
- Originals are stored byte-identical for download; a worker generates JPEG
|
||||
variants (thumb 512px q78, preview 2048px q85, EXIF stripped/orientation
|
||||
applied) queued in the DB with retries.
|
||||
- Storage: local disk (`STORAGE_PATH`) or S3/Garage/MinIO (set `S3_ENDPOINT`
|
||||
+ `S3_BUCKET`), same selection convention as the backend. S3 downloads use
|
||||
short-lived presigned URLs; nothing in the bucket is public.
|
||||
- Auth: validates the backend's HS256 JWTs with the shared `JWT_SECRET`
|
||||
(issuer `spanglish`, audience `spanglish-app`) including the DB-backed
|
||||
tokenVersion/account-status revocation check. Admin surface is
|
||||
`admin`/`organizer` only.
|
||||
- Database: the same Postgres or SQLite database as the backend (`DB_TYPE`,
|
||||
`DATABASE_URL`). This service owns only the `photos_*` tables via its own
|
||||
embedded migrations (`photo-api migrate`); drizzle-kit never sees them.
|
||||
Reads of backend tables are confined to `internal/store/access.go` and
|
||||
sanity-checked at startup.
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set JWT_SECRET + DATABASE_URL to match backend/.env
|
||||
go run ./cmd/photo-api migrate
|
||||
go run ./cmd/photo-api # serves on :3003 (dev)
|
||||
go test ./... # SQLite; add PHOTO_TEST_PG=<url> to also run on Postgres
|
||||
```
|
||||
|
||||
From the repo root: `npm run dev:photos`, `npm run build:photos`,
|
||||
`npm run test:photos`. The Next dev server rewrites `/api/photos/*` to
|
||||
`PHOTO_API_URL` (default `http://localhost:3003`), so the frontend needs no
|
||||
extra config in dev.
|
||||
|
||||
HEIC uploads require a converter CLI on the host: `apt install libvips-tools`
|
||||
(or `libheif-examples`). Without one, HEIC uploads are rejected with a clear
|
||||
message and a startup warning is logged.
|
||||
|
||||
## API
|
||||
|
||||
Everything under `/api/photos`. Errors are `{"error": string}`.
|
||||
|
||||
Admin (Bearer token, role admin/organizer):
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `/api/photos/galleries` | create gallery |
|
||||
| GET | `/api/photos/galleries` | list all (`?eventId=`) |
|
||||
| GET | `/api/photos/galleries/:id` | gallery + photos (all statuses) |
|
||||
| PATCH | `/api/photos/galleries/:id` | update title/visibility/event/cover |
|
||||
| DELETE | `/api/photos/galleries/:id` | delete gallery + objects |
|
||||
| POST | `/api/photos/galleries/:id/photos` | multipart upload (`files`) |
|
||||
| PATCH | `/api/photos/galleries/:id/order` | reorder (`{photoIds}`) |
|
||||
| POST | `/api/photos/galleries/:id/share-token` | rotate share token |
|
||||
| DELETE | `/api/photos/photos/:photoId` | delete photo |
|
||||
| POST | `/api/photos/photos/:photoId/retry` | requeue failed photo |
|
||||
|
||||
Viewer (anonymous or member token; `?token=` carries the share token):
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/photos/public/galleries` | public index |
|
||||
| GET | `/api/photos/public/galleries/:slug` | gallery view (access-checked) |
|
||||
| GET | `/api/photos/public/events/:eventSlug/gallery` | newest gallery of an event (access-checked) |
|
||||
| GET | `/api/photos/files/:photoId/:variant` | bytes; `thumb\|preview\|original` |
|
||||
| GET | `/api/photos/health` | liveness |
|
||||
|
||||
## Deploy (systemd, primary)
|
||||
|
||||
```bash
|
||||
cd photo-api && go build -o bin/photo-api ./cmd/photo-api
|
||||
sudo apt install libvips-tools
|
||||
sudo cp ../deploy/spanglish-photos.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now spanglish-photos
|
||||
sudo nginx -t && sudo systemctl reload nginx # after installing the updated confs
|
||||
```
|
||||
|
||||
nginx routes `location ^~ /api/photos/` → `127.0.0.1:3020` (see
|
||||
`deploy/front-end_nginx.conf`, `deploy/back-end_nginx.conf`,
|
||||
`deploy/spanglish_upstreams.conf`). The frontend's production `.env` should
|
||||
set `PHOTO_API_URL=http://127.0.0.1:3020` for server-side rendering of the
|
||||
public gallery pages. A `Dockerfile` is included as a secondary path for the
|
||||
compose-scale setup.
|
||||
@@ -0,0 +1,106 @@
|
||||
// photo-api serves event photo galleries for the Spanglish platform.
|
||||
//
|
||||
// photo-api start the HTTP server
|
||||
// photo-api migrate apply pending photos_* migrations and exit
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"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/httpapi"
|
||||
"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"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("photo-api: ")
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
db, err := store.Open(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
log.Println("migrations up to date")
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 {
|
||||
log.Fatalf("unknown subcommand %q (expected: migrate)", os.Args[1])
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
log.Fatalf("database ping: %v", err)
|
||||
}
|
||||
// Fail fast if the columns read from Drizzle-owned tables changed.
|
||||
if err := db.CheckMainSchema(ctx); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
st, err := storage.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("storage: %v", err)
|
||||
}
|
||||
if cfg.S3Enabled() {
|
||||
log.Printf("storage: S3 bucket %s at %s", cfg.S3Bucket, cfg.S3Endpoint)
|
||||
} else {
|
||||
log.Printf("storage: local disk at %s", cfg.StoragePath)
|
||||
}
|
||||
|
||||
heic := imaging.DetectHeicConverter(cfg.HeicConverter)
|
||||
if heic == nil {
|
||||
log.Println("WARNING: no HEIC converter found (install libvips-tools or libheif-examples); HEIC uploads will be rejected")
|
||||
} else {
|
||||
log.Printf("HEIC converter: %s", heic.Name())
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.StoragePath, 0o755); err != nil {
|
||||
log.Fatalf("storage path: %v", err)
|
||||
}
|
||||
wrk := worker.New(db, st, heic, cfg.StoragePath, cfg.WorkerConcurrency)
|
||||
wrk.Run(ctx)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("listening on :%d", cfg.Port)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
module git.azzamo.net/Michilis/Spanglish/photo-api
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.31
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.30
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||
golang.org/x/image v0.44.0
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect
|
||||
github.com/aws/smithy-go v1.27.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg=
|
||||
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE IF NOT EXISTS photos_galleries (
|
||||
id uuid PRIMARY KEY,
|
||||
slug varchar(160) NOT NULL UNIQUE,
|
||||
title varchar(200) NOT NULL,
|
||||
title_es varchar(200),
|
||||
description text,
|
||||
description_es text,
|
||||
event_id uuid,
|
||||
visibility varchar(10) NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('public','private','link','ticket')),
|
||||
share_token varchar(64) NOT NULL UNIQUE,
|
||||
cover_photo_id uuid,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photos_galleries_event_idx ON photos_galleries (event_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photos_galleries_visibility_idx ON photos_galleries (visibility);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photos_photos (
|
||||
id uuid PRIMARY KEY,
|
||||
gallery_id uuid NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE,
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
original_key text NOT NULL,
|
||||
original_filename text,
|
||||
content_type varchar(50) NOT NULL,
|
||||
size_bytes bigint NOT NULL,
|
||||
width integer,
|
||||
height integer,
|
||||
thumb_key text,
|
||||
preview_key text,
|
||||
taken_at timestamptz,
|
||||
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,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed');
|
||||
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE IF NOT EXISTS photos_galleries (
|
||||
id text PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE,
|
||||
title text NOT NULL,
|
||||
title_es text,
|
||||
description text,
|
||||
description_es text,
|
||||
event_id text,
|
||||
visibility text NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('public','private','link','ticket')),
|
||||
share_token text NOT NULL UNIQUE,
|
||||
cover_photo_id text,
|
||||
created_by text,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photos_galleries_event_idx ON photos_galleries (event_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photos_galleries_visibility_idx ON photos_galleries (visibility);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photos_photos (
|
||||
id text PRIMARY KEY,
|
||||
gallery_id text NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE,
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
original_key text NOT NULL,
|
||||
original_filename text,
|
||||
content_type text NOT NULL,
|
||||
size_bytes integer NOT NULL,
|
||||
width integer,
|
||||
height integer,
|
||||
thumb_key text,
|
||||
preview_key text,
|
||||
taken_at text,
|
||||
status text NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued','processing','ready','failed')),
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at text NOT NULL,
|
||||
last_error text,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed');
|
||||
@@ -0,0 +1,8 @@
|
||||
// Package migrations embeds the dual-dialect SQL migrations owned by
|
||||
// photo-api. Drizzle (backend) never sees these tables.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
Reference in New Issue
Block a user