10 Commits
Author SHA1 Message Date
MichilisandCursor be4dd5b47f Add mobile save sheet with same-origin photo downloads.
Phones cannot land downloads in the photo library, so the gallery opens a share sheet for a cacheable preview while streaming via a dedicated download endpoint and recording preview sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 20:14:31 +00:00
MichilisandCursor 390e1dc0ea Hide admin payment summary cards when privacy mode is enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:15:24 +00:00
MichilisandCursor fa8686276d Share gallery layout with loading skeletons and add download progress.
Keep the public gallery hero/masonry geometry stable across route and client loading, and show busy state while photos download.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:05:33 +00:00
Michilis 498d7d8a7d Add photo content-hash dedup and local↔S3 library sync.
Uploads skip per-gallery duplicates, checksums can be backfilled, and STORAGE_BACKEND plus sync tooling make switching storage backends safe.
2026-08-05 05:41:12 +00:00
MichilisandClaude Opus 4.6 dafa3711f8 Fix post-login redirect when the session cookie is off-origin
Signing in showed the "Welcome back!" toast but never left /login. The
session cookie was host-only on the API subdomain, so the Next middleware
guard on the site origin saw no cookie and bounced /dashboard straight
back to /login?redirect=/dashboard.

- Add AUTH_COOKIE_DOMAIN, wiring Better Auth's crossSubDomainCookies so
  the cookie also reaches the site origin. Unset in dev, where localhost
  is single-host and must stay host-only.
- Navigate after authentication with a full page load, via a shared
  authRedirect helper: only a top-level request carries the httpOnly
  cookie. Used by the login, register, magic-link and Google flows.
- Show "Redirecting..." on the login and register pages and keep the
  submit button disabled until the browser replaces the page, instead of
  re-enabling it mid-navigation.
- Guard against a redirect loop with a sessionStorage marker. A React ref
  cannot do this: the full page load resets component state. If the
  destination bounces back, explain it rather than navigating again.
- Middleware: accept any *.session_token cookie so a cookiePrefix change
  cannot lock everyone out, and preserve the destination's query string.
- Trust any loopback port in dev, so reaching the dev server through a
  forwarded port does not fail Better Auth's CSRF origin check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-29 20:45:05 +00:00
MichilisandClaude Opus 5 733d2459df Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:07:04 +00:00
MichilisandClaude Opus 5 4afa5d6fa0 Show when the booking was made in the verify-payment modal
The modal only had "User marked as paid", which is absent for manual
payments the customer never confirmed — those opened with no timestamp
at all. Show the payment's createdAt unconditionally as "Booking made",
and give both lines a relative age suffix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:06:39 +00:00
MichilisandClaude Opus 4.6 617c884012 Add view tokens for non-public gallery images and per-mode gate pages.
<img> tags cannot send Authorization headers, so non-public gallery photos
were invisible even to authorized viewers. The server now mints short-lived
HMAC view tokens (gallery-scoped, hour-bucketed) and embeds them in every
file URL for non-public galleries. Access denials return distinct 403
messages per visibility mode, and the frontend renders a matching gate page
(private, link-only, ticket-holders, login prompt) with an inline login
modal so visitors never leave the gallery page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-26 06:48:05 +00:00
MichilisandClaude Opus 4.8 93476ac72a Document photo galleries, payment providers, and updated deploy topology in README
Cover features added since the last README update: the standalone photo-api
Go service, the central payment provider registry (Lightning + manual
TPago/bank/card/cash), stable /next and /featured URLs, dashboard overview,
and the three-service dev/deploy setup (photo-api on 3003 dev / 3020 prod).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:57:30 +00:00
MichilisandClaude Fable 5 c4094630d9 Add a thumbnail filmstrip to the photo lightbox.
- Render a horizontally scrollable strip of thumbnails below the image
  that highlights and auto-centres the active photo and lets you jump
  straight to any shot.
- Pass thumbUrl through from the public and admin galleries, falling
  back to previewUrl when no thumbnail exists.
- Restructure the overlay into a flex column (image area + strip) and
  require a clearly horizontal gesture before treating a touch as a
  prev/next swipe, so vertical scrolls no longer trigger navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:53:37 +00:00
99 changed files with 6979 additions and 1928 deletions
+70 -18
View File
@@ -4,17 +4,24 @@ A full-stack web app for organizing and managing language exchange events (Asunc
## Features
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
- **User dashboard**: profile, tickets, payments, sessions/security
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
- Stable `/next` and `/featured` URLs that redirect to the current event
- Human-readable event URL slugs (with legacy-ID redirect support)
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
- Lightning invoice reuse and re-payment straight from the dashboard
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
- One unified ticket-creation modal with first-class payment status
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
## Tech stack
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
- **Email**: `nodemailer` (SMTP) with optional provider config
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
## Local development
@@ -22,6 +29,8 @@ A full-stack web app for organizing and managing language exchange events (Asunc
- Node.js 18+
- npm
- Go 1.26+ (for the `photo-api` service)
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
### Setup
@@ -29,12 +38,14 @@ A full-stack web app for organizing and managing language exchange events (Asunc
npm install
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
```
### Initialize database (SQLite by default)
```bash
npm run db:migrate
npm run db:migrate # backend (Drizzle) tables
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
```
### Run
@@ -43,10 +54,13 @@ npm run db:migrate
npm run dev
```
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
Default URLs:
- Frontend: `http://localhost:3002`
- Backend API: `http://localhost:3001`
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
- API docs: `http://localhost:3001/api-docs`
### First user becomes admin
@@ -58,16 +72,37 @@ The first user to register becomes the **admin**. Register at `/register`.
Run these from the repo root:
```bash
npm run dev
npm run build
npm run dev # backend + frontend + photo-api
npm run build # build all workspaces
npm run start
npm run db:generate
npm run db:migrate
npm run db:studio
npm run db:export # Backup database
npm run db:import # Restore from backup
npm run db:export # Backup database
npm run db:import # Restore from backup
# Photo service (Go)
npm run dev:photos # go run ./cmd/photo-api
npm run build:photos # go build -o bin/photo-api
npm run migrate:photos # apply photos_* migrations
npm run test:photos # go test ./...
# Move the photo library between storage backends (both must be configured
# in photo-api/.env; the source is left untouched, reruns skip what is there)
npm run sync:photos:to-s3 # local disk -> S3
npm run sync:photos:to-local # S3 -> local disk
npm run sync:photos -- to-s3 --dry-run # flags: --dry-run --overwrite
# --concurrency=N --gallery=<id>
# Uploads are deduplicated per gallery by content hash. Photos uploaded before
# that existed need hashing once (idempotent, deletes nothing):
npm run backfill:photos:checksums
```
After a sync, set `STORAGE_BACKEND=s3` (or `local`) in `photo-api/.env` and
restart the service to serve from the new backend. See
[`photo-api/README.md`](photo-api/README.md#move-the-library-between-backends).
You can also run per workspace:
```bash
@@ -82,20 +117,34 @@ npm run dev --workspace=frontend
Key settings (see `backend/.env.example` for the full list):
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
- **Auth**: `JWT_SECRET` (change in production)
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development.
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
### Photo service (`photo-api/.env`)
Key settings (see `photo-api/.env.example`):
- **Port**: `PORT=3003` (dev)
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
- **Storage switch**: `STORAGE_BACKEND=auto|local|s3` (`auto` = S3 when it is configured). Keep both sides configured and flip this one line to move between them; `npm run sync:photos:to-s3` / `:to-local` copies the existing library first
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
### Frontend (`frontend/.env`)
Key settings (see `frontend/.env.example`):
- **Server port**: `PORT=3002`
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
- Leave empty to use same-origin `/api` (recommended when running behind nginx)
- In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend
- Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port.
- Inlined at build time: changing it requires a rebuild, not just a restart.
- Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well.
- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`).
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
## Database
@@ -143,12 +192,13 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
This repo includes example configs in `deploy/`:
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
- Backend needs write access to `backend/data` and `backend/uploads`
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
- **nginx**:
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
- `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
Typical production flow:
@@ -156,7 +206,9 @@ Typical production flow:
```bash
npm ci
npm run build
npm run build:photos
npm run db:migrate
npm run migrate:photos
```
Then install/enable the systemd services and nginx configs for your server.
+29 -1
View File
@@ -50,13 +50,41 @@ DATABASE_URL=./data/spanglish.db
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
# S3_FORCE_PATH_STYLE=true
# JWT Secret (change in production!)
# Better Auth session secret. REQUIRED in production, 32+ characters.
# Generate one with: openssl rand -base64 48
# Rotating it signs everyone out (cookie signatures invalidate).
BETTER_AUTH_SECRET=
# Public site origin Better Auth builds its URLs against (falls back to
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
BETTER_AUTH_URL=
# Session cookie domain, shared across subdomains. Set this when the API is served
# from a different host than the site (e.g. api.spanglishcommunity.com vs
# spanglishcommunity.com): without it the cookie is host-only and the frontend's
# Next middleware cannot see it, so /dashboard and /admin bounce back to /login.
# Must start with a dot. Leave EMPTY in development (localhost is single-host).
# E.g. .spanglishcommunity.com
AUTH_COOKIE_DOMAIN=
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
# trusted; anything else is treated as a client and rate-limited by its
# actual socket address.
# TRUSTED_PROXIES=
# DEPRECATED: no longer used for API auth (Better Auth replaced the JWTs).
# Still read by photo-api as the fallback secret for gallery view tokens
# until PHOTO_VIEW_SECRET is set there; safe to remove after that.
JWT_SECRET=your-super-secret-key-change-in-production
# Google OAuth (optional - for Google Sign-In)
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
# Note: The same Client ID should be used in frontend/.env
GOOGLE_CLIENT_ID=
# Only needed for the redirect OAuth flow; the Google Identity Services
# button (ID token sign-in) works with the Client ID alone.
GOOGLE_CLIENT_SECRET=
# Server Configuration
PORT=3001
+4 -3
View File
@@ -20,9 +20,10 @@
"@hono/zod-openapi": "^0.14.4",
"argon2": "^0.44.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0",
"better-auth": "1.6.25",
"better-sqlite3": "^12.11.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.31.2",
"drizzle-orm": "^0.45.2",
"hono": "^4.4.7",
"ioredis": "^5.11.1",
"jose": "^5.4.0",
@@ -42,7 +43,7 @@
"@types/pdfkit": "^0.17.4",
"@types/pg": "^8.11.6",
"@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.22.8",
"drizzle-kit": "^0.31.10",
"ioredis-mock": "^8.13.1",
"tsx": "^4.15.7",
"typescript": "^5.5.2",
+200
View File
@@ -0,0 +1,200 @@
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
import {
pgTable,
uuid,
varchar,
text as pgText,
timestamp,
boolean as pgBoolean,
bigint,
customType as pgCustomType,
} from 'drizzle-orm/pg-core';
// Better Auth table definitions for both dialects.
//
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
// foreign key that references them) survive the auth migration untouched. Only
// the columns Better Auth reads/writes are declared here; legacy columns
// (password, google_id, token_version) stay physically present but invisible
// to Better Auth. The full application-facing definition lives in schema.ts —
// two Drizzle table objects can safely describe the same SQL table.
const dbType = process.env.DB_TYPE || 'sqlite';
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
dataType() {
return 'text';
},
toDriver(value: Date): string {
return (value instanceof Date ? value : new Date(value)).toISOString();
},
fromDriver(value: string): Date {
return new Date(value);
},
});
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
dataType() {
return 'integer';
},
toDriver(value: boolean): number {
return value ? 1 : 0;
},
fromDriver(value: number | boolean): boolean {
return Boolean(value);
},
});
// ==================== SQLite ====================
export const sqliteAuthUsers = sqliteTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
createdAt: isoText('created_at').notNull(),
updatedAt: isoText('updated_at').notNull(),
// admin plugin fields
role: text('role').notNull().default('user'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
// application additionalFields
phone: text('phone'),
languagePreference: text('language_preference'),
rucNumber: text('ruc_number'),
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
accountStatus: text('account_status').notNull().default('active'),
});
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
// admin plugin (impersonation)
impersonatedBy: text('impersonated_by'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
id: text('id').primaryKey(),
key: text('key'),
count: integer('count'),
lastRequest: integer('last_request'),
});
// ==================== PostgreSQL ====================
export const pgAuthUsers = pgTable('users', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
// admin plugin fields
role: varchar('role', { length: 20 }).notNull().default('user'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
// application additionalFields
phone: varchar('phone', { length: 50 }),
languagePreference: varchar('language_preference', { length: 10 }),
rucNumber: varchar('ruc_number', { length: 15 }),
isClaimed: pgIntBool('is_claimed').notNull(),
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
});
export const pgAuthSessions = pgTable('auth_sessions', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
token: varchar('token', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: varchar('ip_address', { length: 45 }),
userAgent: pgText('user_agent'),
// admin plugin (impersonation)
impersonatedBy: uuid('impersonated_by'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthAccounts = pgTable('auth_accounts', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
accountId: varchar('account_id', { length: 255 }).notNull(),
providerId: varchar('provider_id', { length: 100 }).notNull(),
accessToken: pgText('access_token'),
refreshToken: pgText('refresh_token'),
idToken: pgText('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: pgText('scope'),
password: pgText('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthVerifications = pgTable('auth_verifications', {
id: uuid('id').primaryKey(),
identifier: varchar('identifier', { length: 255 }).notNull(),
value: pgText('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
id: varchar('id', { length: 64 }).primaryKey(),
key: varchar('key', { length: 255 }),
count: bigint('count', { mode: 'number' }),
lastRequest: bigint('last_request', { mode: 'number' }),
});
// ==================== Runtime-switched exports ====================
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import Database from 'better-sqlite3';
// Migration idempotency for the Better Auth backfill: seed legacy-shaped user
// rows, run migrate repeatedly, and assert the backfill is correct and never
// duplicates.
const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-'));
const dbPath = join(dir, 'migrate.db');
function runMigrate() {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
env: {
...process.env,
DB_TYPE: 'sqlite',
DATABASE_URL: dbPath,
REDIS_URL: '',
},
stdio: 'pipe',
});
}
let db: Database.Database;
beforeAll(() => {
// First run creates the schema
runMigrate();
db = new Database(dbPath);
// Seed legacy-shaped users (pre-Better-Auth): password lives on users,
// google_id links Google, '' marks guest accounts, suspended via status.
const now = new Date().toISOString();
const insert = db.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
);
insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now);
insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now);
insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now);
insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now);
insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now);
insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now);
// Second run performs the backfill against the seeded rows
runMigrate();
}, 240_000);
describe('Better Auth migration backfill', () => {
it('creates credential accounts for users with real passwords only', () => {
const rows = db
.prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id")
.all() as any[];
const byUser = new Map(rows.map((r) => [r.user_id, r.password]));
expect(byUser.get('legacy-argon')).toContain('$argon2id$');
expect(byUser.get('legacy-bcrypt')).toContain('$2a$');
expect(byUser.get('legacy-both')).toContain('$argon2id$');
expect(byUser.get('legacy-suspended')).toBeTruthy();
// Guests ('' password) and Google-only users get no credential account
expect(byUser.has('legacy-guest')).toBe(false);
expect(byUser.has('legacy-google')).toBe(false);
});
it('creates google accounts from google_id', () => {
const rows = db
.prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id")
.all() as any[];
expect(rows).toEqual([
{ user_id: 'legacy-both', account_id: 'google-sub-456' },
{ user_id: 'legacy-google', account_id: 'google-sub-123' },
]);
});
it('marks claimed legacy users email-verified, guests not', () => {
const verified = (email: string) =>
(db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified;
expect(verified('argon@old.py')).toBe(1);
expect(verified('google@old.py')).toBe(1);
expect(verified('guest@old.py')).toBe(0);
});
it('mirrors suspended accounts to banned', () => {
const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any;
expect(row.banned).toBe(1);
expect(row.ban_reason).toContain('suspended');
const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any;
expect(active.banned).toBe(0);
});
it('is idempotent: a third run adds nothing', () => {
const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n;
const before = count();
runMigrate();
expect(count()).toBe(before);
}, 60_000);
});
+229
View File
@@ -544,6 +544,81 @@ async function migrate() {
updated_by TEXT REFERENCES users(id)
)
`);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table).
// Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode).
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
ip_address TEXT,
user_agent TEXT,
impersonated_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id TEXT PRIMARY KEY,
key TEXT,
count INTEGER,
last_request INTEGER
)
`);
} else {
// PostgreSQL migrations
await (db as any).execute(sql`
@@ -1044,6 +1119,80 @@ async function migrate() {
updated_by UUID REFERENCES users(id)
)
`);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
impersonated_by UUID,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id VARCHAR(255) NOT NULL,
provider_id VARCHAR(100) NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
password TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id UUID PRIMARY KEY,
identifier VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id VARCHAR(64) PRIMARY KEY,
key VARCHAR(255),
count BIGINT,
last_request BIGINT
)
`);
}
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
@@ -1056,6 +1205,11 @@ async function migrate() {
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS auth_accounts_user_id_idx ON auth_accounts(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
];
for (const stmt of indexStatements) {
try {
@@ -1067,6 +1221,81 @@ async function migrate() {
} catch (e) { /* index may already exist */ }
}
// ==================== Better Auth data backfill ====================
// Idempotent: every statement is guarded so re-running migrate is safe, and
// legacy users are distinguished from Better-Auth-created users by having
// users.password / users.google_id set (Better Auth never writes either).
if (dbType === 'sqlite') {
// Legacy password hashes -> credential accounts (argon2 and bcrypt hashes
// both stay valid via the custom password verifier in lib/betterAuth.ts)
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.id, 'credential', u.password,
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
// Legacy Google links -> google provider accounts
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.google_id, 'google',
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
// Claimed legacy accounts proved their email (register/claim link/Google)
await (db as any).run(sql`
UPDATE users SET email_verified = 1
WHERE email_verified = 0 AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
// Suspended -> banned (admin plugin field); users.ts keeps them in sync
await (db as any).run(sql`
UPDATE users SET banned = 1, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = 0
`);
} else {
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.id::text, 'credential', u.password, NOW(), NOW()
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.google_id, 'google', NOW(), NOW()
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
await (db as any).execute(sql`
UPDATE users SET email_verified = TRUE
WHERE email_verified = FALSE AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
await (db as any).execute(sql`
UPDATE users SET banned = TRUE, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = FALSE
`);
}
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
+13 -1
View File
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
// Type to determine which schema to use
const dbType = process.env.DB_TYPE || 'sqlite';
@@ -20,6 +20,12 @@ export const sqliteUsers = sqliteTable('users', {
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: integer('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -380,6 +386,12 @@ export const pgUsers = pgTable('users', {
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: pgInteger('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
+132 -124
View File
@@ -7,7 +7,9 @@ import { logger } from 'hono/logger';
import { swaggerUI } from '@hono/swagger-ui';
import { serveStatic } from '@hono/node-server/serve-static';
import authRoutes from './routes/auth.js';
import { auth } from './lib/betterAuth.js';
import authExtRoutes from './routes/authExt.js';
import { getClientIp } from './lib/rateLimit.js';
import eventsRoutes from './routes/events.js';
import ticketsRoutes from './routes/tickets.js';
import usersRoutes from './routes/users.js';
@@ -57,7 +59,7 @@ app.use(
if (!origin) return frontendUrl;
return allowedOrigins.has(origin) ? origin : null;
},
// We use bearer tokens, but keeping credentials=true matches nginx config.
// Session cookies must be allowed on cross-origin API calls (api.* vhost).
credentials: true,
})
);
@@ -110,11 +112,15 @@ const openApiSpec = {
],
paths: {
// ==================== Auth Endpoints ====================
'/api/auth/register': {
// Authentication is handled by Better Auth, mounted at /api/auth/*.
// Sessions are httpOnly cookies (spanglish.session_token); the endpoints
// below are the subset the frontend uses. See https://better-auth.com/docs
// for the full endpoint reference.
'/api/auth/sign-up/email': {
post: {
tags: ['Auth'],
summary: 'Register a new user',
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
summary: 'Register a new user (Better Auth)',
description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.',
requestBody: {
required: true,
content: {
@@ -124,8 +130,8 @@ const openApiSpec = {
required: ['email', 'password', 'name'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
name: { type: 'string', minLength: 2 },
password: { type: 'string', minLength: 10 },
name: { type: 'string' },
phone: { type: 'string' },
languagePreference: { type: 'string', enum: ['en', 'es'] },
},
@@ -134,16 +140,17 @@ const openApiSpec = {
},
},
responses: {
201: { description: 'User created successfully' },
400: { description: 'Email already registered or validation error' },
200: { description: 'User created; session cookie set' },
422: { description: 'Email already registered' },
400: { description: 'Validation error' },
},
},
},
'/api/auth/login': {
'/api/auth/sign-in/email': {
post: {
tags: ['Auth'],
summary: 'Login with email and password',
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
summary: 'Login with email and password (Better Auth)',
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
requestBody: {
required: true,
content: {
@@ -160,42 +167,46 @@ const openApiSpec = {
},
},
responses: {
200: { description: 'Login successful, returns JWT token' },
200: { description: 'Login successful; session cookie set' },
401: { description: 'Invalid credentials' },
429: { description: 'Too many login attempts' },
429: { description: 'Too many attempts' },
},
},
},
'/api/auth/google': {
'/api/auth/sign-in/social': {
post: {
tags: ['Auth'],
summary: 'Login or register with Google',
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
summary: 'Login or register with Google (Better Auth)',
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['credential'],
required: ['provider'],
properties: {
credential: { type: 'string', description: 'Google ID token' },
provider: { type: 'string', enum: ['google'] },
idToken: {
type: 'object',
properties: { token: { type: 'string', description: 'Google ID token' } },
},
},
},
},
},
},
responses: {
200: { description: 'Login successful' },
400: { description: 'Invalid Google token' },
200: { description: 'Login successful; session cookie set' },
401: { description: 'Invalid Google token' },
},
},
},
'/api/auth/magic-link/request': {
'/api/auth/sign-in/magic-link': {
post: {
tags: ['Auth'],
summary: 'Request magic link login',
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
summary: 'Request magic link login (Better Auth)',
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
requestBody: {
required: true,
content: {
@@ -205,46 +216,33 @@ const openApiSpec = {
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
callbackURL: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Magic link sent (if account exists)' },
},
responses: { 200: { description: 'Magic link sent (if account exists)' } },
},
},
'/api/auth/magic-link/verify': {
post: {
get: {
tags: ['Auth'],
summary: 'Verify magic link token',
description: 'Verify the magic link token and login user.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
},
},
},
},
},
summary: 'Verify magic link token (Better Auth)',
parameters: [
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Login successful' },
200: { description: 'Login successful; session cookie set' },
400: { description: 'Invalid or expired token' },
},
},
},
'/api/auth/password-reset/request': {
'/api/auth/request-password-reset': {
post: {
tags: ['Auth'],
summary: 'Request password reset',
description: 'Send a password reset link to email. Link expires in 30 minutes.',
summary: 'Request password reset (Better Auth)',
description: 'Emails a reset link. Token expires in 30 minutes.',
requestBody: {
required: true,
content: {
@@ -254,31 +252,30 @@ const openApiSpec = {
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
redirectTo: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Reset link sent (if account exists)' },
},
responses: { 200: { description: 'Reset link sent (if account exists)' } },
},
},
'/api/auth/password-reset/confirm': {
'/api/auth/reset-password': {
post: {
tags: ['Auth'],
summary: 'Confirm password reset',
description: 'Reset password using the token from email.',
summary: 'Reset password with token (Better Auth)',
description: 'Sets a new password and revokes all existing sessions.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token', 'password'],
required: ['newPassword', 'token'],
properties: {
newPassword: { type: 'string', minLength: 10 },
token: { type: 'string' },
password: { type: 'string', minLength: 10 },
},
},
},
@@ -290,62 +287,10 @@ const openApiSpec = {
},
},
},
'/api/auth/claim-account/request': {
post: {
tags: ['Auth'],
summary: 'Request account claim link',
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
},
},
},
responses: {
200: { description: 'Claim link sent (if unclaimed account exists)' },
},
},
},
'/api/auth/claim-account/confirm': {
post: {
tags: ['Auth'],
summary: 'Confirm account claim',
description: 'Claim an unclaimed account by setting password or linking Google.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
},
},
},
},
},
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Invalid token or missing credentials' },
},
},
},
'/api/auth/change-password': {
post: {
tags: ['Auth'],
summary: 'Change password',
description: 'Change password for authenticated user.',
summary: 'Change password (Better Auth)',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
@@ -357,6 +302,7 @@ const openApiSpec = {
properties: {
currentPassword: { type: 'string' },
newPassword: { type: 'string', minLength: 10 },
revokeOtherSessions: { type: 'boolean' },
},
},
},
@@ -369,26 +315,66 @@ const openApiSpec = {
},
},
},
'/api/auth/me': {
'/api/auth/get-session': {
get: {
tags: ['Auth'],
summary: 'Get current user',
description: 'Get the currently authenticated user profile.',
summary: 'Get current session and user (Better Auth)',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Current user data' },
401: { description: 'Unauthorized' },
200: { description: '{ session, user } or null when not authenticated' },
},
},
},
'/api/auth/logout': {
'/api/auth/sign-out': {
post: {
tags: ['Auth'],
summary: 'Logout',
description: 'Logout current user (client-side token removal).',
responses: {
200: { description: 'Logged out' },
summary: 'Logout (Better Auth)',
description: 'Revokes the current session and clears the session cookie.',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Logged out' } },
},
},
'/api/auth/list-sessions': {
get: {
tags: ['Auth'],
summary: 'List active sessions (Better Auth)',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Active sessions for the current user' } },
},
},
'/api/auth-ext/claim-account': {
post: {
tags: ['Auth'],
summary: 'Claim a guest-created account',
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string', minLength: 10 } },
},
},
},
},
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Already claimed or validation error' },
401: { description: 'No session (claim link required)' },
},
},
},
'/api/auth-ext/claim-eligibility': {
get: {
tags: ['Auth'],
summary: 'Check whether an email has an unclaimed account',
parameters: [
{ name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } },
],
responses: { 200: { description: '{ canClaim: boolean }' } },
},
},
@@ -1762,10 +1748,10 @@ const openApiSpec = {
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT token obtained from login endpoint',
type: 'apiKey',
in: 'cookie',
name: 'spanglish.session_token',
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
},
},
schemas: {
@@ -1899,7 +1885,29 @@ app.get('/health', (c) => {
});
// API Routes
app.route('/api/auth', authRoutes);
// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link,
// password reset, Google, session management). CORS above runs first.
//
// Better Auth only sees the Request (no TCP peer address), so its per-IP rate
// limiting is fed the socket-anchored client IP resolved by getClientIp via a
// private header. The inbound value is always discarded — a client cannot
// choose its own rate-limit bucket.
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
const headers = new Headers(c.req.raw.headers);
headers.delete('x-client-ip');
const clientIp = getClientIp(c);
if (clientIp && clientIp !== 'unknown') {
headers.set('x-client-ip', clientIp);
}
return auth.handler(
new Request(c.req.raw, {
headers,
// Node's fetch requires duplex for requests carrying a body stream
...(c.req.raw.body ? { duplex: 'half' as const } : {}),
} as RequestInit)
);
});
app.route('/api/auth-ext', authExtRoutes);
app.route('/api/events', eventsRoutes);
app.route('/api/tickets', ticketsRoutes);
app.route('/api/users', usersRoutes);
+92 -331
View File
@@ -1,366 +1,127 @@
import * as jose from 'jose';
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { Context } from 'hono';
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
import { generateId, getNow, toDbDate } from './utils.js';
import { and, eq } from 'drizzle-orm';
import { auth } from './betterAuth.js';
import { db, dbGet } from '../db/index.js';
import { authAccounts } from '../db/auth-schema.js';
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
const rawJwtSecret = process.env.JWT_SECRET;
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
// sessions validated against the auth_sessions table on every request, so
// revocation (ban/suspend/password reset) applies instantly. This module keeps
// the request-side helpers that the route files use.
// Never allow the insecure default in production: forgeable tokens = full account takeover.
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
}
if (!rawJwtSecret) {
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
}
// Re-exported for routes that hash/validate passwords outside Better Auth
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
const JWT_ISSUER = 'spanglish';
const JWT_AUDIENCE = 'spanglish-app';
export interface JWTPayload {
sub: string;
export interface AuthUser {
id: string;
email: string;
name: string;
phone: string | null;
role: string;
tokenVersion?: number;
iat: number;
exp: number;
languagePreference: string | null;
isClaimed: boolean;
rucNumber: string | null;
accountStatus: string;
emailVerified: boolean;
image: string | null;
createdAt: Date | string;
updatedAt: Date | string;
/** ID of the Better Auth session backing this request. */
sessionId: string;
}
// Password hashing with Argon2 (spec requirement)
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Generate secure random token for magic links
export function generateSecureToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// Create magic link token
export async function createMagicLinkToken(
userId: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
expiresInMinutes: number = 10
): Promise<string> {
const token = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
await (db as any).insert(magicLinkTokens).values({
id: generateId(),
userId,
token,
type,
expiresAt,
createdAt: now,
});
return token;
}
// Verify and consume magic link token
export async function verifyMagicLinkToken(
token: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
): Promise<{ valid: boolean; userId?: string; error?: string }> {
const now = getNow();
const tokenRecord = await dbGet<any>(
(db as any)
.select()
.from(magicLinkTokens)
.where(
and(
eq((magicLinkTokens as any).token, token),
eq((magicLinkTokens as any).type, type)
)
)
);
// Use a single generic error for all invalid states to avoid leaking token state
const genericError = 'Invalid or expired token';
if (!tokenRecord) {
return { valid: false, error: genericError };
}
if (tokenRecord.usedAt) {
return { valid: false, error: genericError };
}
if (new Date(tokenRecord.expiresAt) < new Date()) {
return { valid: false, error: genericError };
}
// Atomically consume the token: only the request that flips used_at from NULL wins.
// This prevents a double-spend race where two concurrent requests both pass the
// read-time "not used" check above.
const result: any = await (db as any)
.update(magicLinkTokens)
.set({ usedAt: now })
.where(and(
eq((magicLinkTokens as any).id, tokenRecord.id),
isNull((magicLinkTokens as any).usedAt)
));
const affected = result?.changes ?? result?.rowCount ?? 0;
if (affected === 0) {
return { valid: false, error: genericError };
}
return { valid: true, userId: tokenRecord.userId };
}
// Create user session
export async function createUserSession(
userId: string,
userAgent?: string,
ipAddress?: string
): Promise<string> {
const sessionToken = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
await (db as any).insert(userSessions).values({
id: generateId(),
userId,
token: sessionToken,
userAgent: userAgent || null,
ipAddress: ipAddress || null,
lastActiveAt: now,
expiresAt,
createdAt: now,
});
return sessionToken;
}
// Get user's active sessions
export async function getUserSessions(userId: string) {
const now = getNow();
return dbAll(
(db as any)
.select()
.from(userSessions)
.where(
and(
eq((userSessions as any).userId, userId),
gt((userSessions as any).expiresAt, now)
)
)
);
}
// Invalidate a specific session
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
const result = await (db as any)
.delete(userSessions)
.where(
and(
eq((userSessions as any).id, sessionId),
eq((userSessions as any).userId, userId)
)
);
return true;
}
// Invalidate all user sessions (logout everywhere)
export async function invalidateAllUserSessions(userId: string): Promise<void> {
await (db as any)
.delete(userSessions)
.where(eq((userSessions as any).userId, userId));
}
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime('1d')
.sign(JWT_SECRET);
return token;
}
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
export async function bumpTokenVersion(userId: string): Promise<void> {
await (db as any)
.update(users)
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
.where(eq((users as any).id, userId));
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setExpirationTime('30d')
.sign(JWT_SECRET);
return token;
}
export async function verifyToken(token: string): Promise<JWTPayload | null> {
/**
* Resolve the authenticated user for a request from its Better Auth session
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
* accounts never get API access even with a live session cookie.
*/
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
try {
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
});
return payload as unknown as JWTPayload;
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return null;
}
const user = session.user as any;
// Suspended (banned) or unclaimed accounts must not retain API access
if (user.banned) {
return null;
}
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone ?? null,
role: user.role ?? 'user',
languagePreference: user.languagePreference ?? null,
isClaimed: Boolean(user.isClaimed),
rucNumber: user.rucNumber ?? null,
accountStatus: user.accountStatus ?? 'active',
emailVerified: Boolean(user.emailVerified),
image: user.image ?? null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
sessionId: session.session.id,
};
} catch {
return null;
}
}
export async function getAuthUser(c: Context): Promise<any | null> {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload) {
return null;
}
// Never load the password hash into request context — it is only needed for
// explicit password-verification routes that query it separately.
const user = await dbGet<any>(
(db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
googleId: (users as any).googleId,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
tokenVersion: (users as any).tokenVersion,
createdAt: (users as any).createdAt,
updatedAt: (users as any).updatedAt,
})
.from(users)
.where(eq((users as any).id, payload.sub))
);
if (!user) {
return null;
}
// Reject tokens issued before a logout-everywhere / password change
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
return null;
}
// Suspended/unclaimed accounts must not retain API access via an old JWT
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return user;
}
export function requireAuth(roles?: string[]) {
return async (c: Context, next: () => Promise<void>) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
if (roles && !roles.includes(user.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', user);
await next();
};
}
export async function isFirstUser(): Promise<boolean> {
const result = await dbAll(
(db as any).select().from(users).limit(1)
);
return !result || result.length === 0;
}
/** Fetch only the password hash column (never expose via getAuthUser). */
/**
* Fetch only the credential password hash (never exposed via getAuthUser).
* Returns null when the user has no password set (Google-only or unclaimed).
*/
export async function getUserPasswordHash(userId: string): Promise<string | null> {
const row = await dbGet<any>(
(db as any)
.select({ password: (users as any).password })
.from(users)
.where(eq((users as any).id, userId))
.select({ password: (authAccounts as any).password })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'credential')
)
)
);
const hash = row?.password;
return hash && String(hash).length > 0 ? String(hash) : null;
}
/** Whether the user has a linked Google account. */
export async function hasGoogleAccount(userId: string): Promise<boolean> {
const row = await dbGet<any>(
(db as any)
.select({ id: (authAccounts as any).id })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'google')
)
)
);
return !!row;
}
@@ -0,0 +1,293 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
// (dotenv never overrides pre-set values).
const dir = mkdtempSync(join(tmpdir(), 'ba-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
delete process.env.GOOGLE_CLIENT_ID;
// Capture outgoing auth emails (magic links, password resets)
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
vi.mock('./email.js', () => ({
sendEmail: vi.fn(async (opts: any) => {
sentEmails.push(opts);
}),
emailService: {},
default: {},
}));
let auth: (typeof import('./betterAuth.js'))['auth'];
let db: any;
let sqlite: any;
function lastEmailTo(email: string) {
const found = [...sentEmails].reverse().find((e) => e.to === email);
expect(found, `expected an email sent to ${email}`).toBeTruthy();
return found!;
}
function extractToken(html: string, param = 'token'): string {
const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`));
expect(match, `expected a ${param} in the email link`).toBeTruthy();
return decodeURIComponent(match![1]);
}
function cookieHeaders(setCookie: string | null): Headers {
const sessionPart = (setCookie || '')
.split(/,(?=[^ ;]+=)/)
.map((c) => c.split(';')[0].trim())
.filter((c) => c.includes('session_token'))
.join('; ');
return new Headers({ cookie: sessionPart });
}
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
env: { ...process.env },
stdio: 'pipe',
});
return (async () => {
({ auth } = await import('./betterAuth.js'));
({ db } = await import('../db/index.js'));
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
})();
}, 120_000);
describe('Better Auth integration', () => {
it('makes the first registered user an admin, later users regular', async () => {
const first = await auth.api.signUpEmail({
body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' },
});
expect((first.user as any).id).toBeTruthy();
const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py');
expect(row.role).toBe('admin');
expect(row.account_status).toBe('active');
await auth.api.signUpEmail({
body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' },
});
const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py');
expect(row2.role).toBe('user');
});
it('stores credential passwords as argon2id in auth_accounts, not users', async () => {
const acct = sqlite
.prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'")
.get('admin@test.py');
expect(acct.password.startsWith('$argon2id$')).toBe(true);
const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py');
expect(user.password).toBeNull();
});
it('rejects passwords that violate the policy', async () => {
// Too short
await expect(
auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } })
).rejects.toThrow(/at least 10 characters/);
// Long enough but no character mix (app policy hook)
await expect(
auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } })
).rejects.toThrow(/uppercase and lowercase/);
// Common password normalized (policy blocklist)
await expect(
auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } })
).rejects.toThrow(/too common/);
expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0);
});
it('locks an email after 5 failed sign-ins', async () => {
await auth.api.signUpEmail({
body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' },
});
for (let i = 0; i < 5; i++) {
await expect(
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } })
).rejects.toThrow();
}
// Correct password now also refused: locked
await expect(
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } })
).rejects.toThrow(/Too many login attempts/);
});
it('refuses sign-in for banned (suspended) users and kills nothing else', async () => {
await auth.api.signUpEmail({
body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' },
});
sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py');
await expect(
auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } })
).rejects.toThrow(/suspended|banned/i);
});
it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => {
const bcrypt = (await import('bcryptjs')).default;
const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10);
const su = await auth.api.signUpEmail({
body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' },
});
sqlite
.prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'")
.run(legacyHash, (su.user as any).id);
const si = await auth.api.signInEmail({
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
});
expect(si.user.email).toBe('legacy@test.py');
// Upgrade happens in the after-hook; poll briefly for it
let upgraded = '';
for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) {
await new Promise((r) => setTimeout(r, 100));
upgraded = sqlite
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
.get((su.user as any).id).password;
}
expect(upgraded.startsWith('$argon2id$')).toBe(true);
// And the upgraded hash still verifies
const again = await auth.api.signInEmail({
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
});
expect(again.user.email).toBe('legacy@test.py');
});
it('magic link signs in existing users but never creates accounts', async () => {
await auth.api.signUpEmail({
body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' },
});
await auth.api.signInMagicLink({
body: { email: 'magic@test.py', callbackURL: '/dashboard' },
headers: new Headers(),
});
const email = lastEmailTo('magic@test.py');
// Emails link to the frontend page, not the raw API endpoint
expect(email.html).toContain('http://localhost:3002/auth/magic-link?token=');
const token = extractToken(email.html);
const verified = await auth.api.magicLinkVerify({
query: { token },
headers: new Headers(),
});
expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy();
// Unknown email: enumeration-safe success (an email may still go out),
// but verification can never create an account (disableSignUp)
const ghost = await auth.api.signInMagicLink({
body: { email: 'ghost@test.py' },
headers: new Headers(),
});
expect((ghost as any).status).toBe(true);
const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop();
if (ghostEmail) {
const ghostToken = extractToken(ghostEmail.html);
await expect(
auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() })
).rejects.toThrow();
}
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0);
});
it('completes the guest claim path: magic link session + setPassword', async () => {
// Simulate tickets.ts guest creation: user row, no credential account
const guestId = 'guest-claim-user-000001';
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at)
VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)`
)
.run(guestId, 'guest@test.py', now, now);
await auth.api.signInMagicLink({
body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' },
headers: new Headers(),
});
const token = extractToken(lastEmailTo('guest@test.py').html);
const verified = await auth.api.magicLinkVerify({
query: { token },
returnHeaders: true,
headers: new Headers(),
});
const headers = cookieHeaders(verified.headers.get('set-cookie'));
// The session works even while unclaimed (the claim endpoint depends on this)
const session = await auth.api.getSession({ headers });
expect((session?.user as any)?.accountStatus).toBe('unclaimed');
// Set the password (what /api/auth-ext/claim-account does)
await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers });
const acct = sqlite
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
.get(guestId);
expect(acct.password.startsWith('$argon2id$')).toBe(true);
// The claim email uses the claim template with the frontend link
const claimEmail = lastEmailTo('guest@test.py');
expect(claimEmail.subject).toContain('Claim');
expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account');
});
it('password reset revokes existing sessions and applies the new password', async () => {
const su = await auth.api.signUpEmail({
body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' },
});
const userId = (su.user as any).id;
// A live session from sign-in
await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } });
expect(
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
).toBeGreaterThan(0);
await auth.api.requestPasswordReset({
body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' },
});
const email = lastEmailTo('reset@test.py');
// Reset URLs are either .../reset-password/{token}?... or ...?token={token}
const html = email.html;
const pathMatch = html.match(/reset-password\/([^?"&\s]+)/);
const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html);
await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } });
// revokeSessionsOnPasswordReset: true
expect(
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
).toBe(0);
await expect(
auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } })
).rejects.toThrow();
const after = await auth.api.signInEmail({
body: { email: 'reset@test.py', password: 'AfterReset1!x' },
});
expect(after.user.email).toBe('reset@test.py');
});
it('sessions are stored in auth_sessions with 7-day expiry', async () => {
const si = await auth.api.signInEmail({
body: { email: 'admin@test.py', password: 'FirstAdmin1!x' },
});
const row = sqlite
.prepare('SELECT expires_at FROM auth_sessions WHERE token = ?')
.get((si as any).token);
expect(row).toBeTruthy();
const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24);
expect(days).toBeGreaterThan(6.5);
expect(days).toBeLessThan(7.5);
});
});
+362
View File
@@ -0,0 +1,362 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { magicLink, admin } from 'better-auth/plugins';
import { APIError, createAuthMiddleware } from 'better-auth/api';
import { eq, and } from 'drizzle-orm';
import { db, dbGet, dbAll, isPostgres } from '../db/index.js';
import {
authUsers,
authSessions,
authAccounts,
authVerifications,
authRateLimits,
} from '../db/auth-schema.js';
import { generateId } from './utils.js';
import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
import { sendEmail } from './email.js';
import { getLoginLockout } from './stores/loginLockout.js';
const isProduction = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
// and needs a host-only cookie.
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
const rawSecret = process.env.BETTER_AUTH_SECRET;
// Never allow a weak/default secret in production: the secret signs session
// cookies, so a guessable value means forgeable sessions = account takeover.
if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) {
throw new Error(
'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.'
);
}
if (!rawSecret) {
console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.');
}
// The site origin plus its www/non-www alias, mirroring the CORS allowlist in
// index.ts. Requests whose Origin is not listed here are rejected by Better
// Auth's CSRF origin check.
function computeTrustedOrigins(): string[] {
const origins = new Set<string>([frontendUrl]);
try {
const url = new URL(frontendUrl);
const alias = url.hostname.startsWith('www.')
? url.hostname.slice(4)
: `www.${url.hostname}`;
origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`);
} catch {
/* keep frontendUrl as-is */
}
if (process.env.API_URL) origins.add(process.env.API_URL);
if (!isProduction) {
// Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
// port forwarding), so the browser's Origin is http://localhost:<random> and every
// POST would fail the CSRF origin check. Trust any loopback port rather than pinning
// FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
// per better-auth's trusted-origins helper; production stays on the exact allowlist.
origins.add('http://localhost:*');
origins.add('http://127.0.0.1:*');
}
return [...origins];
}
// Paths whose request body carries a new password that must satisfy the policy
// (Better Auth's minPasswordLength alone is weaker than the app's policy).
const PASSWORD_SETTING_PATHS = new Set([
'/sign-up/email',
'/reset-password',
'/change-password',
'/set-password',
]);
async function getCredentialAccount(userId: string): Promise<any | null> {
return dbGet<any>(
(db as any)
.select()
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'credential')
)
)
);
}
export const auth = betterAuth({
appName: 'Spanglish',
baseURL: process.env.BETTER_AUTH_URL || frontendUrl,
basePath: '/api/auth',
secret: rawSecret || DEFAULT_DEV_SECRET,
trustedOrigins: computeTrustedOrigins(),
telemetry: { enabled: false },
database: drizzleAdapter(db as any, {
provider: isPostgres() ? 'pg' : 'sqlite',
schema: {
user: authUsers,
session: authSessions,
account: authAccounts,
verification: authVerifications,
rateLimit: authRateLimits,
},
// better-sqlite3 cannot run Drizzle's async transactions; operations run
// sequentially instead (also the adapter default).
transaction: false,
}),
advanced: {
cookiePrefix: 'spanglish',
useSecureCookies: isProduction,
// Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
// so the cookie names hardcoded in the frontend middleware and the Go photo-api
// stay valid.
...(cookieDomain
? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
: {}),
ipAddress: {
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
// anchors trust in the TCP peer address (only our own proxies may speak
// for the client via X-Real-IP / X-Forwarded-For). Never read the raw
// forwarded headers here: without the socket they are spoofable, and
// Better Auth would fall back to one shared rate-limit bucket.
ipAddressHeaders: ['x-client-ip'],
},
database: {
// Match the app's existing ID convention (uuid on pg, nanoid on sqlite)
// so Better Auth rows are indistinguishable from legacy rows.
generateId: () => generateId(),
},
},
user: {
additionalFields: {
// `role`, `banned`, `banReason`, `banExpires` come from the admin plugin.
phone: { type: 'string', required: false, input: true },
languagePreference: { type: 'string', required: false, input: true },
rucNumber: { type: 'string', required: false, input: false },
isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true },
accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' },
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling
updateAge: 60 * 60 * 24, // refresh expiry at most once a day
freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this
// Disabled deliberately: every request validates against the session table,
// so ban/suspend/password-reset revocations apply instantly — and the Go
// photo-api (which reads the same table) can never disagree with us.
cookieCache: { enabled: false },
storeSessionInDatabase: true,
},
emailAndPassword: {
enabled: true,
minPasswordLength: 10,
maxPasswordLength: 128,
autoSignIn: true,
requireEmailVerification: false,
revokeSessionsOnPasswordReset: true,
resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow
sendResetPassword: async ({ user, url }) => {
try {
await sendEmail({
to: user.email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${url}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
},
password: {
// Keep the existing argon2id parameters; legacy bcrypt hashes (migrated
// into auth_accounts) still verify and are upgraded on login below.
hash: (password) => hashPassword(password),
verify: ({ hash, password }) => verifyPassword(password, hash),
},
},
...(process.env.GOOGLE_CLIENT_ID
? {
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
// Not required for ID-token (Google Identity Services) sign-in,
// only for the redirect OAuth flow.
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
},
},
}
: {}),
account: {
accountLinking: {
enabled: true,
// Google verifies email ownership, so linking by email is safe — this
// matches the legacy /api/auth/google auto-link behavior.
trustedProviders: ['google'],
},
},
rateLimit: {
// Explicitly enabled so dev behaves like production (off in dev by default).
enabled: true,
window: 60,
max: 100,
// DB-backed rather than Redis: our Redis layer is fail-open by design,
// which is the wrong default for auth rate limiting. The per-email login
// lockout below is already Redis-shared across replicas.
storage: 'database',
modelName: 'rateLimit',
customRules: {
'/sign-in/email': { window: 900, max: 10 },
'/sign-up/email': { window: 900, max: 10 },
'/sign-in/magic-link': { window: 900, max: 5 },
'/magic-link/verify': { window: 900, max: 30 },
'/request-password-reset': { window: 900, max: 5 },
'/reset-password': { window: 900, max: 10 },
'/sign-in/social': { window: 900, max: 20 },
'/change-password': { window: 900, max: 10 },
},
},
databaseHooks: {
user: {
create: {
before: async (user) => {
// First user to register becomes admin (replaces isFirstUser())
const existing = await dbAll<any>((db as any).select().from(authUsers).limit(1));
if (!existing || existing.length === 0) {
return { data: { ...user, role: 'admin' } };
}
},
},
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Enforce the full password policy (character classes + blocklist) on
// every password-setting path, for both client and server-side calls.
if (PASSWORD_SETTING_PATHS.has(ctx.path)) {
const password = ctx.body?.password ?? ctx.body?.newPassword;
if (typeof password === 'string') {
const result = validatePassword(password);
if (!result.valid) {
throw new APIError('BAD_REQUEST', { message: result.error });
}
}
}
// Per-email lockout: 5 failures / 15 min, Redis-shared when configured.
// Kept as defense-in-depth on top of Better Auth's per-IP rate limits.
if (ctx.path === '/sign-in/email' && typeof ctx.body?.email === 'string') {
const lockout = await getLoginLockout().isLocked(ctx.body.email);
if (lockout.locked) {
throw new APIError('TOO_MANY_REQUESTS', {
message: 'Too many login attempts. Please try again later.',
});
}
}
}),
after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-in/email' || typeof ctx.body?.email !== 'string') return;
const email = ctx.body.email as string;
if (ctx.context.returned instanceof APIError) {
// Failed sign-in attempt counts toward the per-email lockout
await getLoginLockout().recordFailure(email);
return;
}
await getLoginLockout().clear(email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have
// the verified plaintext. Best-effort: never block the login.
try {
const password = ctx.body?.password;
const userId = (ctx.context.newSession?.user as any)?.id;
if (typeof password === 'string' && userId) {
const account = await getCredentialAccount(userId);
if (account?.password && !String(account.password).startsWith('$argon2')) {
const upgraded = await hashPassword(password);
await (db as any)
.update(authAccounts)
.set({ password: upgraded, updatedAt: new Date() })
.where(eq((authAccounts as any).id, account.id));
}
}
} catch (err: any) {
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
}
}),
},
plugins: [
magicLink({
expiresIn: 60 * 10, // 10 minutes, matches the legacy flow
// Magic links never create accounts (parity with the legacy behavior;
// account creation is register / Google / guest booking only).
disableSignUp: true,
// Hashed at rest: a leaked verification table cannot be replayed.
storeToken: 'hashed',
sendMagicLink: async ({ email, url, token }) => {
// Email a frontend URL (not the raw API verify URL) so the login
// completes on the site, preserving the legacy UX. The page calls
// authClient.magicLink.verify with the token.
let callbackURL = '/';
try {
callbackURL = new URL(url).searchParams.get('callbackURL') || '/';
} catch {
/* default */
}
const link = `${frontendUrl}/auth/magic-link?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`;
const isClaim = callbackURL.startsWith('/auth/claim-account');
try {
await sendEmail({
to: email,
subject: isClaim ? 'Claim Your Spanglish Account' : 'Your Spanglish Login Link',
html: isClaim
? `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.</p>
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${link}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`
: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${link}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
},
}),
admin({
defaultRole: 'user',
adminRoles: ['admin'],
bannedUserMessage: 'Account is suspended. Please contact support.',
}),
],
});
export type Auth = typeof auth;
+62
View File
@@ -0,0 +1,62 @@
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
// Password hashing with Argon2 (spec requirement)
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
// Minimal Hono-Context stand-in: headers + the node-server env with the
// socket peer address.
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
const headers = new Map(
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
);
return {
req: { header: (name: string) => headers.get(name.toLowerCase()) },
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
} as any;
}
describe('isTrustedProxyIp', () => {
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
expect(isTrustedProxyIp('::1')).toBe(true);
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
});
it('does not trust public addresses or near-miss ranges', () => {
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
expect(isTrustedProxyIp('')).toBe(false);
});
});
describe('getClientIp', () => {
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
});
expect(getClientIp(c)).toBe('203.0.113.7');
});
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
// spoofed prefix, then the real client appended by nginx, then the Next
// proxy hop — the rightmost untrusted entry wins
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
});
expect(getClientIp(c)).toBe('203.0.113.7');
});
it('ignores forwarded headers entirely when the peer is untrusted', () => {
// A client hitting the API directly cannot pick its own bucket
const c = fakeContext({
peer: '198.51.100.4',
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
});
expect(getClientIp(c)).toBe('198.51.100.4');
});
it('falls back to the socket address for local traffic with no headers', () => {
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
});
it('falls back to the socket address when every forwarded hop is internal', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': '127.0.0.1' },
});
expect(getClientIp(c)).toBe('127.0.0.1');
});
it('rejects junk header values instead of using them as bucket keys', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
});
expect(getClientIp(c)).toBe('127.0.0.1');
});
it('returns "unknown" without a socket address or trusted headers', () => {
expect(getClientIp(fakeContext({}))).toBe('unknown');
});
});
+61 -4
View File
@@ -10,11 +10,68 @@ import { getRateLimiter } from './stores/rateLimiter.js';
* (horizontal scaling). See lib/stores/rateLimiter.ts.
*/
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
// Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For:
// loopback (nginx on the same host, the Next.js proxy) and RFC1918 ranges
// (the docker-compose scale deployment, where nginx is another container).
// Extend with TRUSTED_PROXIES (comma-separated IP prefixes, e.g. "172.20.").
const DEFAULT_TRUSTED_PROXY_PREFIXES = ['127.', '10.', '192.168.', '::1'];
function trustedProxyPrefixes(): string[] {
const extra = (process.env.TRUSTED_PROXIES || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return [...DEFAULT_TRUSTED_PROXY_PREFIXES, ...extra];
}
/** Strip the IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.". */
function normalizeIp(ip: string | undefined | null): string {
const trimmed = (ip || '').trim();
return trimmed.toLowerCase().startsWith('::ffff:') ? trimmed.slice(7) : trimmed;
}
export function isTrustedProxyIp(ip: string): boolean {
const normalized = normalizeIp(ip);
if (!normalized) return false;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalized)) return true; // 172.16.0.0/12
return trustedProxyPrefixes().some((prefix) => normalized === prefix || normalized.startsWith(prefix));
}
// Rough shape check so a junk header value can't become a rate-limit key.
function looksLikeIp(value: string): boolean {
return value.length > 0 && value.length <= 45 && /^[0-9a-fA-F.:]+$/.test(value);
}
/**
* Spoof-resistant client IP resolution.
*
* The TCP peer address (via @hono/node-server's env.incoming) anchors the
* trust decision: forwarded headers are only honoured when the direct peer is
* one of our own proxies. X-Real-IP is preferred because nginx overwrites it
* at the edge (deploy/*.conf); X-Forwarded-For is append-only, so it is
* walked from the right past our proxy hops — the leftmost entries are
* client-controlled and never trusted on their own.
*/
export function getClientIp(c: Context): string {
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0].trim();
return c.req.header('x-real-ip') || 'unknown';
const socketAddr = normalizeIp((c.env as any)?.incoming?.socket?.remoteAddress);
if (socketAddr && isTrustedProxyIp(socketAddr)) {
const realIp = normalizeIp(c.req.header('x-real-ip'));
if (realIp && looksLikeIp(realIp)) return realIp;
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) {
const chain = forwarded.split(',').map((s) => normalizeIp(s)).filter(Boolean);
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrustedProxyIp(chain[i])) {
return looksLikeIp(chain[i]) ? chain[i] : socketAddr;
}
}
// Every hop is one of ours: a genuinely local/internal client.
}
}
return socketAddr || 'unknown';
}
/**
-684
View File
@@ -1,684 +0,0 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
import { eq } from 'drizzle-orm';
import {
hashPassword,
verifyPassword,
createToken,
createRefreshToken,
isFirstUser,
getAuthUser,
validatePassword,
createMagicLinkToken,
verifyMagicLinkToken,
invalidateAllUserSessions,
bumpTokenVersion,
requireAuth,
getUserPasswordHash,
} from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import { getLoginLockout } from '../lib/stores/loginLockout.js';
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
// email-dispatching flows) to curb credential stuffing and email flooding.
const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' });
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const auth = new Hono();
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
name: z.string().min(2),
phone: z.string().optional(),
languagePreference: z.enum(['en', 'es']).optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
const magicLinkRequestSchema = z.object({
email: z.string().email(),
});
const magicLinkVerifySchema = z.object({
token: z.string(),
});
const passwordResetRequestSchema = z.object({
email: z.string().email(),
});
const passwordResetSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const claimAccountSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
});
const googleAuthSchema = z.object({
credential: z.string(), // Google ID token
});
// Register
auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => {
const data = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(data.password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Check if email exists
const existing = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (existing) {
// If user exists but is unclaimed, allow claiming
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
return c.json({
error: 'Email already registered',
canClaim: true,
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
}, 400);
}
return c.json({ error: 'Email already registered' }, 400);
}
// Check if first user (becomes admin)
const firstUser = await isFirstUser();
const hashedPassword = await hashPassword(data.password);
const now = getNow();
const id = generateId();
const newUser = {
id,
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone || null,
role: firstUser ? 'admin' : 'user',
languagePreference: data.languagePreference || null,
isClaimed: toDbBool(true),
googleId: null,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
const token = await createToken(id, data.email, newUser.role, 0);
const refreshToken = await createRefreshToken(id);
return c.json({
user: {
id,
email: data.email,
name: data.name,
role: newUser.role,
isClaimed: true,
},
token,
refreshToken,
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
}, 201);
});
// Login with email/password
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Per-email lockout (shared across instances when Redis is configured).
const lockout = await getLoginLockout().isLocked(data.email);
if (lockout.locked) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: lockout.retryAfter
}, 429);
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) {
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Check if account is suspended
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
// Check if user has a password set
if (!user.password) {
return c.json({
error: 'No password set for this account',
needsClaim: !user.isClaimed,
message: user.isClaimed
? 'Please use Google login or request a password reset.'
: 'Please claim your account first.'
}, 400);
}
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
await getLoginLockout().clear(data.email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
// plaintext and have verified it. Best-effort: a failure here must not block
// the login.
if (!String(user.password).startsWith('$argon2')) {
try {
const upgradedHash = await hashPassword(data.password);
await (db as any)
.update(users)
.set({ password: upgradedHash })
.where(eq((users as any).id, user.id));
} catch (err: any) {
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
}
}
const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token,
refreshToken,
});
});
// Request magic link login
auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
// Create magic link token (expires in 10 minutes)
const token = await createMagicLinkToken(user.id, 'login', 10);
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Your Spanglish Login Link',
html: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${magicLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
});
// Verify magic link and login
auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => {
const { token } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'login');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
if (!user || user.accountStatus === 'suspended') {
return c.json({ error: 'Invalid token' }, 400);
}
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
});
// Request password reset
auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
// Create reset token (expires in 30 minutes)
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${resetLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
});
// Reset password
auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => {
const { token, password } = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const verification = await verifyMagicLinkToken(token, 'reset_password');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, verification.userId));
// Invalidate all existing sessions/JWTs for security
await invalidateAllUserSessions(verification.userId!);
await bumpTokenVersion(verification.userId!);
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
});
// Claim unclaimed account
auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
}
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
return c.json({ error: 'Account is already claimed' }, 400);
}
// Create claim token (expires in 1 hour)
const token = await createMagicLinkToken(user.id, 'claim_account', 60);
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Claim Your Spanglish Account',
html: `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${claimLink}</p>
<p>This link expires in 1 hour.</p>
`,
});
} catch (error) {
console.error('Failed to send claim account email:', error);
}
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
});
// Complete account claim
auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => {
const { token, password } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'claim_account');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const now = getNow();
// Only set a password here. Linking a Google account requires a verified Google
// ID token via /google; we never trust a client-supplied googleId.
const updates: Record<string, any> = {
isClaimed: toDbBool(true),
accountStatus: 'active',
password: await hashPassword(password),
updatedAt: now,
};
await (db as any)
.update(users)
.set(updates)
.where(eq((users as any).id, verification.userId));
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
message: 'Account claimed successfully!',
});
});
// Google OAuth login/register
auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => {
const { credential } = c.req.valid('json');
try {
// Verify the Google ID token. Google's tokeninfo endpoint validates the
// signature and expiry server-side; we additionally enforce the audience so a
// token minted for a different OAuth client cannot be replayed against us.
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
if (!response.ok) {
return c.json({ error: 'Invalid Google token' }, 400);
}
const googleData = await response.json() as {
sub: string;
email: string;
name: string;
email_verified: string;
aud?: string;
exp?: string;
};
// email_verified can be returned as boolean true or string "true"
if (String(googleData.email_verified) !== 'true') {
return c.json({ error: 'Google email not verified' }, 400);
}
// Enforce audience when a client ID is configured (closes token-confusion attacks)
const expectedAud = process.env.GOOGLE_CLIENT_ID;
if (expectedAud) {
if (googleData.aud !== expectedAud) {
return c.json({ error: 'Invalid Google token audience' }, 400);
}
} else {
console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.');
}
// Reject expired tokens (defense-in-depth; tokeninfo also rejects them)
if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) {
return c.json({ error: 'Google token expired' }, 400);
}
const { sub: googleId, email, name } = googleData;
// Check if user exists by email or google_id
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Check by google_id
user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
);
}
const now = getNow();
if (user) {
// User exists - link Google account if not already linked
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (!user.googleId) {
await (db as any)
.update(users)
.set({
googleId,
isClaimed: toDbBool(true),
accountStatus: 'active',
updatedAt: now,
})
.where(eq((users as any).id, user.id));
}
// Refresh user data
user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
} else {
// Create new user
const firstUser = await isFirstUser();
const id = generateId();
const newUser = {
id,
email,
password: null,
name,
phone: null,
role: firstUser ? 'admin' : 'user',
languagePreference: null,
isClaimed: toDbBool(true),
googleId,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
user = newUser;
}
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
} catch (error) {
console.error('Google auth error:', error);
return c.json({ error: 'Failed to authenticate with Google' }, 500);
}
});
// Get current user
auth.get('/me', async (c) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
phone: user.phone,
isClaimed: user.isClaimed,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
accountStatus: user.accountStatus,
createdAt: user.createdAt,
},
});
});
// Change password (authenticated users)
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { currentPassword, newPassword } = c.req.valid('json');
// Validate new password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Verify current password if user has one
const existingHash = await getUserPasswordHash(user.id);
if (existingHash) {
const validPassword = await verifyPassword(currentPassword, existingHash);
if (!validPassword) {
return c.json({ error: 'Current password is incorrect' }, 400);
}
}
const hashedPassword = await hashPassword(newPassword);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
// Invalidate all previously issued JWTs so a stolen old token can't outlive the change,
// then hand the current client a fresh token so it stays logged in on this device.
await bumpTokenVersion(user.id);
const refreshedUser = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0);
return c.json({ message: 'Password changed successfully', token: newToken });
});
// Logout - invalidate all previously issued JWTs for this user (logout everywhere)
auth.post('/logout', async (c) => {
const user = await getAuthUser(c);
if (user) {
await invalidateAllUserSessions(user.id);
await bumpTokenVersion(user.id);
}
return c.json({ message: 'Logged out successfully' });
});
export default auth;
+96
View File
@@ -0,0 +1,96 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { auth } from '../lib/betterAuth.js';
import { validatePassword } from '../lib/passwordPolicy.js';
import { db, dbGet, users } from '../db/index.js';
import { getNow, toDbBool } from '../lib/utils.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
// at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler.
const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' });
const authExt = new Hono();
const claimAccountSchema = z.object({
password: z.string().min(10, 'Password must be at least 10 characters'),
});
// Complete a progressive-account claim. The user arrives here already holding
// a session established by the claim magic link; this endpoint deliberately
// accepts accountStatus 'unclaimed' sessions (requireAuth would reject them)
// and is the ONLY endpoint that does.
authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401);
}
const user = session.user as any;
if (user.banned || user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (user.isClaimed && user.accountStatus === 'active') {
return c.json({ error: 'Account is already claimed' }, 400);
}
const { password } = c.req.valid('json');
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Creates the credential account with the argon2id hash from lib/betterAuth.ts
await auth.api.setPassword({
body: { newPassword: password },
headers: c.req.raw.headers,
});
// The magic link click proved email ownership
await (db as any)
.update(users)
.set({
isClaimed: toDbBool(true),
accountStatus: 'active',
emailVerified: true,
updatedAt: getNow(),
})
.where(eq((users as any).id, user.id));
return c.json({
message: 'Account claimed successfully!',
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: true,
phone: user.phone ?? null,
rucNumber: user.rucNumber ?? null,
languagePreference: user.languagePreference ?? null,
},
});
});
// Whether an email belongs to an unclaimed account. Deliberate, rate-limited
// exception to enumeration-safety, matching the legacy register/login UX that
// surfaced "this account can be claimed".
authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
const email = c.req.query('email');
if (!email || !z.string().email().safeParse(email).success) {
return c.json({ canClaim: false });
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
return c.json({ canClaim });
});
export default authExt;
+85 -59
View File
@@ -1,18 +1,12 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
import { auth } from '../lib/betterAuth.js';
import { authSessions, authAccounts } from '../db/auth-schema.js';
import { getNow } from '../lib/utils.js';
const dashboard = new Hono();
@@ -50,7 +44,7 @@ dashboard.get('/profile', async (c) => {
isClaimed: user.isClaimed,
accountStatus: user.accountStatus,
hasPassword,
hasGoogleLinked: !!user.googleId,
hasGoogleLinked: await hasGoogleAccount(user.id),
memberSince: user.createdAt,
membershipDays,
createdAt: user.createdAt,
@@ -423,49 +417,77 @@ dashboard.get('/invoices', async (c) => {
// ==================== Security Routes ====================
// Get active sessions
// Get active sessions (Better Auth session table; validated per-request so
// this list is always live). Session tokens are never exposed to the client.
dashboard.get('/sessions', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessions = await getUserSessions(user.id);
const sessions = await dbAll<any>(
(db as any)
.select({
id: (authSessions as any).id,
userAgent: (authSessions as any).userAgent,
ipAddress: (authSessions as any).ipAddress,
createdAt: (authSessions as any).createdAt,
updatedAt: (authSessions as any).updatedAt,
expiresAt: (authSessions as any).expiresAt,
})
.from(authSessions)
.where(
and(
eq((authSessions as any).userId, user.id),
gt((authSessions as any).expiresAt, new Date())
)
)
.orderBy(desc((authSessions as any).updatedAt))
);
return c.json({
sessions: sessions.map((s: any) => ({
id: s.id,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
lastActiveAt: s.lastActiveAt,
lastActiveAt: s.updatedAt,
createdAt: s.createdAt,
expiresAt: s.expiresAt,
current: s.id === user.sessionId,
})),
});
});
// Revoke a specific session
// Revoke a specific session. Deleting the row is immediately effective:
// sessions are validated against the table on every request (no cookie cache).
dashboard.delete('/sessions/:id', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessionId = c.req.param('id');
await invalidateSession(sessionId, user.id);
await (db as any)
.delete(authSessions)
.where(
and(
eq((authSessions as any).id, sessionId),
eq((authSessions as any).userId, user.id)
)
);
return c.json({ message: 'Session revoked' });
});
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
// every previously issued JWT for this user, which is the actual enforcement
// mechanism (auth is stateless JWT, not DB-session based).
// Revoke all other sessions (logout everywhere else); the current session
// stays valid so this device remains signed in.
dashboard.post('/sessions/revoke-all', async (c) => {
const user = (c as any).get('user') as AuthUser;
await invalidateAllUserSessions(user.id);
await bumpTokenVersion(user.id);
// Issue a fresh token so the current device stays signed in
const refreshed = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
return c.json({ message: 'All other sessions revoked.', token });
await (db as any)
.delete(authSessions)
.where(
and(
eq((authSessions as any).userId, user.id),
sql`${(authSessions as any).id} != ${user.sessionId}`
)
);
return c.json({ message: 'All other sessions revoked.' });
});
// Set password (for users without one)
@@ -476,53 +498,57 @@ const setPasswordSchema = z.object({
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { password } = c.req.valid('json');
// Check if user already has a password
if (await getUserPasswordHash(user.id)) {
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
}
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
// policy hook does not cover it — validate explicitly.
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
try {
await auth.api.setPassword({
body: { newPassword: password },
headers: c.req.raw.headers,
});
} catch (err: any) {
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
}
return c.json({ message: 'Password set successfully' });
});
// Unlink Google account (only if password is set)
dashboard.post('/unlink-google', async (c) => {
const user = (c as any).get('user') as AuthUser;
if (!user.googleId) {
if (!(await hasGoogleAccount(user.id))) {
return c.json({ error: 'Google account not linked' }, 400);
}
if (!(await getUserPasswordHash(user.id))) {
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
}
const now = getNow();
await (db as any)
.delete(authAccounts)
.where(
and(
eq((authAccounts as any).userId, user.id),
eq((authAccounts as any).providerId, 'google')
)
);
await (db as any)
.update(users)
.set({
googleId: null,
updatedAt: now,
})
.set({ updatedAt: getNow() })
.where(eq((users as any).id, user.id));
return c.json({ message: 'Google account unlinked' });
});
+13 -4
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
@@ -164,12 +164,15 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
user = {
id: userId,
email: data.email,
password: '', // No password for guest bookings
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
rucNumber,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
};
@@ -1429,11 +1432,14 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
user = {
id: userId,
email: attendeeEmail,
password: '',
password: null,
name: adminFullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
};
@@ -1573,11 +1579,14 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
user = {
id: userId,
email: attendeeEmail,
password: '',
password: null,
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
};
+20 -2
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { authSessions } from '../db/auth-schema.js';
import { getNow, toDbDate } from '../lib/utils.js';
interface UserContext {
@@ -175,11 +176,28 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
return c.json({ error: 'User not found' }, 404);
}
// Keep the Better Auth admin `banned` flag in sync with accountStatus so
// sign-in is blocked at the auth layer too, and kill live sessions on
// suspension so it takes effect immediately (sessions are DB-validated on
// every request by both the backend and the photo API).
const statusMirror: Record<string, any> = {};
if (data.accountStatus === 'suspended') {
statusMirror.banned = true;
statusMirror.banReason = 'Suspended by admin';
} else if (data.accountStatus) {
statusMirror.banned = false;
statusMirror.banReason = null;
}
await (db as any)
.update(users)
.set({ ...data, updatedAt: getNow() })
.set({ ...data, ...statusMirror, updatedAt: getNow() })
.where(eq((users as any).id, id));
if (data.accountStatus === 'suspended') {
await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id));
}
const updated = await dbGet(
(db as any)
.select({
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declaration": false,
"resolveJsonModule": true
},
"include": ["src/**/*"],
+109
View File
@@ -0,0 +1,109 @@
# ============================================================
# Spanglish Community - Photo Gallery API
# photos.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name photos.spanglishcommunity.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://photos.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name photos.spanglishcommunity.com;
# Photos can be larger than typical JSON payloads
client_max_body_size 25m;
# SSL
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_photo_access.log;
error_log /var/log/nginx/spanglish_photo_error.log;
# CORS Configuration
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Ensure 429 (rate limited) returns JSON + CORS
error_page 429 = @rate_limited;
location @rate_limited {
default_type application/json;
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
return 429 '{"error":"Too many requests. Please slow down."}';
}
location / {
limit_req zone=spanglish_photo_limit burst=40 nodelay;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Buffer large image uploads to disk rather than memory
proxy_request_buffering on;
proxy_max_temp_file_size 1024m;
}
}
+146
View File
@@ -0,0 +1,146 @@
# ============================================================
# Spanglish Community - Backend API
# api.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name api.spanglishcommunity.com;
# ACME
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# Force HTTPS
location / {
return 301 https://api.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name api.spanglishcommunity.com;
# Upload size limit (avoid nginx 413 on media uploads)
# Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB).
client_max_body_size 20m;
# SSL
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security (API)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
# Logs
access_log /var/log/nginx/spanglish_api_access.log;
error_log /var/log/nginx/spanglish_api_error.log;
# CORS Configuration (set once, used everywhere)
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
# Add CORS headers to all responses (including nginx-generated errors)
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked")
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Photo gallery service (photo-api, port 3003). ^~ wins over the "/"
# prefix below, so /api/photos/* reaches the Go photo-api instead of the
# Node backend (which has no photo routes and would 404). The admin UI
# calls this cross-origin via NEXT_PUBLIC_API_URL, so preflight + CORS
# must be handled here just like location / below.
location ^~ /api/photos/ {
limit_req zone=spanglish_api_limit burst=50 nodelay;
# Handle preflight OPTIONS requests (add_header inside if{} does NOT
# inherit server-level headers, so repeat all CORS headers here).
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Strip CORS headers from the service (nginx handles CORS here)
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
# Photo batches can be large; allow bigger bodies + unbuffered upload.
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
location / {
limit_req zone=spanglish_api_limit burst=50 nodelay;
# Handle preflight OPTIONS requests
# NOTE: add_header inside if{} does NOT inherit server-level headers,
# so we must repeat all CORS headers here.
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Strip CORS headers from backend (nginx handles CORS at server level)
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
}
+115
View File
@@ -0,0 +1,115 @@
# ============================================================
# Spanglish Community - Frontend
# spanglishcommunity.com / www
# ============================================================
server {
listen 80;
server_name spanglishcommunity.com www.spanglishcommunity.com;
# ACME
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# Force HTTPS
location / {
return 301 https://spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name spanglishcommunity.com www.spanglishcommunity.com;
# Upload size limit (covers same-origin /api uploads via this vhost)
client_max_body_size 20m;
# SSL
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_frontend_access.log;
error_log /var/log/nginx/spanglish_frontend_error.log;
# Proxy /api/photos to the photo-api (Go service, port 3003). ^~ wins over
# the /api prefix below so same-origin image/gallery requests reach the
# photo-api instead of the Node backend (which has no photo routes -> 404).
location ^~ /api/photos/ {
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Photo uploads/batches can be large; allow bigger bodies.
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Let the photo-api set Cache-Control per image visibility (public vs.
# token-gated) rather than forcing a cache policy here.
}
# Proxy /api to backend
location /api {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
# Proxy /uploads to backend
location /uploads {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache static files
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, immutable";
}
# Frontend App
location / {
proxy_pass http://spanglish_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket / HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
}
}
+108
View File
@@ -0,0 +1,108 @@
# ============================================================
# Spanglish Community - Photo Gallery API
# photos.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name photos.spanglishcommunity.com;
location /.well-known/acme-challenge/ {
}
location / {
return 301 https://photos.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name photos.spanglishcommunity.com;
# Photos can be larger than typical JSON payloads
client_max_body_size 25m;
# SSL
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_photo_access.log;
error_log /var/log/nginx/spanglish_photo_error.log;
# CORS Configuration
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Ensure 429 (rate limited) returns JSON + CORS
error_page 429 = @rate_limited;
location @rate_limited {
default_type application/json;
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
return 429 '{"error":"Too many requests. Please slow down."}';
}
location / {
limit_req zone=spanglish_photo_limit burst=40 nodelay;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Buffer large image uploads to disk rather than memory
proxy_request_buffering on;
proxy_max_temp_file_size 1024m;
}
}
@@ -0,0 +1,12 @@
upstream spanglish_frontend {
server 127.0.0.1:3019;
}
upstream spanglish_backend {
server 127.0.0.1:3018;
}
upstream spanglish_photo_api {
server 127.0.0.1:3003;
}
limit_req_zone $binary_remote_addr zone=spanglish_photo_limit:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=spanglish_api_limit:10m rate=30r/s;
+1
View File
@@ -14,6 +14,7 @@
"@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"better-auth": "1.6.25",
"clsx": "^2.1.1",
"html5-qrcode": "^2.3.8",
"next": "^14.2.4",
@@ -1,7 +1,7 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
@@ -9,29 +9,48 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { authApi } from '@/lib/api';
import { authClient } from '@/lib/auth-client';
import toast from 'react-hot-toast';
/**
* Progressive-account claim. The claim email contains a magic link that signs
* the user in (via /auth/magic-link) and redirects here; this page then asks
* for a password and completes the claim against /api/auth-ext/claim-account.
*/
function ClaimAccountContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage();
const { setAuthData } = useAuth();
const { refreshUser } = useAuth();
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [alreadyClaimed, setAlreadyClaimed] = useState(false);
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
const token = searchParams.get('token');
useEffect(() => {
let cancelled = false;
authClient
.getSession()
.then(({ data }) => {
if (cancelled) return;
const user: any = data?.user;
setHasSession(!!user);
setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active');
})
.finally(() => {
if (!cancelled) setChecking(false);
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) {
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
return;
}
if (formData.password !== formData.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
@@ -49,8 +68,8 @@ function ClaimAccountContent() {
setLoading(true);
try {
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
setAuthData({ user: result.user, token: result.token });
await authApi.confirmClaimAccount(formData.password);
await refreshUser();
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
router.push('/dashboard');
} catch (error: any) {
@@ -60,7 +79,21 @@ function ClaimAccountContent() {
}
};
if (!token) {
if (checking) {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
if (alreadyClaimed) {
// Signed-in and already active: nothing to claim
router.push('/dashboard');
return null;
}
if (!hasSession) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
@@ -76,8 +109,8 @@ function ClaimAccountContent() {
</h2>
<p className="text-gray-600 mb-6">
{language === 'es'
? 'Este enlace de activación no es válido o ha expirado.'
: 'This activation link is invalid or has expired.'}
? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.'
: 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}
</p>
<Link href="/login">
<Button>
@@ -127,7 +160,7 @@ function ClaimAccountContent() {
<p className="text-xs text-gray-500 -mt-4">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmPassword"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
@@ -136,7 +169,7 @@ function ClaimAccountContent() {
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{language === 'es' ? 'Activar Cuenta' : 'Activate Account'}
</Button>
@@ -6,6 +6,8 @@ import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { safeInternalPath } from '@/lib/safeRedirect';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast';
function MagicLinkContent() {
@@ -18,11 +20,12 @@ function MagicLinkContent() {
const verificationAttempted = useRef(false);
const token = searchParams.get('token');
const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard');
useEffect(() => {
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
if (verificationAttempted.current) return;
if (token) {
verificationAttempted.current = true;
verifyToken();
@@ -34,11 +37,17 @@ function MagicLinkContent() {
const verifyToken = async () => {
try {
await loginWithMagicLink(token!);
const user = await loginWithMagicLink(token!);
setStatus('success');
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
// Unclaimed accounts must finish the claim (set a password) before the
// rest of the API will accept their session.
const destination =
user && (user.isClaimed === false || user.accountStatus === 'unclaimed')
? '/auth/claim-account'
: callbackURL;
setTimeout(() => {
router.push('/dashboard');
redirectAfterAuth(destination);
}, 1500);
} catch (err: any) {
setStatus('error');
@@ -25,7 +25,7 @@ interface AccountTabProps {
*/
export default function AccountTab({ onUpdate }: AccountTabProps) {
const { locale } = useLanguage();
const { user, updateUser, logout } = useAuth();
const { user, updateUser } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
@@ -187,15 +187,17 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
if (
!confirm(
locale === 'es'
? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all sessions? You will be logged out.'
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
: 'Log out of all other sessions? This session stays signed in.'
)
)
return;
try {
await dashboardApi.revokeAllSessions();
toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
logout();
toast.success(
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
);
loadData();
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
@@ -477,7 +479,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
</p>
) : (
<div className="space-y-3">
{sessions.map((session, index) => (
{sessions.map((session) => (
<div
key={session.id}
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
@@ -494,7 +496,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{index === 0 ? (
{session.current ? (
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
{locale === 'es' ? 'Esta sesión' : 'This session'}
</span>
+5 -3
View File
@@ -23,7 +23,7 @@ type Tab = 'overview' | 'tickets' | 'payments' | 'account';
export default function DashboardPage() {
const router = useRouter();
const { locale } = useLanguage();
const { user, isLoading: authLoading, token } = useAuth();
const { user, isLoading: authLoading } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
@@ -36,11 +36,13 @@ export default function DashboardPage() {
router.push('/login');
return;
}
if (user && token) {
// Auth rides on the httpOnly session cookie; once the user has resolved
// the API calls are authenticated automatically.
if (user) {
loadDashboardData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authLoading, token]);
}, [user, authLoading]);
const loadDashboardData = async () => {
setLoading(true);
@@ -0,0 +1,6 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the event gallery.
export default function Loading() {
return <GallerySkeleton />;
}
+84 -7
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
@@ -11,14 +11,20 @@ import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton';
import { authApi } from '@/lib/api';
import { safeInternalPath } from '@/lib/safeRedirect';
import {
clearRedirectAttempt,
didRedirectBounce,
redirectAfterAuth,
} from '@/lib/authRedirect';
import toast from 'react-hot-toast';
function LoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { t, locale: language } = useLanguage();
const { login } = useAuth();
const { login, user, isLoading: authLoading } = useAuth();
const [loading, setLoading] = useState(false);
const [redirecting, setRedirecting] = useState(false);
const [bounced, setBounced] = useState(false);
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [formData, setFormData] = useState({
@@ -29,6 +35,27 @@ function LoginContent() {
// Check for redirect after login (only same-origin relative paths are honoured)
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
// Send an already-signed-in visitor on to their destination — and detect the case
// where that destination bounced them back here, which otherwise looks like the
// login page silently ignoring a successful sign-in.
useEffect(() => {
if (authLoading || redirecting) return;
if (!user) {
// Signed out on the login page is a clean slate.
clearRedirectAttempt();
return;
}
if (didRedirectBounce(redirectTo)) {
setBounced(true);
return;
}
setRedirecting(true);
redirectAfterAuth(redirectTo);
}, [authLoading, redirecting, user, redirectTo]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -36,10 +63,12 @@ function LoginContent() {
try {
await login(formData.email, formData.password);
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
router.push(redirectTo);
// Deliberately leaves `loading` set: the button must stay disabled until the
// browser replaces this page.
setRedirecting(true);
redirectAfterAuth(redirectTo);
} catch (error: any) {
toast.error(error.message || t('auth.errors.invalidCredentials'));
} finally {
setLoading(false);
}
};
@@ -67,6 +96,38 @@ function LoginContent() {
}
};
// The destination sent us back here even though the session is valid. Say so, rather
// than re-showing a form that appears to do nothing. The retry link is a plain <a> so
// it is a full page load, like every other navigation out of this page.
if (bounced) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
<div className="max-w-md mx-auto">
<Card className="p-8 text-center">
<h1 className="text-2xl font-bold">{t('auth.login.redirectBlocked')}</h1>
{user && <p className="mt-2 text-sm text-gray-600">{user.email}</p>}
<p className="mt-4 text-sm text-gray-600">
<code className="px-1.5 py-0.5 bg-gray-100 rounded">{redirectTo}</code>
</p>
<a href={redirectTo} className="mt-6 block">
<Button className="w-full" size="lg">
{t('auth.login.redirectRetry')}
</Button>
</a>
<Link
href="/"
className="mt-3 inline-block text-sm text-secondary-blue hover:underline"
>
{t('nav.home')}
</Link>
</Card>
</div>
</div>
</div>
);
}
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
@@ -151,9 +212,25 @@ function LoginContent() {
</Link>
</div>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
<Button
type="submit"
className="w-full"
size="lg"
isLoading={loading || redirecting}
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
>
{t('auth.login.submit')}
</Button>
{redirecting && (
<p
className="text-center text-sm text-gray-600"
role="status"
aria-live="polite"
>
{t('auth.login.redirecting')}
</p>
)}
</form>
) : magicLinkSent ? (
<div className="text-center py-8">
@@ -2,17 +2,26 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
import Button from '@/components/ui/Button';
import { ImageGridSkeleton } from '@/components/ui/Skeleton';
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
import PhotoTile from '@/components/gallery/PhotoTile';
import {
GalleryContainer,
GalleryHeroFrame,
MasonryGrid,
} from '@/components/gallery/GalleryLayout';
import { useDownloads } from '@/components/gallery/useDownloads';
import SaveSheet, { SavePhoto, useMobileSave } from '@/components/gallery/SaveSheet';
import Lightbox from '@/components/Lightbox';
import LoginModal from '@/components/LoginModal';
import {
ArrowDownTrayIcon,
CalendarIcon,
CameraIcon,
LinkIcon,
LockClosedIcon,
TicketIcon,
} from '@heroicons/react/24/outline';
@@ -25,15 +34,13 @@ interface GalleryClientProps {
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
}
type DeniedState = 'login' | 'ticket' | 'notfound' | null;
type DeniedState = 'login' | 'ticket' | 'private' | 'link' | 'notfound' | null;
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
const { locale } = useLanguage();
const es = locale === 'es';
const { user, isLoading: authLoading } = useAuth();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const shareToken = searchParams.get('token') || undefined;
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
@@ -41,13 +48,43 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
const [loading, setLoading] = useState(!initial);
const [denied, setDenied] = useState<DeniedState>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [loginOpen, setLoginOpen] = useState(false);
const downloads = useDownloads(es);
const downloadLabels = {
download: es ? 'Descargar' : 'Download',
downloading: es ? 'Descargando…' : 'Downloading…',
};
// On a phone a download cannot reach the photo library, so the button
// opens a sheet offering the shareable preview instead (SaveSheet).
// Desktop keeps downloading the original straight away.
const mobileSave = useMobileSave();
const [savePhoto, setSavePhoto] = useState<SavePhoto | null>(null);
const downloadPhoto = (photo: Photo) => {
if (mobileSave) {
setSavePhoto({
id: photo.id,
previewUrl: photo.urls.download || photo.urls.preview || photo.urls.original,
originalUrl: photo.urls.downloadOriginal || photo.urls.original,
previewSize: photo.previewSizeBytes,
originalSize: photo.sizeBytes,
});
return;
}
downloads.start({
id: photo.id,
url: photo.urls.original,
filename: photo.originalFilename,
});
};
// Server-rendered public galleries need no client fetch. Everything else
// (link/ticket/private) is fetched here with the share token and/or the
// viewer's own auth token attached.
useEffect(() => {
if (initial) return;
if (authLoading) return; // wait so the Bearer token is available
if (authLoading) return; // wait until the session state has resolved
let cancelled = false;
setLoading(true);
const fetcher = eventSlug
@@ -62,9 +99,13 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
})
.catch((err: Error) => {
if (cancelled) return;
// The photo-api returns a distinct message per visibility mode so
// each gets its own gate page (see accessDenial in access.go).
const msg = err.message || '';
if (msg.includes('Authentication required')) setDenied('login');
else if (msg.includes('attendees')) setDenied('ticket');
else if (msg.includes('private')) setDenied('private');
else if (msg.includes('share link')) setDenied('link');
else setDenied('notfound');
})
.finally(() => !cancelled && setLoading(false));
@@ -73,54 +114,123 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
};
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
// Mirrors the hero + masonry layout below, so the real page drops straight
// into the placeholder's geometry instead of replacing it.
if (loading || (authLoading && !initial)) {
return (
<div className="section-padding">
<div className="container-page">
<ImageGridSkeleton />
</div>
</div>
);
return <GallerySkeleton count={gallery?.photoCount || undefined} />;
}
// Gate pages for restricted galleries. After a successful login in the
// pop-up, AuthContext's user changes, which re-runs the fetch effect —
// the gate resolves by itself when access is granted.
const loginModal = (
<LoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
message={
es
? 'Inicia sesión para ver esta galería.'
: 'Log in to view this gallery.'
}
/>
);
if (denied === 'login') {
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
return (
<GateMessage
icon={LockClosedIcon}
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
body={
es
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
: 'This gallery is for event attendees. Log in with the account you used to book.'
}
action={
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}>
{es ? 'Iniciar sesión' : 'Log in'}
</Button>
}
/>
<>
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
: 'This gallery is for event attendees. Log in with the account you used to book.'
}
action={
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
}
/>
{loginModal}
</>
);
}
if (denied === 'ticket') {
return (
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.'
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
}
action={
eventSlug || gallery?.event ? (
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
</Link>
) : undefined
}
/>
<>
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada. ¿Reservaste con otra cuenta?'
: 'This gallery is only available to people who attended the event with a confirmed ticket. Booked with a different account?'
}
action={
<div className="flex flex-wrap justify-center gap-3">
{(eventSlug || gallery?.event) && (
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
</Link>
)}
<Button onClick={() => setLoginOpen(true)}>
{es ? 'Cambiar de cuenta' : 'Switch account'}
</Button>
</div>
}
/>
{loginModal}
</>
);
}
if (denied === 'private') {
return (
<>
<GateMessage
icon={LockClosedIcon}
title={es ? 'Esta galería es privada' : 'This gallery is private'}
body={
es
? 'Solo los organizadores pueden verla. Si eres parte del equipo, inicia sesión.'
: 'Only the organizers can see it. If thats you, log in.'
}
action={
<div className="flex flex-wrap justify-center gap-3">
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</div>
}
/>
{loginModal}
</>
);
}
if (denied === 'link') {
return (
<>
<GateMessage
icon={LinkIcon}
title={es ? 'Esta galería necesita su enlace' : 'This gallery needs its share link'}
body={
es
? 'Solo se puede abrir con el enlace que compartieron los organizadores. Pídeles el enlace completo, o inicia sesión si eres parte del equipo.'
: 'It can only be opened with the link the organizers shared. Ask them for the full link, or log in if youre part of the team.'
}
action={
<div className="flex flex-wrap justify-center gap-3">
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</div>
}
/>
{loginModal}
</>
);
}
@@ -144,11 +254,14 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
}
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
// previewUrl is the download endpoint's preview: rendering and saving from
// the same URL is what lets "Save photo" be answered by the HTTP cache.
const lightboxItems = readyPhotos.map((p) => ({
id: p.id,
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
previewUrl: p.urls.download || p.urls.preview || p.urls.original,
downloadUrl: p.urls.downloadOriginal || p.urls.original,
filename: p.originalFilename,
thumbUrl: p.urls.thumb,
}));
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
@@ -171,51 +284,56 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
// first photo. Falls back to a navy gradient with no image.
const coverPhoto =
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
// Same preview URL the lightbox uses, so the cover photo's bytes are
// fetched once for both.
const heroUrl = coverPhoto
? coverPhoto.urls.download || coverPhoto.urls.preview || coverPhoto.urls.thumb
: null;
return (
<div>
{/* Hero */}
<div className="relative bg-brand-navy overflow-hidden">
{heroUrl && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
{/* Hero — same frame the skeleton renders (GalleryLayout). */}
<GalleryHeroFrame
backdrop={
heroUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
) : null
}
>
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
)}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)}
</div>
</div>
</div>
</GalleryHeroFrame>
{/* Masonry grid */}
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
<GalleryContainer>
{readyPhotos.length === 0 ? (
<div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
@@ -224,46 +342,19 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
</p>
</div>
) : (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
<MasonryGrid>
{readyPhotos.map((photo, i) => (
<div
<PhotoTile
key={photo.id}
role="button"
tabIndex={0}
onClick={() => setLightboxIndex(i)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setLightboxIndex(i);
}
}}
className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow"
style={
photo.width && photo.height
? { aspectRatio: `${photo.width} / ${photo.height}` }
: undefined
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.urls.thumb}
alt=""
loading={i < 8 ? 'eager' : 'lazy'}
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<a
href={photo.urls.original}
download={photo.originalFilename || true}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
aria-label={es ? 'Descargar' : 'Download'}
>
<ArrowDownTrayIcon className="w-4 h-4" />
</a>
</div>
photo={photo}
eager={i < 8}
onOpen={() => setLightboxIndex(i)}
onDownload={() => downloadPhoto(photo)}
downloading={downloads.isPending(photo.id)}
labels={downloadLabels}
/>
))}
</div>
</MasonryGrid>
)}
{lightboxIndex !== null && (
@@ -272,8 +363,59 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
onDownload={(it) => {
const photo = readyPhotos.find((p) => p.id === it.id);
if (photo) downloadPhoto(photo);
}}
downloadingId={
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
}
downloadLabels={downloadLabels}
/>
)}
{/* Shared by both entry points: the tile button and the lightbox's.
Fetches nothing until it is open. */}
<SaveSheet photo={savePhoto} onClose={() => setSavePhoto(null)} />
</GalleryContainer>
{/* Call to action: send attendees to their dashboard, everyone else to
the next event. Auth state comes from the same useAuth() the gate
pages use. */}
<div className="bg-brand-navy">
<div className="container-page px-4 py-12 md:py-16 text-center">
<h2 className="font-heading font-bold text-2xl md:text-3xl text-white">
{user
? es
? '¿Listo para lo que sigue?'
: 'Ready for whats next?'
: es
? '¿Te gustó lo que viste?'
: 'Liked what you saw?'}
</h2>
<p className="mt-2 max-w-xl mx-auto text-white/80 text-sm md:text-base">
{user
? es
? 'Revisa tus entradas y próximos eventos en tu panel.'
: 'Check your tickets and upcoming events from your dashboard.'
: es
? 'Únete a nuestro próximo evento y sé parte de las próximas fotos.'
: 'Join our next event and be part of the next set of photos.'}
</p>
<div className="mt-6">
<Link href={user ? '/dashboard' : '/next'}>
<Button size="lg">
{user
? es
? 'Ir a mi panel'
: 'Go to dashboard'
: es
? 'Únete al próximo evento'
: 'Join the next event'}
</Button>
</Link>
</div>
</div>
</div>
</div>
);
@@ -0,0 +1,7 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the gallery, so the first paint is
// already the gallery's layout rather than an empty page.
export default function Loading() {
return <GallerySkeleton />;
}
+25 -5
View File
@@ -1,7 +1,6 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
@@ -9,13 +8,16 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast';
const REDIRECT_TO = '/dashboard';
export default function RegisterPage() {
const router = useRouter();
const { t, locale: language } = useLanguage();
const { register } = useAuth();
const [loading, setLoading] = useState(false);
const [redirecting, setRedirecting] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
@@ -30,10 +32,12 @@ export default function RegisterPage() {
try {
await register(formData);
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
router.push('/dashboard');
// Deliberately leaves `loading` set: the button must stay disabled until the
// browser replaces this page.
setRedirecting(true);
redirectAfterAuth(REDIRECT_TO);
} catch (error: any) {
toast.error(error.message || t('auth.errors.emailExists'));
} finally {
setLoading(false);
}
};
@@ -104,9 +108,25 @@ export default function RegisterPage() {
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
<Button
type="submit"
className="w-full"
size="lg"
isLoading={loading || redirecting}
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
>
{t('auth.register.submit')}
</Button>
{redirecting && (
<p
className="text-center text-sm text-gray-600"
role="status"
aria-live="polite"
>
{t('auth.login.redirecting')}
</p>
)}
</form>
<p className="mt-6 text-center text-sm text-gray-600">
+2 -8
View File
@@ -107,11 +107,7 @@ export default function AdminEmailsPage() {
const loadEvents = async () => {
try {
const res = await fetch('/api/events', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
const res = await fetch('/api/events', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
setEvents(data.events || []);
@@ -169,9 +165,7 @@ export default function AdminEmailsPage() {
try {
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
credentials: 'same-origin',
});
if (res.ok) {
const data = await res.json();
+1 -5
View File
@@ -35,11 +35,7 @@ export default function AdminGalleryPage() {
const loadMedia = async () => {
try {
// We need to call the media API - let's add it if it doesn't exist
const res = await fetch('/api/media', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
const res = await fetch('/api/media', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
setMedia(data.media || []);
+28 -6
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import { isManualProvider } from '@/lib/api/payments';
import { parseDate, formatRucDisplay } from '@/lib/utils';
@@ -22,6 +23,7 @@ import {
BoltIcon,
BanknotesIcon,
BuildingLibraryIcon,
CalendarDaysIcon,
CreditCardIcon,
EnvelopeIcon,
FunnelIcon,
@@ -35,6 +37,7 @@ type Tab = 'pending_approval' | 'all';
export default function AdminPaymentsPage() {
const { t, locale } = useLanguage();
const { privacyMode } = usePrivacy();
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
// Manual-gateway payments still in bare 'pending': the customer may have paid
@@ -534,12 +537,29 @@ export default function AdminPaymentsPage() {
</div>
)}
{selectedPayment.userMarkedPaidAt && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<ClockIcon className="w-4 h-4" />
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
</div>
)}
{/* Always shown for payments the customer never confirmed, this
is the only timestamp there is. */}
{(() => {
const age = getAgeInfo(selectedPayment.createdAt);
return (
<div className="flex items-center gap-2 text-sm text-gray-600">
<CalendarDaysIcon className="w-4 h-4" />
{locale === 'es' ? 'Reserva realizada:' : 'Booking made:'} {formatDate(selectedPayment.createdAt)}
{age && <span className="text-gray-400">({age.label})</span>}
</div>
);
})()}
{selectedPayment.userMarkedPaidAt && (() => {
const age = getAgeInfo(selectedPayment.userMarkedPaidAt);
return (
<div className="flex items-center gap-2 text-sm text-gray-600">
<ClockIcon className="w-4 h-4" />
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
{age && <span className="text-gray-400">({age.label})</span>}
</div>
);
})()}
{selectedPayment.reminderSentAt && (
<div className="flex items-center gap-2 text-sm text-amber-600">
@@ -760,6 +780,7 @@ export default function AdminPaymentsPage() {
)}
{/* Summary Cards */}
{!privacyMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<Card className="p-4">
<div className="flex items-center gap-3">
@@ -828,6 +849,7 @@ export default function AdminPaymentsPage() {
</div>
</Card>
</div>
)}
{/* Tabs */}
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
+28 -4
View File
@@ -18,6 +18,7 @@ import {
CheckCircleIcon,
ChevronDownIcon,
ChevronUpIcon,
DocumentDuplicateIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
LinkIcon,
@@ -34,7 +35,9 @@ interface UploadItem {
name: string;
sizeBytes: number;
progress: number; // 0..1 while uploading
status: 'queued' | 'uploading' | 'processing' | 'error';
// 'duplicate' is terminal: the gallery already held these bytes, so nothing
// was stored and no new tile appears in the grid.
status: 'queued' | 'uploading' | 'processing' | 'error' | 'duplicate';
error?: string;
photoId?: string;
}
@@ -117,8 +120,19 @@ export default function AdminGalleryDetailPage() {
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
patchUpload(key, { progress: fraction })
);
setPhotos((prev) => [...prev, ...added]);
patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id });
// A duplicate echoes back a photo already in the grid, so merge by id
// instead of appending (`duplicate` is an upload outcome, not photo
// state, so it is dropped here).
setPhotos((prev) => {
const byId = new Map(prev.map((p) => [p.id, p]));
added.forEach(({ duplicate: _duplicate, ...photo }) => byId.set(photo.id, photo));
return Array.from(byId.values());
});
patchUpload(key, {
status: added[0]?.duplicate ? 'duplicate' : 'processing',
progress: 1,
photoId: added[0]?.id,
});
} catch (err) {
patchUpload(key, {
status: 'error',
@@ -148,7 +162,8 @@ export default function AdminGalleryDetailPage() {
};
// A row is "done" once its photo finished processing; the panel derives
// this from the photos list instead of tracking it separately.
// this from the photos list instead of tracking it separately. 'duplicate'
// is terminal and never reconciled — its photo was already there.
const displayStatus = (u: UploadItem): { state: string; error?: string } => {
if (u.status === 'processing' && u.photoId) {
const photo = photos.find((p) => p.id === u.photoId);
@@ -266,6 +281,7 @@ export default function AdminGalleryDetailPage() {
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
filename: p.originalFilename,
thumbUrl: p.urls.thumb,
}));
const openLightboxFor = (photoId: string) => {
const idx = readyPhotos.findIndex((p) => p.id === photoId);
@@ -578,6 +594,11 @@ export default function AdminGalleryDetailPage() {
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
{ds.state === 'done' && formatBytes(u.sizeBytes)}
{ds.state === 'duplicate' && (
<span className="text-amber-600">
{es ? 'Ya está en esta galería' : 'Already in this gallery'}
</span>
)}
{ds.state === 'error' && (
<span className="text-red-600" title={ds.error}>
{ds.error}
@@ -587,6 +608,9 @@ export default function AdminGalleryDetailPage() {
</div>
<span className="shrink-0">
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
{ds.state === 'duplicate' && (
<DocumentDuplicateIcon className="w-6 h-6 text-amber-600" />
)}
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
{ds.state === 'processing' && (
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useLanguage } from '@/context/LanguageContext';
import { safeInternalPath } from '@/lib/safeRedirect';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast';
declare global {
@@ -83,9 +84,10 @@ export default function GoogleSignInButton({
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
onSuccess?.();
// Use window.location for navigation to ensure clean state.
// Constrain to a same-origin path to avoid open redirects.
window.location.href = safeInternalPath(redirectTo, '/dashboard');
// Full page load for a clean state, recording the attempt so the login page can
// tell a bounced redirect from a fresh visit. Constrained to a same-origin path
// to avoid open redirects.
redirectAfterAuth(safeInternalPath(redirectTo, '/dashboard'));
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
+154 -53
View File
@@ -1,18 +1,21 @@
'use client';
import { useEffect, useCallback, useState } from 'react';
import { useEffect, useCallback, useRef, useState } from 'react';
import {
XMarkIcon,
ChevronLeftIcon,
ChevronRightIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
import Spinner from '@/components/ui/Spinner';
export interface LightboxItem {
id: string;
previewUrl: string;
downloadUrl: string;
filename?: string;
/** Small thumbnail for the filmstrip; falls back to previewUrl. */
thumbUrl?: string;
}
interface LightboxProps {
@@ -22,15 +25,35 @@ interface LightboxProps {
onNavigate: (index: number) => void;
/** Extra per-item action buttons rendered in the top bar (admin use). */
renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
/**
* Handles the download in JS instead of navigating, so the button can show
* progress. Without it the button stays a plain <a download>.
*/
onDownload?: (item: LightboxItem) => void;
/** Id of the item currently downloading (pairs with onDownload). */
downloadingId?: string | null;
downloadLabels?: { download: string; downloading: string };
}
/**
* Full-screen photo lightbox with keyboard and swipe navigation, in the
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
*/
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
const [touchStartX, setTouchStartX] = useState<number | null>(null);
export default function Lightbox({
items,
index,
onClose,
onNavigate,
renderActions,
onDownload,
downloadingId,
downloadLabels,
}: LightboxProps) {
const touchStart = useRef<{ x: number; y: number } | null>(null);
const activeThumbRef = useRef<HTMLButtonElement | null>(null);
const item = items[index];
const hasMultiple = items.length > 1;
const downloading = !!downloadingId && item?.id === downloadingId;
const prev = useCallback(() => {
onNavigate(index > 0 ? index - 1 : items.length - 1);
@@ -53,19 +76,33 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
};
}, [onClose, prev, next]);
// Keep the active thumbnail centred in the filmstrip as you navigate.
useEffect(() => {
activeThumbRef.current?.scrollIntoView({
behavior: 'smooth',
inline: 'center',
block: 'nearest',
});
}, [index]);
if (!item) return null;
return (
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
className="fixed inset-0 bg-black/90 z-50 flex flex-col"
onClick={onClose}
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
onTouchStart={(e) => {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}}
onTouchEnd={(e) => {
if (touchStartX === null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (dx > 60) prev();
if (dx < -60) next();
setTouchStartX(null);
if (touchStart.current === null) return;
const dx = e.changedTouches[0].clientX - touchStart.current.x;
const dy = e.changedTouches[0].clientY - touchStart.current.y;
touchStart.current = null;
// Only treat as a swipe when it's clearly horizontal.
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return;
if (dx > 0) prev();
else next();
}}
>
<button
@@ -80,58 +117,122 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
className="absolute top-4 left-4 z-10 flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<a
href={item.downloadUrl}
download={item.filename || true}
className="text-white hover:text-gray-300"
aria-label="Download"
>
<ArrowDownTrayIcon className="w-7 h-7" />
</a>
{onDownload ? (
<button
type="button"
onClick={() => onDownload(item)}
disabled={downloading}
aria-busy={downloading}
aria-label={
downloading
? downloadLabels?.downloading || 'Downloading…'
: downloadLabels?.download || 'Download'
}
className={`text-white hover:text-gray-300 flex items-center ${
downloading ? 'cursor-wait' : ''
}`}
>
{downloading ? (
<Spinner className="w-6 h-6" />
) : (
<ArrowDownTrayIcon className="w-7 h-7" />
)}
</button>
) : (
<a
href={item.downloadUrl}
download={item.filename || true}
className="text-white hover:text-gray-300"
aria-label="Download"
>
<ArrowDownTrayIcon className="w-7 h-7" />
</a>
)}
{renderActions?.(item, index)}
</div>
{items.length > 1 && (
<>
<button
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{/* Main image area */}
<div className="relative flex-1 min-h-0 flex items-center justify-center">
{hasMultiple && (
<>
<button
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
{/* eslint-disable-next-line @next/next/no-img-element */}
{/* Nothing is layered over the image (the navigation buttons sit at
the edges), and neither -webkit-touch-callout nor -webkit-user-
select is suppressed here, so iOS long-press "Save to Photos"
still works as a backup to the save sheet. Don't add select-none. */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-full object-contain [-webkit-touch-callout:default]"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
</div>
{/* Counter + thumbnail filmstrip you can skip through. */}
<div
className="shrink-0 pb-3 pt-2"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
{index + 1} / {items.length}
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
<div className="text-center text-white/70 text-sm mb-2">
{index + 1} / {items.length}
</div>
{hasMultiple && (
<div className="flex gap-2 overflow-x-auto px-4 pb-1 justify-start sm:justify-center [scrollbar-width:thin]">
{items.map((it, i) => (
<button
key={it.id}
ref={i === index ? activeThumbRef : null}
onClick={() => onNavigate(i)}
aria-label={`Photo ${i + 1}`}
aria-current={i === index}
className={`relative shrink-0 overflow-hidden rounded transition ${
i === index
? 'ring-2 ring-white opacity-100'
: 'opacity-50 hover:opacity-90'
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={it.thumbUrl || it.previewUrl}
alt=""
className="h-14 w-14 sm:h-16 sm:w-16 object-cover"
draggable={false}
/>
</button>
))}
</div>
)}
</div>
{/* Preload neighbours so prev/next feels instant. */}
<div className="hidden" aria-hidden>
{items.length > 1 && (
{hasMultiple && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
+190
View File
@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton';
import { authApi } from '@/lib/api';
import { XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
interface LoginModalProps {
open: boolean;
onClose: () => void;
/** Called after a successful password login (Google logins reload the page). */
onSuccess?: () => void;
/** Optional context line shown under the title (e.g. why login is needed). */
message?: string;
}
/**
* Reusable login pop-up: same capabilities as /login (password,
* magic link, Google) but inline, so the visitor stays on the page
* used by gated photo galleries.
*/
export default function LoginModal({ open, onClose, onSuccess, message }: LoginModalProps) {
const { t, locale } = useLanguage();
const es = locale === 'es';
const { login } = useAuth();
const pathname = usePathname();
const searchParams = useSearchParams();
const [mode, setMode] = useState<'password' | 'magic-link'>('password');
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Google logins hard-reload; send them back to exactly where they are
// (including a ?token= share link).
const query = searchParams.toString();
const currentUrl = query ? `${pathname}?${query}` : pathname;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
toast.success(es ? '¡Bienvenido!' : 'Welcome back!');
onClose();
onSuccess?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('auth.errors.invalidCredentials'));
} finally {
setLoading(false);
}
};
const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error(es ? 'Ingresa tu email' : 'Please enter your email');
return;
}
setLoading(true);
try {
await authApi.requestMagicLink(email);
setMagicLinkSent(true);
toast.success(es ? 'Revisa tu correo para el enlace de acceso' : 'Check your email for the login link');
} catch (error) {
toast.error(error instanceof Error ? error.message : es ? 'Error' : 'Failed');
} finally {
setLoading(false);
}
};
return (
<div
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<Card className="w-full max-w-md p-6 md:p-8" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between mb-1">
<h2 className="text-xl font-bold text-primary-dark">{t('auth.login.title')}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1 -m-1" aria-label="Close">
<XMarkIcon className="w-6 h-6" />
</button>
</div>
{message && <p className="text-sm text-gray-600 mb-4">{message}</p>}
<div className="mt-4">
<GoogleSignInButton redirectTo={currentUrl} />
</div>
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-secondary-light-gray" />
<span className="text-xs text-gray-500">{es ? 'o' : 'or'}</span>
<div className="flex-1 h-px bg-secondary-light-gray" />
</div>
{mode === 'password' ? (
<form onSubmit={handlePasswordLogin} className="space-y-4">
<Input
id="login-modal-email"
label={t('auth.login.email')}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
id="login-modal-password"
label={t('auth.login.password')}
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="submit" className="w-full" isLoading={loading}>
{t('auth.login.submit')}
</Button>
</form>
) : magicLinkSent ? (
<p className="text-sm text-gray-600 text-center py-4">
{es
? 'Te enviamos un enlace de acceso. Abre tu correo y vuelve a esta página.'
: 'We sent you a login link. Open your email and come back to this page.'}
</p>
) : (
<form onSubmit={handleMagicLink} className="space-y-4">
<Input
id="login-modal-magic-email"
label={t('auth.login.email')}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Button type="submit" className="w-full" isLoading={loading}>
{es ? 'Enviarme un enlace de acceso' : 'Email me a login link'}
</Button>
</form>
)}
<div className="mt-4 flex items-center justify-between text-sm">
<button
type="button"
onClick={() => {
setMode(mode === 'password' ? 'magic-link' : 'password');
setMagicLinkSent(false);
}}
className="text-secondary-blue hover:underline"
>
{mode === 'password'
? es
? 'Entrar con enlace por correo'
: 'Log in with an email link'
: es
? 'Entrar con contraseña'
: 'Log in with a password'}
</button>
<Link href="/register" className="text-secondary-blue hover:underline" onClick={onClose}>
{es ? 'Crear cuenta' : 'Create account'}
</Link>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,46 @@
// Layout shell shared by the public gallery page (GalleryClient) and its
// loading placeholder (GallerySkeleton). Column count, gaps, radii and
// container padding are defined once here, so the skeleton's geometry can
// never drift from the grid it stands in for.
export function GalleryHeroFrame({
children,
backdrop,
}: {
children: React.ReactNode;
/** Cover image + scrim, absolutely positioned behind the text. */
backdrop?: React.ReactNode;
}) {
return (
<div className="relative bg-brand-navy overflow-hidden">
{backdrop}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">{children}</div>
</div>
);
}
export function GalleryContainer({ children }: { children: React.ReactNode }) {
return <div className="container-page px-2 sm:px-4 py-4 md:py-8">{children}</div>;
}
export function MasonryGrid({ children }: { children: React.ReactNode }) {
return (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
{children}
</div>
);
}
// Per-tile geometry. Deliberately carries no background so callers can pick
// one (photo tiles: bg-gray-100, skeleton tiles: the skeleton surface) without
// two `bg-*` utilities fighting over CSS order.
export const masonryTileClass = 'mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl';
/**
* Reserves the tile's aspect ratio up front so the image can load into a box
* that is already the right size late arrivals never reflow the columns.
* Returns undefined when the photo has no stored dimensions.
*/
export function aspectStyle(width?: number, height?: number): React.CSSProperties | undefined {
return width && height ? { aspectRatio: `${width} / ${height}` } : undefined;
}
@@ -0,0 +1,59 @@
import clsx from 'clsx';
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
import { GalleryContainer, GalleryHeroFrame, MasonryGrid, masonryTileClass } from './GalleryLayout';
// Loading placeholder for the public gallery page. It renders through the same
// hero frame, container and masonry grid as the real page (see GalleryLayout),
// so replacing it with photos changes only the pixels inside the tiles.
// Portrait/landscape/square mix standing in for a real event set. Fixed order
// on purpose — a random shuffle would differ between the server and client
// render and blow up hydration.
const FALLBACK_RATIOS = [3 / 4, 4 / 3, 1, 2 / 3, 3 / 2, 4 / 5, 1, 3 / 4, 16 / 9, 4 / 5, 3 / 4, 4 / 3];
interface GallerySkeletonProps {
/** Number of tiles to draw; ignored when `ratios` is given. */
count?: number;
/**
* Real width/height ratios when the caller already knows them (e.g. a
* cached gallery payload). Produces a grid with exactly the right geometry
* instead of the guessed mix above.
*/
ratios?: number[];
}
export default function GallerySkeleton({ count = 12, ratios }: GallerySkeletonProps) {
const tiles =
ratios && ratios.length > 0
? ratios
: Array.from({ length: count }, (_, i) => FALLBACK_RATIOS[i % FALLBACK_RATIOS.length]);
return (
<SkeletonGroup>
<GalleryHeroFrame>
{/* Matches the h1 (text-3xl / md:text-5xl) and the pill row below it. */}
<Skeleton tone="on-dark" className="h-9 md:h-12 w-2/3 max-w-md" />
<div className="mt-4 flex flex-wrap items-center gap-2">
<Skeleton tone="on-dark" className="h-7 w-28 rounded-full" />
<Skeleton tone="on-dark" className="h-7 w-56 max-w-[60%] rounded-full" />
</div>
</GalleryHeroFrame>
<GalleryContainer>
<MasonryGrid>
{tiles.map((ratio, i) => (
<div
key={i}
aria-hidden="true"
className={clsx(
masonryTileClass,
'bg-secondary-light-gray/70 animate-pulse motion-reduce:animate-none'
)}
style={{ aspectRatio: `${ratio}` }}
/>
))}
</MasonryGrid>
</GalleryContainer>
</SkeletonGroup>
);
}
@@ -0,0 +1,102 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import type { Photo } from '@/lib/api';
import Spinner from '@/components/ui/Spinner';
import { aspectStyle, masonryTileClass } from './GalleryLayout';
interface PhotoTileProps {
photo: Photo;
/** Above-the-fold tiles load eagerly, the rest lazily. */
eager?: boolean;
onOpen: () => void;
onDownload: () => void;
downloading: boolean;
labels: { download: string; downloading: string };
}
export default function PhotoTile({
photo,
eager,
onOpen,
onDownload,
downloading,
labels,
}: PhotoTileProps) {
const imgRef = useRef<HTMLImageElement>(null);
// 'initial' is what the server renders: fully visible, so a public gallery
// still paints its photos if hydration is slow or JS never arrives. The
// fade only takes over once we're mounted and know the image is still in
// flight — a cached image reports `complete` here and skips it entirely
// (its onLoad already fired before React attached the handler).
const [state, setState] = useState<'initial' | 'pending' | 'loaded'>('initial');
const pending = state === 'pending';
useEffect(() => {
setState(imgRef.current?.complete ? 'loaded' : 'pending');
}, []);
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen();
}
}}
className={clsx(
masonryTileClass,
'group relative bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow'
)}
/* Sized from the stored dimensions before the bytes arrive, so a slow
image never pushes its column around. */
style={aspectStyle(photo.width, photo.height)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={photo.urls.thumb}
alt=""
loading={eager ? 'eager' : 'lazy'}
onLoad={() => setState('loaded')}
onError={() => setState('loaded')}
className={clsx(
'w-full h-auto transition-[opacity,transform] duration-300 motion-reduce:transition-none group-hover:scale-[1.03]',
pending ? 'opacity-0' : 'opacity-100'
)}
/>
{pending && (
<span
aria-hidden="true"
className="absolute inset-0 bg-secondary-light-gray/70 animate-pulse motion-reduce:animate-none"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
disabled={downloading}
aria-busy={downloading}
aria-label={downloading ? labels.downloading : labels.download}
className={clsx(
'absolute bottom-2 right-2 flex p-2.5 md:p-2 rounded-full bg-black/50 text-white transition-opacity hover:bg-black/80 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary-yellow',
// Touch devices have no hover to reveal it, and this is where the
// button matters most — it is the entry point to the save sheet.
downloading
? 'opacity-100 cursor-wait hover:bg-black/50'
: 'opacity-100 md:opacity-0 md:group-hover:opacity-100'
)}
>
{downloading ? <Spinner /> : <ArrowDownTrayIcon className="w-5 h-5 md:w-4 md:h-4" />}
</button>
</div>
);
}
@@ -0,0 +1,370 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowDownTrayIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import Spinner from '@/components/ui/Spinner';
// The mobile save flow. On a phone `<a download>` is a dead end for someone
// who wants the photo in their camera roll: iOS is WebKit everywhere, so it
// files the download under Files > Downloads and nothing else, and Android
// drops it in Downloads where Google Photos may or may not pick it up. The
// only route into the device gallery on either platform is the Web Share
// API, which hands the file to the native share sheet ("Save Image" on iOS,
// "Photos" on Android).
//
// So on mobile the download button opens this sheet instead of downloading,
// and the default row shares the 2048px preview — a file a phone screen
// cannot tell from the original, at a fraction of the bytes. The original
// stays one tap away for anyone who actually wants it.
export interface SavePhoto {
id: string;
/** Preview download URL the same URL the lightbox renders, so this is
* usually answered from the HTTP cache rather than the network. */
previewUrl: string;
originalUrl: string;
/** Byte sizes from the gallery response, used to label the rows. */
previewSize?: number;
originalSize?: number;
}
type RowKey = 'preview' | 'original';
type RowStatus = 'idle' | 'loading' | 'ready' | 'error';
interface RowState {
status: RowStatus;
/** 0..1, only tracked for the original's determinate progress bar. */
progress: number;
}
const IDLE: RowState = { status: 'idle', progress: 0 };
/**
* True on finger-first devices, i.e. where a plain download lands in
* Downloads instead of the photo library and this sheet is worth showing.
* Feature detection only user agent strings lie, and iPad has been
* claiming to be a Mac for years.
*
* Deliberately NOT gated on `navigator.share`. Web Share only exists in a
* secure context, so requiring it here made the sheet disappear entirely on
* any plain-HTTP origin (a phone hitting the dev server over the LAN sees
* `navigator.share === undefined`) and on Firefox for Android, which has no
* file sharing. Both of those should still get the sheet their rows just
* fall back to a download. Whether a given row shares or downloads is
* decided per file, at tap time, by canShareFile() below.
*/
export function useMobileSave(): boolean {
const [mobile, setMobile] = useState(false);
useEffect(() => {
// Comma = OR. `hover: none` catches touch devices that report a fine
// pointer because a stylus is paired.
const mq = window.matchMedia('(pointer: coarse), (hover: none)');
const sync = () => setMobile(mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
return mobile;
}
function canShareFile(file: File): boolean {
return typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] });
}
/** Reads the server's Content-Disposition name (spanglish-<event>-<n>.jpg). */
function filenameFrom(header: string | null, fallback: string): string {
const match = header?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
return match ? decodeURIComponent(match[1]) : fallback;
}
function formatBytes(bytes: number | undefined, locale: string): string | null {
if (!bytes || bytes <= 0) return null;
const mb = bytes / 1_000_000;
if (mb < 1) {
return `${Math.round(bytes / 1000).toLocaleString(locale)} kB`;
}
return `${mb.toLocaleString(locale, { maximumFractionDigits: 1 })} MB`;
}
/**
* Fetches a URL into a File. onProgress is only wired up when the caller
* wants a determinate bar reading the body in chunks costs an extra copy,
* which is not worth it for the preview that is normally already cached.
*/
async function fetchAsFile(
url: string,
signal: AbortSignal,
onProgress?: (fraction: number) => void
): Promise<File> {
// No `cache` option: the default is what lets the lightbox's already
// rendered preview be reused instead of refetched.
const res = await fetch(url, { credentials: 'same-origin', signal });
if (!res.ok) throw new Error(`Download failed (${res.status})`);
const type = res.headers.get('Content-Type') || 'image/jpeg';
const name = filenameFrom(res.headers.get('Content-Disposition'), 'spanglish-photo.jpg');
const total = Number(res.headers.get('Content-Length') || 0);
let blob: Blob;
if (onProgress && total > 0 && res.body) {
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
onProgress(Math.min(1, received / total));
}
blob = new Blob(chunks as BlobPart[], { type });
} else {
blob = await res.blob();
}
return new File([blob], name, { type: blob.type || type });
}
/** Fallback for browsers without file sharing: a plain download. */
function saveViaAnchor(file: File) {
const objectUrl = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = objectUrl;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}
interface SaveSheetProps {
/** The photo to save; null keeps the sheet closed. */
photo: SavePhoto | null;
onClose: () => void;
}
export default function SaveSheet({ photo, onClose }: SaveSheetProps) {
if (!photo) return null;
// Keyed by photo so a sheet opened for a different photo starts clean
// instead of inheriting the previous one's fetched file.
return <SaveSheetPanel key={photo.id} photo={photo} onClose={onClose} />;
}
function SaveSheetPanel({ photo, onClose }: { photo: SavePhoto; onClose: () => void }) {
const { t, locale } = useLanguage();
const [rows, setRows] = useState<Record<RowKey, RowState>>({
preview: IDLE,
original: IDLE,
});
// Resolved files live in a ref as well as state: the tap handler has to
// read them *synchronously*. iOS rejects navigator.share() with
// NotAllowedError if anything is awaited between the tap and the call,
// so there is no chance to read them out of a promise first.
const files = useRef<Partial<Record<RowKey, File>>>({});
const aborter = useRef<AbortController>(new AbortController());
const closed = useRef(false);
const setRow = useCallback((key: RowKey, patch: Partial<RowState>) => {
if (closed.current) return;
setRows((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
}, []);
const prepare = useCallback(
async (key: RowKey): Promise<File | null> => {
setRow(key, { status: 'loading', progress: 0 });
try {
const file = await fetchAsFile(
key === 'preview' ? photo.previewUrl : photo.originalUrl,
aborter.current.signal,
// Only the original gets a determinate bar; the preview is
// normally cached and resolves before a spinner would even paint.
key === 'original' ? (progress) => setRow(key, { progress }) : undefined
);
files.current[key] = file;
setRow(key, { status: 'ready', progress: 1 });
return file;
} catch (err) {
// Aborting is how the sheet closes and how the user dismisses the
// native share sheet — neither is an error worth reporting.
if ((err as Error)?.name === 'AbortError') return null;
setRow(key, { status: 'error', progress: 0 });
return null;
}
},
[photo.previewUrl, photo.originalUrl, setRow]
);
// Nothing is fetched until the sheet is open — not on lightbox open, not
// on swipe, not on scroll. Browsing the whole gallery without tapping
// save costs exactly the thumbs and previews already on screen.
useEffect(() => {
// The controller is created here, not at render: closing the sheet
// aborts it, so a re-run of this effect (React StrictMode double-invokes
// it in development) has to start from a fresh, unaborted one.
closed.current = false;
const controller = new AbortController();
aborter.current = controller;
void prepare('preview');
return () => {
closed.current = true;
controller.abort();
};
}, [prepare]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const deliver = (file: File) => {
if (canShareFile(file)) {
// Called with nothing awaited in front of it — see the ref above.
navigator
.share({ files: [file] })
.then(() => onClose())
.catch((err: Error) => {
// AbortError just means the share sheet was dismissed.
if (err?.name !== 'AbortError') saveViaAnchor(file);
});
return;
}
saveViaAnchor(file);
onClose();
};
const onRowTap = (key: RowKey) => {
const ready = files.current[key];
if (ready) {
deliver(ready);
return;
}
if (rows[key].status === 'loading') return;
// Not fetched yet (or it failed): fetch now and stop. The row goes back
// to tappable when it resolves, so the share happens on a fresh
// gesture rather than a stale one iOS would refuse.
void prepare(key);
};
const sizes = {
preview: formatBytes(photo.previewSize, locale),
original: formatBytes(photo.originalSize, locale),
};
return (
<div
className="fixed inset-0 z-[60] flex flex-col justify-end"
role="dialog"
aria-modal="true"
aria-label={t('gallery.save.sheetTitle')}
>
<button
type="button"
aria-label={t('gallery.save.cancel')}
className="absolute inset-0 bg-black/60 motion-safe:animate-sheet-fade"
onClick={onClose}
/>
<div className="relative w-full sm:max-w-md sm:mx-auto bg-white rounded-t-2xl sm:rounded-2xl sm:mb-4 shadow-xl p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] motion-safe:animate-sheet-rise">
<div className="mx-auto mb-2 mt-1 h-1 w-10 rounded-full bg-gray-300" aria-hidden />
<SaveRow
icon={<PhotoIcon className="w-6 h-6 text-primary-dark" />}
label={t('gallery.save.photo')}
hint={t('gallery.save.photoHint')}
size={sizes.preview}
state={rows.preview}
statusLabels={t}
onTap={() => onRowTap('preview')}
/>
<SaveRow
icon={<ArrowDownTrayIcon className="w-6 h-6 text-primary-dark" />}
label={t('gallery.save.original')}
hint={t('gallery.save.originalHint')}
size={sizes.original}
state={rows.original}
statusLabels={t}
showProgress
onTap={() => onRowTap('original')}
/>
<button
type="button"
onClick={onClose}
className="mt-1 w-full rounded-xl py-3 text-center font-medium text-gray-600 hover:bg-gray-50 active:bg-gray-100"
>
{t('gallery.save.cancel')}
</button>
</div>
</div>
);
}
function SaveRow({
icon,
label,
hint,
size,
state,
statusLabels,
showProgress,
onTap,
}: {
icon: React.ReactNode;
label: string;
hint: string;
size: string | null;
state: RowState;
statusLabels: (key: string) => string;
showProgress?: boolean;
onTap: () => void;
}) {
const loading = state.status === 'loading';
// The subtitle carries the row's state, so a slow fetch explains itself
// without the row ever becoming untappable for good.
const subtitle =
state.status === 'loading'
? statusLabels('gallery.save.preparing')
: state.status === 'error'
? statusLabels('gallery.save.failed')
: [hint, size].filter(Boolean).join(' · ');
return (
<button
type="button"
onClick={onTap}
aria-busy={loading}
className="relative w-full overflow-hidden rounded-xl px-4 py-3 text-left flex items-center gap-3 hover:bg-gray-50 active:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow"
>
<span className="shrink-0">
{loading ? (
// Spinner defaults to the white ring used over photos; this one
// sits on the sheet's white surface.
<Spinner className="w-6 h-6 border-gray-300 border-t-primary-dark" />
) : (
icon
)}
</span>
<span className="min-w-0">
<span className="block font-medium text-primary-dark">{label}</span>
<span
className={`block text-sm ${state.status === 'error' ? 'text-red-600' : 'text-gray-500'}`}
>
{subtitle}
</span>
</span>
{showProgress && loading && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-gray-200" aria-hidden>
<span
className="block h-full bg-primary-yellow transition-[width] duration-150"
style={{ width: `${Math.round(state.progress * 100)}%` }}
/>
</span>
)}
</button>
);
}
@@ -0,0 +1,148 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
// Per-photo download state for the public gallery. The photo-api serves full
// quality originals, which takes a few seconds, so downloads run through
// fetch() instead of a bare <a download> — that gives us a spinner, a real
// success/failure signal, a timeout and abort-on-unmount.
// Originals run to ~20 MB, so a single flat timeout would either be too short
// for a phone on mobile data or useless as a stall detector. Time out on the
// response headers instead, then give the body a generous ceiling.
const HEADERS_TIMEOUT_MS = 30_000;
const BODY_TIMEOUT_MS = 10 * 60_000;
export interface DownloadRequest {
/** Photo id; keys the in-flight state so tiles stay independent. */
id: string;
url: string;
filename?: string;
}
/** Saves fetched bytes without navigating away from the gallery. */
function saveBlob(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Give the browser time to start the save before the URL is invalidated.
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}
/**
* Plain-anchor download, i.e. what the page did before this hook existed.
* Used as a fallback when fetch() itself fails: with S3 storage the file
* endpoint 302s to a presigned URL on another origin, which a cross-origin
* fetch cannot read but a navigation downloads fine (the presigned URL
* carries its own Content-Disposition).
*/
function navigateToDownload({ url, filename }: DownloadRequest) {
const a = document.createElement('a');
a.href = url;
if (filename) a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
export function useDownloads(es: boolean) {
const [pending, setPending] = useState<ReadonlySet<string>>(() => new Set());
const controllers = useRef(new Map<string, AbortController>());
const unmounted = useRef(false);
// Lets the retry button in the error toast call the latest `start`.
const startRef = useRef<(req: DownloadRequest) => void>(() => {});
useEffect(() => {
unmounted.current = false;
return () => {
unmounted.current = true;
controllers.current.forEach((c) => c.abort());
controllers.current.clear();
};
}, []);
const start = useCallback(
async (req: DownloadRequest) => {
// Repeat clicks while the same photo is in flight are no-ops; other
// photos are unaffected because state is keyed by id.
if (controllers.current.has(req.id)) return;
const controller = new AbortController();
controllers.current.set(req.id, controller);
setPending((prev) => new Set(prev).add(req.id));
let timer = setTimeout(() => controller.abort(), HEADERS_TIMEOUT_MS);
const timedOut = () => controller.signal.aborted && !unmounted.current;
try {
const res = await fetch(req.url, {
credentials: 'same-origin',
signal: controller.signal,
});
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), BODY_TIMEOUT_MS);
if (!res.ok) throw new Error(`Download failed (${res.status})`);
const blob = await res.blob();
saveBlob(blob, req.filename || `${req.id}.jpg`);
} catch (err) {
if (unmounted.current) return; // page gone, nothing to report
if (err instanceof TypeError) {
// Network-level failure — most likely a cross-origin presigned
// redirect. Hand it to the browser, which can follow it.
navigateToDownload(req);
} else {
const message = timedOut()
? es
? 'La descarga tardó demasiado.'
: 'The download timed out.'
: es
? 'No se pudo descargar la foto.'
: 'Could not download the photo.';
toast.error(
(t) => (
<span className="flex items-center gap-3">
{message}
<button
type="button"
onClick={() => {
toast.dismiss(t.id);
startRef.current(req);
}}
className="font-medium underline underline-offset-2 whitespace-nowrap"
>
{es ? 'Reintentar' : 'Retry'}
</button>
</span>
),
{ duration: 6000 }
);
}
} finally {
clearTimeout(timer);
controllers.current.delete(req.id);
if (!unmounted.current) {
setPending((prev) => {
const next = new Set(prev);
next.delete(req.id);
return next;
});
}
}
},
[es]
);
useEffect(() => {
startRef.current = start;
}, [start]);
const isPending = useCallback((id: string) => pending.has(id), [pending]);
return { start, isPending };
}
+16 -2
View File
@@ -7,10 +7,24 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
/** Label shown in place of the children while loading. */
loadingText?: string;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
(
{
className,
variant = 'primary',
size = 'md',
isLoading,
loadingText = 'Loading...',
children,
disabled,
...props
},
ref
) => {
return (
<button
ref={ref}
@@ -62,7 +76,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
{loadingText}
</>
) : (
children
+12 -2
View File
@@ -9,11 +9,21 @@ interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
export function Skeleton({
className,
tone = 'default',
}: SkeletonProps & {
/** `on-dark` lightens the surface for placeholders over a dark hero. */
tone?: 'default' | 'on-dark';
}) {
return (
<div
aria-hidden="true"
className={clsx('animate-pulse rounded-lg bg-secondary-light-gray/70', className)}
className={clsx(
'animate-pulse motion-reduce:animate-none rounded-lg',
tone === 'on-dark' ? 'bg-white/20' : 'bg-secondary-light-gray/70',
className
)}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import clsx from 'clsx';
// Inline busy indicator in the same style as the full-page loaders
// (animate-spin ring with one contrasting edge). Purely decorative: callers
// own the accessible state via aria-busy / aria-label. Defaults to the
// white-on-dark ring used by the photo overlay controls; pass `border-*`
// classes to restyle it elsewhere.
export default function Spinner({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={clsx(
'inline-block rounded-full border-2 animate-spin border-white/40 border-t-white',
className || 'w-4 h-4'
)}
/>
);
}
+104 -142
View File
@@ -1,8 +1,8 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
import { authClient } from '@/lib/auth-client';
import { fetchApi } from '@/lib/api/client';
interface User {
id: string;
@@ -18,18 +18,19 @@ interface User {
interface AuthContextType {
user: User | null;
/** Always null: auth moved to httpOnly session cookies (Better Auth). Kept for compat. */
token: string | null;
isLoading: boolean;
isAdmin: boolean;
hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<User | null>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
updateUser: (user: User) => void;
setAuthData: (data: { user: User; token: string }) => void;
refreshUser: () => Promise<void>;
setAuthData: (data: { user: User; token?: string }) => void;
refreshUser: () => Promise<User | null>;
}
interface RegisterData {
@@ -42,178 +43,139 @@ interface RegisterData {
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'spanglish-token';
const USER_KEY = 'spanglish-user';
const AUTH_COOKIE = 'spanglish-auth';
// Legacy storage from the pre-Better-Auth JWT era; cleared once on mount.
const LEGACY_TOKEN_KEY = 'spanglish-token';
const LEGACY_USER_KEY = 'spanglish-user';
const LEGACY_AUTH_COOKIE = 'spanglish-auth';
function setAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`;
function mapSessionUser(sessionUser: any): User {
return {
id: sessionUser.id,
email: sessionUser.email,
name: sessionUser.name,
role: sessionUser.role ?? 'user',
phone: sessionUser.phone ?? undefined,
languagePreference: sessionUser.languagePreference ?? undefined,
isClaimed: Boolean(sessionUser.isClaimed ?? true),
rucNumber: sessionUser.rucNumber ?? undefined,
accountStatus: sessionUser.accountStatus ?? 'active',
};
}
function clearAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
function messageFrom(error: { message?: string; code?: string; status?: number } | null, fallback: string): string {
return error?.message || fallback;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async () => {
const currentToken = localStorage.getItem(TOKEN_KEY);
if (!currentToken) return;
const refreshUser = useCallback(async (): Promise<User | null> => {
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: {
'Authorization': `Bearer ${currentToken}`,
'Content-Type': 'application/json',
},
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
} else if (res.status === 401) {
// Token is invalid, clear auth state
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
const { data } = await authClient.getSession();
if (data?.user) {
const mapped = mapSessionUser(data.user);
setUser(mapped);
return mapped;
}
setUser(null);
return null;
} catch (error) {
// Network error, keep using cached data
// Network error: keep current state
console.error('Failed to refresh user data:', error);
return null;
}
}, []);
useEffect(() => {
// Load auth state from localStorage
const savedToken = localStorage.getItem(TOKEN_KEY);
const savedUser = localStorage.getItem(USER_KEY);
if (savedToken && savedUser) {
// Guard against corrupt/tampered localStorage so the whole app doesn't crash.
let parsedUser: User | null = null;
try {
parsedUser = JSON.parse(savedUser);
} catch {
parsedUser = null;
}
if (parsedUser) {
setToken(savedToken);
setUser(parsedUser);
// Refresh user data from server to get latest role/permissions (source of truth)
refreshUser().finally(() => setIsLoading(false));
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setIsLoading(false);
}
} else {
setIsLoading(false);
// One-time cleanup of legacy JWT-era storage
try {
localStorage.removeItem(LEGACY_TOKEN_KEY);
localStorage.removeItem(LEGACY_USER_KEY);
document.cookie = `${LEGACY_AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
} catch {
/* SSR / storage unavailable */
}
refreshUser().finally(() => setIsLoading(false));
}, [refreshUser]);
const setAuthData = useCallback((data: { user: User; token: string }) => {
setToken(data.token);
setUser(data.user);
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
}, []);
const login = async (email: string, password: string) => {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Login failed');
const { error } = await authClient.signIn.email({ email, password });
if (error) {
throw new Error(messageFrom(error, 'Login failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const loginWithGoogle = async (credential: string) => {
const res = await fetch(`${API_BASE}/api/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
const { error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Google login failed');
if (error) {
throw new Error(messageFrom(error, 'Google login failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const loginWithMagicLink = async (magicToken: string) => {
const res = await fetch(`${API_BASE}/api/auth/magic-link/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: magicToken }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Magic link login failed');
const loginWithMagicLink = async (magicToken: string): Promise<User | null> => {
const { error } = await authClient.magicLink.verify({ query: { token: magicToken } });
if (error) {
throw new Error(messageFrom(error, 'Invalid or expired link'));
}
const data = await res.json();
setAuthData(data);
return refreshUser();
};
const register = async (registerData: RegisterData) => {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registerData),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Registration failed');
const { error } = await authClient.signUp.email({
email: registerData.email,
password: registerData.password,
name: registerData.name,
phone: registerData.phone || undefined,
languagePreference: registerData.languagePreference || undefined,
} as any);
if (error) {
// Existing-but-unclaimed accounts (created during guest booking) should
// point the user to the claim flow instead of a bare "already exists".
if (error.code === 'USER_ALREADY_EXISTS') {
try {
const { canClaim } = await fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(registerData.email)}`
);
if (canClaim) {
const err = new Error(
'This email has an unclaimed account from a previous booking. Use "Email Link" on the login page to claim it.'
);
(err as any).canClaim = true;
throw err;
}
} catch (e: any) {
if (e?.canClaim) throw e;
/* eligibility check failed: fall through to generic message */
}
}
throw new Error(messageFrom(error, 'Registration failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const logout = useCallback(() => {
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused).
const currentToken = localStorage.getItem(TOKEN_KEY);
if (currentToken) {
fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentToken}` },
}).catch(() => {
// Ignore network errors; local state is cleared regardless.
});
}
setToken(null);
// Best-effort server-side revocation; local state clears regardless.
authClient.signOut().catch(() => {
/* ignore network errors */
});
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
}, []);
const updateUser = useCallback((updatedUser: User) => {
setUser(updatedUser);
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
}, []);
// Compat shim for callers that used to push {user, token} after custom auth
// flows; the session cookie is already set by then, so only state updates.
const setAuthData = useCallback((data: { user: User; token?: string }) => {
setUser(data.user);
}, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
@@ -221,16 +183,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isAdmin,
value={{
user,
token: null,
isLoading,
isAdmin,
hasAdminAccess,
login,
login,
loginWithGoogle,
loginWithMagicLink,
register,
register,
logout,
updateUser,
setAuthData,
+18
View File
@@ -235,6 +235,9 @@
"email": "Email",
"password": "Password",
"submit": "Sign In",
"redirecting": "Redirecting...",
"redirectBlocked": "You're signed in, but we couldn't open that page.",
"redirectRetry": "Try again",
"noAccount": "Don't have an account?",
"register": "Sign Up"
},
@@ -364,5 +367,20 @@
"title": "TikTok",
"subtitle": "Videos & fun content"
}
},
"gallery": {
"save": {
"sheetTitle": "Save photo",
"photo": "Save photo",
"photoHint": "Best for phone",
"original": "Download original",
"originalHint": "Full quality",
"cancel": "Cancel",
"preparing": "Preparing…",
"ready": "Ready — tap to save",
"failed": "Could not prepare it. Tap to retry.",
"download": "Download",
"downloading": "Downloading…"
}
}
}
+18
View File
@@ -235,6 +235,9 @@
"email": "Email",
"password": "Contraseña",
"submit": "Iniciar Sesión",
"redirecting": "Redirigiendo...",
"redirectBlocked": "Has iniciado sesión, pero no pudimos abrir esa página.",
"redirectRetry": "Intentar de nuevo",
"noAccount": "¿No tienes cuenta?",
"register": "Registrarse"
},
@@ -364,5 +367,20 @@
"title": "TikTok",
"subtitle": "Videos y contenido divertido"
}
},
"gallery": {
"save": {
"sheetTitle": "Guardar foto",
"photo": "Guardar foto",
"photoHint": "Ideal para el teléfono",
"original": "Descargar original",
"originalHint": "Calidad completa",
"cancel": "Cancelar",
"preparing": "Preparando…",
"ready": "Lista: toca para guardar",
"failed": "No se pudo preparar. Toca para reintentar.",
"download": "Descargar",
"downloading": "Descargando…"
}
}
}
+79 -45
View File
@@ -1,63 +1,97 @@
import { authClient } from '../auth-client';
import { fetchApi } from './client';
import type { User } from './types';
// Thin wrappers over the Better Auth client, preserving the legacy authApi
// call surface used by the auth pages.
type ClientError = { message?: string; code?: string; status: number } | null;
function throwIfError(error: ClientError, fallback: string): void {
if (error) {
const err = new Error(error.message || fallback);
(err as any).code = error.code;
(err as any).status = error.status;
throw err;
}
}
export const authApi = {
// Magic link
requestMagicLink: (email: string) =>
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
// Magic link login. Enumeration-safe UX parity: unknown emails resolve with
// the same generic message (magic links never create accounts server-side);
// only rate limiting surfaces as an error.
requestMagicLink: async (email: string, callbackURL: string = '/dashboard') => {
const { error } = await authClient.signIn.magicLink({ email, callbackURL });
if (error && error.status === 429) {
throwIfError(error, 'Too many requests. Please try again later.');
}
return { message: 'If an account exists with this email, a login link has been sent.' };
},
verifyMagicLink: (token: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
method: 'POST',
body: JSON.stringify({ token }),
}),
verifyMagicLink: async (token: string) => {
const { data, error } = await authClient.magicLink.verify({ query: { token } });
throwIfError(error, 'Invalid or expired token');
return data;
},
// Password reset
requestPasswordReset: (email: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
// Password reset (Better Auth is enumeration-safe here by default)
requestPasswordReset: async (email: string) => {
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: '/auth/reset-password',
});
throwIfError(error, 'Failed to request password reset');
return { message: 'If an account exists with this email, a password reset link has been sent.' };
},
confirmPasswordReset: (token: string, password: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
method: 'POST',
body: JSON.stringify({ token, password }),
}),
confirmPasswordReset: async (token: string, password: string) => {
const { error } = await authClient.resetPassword({ newPassword: password, token });
throwIfError(error, 'Invalid or expired token');
return { message: 'Password reset successfully. Please log in with your new password.' };
},
// Account claiming
// Account claiming: a magic link that lands on the claim page, where the
// session-holding user sets a password via /api/auth-ext/claim-account.
requestClaimAccount: (email: string) =>
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
authApi.requestMagicLink(email, '/auth/claim-account'),
confirmClaimAccount: (password: string) =>
fetchApi<{ user: User; message: string }>('/api/auth-ext/claim-account', {
method: 'POST',
body: JSON.stringify({ email }),
body: JSON.stringify({ password }),
}),
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
'/api/auth/claim-account/confirm',
{
method: 'POST',
body: JSON.stringify({ token, ...data }),
}
claimEligibility: (email: string) =>
fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(email)}`
),
// Google OAuth
googleAuth: (credential: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
method: 'POST',
body: JSON.stringify({ credential }),
}),
// Google Identity Services credential (ID token) sign-in
googleAuth: async (credential: string) => {
const { data, error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
});
throwIfError(error, 'Google login failed');
return data;
},
// Change password
changePassword: (currentPassword: string, newPassword: string) =>
fetchApi<{ message: string }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
}),
// Change password; other sessions are revoked so a stolen session can't
// outlive the change (this device stays signed in).
changePassword: async (currentPassword: string, newPassword: string) => {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
});
throwIfError(error, 'Failed to change password');
return { message: 'Password changed successfully' };
},
// Get current user
me: () => fetchApi<{ user: User }>('/api/auth/me'),
me: async (): Promise<{ user: User | null }> => {
const { data, error } = await authClient.getSession();
throwIfError(error, 'Failed to load session');
return { user: (data?.user as unknown as User) ?? null };
},
};
+7 -16
View File
@@ -1,30 +1,25 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
// Auth rides on the Better Auth httpOnly session cookie. With API_BASE unset
// (same-origin via Next rewrites) 'same-origin' sends it; a cross-origin
// API_BASE needs 'include' plus CORS credentials on the backend.
const CREDENTIALS: RequestCredentials = API_BASE ? 'include' : 'same-origin';
export interface ApiError {
error: string;
}
/** Read the stored auth token (browser only). */
export function getToken(): string | null {
return typeof window !== 'undefined' ? localStorage.getItem('spanglish-token') : null;
}
export async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${endpoint}`, {
credentials: CREDENTIALS,
...options,
headers,
});
@@ -53,11 +48,7 @@ export async function fetchBlob(
endpoint: string,
fallbackFilename: string
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${endpoint}`, { headers });
const res = await fetch(`${API_BASE}${endpoint}`, { credentials: CREDENTIALS });
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed');
+3 -4
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE, getToken } from './client';
import { fetchApi, API_BASE } from './client';
import type { Media } from './types';
export const mediaApi = {
@@ -11,16 +11,15 @@ export const mediaApi = {
},
upload: async (file: File, relatedId?: string, relatedType?: string) => {
const token = getToken();
const formData = new FormData();
formData.append('file', file);
if (relatedId) formData.append('relatedId', relatedId);
if (relatedType) formData.append('relatedType', relatedType);
// Auth rides on the session cookie
const res = await fetch(`${API_BASE}/api/media/upload`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
credentials: API_BASE ? 'include' : 'same-origin',
body: formData,
});
+20 -5
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE, getToken } from './client';
import { fetchApi, API_BASE } from './client';
// Client for the standalone photo-api Go service (photo-api/), reachable
// under /api/photos via the Next rewrite (dev) or nginx (prod).
@@ -41,6 +41,8 @@ export interface Photo {
originalFilename?: string;
contentType: string;
sizeBytes: number;
/** Size of the preview variant; absent until the server has measured it. */
previewSizeBytes?: number;
width?: number;
height?: number;
takenAt?: string;
@@ -51,7 +53,19 @@ export interface Photo {
thumb?: string;
preview?: string;
original: string;
/**
* Download endpoints. Unlike `preview`/`original` these always stream
* same-origin instead of redirecting to S3, so fetch() can read them
* into a Blob (needed by the mobile share sheet) without CORS.
* `download` serves the preview and is what the lightbox renders, so
* saving it hits the HTTP cache instead of the network.
*/
download?: string;
downloadOriginal: string;
};
// Upload responses only: these bytes were already in the gallery, so nothing
// was stored and the rest of this object describes the existing photo.
duplicate?: boolean;
}
export interface CreateGalleryInput {
@@ -94,13 +108,13 @@ export const photosApi = {
}),
uploadPhoto: async (galleryId: string, file: File) => {
const token = getToken();
const formData = new FormData();
formData.append('files', file);
// Auth rides on the session cookie (sent same-origin automatically)
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: API_BASE ? 'include' : 'same-origin',
body: formData,
});
if (!res.ok) {
@@ -122,8 +136,9 @@ export const photosApi = {
new Promise<{ photos: Photo[] }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
// Auth rides on the session cookie; XHR sends same-origin cookies by
// default, withCredentials is only needed for a cross-origin API_BASE.
if (API_BASE) xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
};
+3
View File
@@ -439,6 +439,9 @@ export interface UserSession {
ipAddress?: string;
lastActiveAt: string;
createdAt: string;
expiresAt?: string;
/** True for the session backing the current request. */
current?: boolean;
}
export interface DashboardSummary {
+22
View File
@@ -0,0 +1,22 @@
import { createAuthClient } from 'better-auth/react';
import { magicLinkClient, adminClient, inferAdditionalFields } from 'better-auth/client/plugins';
// Better Auth client. Sessions are httpOnly cookies set by the backend; with
// NEXT_PUBLIC_API_URL unset everything is same-origin through the Next.js
// rewrites (see next.config.js), so cookies flow automatically.
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || '',
plugins: [
magicLinkClient(),
adminClient(),
inferAdditionalFields({
user: {
phone: { type: 'string', required: false },
languagePreference: { type: 'string', required: false },
rucNumber: { type: 'string', required: false },
isClaimed: { type: 'boolean', required: false },
accountStatus: { type: 'string', required: false },
},
}),
],
});
+56
View File
@@ -0,0 +1,56 @@
/**
* Post-authentication navigation.
*
* Navigation uses a full page load rather than `router.push`. The session is an httpOnly
* cookie, and only a top-level request lets the middleware guard in `middleware.ts` see it
* and re-render the header and the destination together.
*
* A marker in sessionStorage records where we last sent someone, and when. If the
* middleware cannot see the session cookie it redirects straight back to `/login`, and
* without the marker the login page would immediately navigate again an endless reload
* loop. A React ref cannot do this job: a full page load resets component state.
*/
const ATTEMPT_KEY = 'spanglish.auth-redirect-attempt';
// A real bounce comes back within a few hundred milliseconds (it is a server redirect).
// An older marker means an unrelated later visit to /login, not a failed redirect.
const ATTEMPT_TTL_MS = 30_000;
export function markRedirectAttempt(target: string): void {
try {
sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify({ target, at: Date.now() }));
} catch {
/* storage disabled (private mode): the loop guard degrades, navigation still works */
}
}
export function clearRedirectAttempt(): void {
try {
sessionStorage.removeItem(ATTEMPT_KEY);
} catch {
/* ignore */
}
}
/** True when we just sent the user to `target` and are back on the login page instead. */
export function didRedirectBounce(target: string): boolean {
try {
const raw = sessionStorage.getItem(ATTEMPT_KEY);
if (!raw) return false;
const { target: attempted, at } = JSON.parse(raw) as { target?: string; at?: number };
return (
attempted === target && typeof at === 'number' && Date.now() - at < ATTEMPT_TTL_MS
);
} catch {
return false;
}
}
/**
* Records the attempt, then navigates. Callers should keep their pending/loading UI on
* screen: this never resolves, the page is replaced.
*/
export function redirectAfterAuth(target: string): void {
markRedirectAttempt(target);
window.location.assign(target);
}
+24 -7
View File
@@ -3,18 +3,35 @@ import { NextRequest, NextResponse } from 'next/server';
/**
* Defense-in-depth guard for authenticated areas.
*
* Auth tokens live in localStorage (not readable here), so we rely on a lightweight
* `spanglish-auth` cookie set alongside login. The API remains the authoritative gate;
* this only keeps unauthenticated visitors from loading the admin/dashboard JS shell.
* Auth is a Better Auth httpOnly session cookie, which IS visible to this
* server-side middleware (unlike client JS). The API remains the authoritative
* gate cookie presence is not validated here; this only keeps clearly
* unauthenticated visitors from loading the admin/dashboard JS shell.
*/
const SESSION_COOKIES = [
'__Secure-spanglish.session_token', // production (useSecureCookies)
'spanglish.session_token', // development
];
// Matched as a fallback so a change to Better Auth's `advanced.cookiePrefix` cannot
// silently lock every user out of these routes.
const SESSION_COOKIE_SUFFIX = '.session_token';
function hasSessionCookie(request: NextRequest): boolean {
if (SESSION_COOKIES.some((name) => !!request.cookies.get(name)?.value)) return true;
return request.cookies
.getAll()
.some((cookie) => cookie.name.endsWith(SESSION_COOKIE_SUFFIX) && !!cookie.value);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const { pathname, search } = request.nextUrl;
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
const hasAuthCookie = request.cookies.get('spanglish-auth')?.value === '1';
if (!hasAuthCookie) {
if (!hasSessionCookie(request)) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
// Keep the query string, so a bounced /admin/photos?page=3 resumes where it was.
loginUrl.searchParams.set('redirect', `${pathname}${search}`);
return NextResponse.redirect(loginUrl);
}
}
+12
View File
@@ -37,6 +37,18 @@ module.exports = {
'card': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'card-hover': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
},
// Entrance for the mobile save sheet (components/gallery/SaveSheet).
keyframes: {
'sheet-fade': { from: { opacity: '0' }, to: { opacity: '1' } },
'sheet-rise': {
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
},
},
animation: {
'sheet-fade': 'sheet-fade 150ms ease-out',
'sheet-rise': 'sheet-rise 200ms ease-out',
},
},
},
plugins: [],
+12
View File
@@ -14,6 +14,10 @@
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
"test:photos": "cd photo-api && go test ./...",
"migrate:photos": "cd photo-api && go run ./cmd/photo-api migrate",
"sync:photos": "cd photo-api && go run ./cmd/photo-api sync",
"sync:photos:to-s3": "cd photo-api && go run ./cmd/photo-api sync to-s3",
"sync:photos:to-local": "cd photo-api && go run ./cmd/photo-api sync to-local",
"backfill:photos:checksums": "cd photo-api && go run ./cmd/photo-api backfill-checksums",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"start:backend": "npm run start --workspace=backend",
"start:frontend": "npm run start --workspace=frontend",
@@ -32,5 +36,13 @@
},
"dependencies": {
"next": "^16.2.10"
},
"allowScripts": {
"esbuild@0.18.20": true,
"esbuild@0.25.12": true,
"esbuild@0.27.2": true,
"better-sqlite3@12.11.1": true,
"sharp@0.34.5": true,
"argon2@0.44.0": true
}
}
+20 -5
View File
@@ -16,17 +16,32 @@ DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db
# 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.
# Secret for gallery image view tokens (HMAC). Any strong random value; does
# not need to match any backend secret. Rotating it invalidates outstanding
# image URLs for at most ~1 hour (they are re-minted per page load).
PHOTO_VIEW_SECRET=8b69468724b730dddecba3d04717cf44e6dc5b808773e7e60156d38875f0f6cd
# DEPRECATED: fallback for PHOTO_VIEW_SECRET during the Better Auth migration
# (user auth now validates Better Auth sessions from the shared database, not
# JWTs). Set PHOTO_VIEW_SECRET and remove this.
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.
# Photo storage. Which backend serves requests:
# auto (default) S3 when S3_ENDPOINT + S3_BUCKET are set, local otherwise
# — the backend's convention (backend/src/lib/storage.ts)
# local always local disk, even with the S3 settings filled in
# s3 always S3
# Keep both sides filled in and flip this one line to switch backends. Both
# are also required by the library sync (`npm run sync:photos:to-s3` /
# `:to-local`), which copies every photo from one backend to the other.
STORAGE_BACKEND=auto
STORAGE_PATH=./data/photos
# Use a photos-specific bucket — do not reuse the backend's media bucket.
#S3_ENDPOINT=
#S3_REGION=auto
#S3_BUCKET=spanglish-photos
+11 -2
View File
@@ -1,11 +1,11 @@
.PHONY: start build test migrate clean help
.PHONY: start build test migrate sync-to-s3 sync-to-local backfill-checksums 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}'
awk 'BEGIN {FS = ":.*?## "}; {printf " %-19s %s\n", $$1, $$2}'
start: ## Run the photo-api server (go run)
go run $(CMD)
@@ -19,5 +19,14 @@ test: ## Run all Go tests
migrate: ## Apply pending photos_* migrations
go run $(CMD) migrate
sync-to-s3: ## Copy the photo library from local disk to S3 (needs both in .env)
go run $(CMD) sync to-s3
sync-to-local: ## Copy the photo library from S3 back to local disk
go run $(CMD) sync to-local
backfill-checksums: ## Hash photos uploaded before duplicate detection existed
go run $(CMD) backfill-checksums
clean: ## Remove built binary
rm -rf bin
+1 -1
View File
@@ -131,7 +131,7 @@ All routes under `/api/photos`. Auth = `Authorization: Bearer <existing JWT>`. "
| `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: [...] }` |
| `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. Hashes the bytes into `checksum`: if the gallery already holds them, nothing is stored and the existing photo is echoed with `duplicate: true` (the batch continues, no position consumed). | `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 }` |
+81 -4
View File
@@ -14,9 +14,13 @@ Design/decisions: [PLAN.md](./PLAN.md).
- 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.
- Uploads are deduplicated per gallery: see
[Duplicate detection](#duplicate-detection).
- 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.
+ `S3_BUCKET`), same selection convention as the backend, overridable with
`STORAGE_BACKEND`. S3 downloads use short-lived presigned URLs; nothing in
the bucket is public. Switching backends later: see
[Move the library between backends](#move-the-library-between-backends).
- 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
@@ -37,7 +41,8 @@ go test ./... # SQLite; add PHOTO_TEST_PG=<url> to also run on Post
```
From the repo root: `npm run dev:photos`, `npm run build:photos`,
`npm run test:photos`. The Next dev server rewrites `/api/photos/*` to
`npm run test:photos`, `npm run migrate:photos`, `npm run sync:photos`,
`npm run backfill:photos:checksums`. The Next dev server rewrites `/api/photos/*` to
`PHOTO_API_URL` (default `http://localhost:3003`), so the frontend needs no
extra config in dev.
@@ -45,6 +50,78 @@ 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.
## Move the library between backends
`photo-api sync` copies the whole photo library one way between local disk and
S3, so local↔S3 is a config switch rather than a migration project. **Both
backends must be configured in `photo-api/.env`** (`STORAGE_PATH` *and* the
`S3_*` values); `STORAGE_BACKEND` decides which one actually serves requests,
so filling in S3 does not switch anything by itself.
Local disk → S3:
```bash
npm run sync:photos -- to-s3 --dry-run # see what would be copied
npm run sync:photos:to-s3 # copy it
# then set STORAGE_BACKEND=s3 in photo-api/.env and restart the service
```
S3 → local disk is the same with `to-local` / `STORAGE_BACKEND=local`
(`npm run sync:photos:to-local`). From `photo-api/`: `make sync-to-s3`,
`make sync-to-local`.
Flags (`npm run sync:photos -- to-s3 --overwrite`, or after the direction on
the direct scripts): `--dry-run`, `--overwrite` (re-copy objects already
present with the same size), `--concurrency=N` (default 4), `--gallery=<id>`.
How it behaves:
- The `photos_photos` rows are the inventory — for each photo the original
plus, once processed, the thumb and preview. Objects with no row (worker
scratch files, leftovers of deleted galleries) are not copied.
- Only the destination is written. The source stays as a fallback; delete it
by hand once the switch is verified.
- Reruns are cheap and safe: objects already on the destination with the same
size are skipped, so an interrupted or partly failed sync just needs
rerunning. A failed object is logged and the exit code is non-zero.
- Keys are identical on both backends, so nothing in the database changes and
no re-processing is triggered.
- Photos uploaded *after* the copy but *before* the restart land on the old
backend. For a clean cutover, stop the service, sync, flip
`STORAGE_BACKEND`, start again — or sync a second time after the switch to
pick up stragglers.
## Duplicate detection
Every upload is hashed (sha256 of the original bytes) into
`photos_photos.checksum`, unique per `(gallery_id, checksum)`. If a gallery
already holds those exact bytes, the incoming copy is **discarded**: no object
is stored, no row is inserted, and the upload response echoes the existing
photo with `"duplicate": true`. The rest of the batch continues normally — a
duplicate is not an error and does not consume a position.
Scope is one gallery. The same image can still live in several galleries, each
with its own row and its own stored object, so deleting a gallery never orphans
another one's photos.
The admin uploader panel shows those rows as *"Already in this gallery"*; no
second tile appears in the grid.
Photos uploaded before this existed have no checksum, so they are not matched
until hashed once:
```bash
npm run backfill:photos:checksums -- --dry-run # what would be hashed
npm run backfill:photos:checksums # hash it
```
From `photo-api/`: `make backfill-checksums`. Flags: `--dry-run`,
`--concurrency=N` (default 4), `--gallery=<id>`. It reads from the active
`STORAGE_BACKEND`, only ever writes the checksum column, and is idempotent —
rerun it after a sync or a restore. Photos whose content already matches an
earlier one in the same gallery are **reported and left unhashed**; the command
never deletes anything, so removing the extras is an admin's call.
## API
Everything under `/api/photos`. Errors are `{"error": string}`.
@@ -58,7 +135,7 @@ Admin (Bearer token, role admin/organizer):
| 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`) |
| POST | `/api/photos/galleries/:id/photos` | multipart upload (`files`), deduplicated |
| 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 |
+136 -16
View File
@@ -1,12 +1,15 @@
// 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
// photo-api start the HTTP server
// photo-api migrate apply pending photos_* migrations and exit
// photo-api sync to-s3|to-local copy the photo library between backends
// photo-api backfill-checksums hash photos uploaded before duplicate detection
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
@@ -16,9 +19,11 @@ import (
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/checksum"
"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/photosync"
"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"
@@ -38,15 +43,21 @@ func main() {
}
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])
switch os.Args[1] {
case "migrate":
if err := db.Migrate(context.Background()); err != nil {
log.Fatalf("migrate: %v", err)
}
log.Println("migrations up to date")
case "sync":
runSync(cfg, db, os.Args[2:])
case "backfill-checksums":
runBackfillChecksums(cfg, db, os.Args[2:])
default:
log.Fatalf("unknown subcommand %q (expected: migrate, sync, backfill-checksums)", os.Args[1])
}
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -64,11 +75,7 @@ func main() {
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)
}
log.Printf("storage: %s (STORAGE_BACKEND=%s)", storage.Name(cfg, st), cfg.StorageBackend)
heic := imaging.DetectHeicConverter(cfg.HeicConverter)
if heic == nil {
@@ -85,7 +92,7 @@ func main() {
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(),
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(db), wrk).Handler(),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
@@ -104,3 +111,116 @@ func main() {
log.Printf("shutdown: %v", err)
}
}
const syncUsage = `usage: photo-api sync to-s3|to-local [flags]
Copies every object of every photo (original, thumb, preview) from one storage
backend to the other. Both must be configured in photo-api/.env; the source is
never modified, and reruns skip objects already present on the destination.
Flags:
`
// runSync handles `photo-api sync <direction> [flags]`, the storage migration
// used when moving the library between local disk and S3.
func runSync(cfg config.Config, db *store.DB, args []string) {
fs := flag.NewFlagSet("sync", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprint(fs.Output(), syncUsage)
fs.PrintDefaults()
}
var (
dryRun = fs.Bool("dry-run", false, "report what would be copied without writing")
overwrite = fs.Bool("overwrite", false, "re-copy objects already present with the same size")
workers = fs.Int("concurrency", 4, "objects copied in parallel")
gallery = fs.String("gallery", "", "limit to one gallery id (default: whole library)")
)
if len(args) == 0 {
fs.Usage()
os.Exit(2)
}
direction, err := photosync.ParseDirection(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n\n", err)
fs.Usage()
os.Exit(2)
}
if err := fs.Parse(args[1:]); err != nil {
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if _, err := photosync.Run(ctx, cfg, db, photosync.Options{
Direction: direction,
GalleryID: *gallery,
Concurrency: *workers,
DryRun: *dryRun,
Overwrite: *overwrite,
}); err != nil {
log.Fatalf("sync: %v", err)
}
if *dryRun {
return
}
target := "s3"
if direction == photosync.ToLocal {
target = "local"
}
log.Printf("sync: set STORAGE_BACKEND=%s in photo-api/.env and restart the service to serve from it "+
"(the source copy is left in place — delete it once the switch is verified)", target)
}
const backfillUsage = `usage: photo-api backfill-checksums [flags]
Hashes the stored original of every photo that has no checksum yet, so uploads
of a photo already in a gallery are recognised as duplicates. Runs against the
active storage backend (STORAGE_BACKEND) and only ever writes the checksum
column no photo is deleted. Photos whose content already matches an earlier
one in the same gallery are reported and left unhashed.
Flags:
`
// runBackfillChecksums handles `photo-api backfill-checksums [flags]`, the
// one-off pass needed after the checksum column is added to an existing
// library.
func runBackfillChecksums(cfg config.Config, db *store.DB, args []string) {
fs := flag.NewFlagSet("backfill-checksums", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprint(fs.Output(), backfillUsage)
fs.PrintDefaults()
}
var (
dryRun = fs.Bool("dry-run", false, "report what would be hashed without writing")
workers = fs.Int("concurrency", 4, "originals hashed in parallel")
gallery = fs.String("gallery", "", "limit to one gallery id (default: whole library)")
)
if err := fs.Parse(args); err != nil {
os.Exit(2)
}
st, err := storage.New(cfg)
if err != nil {
log.Fatalf("storage: %v", err)
}
log.Printf("backfill-checksums: reading from %s", storage.Name(cfg, st))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
res, err := checksum.Backfill(ctx, db, st, checksum.Options{
GalleryID: *gallery,
Concurrency: *workers,
DryRun: *dryRun,
})
if err != nil {
log.Fatalf("backfill-checksums: %v", err)
}
if res.Duplicates > 0 {
log.Printf("backfill-checksums: %d existing photos duplicate an earlier one in their gallery "+
"(listed above); they still show in the gallery — delete the unwanted ones from the admin page",
res.Duplicates)
}
}
-1
View File
@@ -7,7 +7,6 @@ require (
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
-2
View File
@@ -39,8 +39,6 @@ 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=
+57 -49
View File
@@ -1,27 +1,31 @@
// 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 validates the Better Auth sessions issued by the backend.
// The session cookie value is "<token>.<signature>" (HMAC-signed with the
// backend's BETTER_AUTH_SECRET); this service resolves the token part against
// the shared auth_sessions table, which is the authoritative validity check
// (the token carries 32+ characters of entropy, and a DB hit is required
// anyway for expiry/ban/role, so revocations apply here instantly).
package auth
import (
"errors"
"net/http"
"net/url"
"strings"
"github.com/golang-jwt/jwt/v5"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
const (
issuer = "spanglish"
audience = "spanglish-app"
)
// Cookie names set by the backend: advanced.cookiePrefix "spanglish", with
// the __Secure- prefix added in production (advanced.useSecureCookies).
var sessionCookieNames = []string{
"__Secure-spanglish.session_token",
"spanglish.session_token",
}
var (
ErrNoToken = errors.New("no bearer token")
ErrInvalidToken = errors.New("invalid token")
ErrNoToken = errors.New("no session token")
ErrInvalidToken = errors.New("invalid session")
)
// AdminRoles mirrors backend/src/routes/media.ts: photo administration is
@@ -43,57 +47,61 @@ func (u User) IsAdmin() bool {
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
db *store.DB
}
func NewVerifier(secret string, db *store.DB) *Verifier {
return &Verifier{secret: []byte(secret), db: db}
func NewVerifier(db *store.DB) *Verifier {
return &Verifier{db: db}
}
// FromRequest returns the authenticated user, ErrNoToken when no
// Authorization header is present, or ErrInvalidToken for anything bad.
// FromRequest returns the authenticated user, ErrNoToken when no session
// cookie or bearer token is present, or ErrInvalidToken for anything bad.
func (v *Verifier) FromRequest(r *http.Request) (User, error) {
header := r.Header.Get("Authorization")
if header == "" {
token := sessionTokenFromRequest(r)
if token == "" {
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)
su, err := v.db.GetSessionUser(r.Context(), token)
if err != nil {
return User{}, ErrInvalidToken
}
if ua.AccountStatus != "active" {
if !su.ExpiresAt.After(time.Now()) {
return User{}, ErrInvalidToken
}
if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion {
// Same semantics as backend getAuthUser: suspended (banned) or unclaimed
// accounts must not retain API access via a live session.
if su.Banned || su.AccountStatus != "active" {
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
// Role comes from the users row, so demotions apply immediately.
return User{ID: su.ID, Email: su.Email, Role: su.Role}, nil
}
// sessionTokenFromRequest extracts the raw session token from the Better Auth
// cookie, or from an Authorization: Bearer header (tests and CLI tooling).
func sessionTokenFromRequest(r *http.Request) string {
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil && c.Value != "" {
return tokenPart(c.Value)
}
}
if raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && raw != "" {
return tokenPart(raw)
}
return ""
}
// tokenPart strips the URL-encoding and HMAC signature from a cookie value:
// "token.base64sig" -> "token". The token itself is alphanumeric, so the
// first '.' always separates it from the signature.
func tokenPart(v string) string {
if dec, err := url.QueryUnescape(v); err == nil {
v = dec
}
if i := strings.IndexByte(v, '.'); i >= 0 {
return v[:i]
}
return v
}
+145
View File
@@ -0,0 +1,145 @@
package checksum
import (
"context"
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// Options configures a backfill run.
type Options struct {
// GalleryID limits the run to one gallery; empty means the whole library.
GalleryID string
// Concurrency is how many originals are hashed at a time.
Concurrency int
// DryRun reports what would be hashed without writing to the database.
DryRun bool
}
// Result counts what a run did. Duplicates are rows whose bytes match an
// earlier photo in the same gallery; they keep a NULL checksum and are listed
// so an admin can decide what to do with them.
type Result struct {
Total int // rows without a checksum
Hashed int // hashed and recorded (or, with DryRun, would be)
Duplicates int
Missing int // original object absent from storage
Failed int
}
// Backfill hashes the stored original of every photo that has no checksum yet,
// so duplicate detection also catches re-uploads of photos from before the
// checksum column existed. It only ever writes the checksum column; no photo,
// row or object is deleted.
//
// It is idempotent: rerunning it finds only what the previous run left behind.
func Backfill(ctx context.Context, db *store.DB, st storage.Storage, opts Options) (Result, error) {
if opts.Concurrency < 1 {
opts.Concurrency = 4
}
photos, err := db.PhotosMissingChecksum(ctx, opts.GalleryID)
if err != nil {
return Result{}, fmt.Errorf("list photos without a checksum: %w", err)
}
prefix := ""
if opts.DryRun {
prefix = "[dry-run] "
}
log.Printf("backfill-checksums: %s%d photos to hash, concurrency %d", prefix, len(photos), opts.Concurrency)
var (
mu sync.Mutex
res = Result{Total: len(photos)}
done int64
jobs = make(chan store.Photo)
wg sync.WaitGroup
)
// Sequential hashing per worker, but the database update is what can
// collide: two photos of the same gallery with identical bytes race, and
// the unique index decides which one keeps the checksum.
for i := 0; i < opts.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range jobs {
if ctx.Err() != nil {
return
}
sum, err := hashOriginal(ctx, st, p)
if err == nil && !opts.DryRun {
err = db.SetPhotoChecksum(ctx, p.ID, sum)
}
n := atomic.AddInt64(&done, 1)
mu.Lock()
switch {
case errors.Is(err, storage.ErrNotExist):
res.Missing++
log.Printf("backfill-checksums: [%d/%d] MISSING object, skipped: %s (photo %s)",
n, len(photos), p.OriginalKey, p.ID)
case store.IsUniqueViolation(err):
res.Duplicates++
other := "an earlier photo"
if existing, findErr := db.FindPhotoByChecksum(ctx, p.GalleryID, sum); findErr == nil {
other = "photo " + existing.ID
}
log.Printf("backfill-checksums: [%d/%d] DUPLICATE: photo %s (%s) has the same content as %s "+
"in gallery %s — left without a checksum, delete it by hand if unwanted",
n, len(photos), p.ID, p.OriginalFilename, other, p.GalleryID)
case err != nil:
res.Failed++
log.Printf("backfill-checksums: [%d/%d] FAILED %s (photo %s): %v",
n, len(photos), p.OriginalKey, p.ID, err)
default:
res.Hashed++
}
mu.Unlock()
}
}()
}
for _, p := range photos {
select {
case jobs <- p:
case <-ctx.Done():
}
if ctx.Err() != nil {
break
}
}
close(jobs)
wg.Wait()
log.Printf("backfill-checksums: %sdone — %d hashed, %d duplicates left unhashed, %d objects missing, %d failed",
prefix, res.Hashed, res.Duplicates, res.Missing, res.Failed)
if err := ctx.Err(); err != nil {
return res, fmt.Errorf("interrupted after %d/%d photos: %w", res.Hashed, res.Total, err)
}
if res.Failed > 0 {
return res, fmt.Errorf("%d of %d photos failed to hash (rerun to retry; already-hashed photos are skipped)",
res.Failed, res.Total)
}
return res, nil
}
// hashOriginal reads a photo's stored original. Stat runs first because it is
// the only call that reports a missing object as storage.ErrNotExist on both
// backends — Open surfaces the driver's own error.
func hashOriginal(ctx context.Context, st storage.Storage, p store.Photo) (string, error) {
if _, err := st.Stat(ctx, p.OriginalKey); err != nil {
return "", err
}
r, _, err := st.Open(ctx, p.OriginalKey)
if err != nil {
return "", err
}
defer r.Close()
return Sum(r)
}
@@ -0,0 +1,175 @@
package checksum
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"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"
)
const galleryID = "11111111-1111-1111-1111-111111111111"
// env builds a migrated scratch SQLite database with one gallery, plus a local
// storage backend rooted next to it.
func env(t *testing.T) (*store.DB, storage.Storage) {
t.Helper()
dir := t.TempDir()
db, err := store.Open(config.Config{DBType: "sqlite", DatabaseURL: filepath.Join(dir, "test.db")})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
if err := db.Migrate(ctx); err != nil {
t.Fatal(err)
}
now := time.Now()
if err := db.CreateGallery(ctx, store.Gallery{
ID: galleryID, Slug: "g", Title: "G", Visibility: "private", ShareToken: "tok",
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
st, err := storage.NewLocal(filepath.Join(dir, "photos"))
if err != nil {
t.Fatal(err)
}
return db, st
}
// seed inserts an unhashed photo whose original holds body, mimicking a row
// uploaded before the checksum column existed.
func seed(t *testing.T, db *store.DB, st storage.Storage, id, body string) store.Photo {
t.Helper()
ctx := context.Background()
key := "galleries/" + galleryID + "/orig/" + id + ".jpg"
if body != "" {
if err := st.Put(ctx, key, strings.NewReader(body), int64(len(body)), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
now := time.Now()
p := store.Photo{
ID: id, GalleryID: galleryID, OriginalKey: key, OriginalFilename: id + ".jpg",
ContentType: "image/jpeg", SizeBytes: int64(len(body)), Status: "ready",
NextAttemptAt: now, CreatedAt: now, UpdatedAt: now,
}
if err := db.InsertPhoto(ctx, p); err != nil {
t.Fatal(err)
}
return p
}
func checksumOf(t *testing.T, db *store.DB, id string) string {
t.Helper()
p, err := db.GetPhoto(context.Background(), id)
if err != nil {
t.Fatal(err)
}
return p.Checksum
}
func TestBackfill(t *testing.T) {
ctx := context.Background()
t.Run("hashes unhashed photos and is idempotent", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "alpha")
seed(t, db, st, "p2", "beta")
res, err := Backfill(ctx, db, st, Options{})
if err != nil {
t.Fatal(err)
}
if res.Total != 2 || res.Hashed != 2 || res.Duplicates != 0 || res.Failed != 0 {
t.Fatalf("first run: %+v", res)
}
want, err := Sum(strings.NewReader("alpha"))
if err != nil {
t.Fatal(err)
}
if got := checksumOf(t, db, "p1"); got != want {
t.Fatalf("p1 checksum = %q, want %q", got, want)
}
res, err = Backfill(ctx, db, st, Options{})
if err != nil {
t.Fatal(err)
}
if res.Total != 0 || res.Hashed != 0 {
t.Fatalf("rerun should find nothing to do: %+v", res)
}
})
t.Run("reports pre-existing duplicates and leaves them unhashed", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "same")
seed(t, db, st, "p2", "same")
res, err := Backfill(ctx, db, st, Options{Concurrency: 1})
if err != nil {
t.Fatal(err)
}
if res.Hashed != 1 || res.Duplicates != 1 || res.Failed != 0 {
t.Fatalf("got %+v, want 1 hashed and 1 duplicate", res)
}
// The earlier row keeps the checksum, the later one is left alone —
// nothing is deleted either way.
if checksumOf(t, db, "p1") == "" {
t.Fatal("p1 should have been hashed")
}
if got := checksumOf(t, db, "p2"); got != "" {
t.Fatalf("p2 checksum = %q, want empty", got)
}
if _, err := db.GetPhoto(ctx, "p2"); err != nil {
t.Fatalf("duplicate row must survive: %v", err)
}
})
t.Run("skips photos whose object is gone", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "") // row without a stored original
res, err := Backfill(ctx, db, st, Options{})
if err != nil {
t.Fatal(err)
}
if res.Missing != 1 || res.Hashed != 0 || res.Failed != 0 {
t.Fatalf("got %+v, want 1 missing", res)
}
})
t.Run("dry run writes nothing", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "alpha")
res, err := Backfill(ctx, db, st, Options{DryRun: true})
if err != nil {
t.Fatal(err)
}
if res.Hashed != 1 {
t.Fatalf("got %+v, want 1 hashed", res)
}
if got := checksumOf(t, db, "p1"); got != "" {
t.Fatalf("dry run recorded %q", got)
}
})
t.Run("limits to one gallery", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "alpha")
res, err := Backfill(ctx, db, st, Options{GalleryID: "22222222-2222-2222-2222-222222222222"})
if err != nil {
t.Fatal(err)
}
if res.Total != 0 {
t.Fatalf("other gallery should have nothing to do: %+v", res)
}
})
}
+31
View File
@@ -0,0 +1,31 @@
// Package checksum defines the content hash used to spot duplicate photos and
// the backfill that fills it in for photos uploaded before it existed.
//
// The hash is sha256 over the untouched original bytes, hex encoded, stored in
// photos_photos.checksum. Duplicate scope is one gallery — the unique index is
// on (gallery_id, checksum) — so the same image may still live in several
// galleries, each with its own row and its own stored object.
package checksum
import (
"crypto/sha256"
"encoding/hex"
"hash"
"io"
)
// New returns a fresh hasher. Upload hashes as it spools the body, so it needs
// the writer rather than a finished reader.
func New() hash.Hash { return sha256.New() }
// Format renders a hasher's digest the way it is stored.
func Format(h hash.Hash) string { return hex.EncodeToString(h.Sum(nil)) }
// Sum reads r to EOF and returns its digest.
func Sum(r io.Reader) (string, error) {
h := New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return Format(h), nil
}
+38 -7
View File
@@ -15,8 +15,16 @@ type Config struct {
Port int
DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE
DatabaseURL string
JWTSecret string
// ViewTokenSecret signs the hour-bucketed gallery view tokens
// (viewtoken.go). PHOTO_VIEW_SECRET, falling back to the legacy
// JWT_SECRET during the Better Auth migration.
ViewTokenSecret string
// StorageBackend selects the active backend: "auto" (S3 when it is
// configured, local otherwise), "local" or "s3". Explicit values let both
// backends stay configured — required to run `photo-api sync`, and the
// one-line switch after a sync.
StorageBackend string
StoragePath string
S3Endpoint string
S3Region string
@@ -31,12 +39,26 @@ type Config struct {
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 {
// S3Configured reports whether the S3 credentials are present at all,
// mirroring backend/src/lib/storage.ts: both S3_ENDPOINT and S3_BUCKET set.
// Kept separate from S3Enabled so a configured-but-inactive S3 backend can
// still be reached by `photo-api sync`.
func (c Config) S3Configured() bool {
return c.S3Endpoint != "" && c.S3Bucket != ""
}
// S3Enabled reports whether S3 is the backend serving requests.
func (c Config) S3Enabled() bool {
switch c.StorageBackend {
case "s3":
return true
case "local":
return false
default: // auto
return c.S3Configured()
}
}
func Load() (Config, error) {
loadDotenv(".env")
@@ -44,7 +66,8 @@ func Load() (Config, error) {
Port: envInt("PORT", 3003),
DBType: strings.ToLower(env("DB_TYPE", "sqlite")),
DatabaseURL: env("DATABASE_URL", ""),
JWTSecret: env("JWT_SECRET", ""),
ViewTokenSecret: env("PHOTO_VIEW_SECRET", env("JWT_SECRET", "")),
StorageBackend: strings.ToLower(env("STORAGE_BACKEND", "auto")),
StoragePath: env("STORAGE_PATH", "./data/photos"),
S3Endpoint: env("S3_ENDPOINT", ""),
S3Region: env("S3_REGION", "auto"),
@@ -64,8 +87,16 @@ func Load() (Config, error) {
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)")
if cfg.ViewTokenSecret == "" {
return cfg, fmt.Errorf("PHOTO_VIEW_SECRET is required (or legacy JWT_SECRET as fallback)")
}
switch cfg.StorageBackend {
case "auto", "local", "s3":
default:
return cfg, fmt.Errorf("STORAGE_BACKEND must be auto, local or s3, got %q", cfg.StorageBackend)
}
if cfg.StorageBackend == "s3" && !cfg.S3Configured() {
return cfg, fmt.Errorf("STORAGE_BACKEND=s3 requires S3_ENDPOINT and S3_BUCKET")
}
return cfg, nil
}
+13 -7
View File
@@ -7,11 +7,10 @@ import (
"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).
// accessDenial describes why a viewer may not see a gallery. Each mode has
// a distinct message so the frontend can show a matching gate page (log in,
// ask for the share link, buy a ticket). The messages are part of the API
// contract with GalleryClient.tsx — change both together.
type accessDenial struct {
status int
msg string
@@ -24,6 +23,13 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to
if user != nil && user.IsAdmin() {
return nil
}
// A server-minted view token grants access to this one gallery until it
// expires; it is only ever issued after a successful authorize, and it
// is what lets <img> requests (which cannot carry a Bearer header)
// through for non-public galleries.
if token != "" && verifyViewToken([]byte(s.cfg.ViewTokenSecret), g.ID, token) {
return nil
}
switch g.Visibility {
case store.VisibilityPublic:
return nil
@@ -31,7 +37,7 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to
if token != "" && token == g.ShareToken {
return nil
}
return &accessDenial{http.StatusNotFound, "Gallery not found"}
return &accessDenial{http.StatusForbidden, "This gallery needs its share link"}
case store.VisibilityTicket:
// The share token is honored as an escape hatch (e.g. attendee +1s
// without accounts, at the admin's discretion).
@@ -50,6 +56,6 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to
}
return nil
default: // private
return &accessDenial{http.StatusNotFound, "Gallery not found"}
return &accessDenial{http.StatusForbidden, "This gallery is private"}
}
}
+472 -50
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/color"
@@ -17,8 +18,6 @@ import (
"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"
@@ -40,9 +39,11 @@ const (
)
type testEnv struct {
handler http.Handler
db *store.DB
worker *worker.Worker
handler http.Handler
db *store.DB
worker *worker.Worker
storagePath string
pg bool
}
// setup migrates a scratch DB (SQLite by default; Postgres when
@@ -55,7 +56,7 @@ func setup(t *testing.T) *testEnv {
Port: 0,
DBType: "sqlite",
DatabaseURL: filepath.Join(dir, "test.db"),
JWTSecret: testSecret,
ViewTokenSecret: testSecret,
StoragePath: filepath.Join(dir, "photos"),
MaxUploadMB: 5,
WorkerConcurrency: 1,
@@ -72,7 +73,7 @@ func setup(t *testing.T) *testEnv {
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 {
if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments, auth_sessions CASCADE`); err != nil {
t.Fatal(err)
}
}
@@ -81,20 +82,27 @@ func setup(t *testing.T) *testEnv {
}
idType := "text"
expiresType := "integer" // sqlite: Better Auth stores epoch-ms integers
boolType := "integer"
falseLit := "0"
if cfg.DBType == "postgres" {
idType = "uuid"
expiresType = "timestamp"
boolType = "boolean"
falseLit = "FALSE"
}
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 users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, account_status text NOT NULL DEFAULT 'active', banned ` + boolType + ` NOT NULL DEFAULT ` + falseLit + `)`,
`CREATE TABLE auth_sessions (id text PRIMARY KEY, user_id ` + idType + `, token text NOT NULL UNIQUE, expires_at ` + expiresType + ` NOT NULL)`,
`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 users VALUES ('` + uAdmin + `','a@x.py','Admin','admin','active',` + falseLit + `)`,
`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user','active',` + falseLit + `)`,
`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user','active',` + falseLit + `)`,
`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user','active',` + falseLit + `)`,
`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user','active',` + falseLit + `)`,
`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)`,
@@ -116,23 +124,33 @@ func setup(t *testing.T) *testEnv {
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}
srv := New(cfg, db, st, auth.NewVerifier(db), wrk)
return &testEnv{
handler: srv.Handler(),
db: db,
worker: wrk,
storagePath: cfg.StoragePath,
pg: cfg.DBType == "postgres",
}
}
func makeToken(t *testing.T, sub, email, role string) string {
// makeToken inserts a Better Auth session row for the user and returns a
// bearer value in the cookie's "token.signature" format (the verifier
// resolves the token part against auth_sessions; the signature is unused).
func (e *testEnv) makeToken(t *testing.T, sub 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(),
token := "tok-" + newID()
expires := time.Now().Add(time.Hour)
var expiresVal any = expires.UnixMilli()
if e.pg {
expiresVal = expires
}
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret))
if err != nil {
if _, err := e.db.Exec(e.db.Rebind(
`INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`),
newID(), sub, token, expiresVal); err != nil {
t.Fatal(err)
}
return s
return token + ".testsig"
}
func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
@@ -165,12 +183,16 @@ func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
return v
}
func testJPEG(t *testing.T) []byte {
func testJPEG(t *testing.T) []byte { return testJPEGTinted(t, 128) }
// testJPEGTinted varies the blue channel so tests that need two *different*
// images (duplicate detection) can get them without a fixture file.
func testJPEGTinted(t *testing.T, blue uint8) []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})
img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: blue, A: 255})
}
}
var buf bytes.Buffer
@@ -193,16 +215,61 @@ func TestAdminGate(t *testing.T) {
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")
member := e.makeToken(t, uMember)
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")
unknown := e.makeToken(t, newID())
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)
}
}
func TestSessionValidation(t *testing.T) {
e := setup(t)
body := map[string]string{"title": "Test"}
// Expired session -> 401
expiredTok := "tok-" + newID()
var expiresVal any = time.Now().Add(-time.Hour).UnixMilli()
if e.pg {
expiresVal = time.Now().Add(-time.Hour)
}
if _, err := e.db.Exec(e.db.Rebind(
`INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`),
newID(), uAdmin, expiredTok, expiresVal); err != nil {
t.Fatal(err)
}
if w := e.request(t, "POST", "/api/photos/galleries", expiredTok+".sig", body); w.Code != 401 {
t.Fatalf("expired session: want 401, got %d", w.Code)
}
// Banned (suspended) user -> 401 even with a live session
bannedTok := e.makeToken(t, uMember)
if _, err := e.db.Exec(e.db.Rebind(
`UPDATE users SET account_status = 'suspended' WHERE id = ?`), uMember); err != nil {
t.Fatal(err)
}
if w := e.request(t, "POST", "/api/photos/galleries", bannedTok, body); w.Code != 401 {
t.Fatalf("suspended user: want 401, got %d", w.Code)
}
if _, err := e.db.Exec(e.db.Rebind(
`UPDATE users SET account_status = 'active' WHERE id = ?`), uMember); err != nil {
t.Fatal(err)
}
// Session via the browser cookie (URL-encoded token.signature) -> works
adminTok := e.makeToken(t, uAdmin)
req := httptest.NewRequest("POST", "/api/photos/galleries", bytes.NewReader([]byte(`{"title":"Via cookie"}`)))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "spanglish.session_token", Value: strings.ReplaceAll(adminTok, ".", "%2E")})
w := httptest.NewRecorder()
e.handler.ServeHTTP(w, req)
if w.Code != 200 && w.Code != 201 {
t.Fatalf("cookie auth: want 2xx, got %d: %s", w.Code, w.Body.String())
}
}
type galleryResp struct {
Gallery galleryJSON `json:"gallery"`
Photos []photoJSON `json:"photos"`
@@ -241,6 +308,130 @@ func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte)
return photos[0]
}
// uploadFiles posts several parts in one request, the way the API allows even
// though the admin UI sends one file per request.
func uploadFiles(t *testing.T, e *testEnv, admin, galleryID string, files ...[]byte) []photoJSON {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for i, f := range files {
fw, _ := mw.CreateFormFile("files", fmt.Sprintf("photo-%d.jpg", i))
fw.Write(f)
}
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())
}
return decode[struct {
Photos []photoJSON `json:"photos"`
}](t, w).Photos
}
// countOriginals is how many objects actually landed on disk for a gallery —
// a duplicate must not add one.
func countOriginals(t *testing.T, e *testEnv, galleryID string) int {
t.Helper()
entries, err := os.ReadDir(filepath.Join(e.storagePath, "galleries", galleryID, "orig"))
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatal(err)
}
return len(entries)
}
func TestUploadSkipsDuplicates(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Dupes"})
img := testJPEG(t)
first := uploadPhoto(t, e, admin, g.ID, img)
if first.Duplicate {
t.Fatalf("first upload flagged as duplicate: %+v", first)
}
// Same bytes again: echoed back as the existing photo, nothing stored.
second := uploadPhoto(t, e, admin, g.ID, img)
if !second.Duplicate {
t.Fatalf("second upload not flagged as duplicate: %+v", second)
}
if second.ID != first.ID {
t.Fatalf("duplicate should echo the existing photo: got %s, want %s", second.ID, first.ID)
}
photos, err := e.db.ListPhotos(context.Background(), g.ID, false)
if err != nil {
t.Fatal(err)
}
if len(photos) != 1 {
t.Fatalf("want 1 row after re-upload, got %d", len(photos))
}
if n := countOriginals(t, e, g.ID); n != 1 {
t.Fatalf("want 1 stored original after re-upload, got %d", n)
}
if photos[0].Checksum == "" {
t.Fatal("checksum was not recorded")
}
// A different image is unaffected and takes the next position.
other := uploadPhoto(t, e, admin, g.ID, testJPEGTinted(t, 32))
if other.Duplicate || other.ID == first.ID {
t.Fatalf("distinct image treated as duplicate: %+v", other)
}
if other.Position != 1 {
t.Fatalf("want position 1 for the second distinct photo, got %d", other.Position)
}
// Duplicate scope is one gallery: the same bytes elsewhere upload normally.
g2 := createGallery(t, e, admin, map[string]any{"title": "Other gallery"})
elsewhere := uploadPhoto(t, e, admin, g2.ID, img)
if elsewhere.Duplicate {
t.Fatalf("same image in another gallery must not be a duplicate: %+v", elsewhere)
}
if elsewhere.ID == first.ID {
t.Fatal("second gallery should get its own photo row")
}
}
func TestUploadBatchContinuesPastDuplicate(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Batch"})
a, b, c := testJPEG(t), testJPEGTinted(t, 32), testJPEGTinted(t, 200)
if got := uploadFiles(t, e, admin, g.ID, a); len(got) != 1 {
t.Fatalf("seed upload: %d photos", len(got))
}
// b duplicates nothing, a is already there, c is new: the batch must not
// abort, and the duplicate must not consume a position.
batch := uploadFiles(t, e, admin, g.ID, b, a, c)
if len(batch) != 3 {
t.Fatalf("want 3 results, got %d", len(batch))
}
if batch[0].Duplicate || !batch[1].Duplicate || batch[2].Duplicate {
t.Fatalf("duplicate flags: %v %v %v", batch[0].Duplicate, batch[1].Duplicate, batch[2].Duplicate)
}
if batch[0].Position != 1 || batch[2].Position != 2 {
t.Fatalf("positions should stay dense: %d, %d", batch[0].Position, batch[2].Position)
}
photos, err := e.db.ListPhotos(context.Background(), g.ID, false)
if err != nil {
t.Fatal(err)
}
if len(photos) != 3 {
t.Fatalf("want 3 rows, got %d", len(photos))
}
if n := countOriginals(t, e, g.ID); n != 3 {
t.Fatalf("want 3 stored originals, got %d", n)
}
}
// processQueue runs the worker until the photo is ready or failed.
func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
t.Helper()
@@ -265,7 +456,7 @@ func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
func TestUploadProcessAndServe(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
admin := e.makeToken(t, uAdmin)
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)
@@ -325,11 +516,11 @@ func TestUploadProcessAndServe(t *testing.T) {
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")
admin := e.makeToken(t, uAdmin)
member := e.makeToken(t, uMember)
buyer := e.makeToken(t, uBuyer)
pending := e.makeToken(t, uPending)
freeguy := e.makeToken(t, uFree)
get := func(slug, token, bearer string) int {
path := "/api/photos/public/galleries/" + slug
@@ -339,12 +530,13 @@ func TestAccessMatrix(t *testing.T) {
return e.request(t, "GET", path, bearer, nil).Code
}
// private
// private: 403 with a distinct message so the frontend can show its
// gate page (revealing existence is accepted for this community site)
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 code != 403 {
t.Errorf("private/%s: want 403, got %d", name, code)
}
}
if got := get(priv.Slug, "", admin); got != 200 {
@@ -353,11 +545,11 @@ func TestAccessMatrix(t *testing.T) {
// 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, "", ""); got != 403 {
t.Errorf("link/anon: want 403, 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, "wrong-token", ""); got != 403 {
t.Errorf("link/bad-token: want 403, got %d", got)
}
if got := get(link.Slug, link.ShareToken, ""); got != 200 {
t.Errorf("link/token: want 200, got %d", got)
@@ -407,11 +599,11 @@ func TestAccessMatrix(t *testing.T) {
func TestReorderAndVisibilityUpdate(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
admin := e.makeToken(t, uAdmin)
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)
// Two distinct images: the same bytes twice would be deduplicated.
p1 := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
p2 := uploadPhoto(t, e, admin, g.ID, testJPEGTinted(t, 32))
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 {
@@ -435,15 +627,71 @@ func TestReorderAndVisibilityUpdate(t *testing.T) {
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)
if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 403 {
t.Fatalf("after switch to link, anon: want 403, got %d", w.Code)
}
}
// TestViewTokens covers the <img>-tag reality: image requests cannot carry
// an Authorization header, so authorized gallery responses embed a
// short-lived gallery-scoped token in file URLs that the file handler
// accepts anonymously.
func TestViewTokens(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Privada Con Fotos", "visibility": "private"})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if processQueue(t, e, p.ID).Status != "ready" {
t.Fatal("processing failed")
}
// The admin detail response must carry tokenized image URLs.
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
thumbURL := detail.Photos[0].URLs.Thumb
if !strings.Contains(thumbURL, "?token=v1.") {
t.Fatalf("private gallery photo URL should carry a view token: %s", thumbURL)
}
// That URL works with NO Authorization header, exactly like an <img> tag.
if w := e.request(t, "GET", thumbURL, "", nil); w.Code != 200 {
t.Fatalf("anon fetch with view token: want 200, got %d %s", w.Code, w.Body.String())
}
// Without the token (or with a forged one) the same file is denied.
bare := strings.SplitN(thumbURL, "?", 2)[0]
if w := e.request(t, "GET", bare, "", nil); w.Code != 403 {
t.Fatalf("anon fetch without token: want 403, got %d", w.Code)
}
if w := e.request(t, "GET", bare+"?token=v1.9999999999.forged", "", nil); w.Code != 403 {
t.Fatalf("forged token: want 403, got %d", w.Code)
}
// A view token for one gallery must not open another gallery's files.
other := createGallery(t, e, admin, map[string]any{"title": "Otra Privada", "visibility": "private"})
op := uploadPhoto(t, e, admin, other.ID, testJPEG(t))
if processQueue(t, e, op.ID).Status != "ready" {
t.Fatal("processing failed")
}
viewToken := strings.SplitN(thumbURL, "?token=", 2)[1]
if w := e.request(t, "GET", "/api/photos/files/"+op.ID+"/thumb?token="+viewToken, "", nil); w.Code != 403 {
t.Fatalf("cross-gallery token reuse: want 403, got %d", w.Code)
}
// Public galleries keep clean URLs (no token needed).
pub := createGallery(t, e, admin, map[string]any{"title": "Publica Limpia", "visibility": "public"})
pp := uploadPhoto(t, e, admin, pub.ID, testJPEG(t))
if processQueue(t, e, pp.ID).Status != "ready" {
t.Fatal("processing failed")
}
pubDetail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+pub.Slug, "", nil))
if strings.Contains(pubDetail.Photos[0].URLs.Thumb, "token=") {
t.Fatalf("public photo URL should be clean: %s", pubDetail.Photos[0].URLs.Thumb)
}
}
func TestEventGalleryRoute(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
buyer := makeToken(t, uBuyer, "b@x.py", "user")
admin := e.makeToken(t, uAdmin)
buyer := e.makeToken(t, uBuyer)
g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid})
@@ -489,7 +737,7 @@ func TestEventGalleryRoute(t *testing.T) {
func TestSlugCollision(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
admin := e.makeToken(t, uAdmin)
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 {
@@ -502,3 +750,177 @@ func TestSlugCollision(t *testing.T) {
t.Fatalf("default visibility: %s", a.Visibility)
}
}
// TestDownloadEndpoint pins the guarantees the mobile save sheet is built
// on: the bytes arrive same-origin (never a redirect), typed as an image,
// named after the event, sized up front and cacheable forever.
func TestDownloadEndpoint(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{
"title": "Fotos Del Evento", "visibility": "public", "eventId": evPaid,
})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if processQueue(t, e, p.ID).Status != "ready" {
t.Fatal("processing failed")
}
base := "/api/photos/files/" + p.ID + "/download"
for _, tc := range []struct {
name, path, wantDisposition, wantFilename string
}{
// No ?size at all must behave exactly like ?size=preview.
{"default", base, "inline", "spanglish-fiesta-1.jpg"},
{"preview", base + "?size=preview", "inline", "spanglish-fiesta-1.jpg"},
{"original", base + "?size=original", "attachment", "spanglish-fiesta-1.jpg"},
} {
t.Run(tc.name, func(t *testing.T) {
w := e.request(t, "GET", tc.path, "", nil)
if w.Code != 200 {
t.Fatalf("status %d %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type %q: iOS only offers Save Image for image/*", ct)
}
if n := w.Header().Get("Content-Length"); n != fmt.Sprint(w.Body.Len()) || n == "0" {
t.Fatalf("content length %q, body %d bytes", n, w.Body.Len())
}
cd := w.Header().Get("Content-Disposition")
if !strings.HasPrefix(cd, tc.wantDisposition) || !strings.Contains(cd, tc.wantFilename) {
t.Fatalf("disposition %q, want %s with %s", cd, tc.wantDisposition, tc.wantFilename)
}
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
t.Fatalf("cache-control %q", cc)
}
})
}
// Each size serves its own variant byte-for-byte. (No size comparison
// between the two here: the fixture is a synthetic 800x600 image, so its
// 2048px preview re-encode is larger than the "original" — which says
// nothing about the multi-megapixel photos the sheet exists for.)
prev := e.request(t, "GET", base, "", nil)
orig := e.request(t, "GET", base+"?size=original", "", nil)
if !bytes.Equal(prev.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/preview", "", nil).Body.Bytes()) {
t.Fatal("download?size=preview must serve the same bytes as the preview variant")
}
if !bytes.Equal(orig.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil).Body.Bytes()) {
t.Fatal("download?size=original must serve the same bytes as the original variant")
}
// An unknown size is not a silent fallback.
if w := e.request(t, "GET", base+"?size=thumb", "", nil); w.Code != 404 {
t.Fatalf("unknown size: want 404, got %d", w.Code)
}
// The gallery response labels both rows without any extra request.
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
got := detail.Photos[0]
if got.PreviewSizeBytes != int64(prev.Body.Len()) {
t.Fatalf("previewSizeBytes %d, served %d", got.PreviewSizeBytes, prev.Body.Len())
}
if got.SizeBytes != int64(orig.Body.Len()) {
t.Fatalf("sizeBytes %d, served %d", got.SizeBytes, orig.Body.Len())
}
if got.URLs.Download != base {
t.Fatalf("download url %q, want %q", got.URLs.Download, base)
}
if got.URLs.DownloadOriginal != base+"?size=original" {
t.Fatalf("downloadOriginal url %q", got.URLs.DownloadOriginal)
}
}
// A restricted gallery's download must obey the same token check as every
// other byte-serving route, and must not become shared-cacheable.
func TestDownloadRespectsAccess(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Privada Descarga", "visibility": "private"})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if processQueue(t, e, p.ID).Status != "ready" {
t.Fatal("processing failed")
}
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
url := detail.Photos[0].URLs.Download
if !strings.Contains(url, "?token=v1.") {
t.Fatalf("private download URL should carry a view token: %s", url)
}
// ?size and ?token have to coexist on the original's URL.
if o := detail.Photos[0].URLs.DownloadOriginal; !strings.Contains(o, "?size=original&token=v1.") {
t.Fatalf("private original download URL: %s", o)
}
w := e.request(t, "GET", url, "", nil)
if w.Code != 200 {
t.Fatalf("view token fetch: want 200, got %d", w.Code)
}
// Restricted galleries stay out of shared caches; the browser's own
// cache (which is what the save sheet reuses) still applies.
if cc := w.Header().Get("Cache-Control"); cc != "private, max-age=31536000, immutable" {
t.Fatalf("cache-control %q must not be public for a private gallery", cc)
}
bare := strings.SplitN(url, "?", 2)[0]
if w := e.request(t, "GET", bare, "", nil); w.Code != 403 {
t.Fatalf("anon download without token: want 403, got %d", w.Code)
}
if w := e.request(t, "GET", bare+"?size=original", "", nil); w.Code != 403 {
t.Fatalf("anon original download without token: want 403, got %d", w.Code)
}
}
// Photos processed before preview_size_bytes existed have no stored size.
// The first gallery view measures them and writes the result back, so no
// separate backfill command is needed for an existing library.
func TestPreviewSizeBackfilledOnRead(t *testing.T) {
e := setup(t)
ctx := context.Background()
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Antigua", "visibility": "public"})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if processQueue(t, e, p.ID).Status != "ready" {
t.Fatal("processing failed")
}
// Rewind to what an pre-migration row looks like.
if _, err := e.db.ExecContext(ctx, e.db.Rebind(
"UPDATE photos_photos SET preview_size_bytes = NULL WHERE id = ?"), p.ID); err != nil {
t.Fatal(err)
}
if before, err := e.db.GetPhoto(ctx, p.ID); err != nil {
t.Fatal(err)
} else if before.PreviewSizeBytes != 0 {
t.Fatalf("setup: want an unmeasured row, got %d", before.PreviewSizeBytes)
}
served := e.request(t, "GET", "/api/photos/files/"+p.ID+"/download", "", nil).Body.Len()
got := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
if got.Photos[0].PreviewSizeBytes != int64(served) {
t.Fatalf("response size %d, served %d", got.Photos[0].PreviewSizeBytes, served)
}
// …and it was persisted, so the next view costs no Stat.
after, err := e.db.GetPhoto(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if after.PreviewSizeBytes != int64(served) {
t.Fatalf("stored size %d, served %d", after.PreviewSizeBytes, served)
}
}
// Originals uploaded as HEIC or PNG must keep their real type and extension,
// and a row with a missing/generic type must still go out as an image.
func TestDownloadContentTypeNeverOctetStream(t *testing.T) {
for _, tc := range []struct{ ct, name, want string }{
{"image/heic", "IMG_1.HEIC", "image/heic"},
{"image/png", "shot.png", "image/png"},
{"application/octet-stream", "IMG_2.heic", "image/heic"},
{"application/octet-stream", "scan.PNG", "image/png"},
{"", "photo.jpeg", "image/jpeg"},
{"", "", "image/jpeg"},
} {
if got := imageContentType(tc.ct, tc.name); got != tc.want {
t.Errorf("imageContentType(%q, %q) = %q, want %q", tc.ct, tc.name, got, tc.want)
}
}
}
+46 -1
View File
@@ -42,6 +42,13 @@ type photoURLs struct {
Thumb string `json:"thumb,omitempty"`
Preview string `json:"preview,omitempty"`
Original string `json:"original"`
// Download / DownloadOriginal hit the download endpoint (files.go), which
// always streams the bytes same-origin instead of redirecting to S3, so a
// fetch() can read them into a Blob without CORS. Download serves the
// preview variant and doubles as the lightbox's <img> source, so the save
// fetch is answered from the HTTP cache rather than the network.
Download string `json:"download,omitempty"`
DownloadOriginal string `json:"downloadOriginal"`
}
type photoJSON struct {
@@ -51,6 +58,9 @@ type photoJSON struct {
OriginalFilename string `json:"originalFilename,omitempty"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
// PreviewSizeBytes lets the mobile save sheet label both of its rows with
// a real file size without fetching anything. Omitted while unknown.
PreviewSizeBytes int64 `json:"previewSizeBytes,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
TakenAt string `json:"takenAt,omitempty"`
@@ -58,6 +68,20 @@ type photoJSON struct {
LastError string `json:"lastError,omitempty"` // admin only
CreatedAt string `json:"createdAt"`
URLs photoURLs `json:"urls"`
// Duplicate marks an upload that was skipped because the gallery already
// held these bytes; the rest of the object describes the existing photo.
// Set by the upload handler only, never persisted.
Duplicate bool `json:"duplicate,omitempty"`
}
// viewTokenFor returns the token to embed in a gallery's file URLs: none
// for public galleries (files are anonymously accessible), a short-lived
// gallery-scoped view token otherwise (see viewtoken.go).
func (s *Server) viewTokenFor(g store.Gallery) string {
if g.Visibility == store.VisibilityPublic {
return ""
}
return mintViewToken([]byte(s.cfg.ViewTokenSecret), g.ID)
}
func isoTime(t time.Time) string {
@@ -77,6 +101,22 @@ func fileURL(photoID, variant, token string) string {
return u
}
// downloadURL builds the download endpoint's URL. size is left off for the
// preview, which the endpoint serves by default — a shorter, stabler string
// for the URL the lightbox also renders.
func downloadURL(photoID, size, token string) string {
u := "/api/photos/files/" + photoID + "/download"
sep := "?"
if size != "" && size != downloadSizePreview {
u += "?size=" + size
sep = "&"
}
if token != "" {
u += sep + "token=" + token
}
return u
}
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
out := photoJSON{
ID: p.ID,
@@ -87,16 +127,21 @@ func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON
SizeBytes: p.SizeBytes,
Width: p.Width,
Height: p.Height,
PreviewSizeBytes: p.PreviewSizeBytes,
TakenAt: isoTime(p.TakenAt),
Status: p.Status,
CreatedAt: isoTime(p.CreatedAt),
URLs: photoURLs{Original: fileURL(p.ID, "original", token)},
URLs: photoURLs{
Original: fileURL(p.ID, "original", token),
DownloadOriginal: downloadURL(p.ID, downloadSizeOriginal, token),
},
}
if p.ThumbKey != "" {
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
}
if p.PreviewKey != "" {
out.URLs.Preview = fileURL(p.ID, "preview", token)
out.URLs.Download = downloadURL(p.ID, downloadSizePreview, token)
}
if admin {
out.LastError = p.LastError
+162 -21
View File
@@ -7,13 +7,24 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
const presignExpiry = 15 * time.Minute
// Sizes the download endpoint offers. Preview is the default because it is
// what a phone can actually save to its photo library: iOS and Android only
// reach the gallery through the share sheet, and the share sheet is worth
// handing a 2048px JPEG rather than a multi-megabyte original.
const (
downloadSizePreview = "preview"
downloadSizeOriginal = "original"
)
// 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) {
@@ -22,23 +33,10 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
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")
p, _, ok := s.authorizedPhoto(w, r)
if !ok {
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 {
@@ -58,12 +56,6 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
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)
@@ -100,6 +92,155 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
}
}
// serveDownload backs the gallery's save/download actions. It differs from
// serveFile in three ways that the mobile save flow depends on:
//
// - It always streams. serveFile 302s to S3 when presigning is available,
// and a cross-origin redirect is opaque to fetch() — the client could
// never read the bytes into a Blob to hand to navigator.share().
// - It names the file (spanglish-<event>-<n>.jpg) and always declares a
// real image/* type, because iOS only offers "Save Image" in the share
// sheet for something it recognises as an image.
// - It caches for a year as immutable. ?size=preview is also the URL the
// lightbox renders, so tapping save re-reads the bytes already on screen
// out of the HTTP cache instead of fetching them again.
func (s *Server) serveDownload(w http.ResponseWriter, r *http.Request) {
size := r.URL.Query().Get("size")
if size == "" {
size = downloadSizePreview
}
if size != downloadSizePreview && size != downloadSizeOriginal {
writeError(w, http.StatusNotFound, "Not Found")
return
}
p, g, ok := s.authorizedPhoto(w, r)
if !ok {
return
}
key, contentType := p.PreviewKey, "image/jpeg"
if size == downloadSizeOriginal {
key, contentType = p.OriginalKey, imageContentType(p.ContentType, p.OriginalFilename)
}
if key == "" {
writeError(w, http.StatusNotFound, "Not ready")
return
}
reader, n, err := s.storage.Open(r.Context(), key)
if err != nil {
if os.IsNotExist(err) || errors.Is(err, storage.ErrNotExist) {
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", n))
w.Header().Set("X-Content-Type-Options", "nosniff")
// The preview is rendered as an <img> as well as saved, so it is marked
// inline; only the original is a pure download. Either way the filename
// travels with it, which is what a bare <a download> fallback and the
// Android share sheet display.
disposition := "inline"
if size == downloadSizeOriginal {
disposition = "attachment"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q",
disposition, s.downloadFilename(r, g, p, size)))
// Objects are immutable (a new upload writes a new key), so the response
// can be cached for as long as the browser will keep it. Restricted
// galleries stay out of shared caches: access is re-checked per request
// here, and only the browser that passed the check may reuse the bytes.
scope := "private"
if g.Visibility == store.VisibilityPublic {
scope = "public"
}
w.Header().Set("Cache-Control", scope+", max-age=31536000, immutable")
if _, err := io.Copy(w, reader); err != nil {
// Routine on mobile: the client aborts the fetch when the save sheet
// is cancelled, which lands here as a broken pipe.
log.Printf("stream %s: %v", key, err)
}
}
// authorizedPhoto loads the photo and its gallery and re-runs the access
// check every byte-serving handler owes (PLAN.md §7). It writes the error
// response itself; ok=false means the caller is done.
func (s *Server) authorizedPhoto(w http.ResponseWriter, r *http.Request) (store.Photo, store.Gallery, bool) {
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
if err != nil {
writeStoreError(w, err, "Photo not found")
return store.Photo{}, store.Gallery{}, false
}
g, err := s.db.GetGallery(r.Context(), p.GalleryID)
if err != nil {
writeStoreError(w, err, "Photo not found")
return store.Photo{}, store.Gallery{}, false
}
user := s.optionalUser(r)
if denial := s.authorize(r, g, user, r.URL.Query().Get("token")); denial != nil {
writeError(w, denial.status, denial.msg)
return store.Photo{}, store.Gallery{}, false
}
// Non-ready originals stay admin-only so uploads that fail processing
// never leak to viewers.
if p.Status != "ready" && !(user != nil && user.IsAdmin()) {
writeError(w, http.StatusNotFound, "Not ready")
return store.Photo{}, store.Gallery{}, false
}
return p, g, true
}
// downloadFilename names the saved file after the event it came from rather
// than the photographer's IMG_1234.JPG: spanglish-<event-slug>-<n>.<ext>,
// numbered from 1 in gallery order. Standalone galleries use their own slug.
func (s *Server) downloadFilename(r *http.Request, g store.Gallery, p store.Photo, size string) string {
slug := g.Slug
if g.EventID != "" {
if ev, err := s.db.GetEventSummary(r.Context(), g.EventID); err == nil && ev.Slug != "" {
slug = ev.Slug
}
}
ext := ".jpg"
if size == downloadSizeOriginal {
if e := extForContentType(p.ContentType); e != "" {
ext = e
}
}
return fmt.Sprintf("spanglish-%s-%d%s", slug, p.Position+1, ext)
}
// imageContentType keeps the download endpoint's Content-Type in the image/*
// family. An original stored with a missing or generic type would otherwise
// go out as application/octet-stream, and iOS drops "Save Image" from the
// share sheet for anything it cannot see as an image.
func imageContentType(ct, filename string) string {
if strings.HasPrefix(ct, "image/") {
return ct
}
if i := strings.LastIndex(filename, "."); i >= 0 {
switch strings.ToLower(filename[i:]) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".heic", ".heif":
return "image/heic"
}
}
return "image/jpeg"
}
func extForContentType(ct string) string {
switch ct {
case "image/jpeg":
+8 -6
View File
@@ -76,7 +76,7 @@ func (s *Server) createGallery(w http.ResponseWriter, r *http.Request, user auth
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
"gallery": s.galleryToJSON(r.Context(), g, s.viewTokenFor(g), true),
})
}
@@ -90,7 +90,7 @@ func (s *Server) listGalleries(w http.ResponseWriter, r *http.Request, _ auth.Us
}
out := make([]galleryJSON, 0, len(galleries))
for _, g := range galleries {
out = append(out, s.galleryToJSON(r.Context(), g, "", true))
out = append(out, s.galleryToJSON(r.Context(), g, s.viewTokenFor(g), true))
}
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
}
@@ -106,12 +106,14 @@ func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User)
writeStoreError(w, err, "")
return
}
s.fillPreviewSizes(r.Context(), photos)
urlToken := s.viewTokenFor(g)
out := make([]photoJSON, 0, len(photos))
for _, p := range photos {
out = append(out, s.photoToJSON(p, "", true))
out = append(out, s.photoToJSON(p, urlToken, true))
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
"gallery": s.galleryToJSON(r.Context(), g, urlToken, true),
"photos": out,
})
}
@@ -174,7 +176,7 @@ func (s *Server) updateGallery(w http.ResponseWriter, r *http.Request, _ auth.Us
return
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
"gallery": s.galleryToJSON(r.Context(), g, s.viewTokenFor(g), true),
})
}
@@ -211,7 +213,7 @@ func (s *Server) rotateShareToken(w http.ResponseWriter, r *http.Request, _ auth
return
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
"gallery": s.galleryToJSON(r.Context(), g, s.viewTokenFor(g), true),
})
}
+50 -15
View File
@@ -12,6 +12,7 @@ import (
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/checksum"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
@@ -20,6 +21,10 @@ import (
// 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.
//
// A file whose bytes are already in this gallery is not stored a second time:
// the incoming copy is discarded, the existing photo is echoed back with
// "duplicate": true, and the rest of the batch carries on.
func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
galleryID := r.PathValue("id")
g, err := s.db.GetGallery(r.Context(), galleryID)
@@ -57,7 +62,7 @@ func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.Use
part.Close()
continue
}
photo, uploadErr := s.saveUpload(r, g, part, position, maxFile)
photo, duplicate, uploadErr := s.saveUpload(r, g, part, position, maxFile)
part.Close()
if uploadErr != nil {
// One bad file fails the request explicitly rather than silently
@@ -65,7 +70,12 @@ func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.Use
writeError(w, uploadErr.status, uploadErr.msg)
return
}
created = append(created, s.photoToJSON(photo, "", true))
out := s.photoToJSON(photo, s.viewTokenFor(g), true)
out.Duplicate = duplicate
created = append(created, out)
if duplicate {
continue // nothing was stored, so the next file keeps this position
}
position++
}
@@ -82,51 +92,67 @@ type uploadError struct {
msg string
}
func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, *uploadError) {
// saveUpload stores one part. The bool it returns reports a duplicate: the
// gallery already holds these bytes, so nothing was written and the photo
// returned is the existing one.
func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, bool, *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"}
return store.Photo{}, false, &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}
return store.Photo{}, false, &uploadError{http.StatusUnsupportedMediaType, reason}
}
if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil {
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType,
return store.Photo{}, false, &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).
// (S3 wants a length; local rename wants a file anyway). The same pass
// hashes the bytes for the duplicate check below.
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"}
return store.Photo{}, false, &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)))
hasher := checksum.New()
size, err := io.Copy(io.MultiWriter(tmp, hasher),
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"}
return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
if size > maxFile {
return store.Photo{}, &uploadError{http.StatusRequestEntityTooLarge,
return store.Photo{}, false, &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"}
return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
sum := checksum.Format(hasher)
// Duplicate check before anything is written: the temp copy is dropped by
// the deferred Remove, no object is stored and no row is inserted.
if existing, err := s.db.FindPhotoByChecksum(r.Context(), g.ID, sum); err == nil {
return existing, true, nil
} else if err != store.ErrNotFound {
log.Printf("Error: %v", err)
return store.Photo{}, false, &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"}
return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
now := time.Now()
@@ -142,13 +168,22 @@ func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Pa
NextAttemptAt: now,
CreatedAt: now,
UpdatedAt: now,
Checksum: sum,
}
if err := s.db.InsertPhoto(r.Context(), photo); err != nil {
s.storage.Delete(r.Context(), key)
// A concurrent upload of the same bytes won the race between the
// check above and this insert; the unique index caught it, so report
// the winner as the duplicate instead of failing.
if store.IsUniqueViolation(err) {
if existing, findErr := s.db.FindPhotoByChecksum(r.Context(), g.ID, sum); findErr == nil {
return existing, true, nil
}
}
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
return photo, nil
return photo, false, nil
}
type reorderBody struct {
+5 -6
View File
@@ -56,18 +56,17 @@ func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g st
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
}
// Non-public galleries get a short-lived view token on every file URL,
// because the browser's <img> requests cannot carry the Bearer header
// that authorized this gallery fetch. Public galleries need none.
urlToken := s.viewTokenFor(g)
photos, err := s.db.ListPhotos(r.Context(), g.ID, true)
if err != nil {
writeStoreError(w, err, "")
return
}
s.fillPreviewSizes(r.Context(), photos)
out := make([]photoJSON, 0, len(photos))
for _, p := range photos {
out = append(out, s.photoToJSON(p, urlToken, false))
+3
View File
@@ -52,6 +52,9 @@ func (s *Server) Handler() http.Handler {
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)
// The literal "download" segment takes precedence over {variant} under
// ServeMux's specificity rules, so the two can share the prefix.
mux.HandleFunc("GET /api/photos/files/{photoId}/download", s.serveDownload)
mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile)
return s.withCommon(mux)
+64
View File
@@ -0,0 +1,64 @@
package httpapi
import (
"context"
"log"
"sync"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// statConcurrency bounds the one-off Stat burst below; a gallery of ~70
// photos then costs a handful of round-trips against S3, once ever.
const statConcurrency = 8
// fillPreviewSizes measures the preview objects of photos whose
// preview_size_bytes is still unset and writes the result back, updating the
// slice in place.
//
// The worker records the size when it generates the preview, so this only
// ever fires for rows that predate the column (or arrived via `photo-api
// sync`). It is a self-healing cache fill rather than a migration step: the
// first gallery view after deploy pays for one Stat per photo, every view
// after that reads the stored value. Errors are logged and swallowed — a
// missing size costs the save sheet its row label, which is not worth
// failing a gallery response over.
func (s *Server) fillPreviewSizes(ctx context.Context, photos []store.Photo) {
var (
wg sync.WaitGroup
sem = make(chan struct{}, statConcurrency)
mu sync.Mutex
size = make(map[string]int64, len(photos))
)
for _, p := range photos {
if p.PreviewKey == "" || p.PreviewSizeBytes > 0 {
continue
}
wg.Add(1)
go func(p store.Photo) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
n, err := s.storage.Stat(ctx, p.PreviewKey)
if err != nil {
log.Printf("preview size %s: %v", p.PreviewKey, err)
return
}
mu.Lock()
size[p.ID] = n
mu.Unlock()
}(p)
}
wg.Wait()
if len(size) == 0 {
return
}
for i := range photos {
if n, ok := size[photos[i].ID]; ok {
photos[i].PreviewSizeBytes = n
if err := s.db.SetPreviewSize(ctx, photos[i].ID, n); err != nil {
log.Printf("store preview size %s: %v", photos[i].ID, err)
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package httpapi
// View tokens solve a browser reality: the gallery JSON is fetched with an
// Authorization header, but <img> tags cannot send headers, so image
// requests for non-public galleries would arrive anonymous and be denied.
// After a viewer passes the gallery access check, the server mints a
// short-lived HMAC token scoped to that one gallery and appends it to the
// file URLs it returns — the same idea as an S3 presigned URL. Possession
// grants view access to that gallery only, until expiry.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
)
// Expiries are aligned to hour buckets so repeated requests within the
// same hour mint the *same* token — image URLs stay stable and the
// browser cache keeps working across refetches/polls. A token minted at
// time t expires between 1h and 2h later.
const viewTokenBucket = int64(3600)
func mintViewToken(secret []byte, galleryID string) string {
exp := (time.Now().Unix()/viewTokenBucket + 2) * viewTokenBucket
return fmt.Sprintf("v1.%d.%s", exp, viewTokenSig(secret, galleryID, exp))
}
func verifyViewToken(secret []byte, galleryID, token string) bool {
parts := strings.Split(token, ".")
if len(parts) != 3 || parts[0] != "v1" {
return false
}
exp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || time.Now().Unix() > exp {
return false
}
expected := viewTokenSig(secret, galleryID, exp)
return hmac.Equal([]byte(expected), []byte(parts[2]))
}
func viewTokenSig(secret []byte, galleryID string, exp int64) string {
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "photos-view:%s:%d", galleryID, exp)
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
+229
View File
@@ -0,0 +1,229 @@
// Package photosync copies the photo library between the local-disk and the
// S3 backend so the active one (STORAGE_BACKEND) can be switched without
// losing photos. Both backends must be configured in photo-api/.env for a
// sync to run — the direction picks which is the source.
//
// The photos_photos rows are the inventory: every row contributes its
// original key and, once the worker has processed it, its thumb and preview
// key. Objects on disk or in the bucket with no row (worker scratch dirs,
// leftovers of deleted galleries) are deliberately not copied.
//
// Sync only ever writes to the destination. The source is left untouched, so
// a sync is repeatable, safe to interrupt, and leaves the old backend as a
// fallback until it is cleaned up by hand.
package photosync
import (
"context"
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
"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"
)
// Direction is which way objects move.
type Direction string
const (
ToS3 Direction = "to-s3"
ToLocal Direction = "to-local"
)
// ParseDirection accepts the short and the spelled-out form.
func ParseDirection(s string) (Direction, error) {
switch s {
case "to-s3", "local-to-s3":
return ToS3, nil
case "to-local", "s3-to-local":
return ToLocal, nil
default:
return "", fmt.Errorf("unknown direction %q (expected to-s3 or to-local)", s)
}
}
type Options struct {
Direction Direction
// GalleryID limits the sync to one gallery; empty means the whole library.
GalleryID string
// Concurrency is how many objects are copied at a time.
Concurrency int
// DryRun reports what would be copied without writing anything.
DryRun bool
// Overwrite re-copies objects that already exist on the destination with
// the same size (default: those are skipped, which makes reruns cheap).
Overwrite bool
}
type Result struct {
Total int // objects in the inventory
Copied int // copied (or, with DryRun, would be copied)
Skipped int // already on the destination
Missing int // absent from the source — nothing to copy
Failed int // copy attempted and errored
Bytes int64 // bytes copied
}
// Run copies the library in the requested direction. It returns a Result even
// on error, and an error if any object failed.
func Run(ctx context.Context, cfg config.Config, db *store.DB, opts Options) (Result, error) {
if !cfg.S3Configured() {
return Result{}, errors.New("sync needs both backends configured: set S3_ENDPOINT, S3_BUCKET, " +
"S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY (next to STORAGE_PATH) in photo-api/.env")
}
if opts.Concurrency < 1 {
opts.Concurrency = 4
}
local, err := storage.NewLocal(cfg.StoragePath)
if err != nil {
return Result{}, fmt.Errorf("local storage: %w", err)
}
s3, err := storage.NewS3(cfg)
if err != nil {
return Result{}, fmt.Errorf("s3 storage: %w", err)
}
src, dst := local, s3
srcName, dstName := "local disk "+cfg.StoragePath, "s3 bucket "+cfg.S3Bucket
if opts.Direction == ToLocal {
src, dst = s3, local
srcName, dstName = dstName, srcName
}
objects, err := db.AllPhotoObjects(ctx, opts.GalleryID)
if err != nil {
return Result{}, fmt.Errorf("list photo objects: %w", err)
}
prefix := ""
if opts.DryRun {
prefix = "[dry-run] "
}
log.Printf("sync: %s%s → %s: %d objects, concurrency %d",
prefix, srcName, dstName, len(objects), opts.Concurrency)
var (
mu sync.Mutex
res = Result{Total: len(objects)}
done int64
jobs = make(chan store.PhotoObject)
wg sync.WaitGroup
)
for i := 0; i < opts.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for o := range jobs {
if ctx.Err() != nil {
return
}
act, size, err := copyObject(ctx, src, dst, o, opts)
n := atomic.AddInt64(&done, 1)
mu.Lock()
switch {
case err != nil:
res.Failed++
log.Printf("sync: [%d/%d] FAILED %s (photo %s %s): %v",
n, len(objects), o.Key, o.PhotoID, o.Variant, err)
case act == actionCopied:
res.Copied++
res.Bytes += size
log.Printf("sync: [%d/%d] %scopied %s (%s)", n, len(objects), prefix, o.Key, humanBytes(size))
case act == actionMissing:
res.Missing++
log.Printf("sync: [%d/%d] MISSING on source, skipped: %s (photo %s %s)",
n, len(objects), o.Key, o.PhotoID, o.Variant)
default:
res.Skipped++
}
mu.Unlock()
}
}()
}
for _, o := range objects {
select {
case jobs <- o:
case <-ctx.Done():
}
if ctx.Err() != nil {
break
}
}
close(jobs)
wg.Wait()
log.Printf("sync: %sdone — %d copied (%s), %d already present, %d missing on source, %d failed",
prefix, res.Copied, humanBytes(res.Bytes), res.Skipped, res.Missing, res.Failed)
if err := ctx.Err(); err != nil {
return res, fmt.Errorf("interrupted after %d/%d objects: %w", res.Copied+res.Skipped, res.Total, err)
}
if res.Failed > 0 {
return res, fmt.Errorf("%d of %d objects failed to copy (rerun to retry; already-copied objects are skipped)",
res.Failed, res.Total)
}
return res, nil
}
type action int
const (
actionCopied action = iota
actionSkipped
actionMissing
)
func copyObject(ctx context.Context, src, dst storage.Storage, o store.PhotoObject, opts Options) (action, int64, error) {
srcSize, err := src.Stat(ctx, o.Key)
if errors.Is(err, storage.ErrNotExist) {
return actionMissing, 0, nil
}
if err != nil {
return actionSkipped, 0, fmt.Errorf("stat source: %w", err)
}
if !opts.Overwrite {
if dstSize, err := dst.Stat(ctx, o.Key); err == nil && dstSize == srcSize {
return actionSkipped, 0, nil
} else if err != nil && !errors.Is(err, storage.ErrNotExist) {
return actionSkipped, 0, fmt.Errorf("stat destination: %w", err)
}
}
if opts.DryRun {
return actionCopied, srcSize, nil
}
r, size, err := src.Open(ctx, o.Key)
if err != nil {
return actionSkipped, 0, fmt.Errorf("read source: %w", err)
}
defer r.Close()
if size <= 0 {
size = srcSize // local Open reports the stat size; be defensive anyway
}
contentType := o.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
if err := dst.Put(ctx, o.Key, r, size, contentType); err != nil {
return actionSkipped, 0, fmt.Errorf("write destination: %w", err)
}
return actionCopied, size, nil
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
v, exp := float64(n), 0
for v >= unit && exp < 4 {
v /= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", v, "KMGT"[exp-1])
}
@@ -0,0 +1,135 @@
package photosync
import (
"context"
"io"
"path/filepath"
"strings"
"testing"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// Two local backends stand in for the real pair: copyObject only talks to the
// storage.Storage interface, so the S3 side needs no fake here.
func backends(t *testing.T) (src, dst storage.Storage, srcRoot string) {
t.Helper()
dir := t.TempDir()
srcRoot = filepath.Join(dir, "src")
src, err := storage.NewLocal(srcRoot)
if err != nil {
t.Fatal(err)
}
dst, err = storage.NewLocal(filepath.Join(dir, "dst"))
if err != nil {
t.Fatal(err)
}
return src, dst, srcRoot
}
func put(t *testing.T, s storage.Storage, key, body string) {
t.Helper()
if err := s.Put(context.Background(), key, strings.NewReader(body), int64(len(body)), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
const testKey = "galleries/g1/original/p1.jpg"
func testObject() store.PhotoObject {
return store.PhotoObject{PhotoID: "p1", GalleryID: "g1", Variant: "original", Key: testKey, ContentType: "image/jpeg"}
}
func TestCopyObject(t *testing.T) {
ctx := context.Background()
t.Run("copies to destination", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
act, size, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionCopied || size != 11 {
t.Fatalf("got (%v, %d, %v), want (copied, 11, nil)", act, size, err)
}
if got, err := dst.Stat(ctx, testKey); err != nil || got != 11 {
t.Fatalf("destination stat: (%d, %v)", got, err)
}
})
t.Run("skips same-size object already present", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
put(t, dst, testKey, "hello-photo")
act, _, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionSkipped {
t.Fatalf("got (%v, %v), want (skipped, nil)", act, err)
}
})
t.Run("overwrite re-copies", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "new-content")
put(t, dst, testKey, "old-content")
act, _, err := copyObject(ctx, src, dst, testObject(), Options{Overwrite: true})
if err != nil || act != actionCopied {
t.Fatalf("got (%v, %v), want (copied, nil)", act, err)
}
r, _, err := dst.Open(ctx, testKey)
if err != nil {
t.Fatal(err)
}
defer r.Close()
body, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(body) != "new-content" {
t.Fatalf("destination body = %q, want new-content", body)
}
})
t.Run("reports objects missing on the source", func(t *testing.T) {
src, dst, _ := backends(t)
act, _, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionMissing {
t.Fatalf("got (%v, %v), want (missing, nil)", act, err)
}
if _, err := dst.Stat(ctx, testKey); err != storage.ErrNotExist {
t.Fatalf("destination stat err = %v, want ErrNotExist", err)
}
})
t.Run("dry run writes nothing", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
act, size, err := copyObject(ctx, src, dst, testObject(), Options{DryRun: true})
if err != nil || act != actionCopied || size != 11 {
t.Fatalf("got (%v, %d, %v), want (copied, 11, nil)", act, size, err)
}
if _, err := dst.Stat(ctx, testKey); err != storage.ErrNotExist {
t.Fatalf("destination stat err = %v, want ErrNotExist", err)
}
})
}
func TestParseDirection(t *testing.T) {
for in, want := range map[string]Direction{
"to-s3": ToS3,
"local-to-s3": ToS3,
"to-local": ToLocal,
"s3-to-local": ToLocal,
} {
got, err := ParseDirection(in)
if err != nil || got != want {
t.Errorf("ParseDirection(%q) = (%v, %v), want %v", in, got, err, want)
}
}
if _, err := ParseDirection("sideways"); err == nil {
t.Error("ParseDirection(\"sideways\") should fail")
}
}
+262
View File
@@ -0,0 +1,262 @@
package photosync
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"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"
)
// stubS3 is a minimal path-style S3 (PUT/HEAD/GET on /bucket/key) so the sync
// can be driven end to end over the real aws-sdk client.
type stubS3 struct {
mu sync.Mutex
objects map[string][]byte
puts int
}
func newStubS3(t *testing.T) (*stubS3, string) {
t.Helper()
s := &stubS3{objects: map[string][]byte{}}
srv := httptest.NewServer(s)
t.Cleanup(srv.Close)
return s, srv.URL
}
func (s *stubS3) get(key string) ([]byte, bool) {
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.objects[key]
return b, ok
}
func (s *stubS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/test-bucket/")
switch r.Method {
case http.MethodPut:
body, err := readS3Body(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.objects[key] = body
s.puts++
s.mu.Unlock()
w.WriteHeader(http.StatusOK)
case http.MethodHead:
body, ok := s.get(key)
if !ok {
w.WriteHeader(http.StatusNotFound) // a HEAD carries no error body
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(http.StatusOK)
case http.MethodGet:
body, ok := s.get(key)
if !ok {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `<Error><Code>NoSuchKey</Code></Error>`)
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.Write(body)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// readS3Body undoes the SDK's aws-chunked framing when it streams with a
// trailing checksum (what it does for the non-seekable S3-to-S3 style reader).
func readS3Body(r *http.Request) ([]byte, error) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
if !strings.Contains(r.Header.Get("Content-Encoding"), "aws-chunked") {
return raw, nil
}
var out []byte
rest := raw
for {
nl := strings.Index(string(rest), "\r\n")
if nl < 0 {
return out, nil
}
header := string(rest[:nl])
rest = rest[nl+2:]
size, err := strconv.ParseInt(strings.SplitN(header, ";", 2)[0], 16, 64)
if err != nil || size == 0 {
return out, nil // trailer section or malformed: body is complete
}
if int64(len(rest)) < size {
return nil, fmt.Errorf("truncated aws-chunked body")
}
out = append(out, rest[:size]...)
rest = rest[size:]
if len(rest) >= 2 {
rest = rest[2:] // chunk CRLF
}
}
}
// syncEnv seeds a scratch SQLite DB with one gallery holding one ready photo
// (original + thumb + preview) and returns a config wired to the stub bucket.
func syncEnv(t *testing.T) (config.Config, *store.DB, *stubS3, []string) {
t.Helper()
dir := t.TempDir()
stub, endpoint := newStubS3(t)
cfg := config.Config{
DBType: "sqlite",
DatabaseURL: filepath.Join(dir, "test.db"),
ViewTokenSecret: "test-secret",
StoragePath: filepath.Join(dir, "photos"),
S3Endpoint: endpoint,
S3Region: "auto",
S3Bucket: "test-bucket",
S3AccessKeyID: "key",
S3SecretKey: "secret",
S3ForcePathStyle: true,
}
db, err := store.Open(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
if err := db.Migrate(ctx); err != nil {
t.Fatal(err)
}
const (
galleryID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
photoID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
)
now := time.Now()
if err := db.CreateGallery(ctx, store.Gallery{
ID: galleryID, Slug: "trip", Title: "Trip", Visibility: store.VisibilityPublic,
ShareToken: "tok", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
origKey := "galleries/" + galleryID + "/original/" + photoID + ".jpg"
thumbKey := "galleries/" + galleryID + "/thumb/" + photoID + ".jpg"
previewKey := "galleries/" + galleryID + "/preview/" + photoID + ".jpg"
if err := db.InsertPhoto(ctx, store.Photo{
ID: photoID, GalleryID: galleryID, OriginalKey: origKey, ContentType: "image/jpeg",
SizeBytes: 11, NextAttemptAt: now, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
if err := db.MarkPhotoReady(ctx, photoID, thumbKey, previewKey, 7, 100, 80, now); err != nil {
t.Fatal(err)
}
return cfg, db, stub, []string{origKey, thumbKey, previewKey}
}
func TestRunToS3(t *testing.T) {
ctx := context.Background()
cfg, db, stub, keys := syncEnv(t)
src, err := storage.NewLocal(cfg.StoragePath)
if err != nil {
t.Fatal(err)
}
for i, k := range keys {
put(t, src, k, fmt.Sprintf("photo-bytes-%d", i))
}
res, err := Run(ctx, cfg, db, Options{Direction: ToS3, Concurrency: 2})
if err != nil {
t.Fatal(err)
}
if res.Total != 3 || res.Copied != 3 || res.Skipped != 0 || res.Failed != 0 || res.Missing != 0 {
t.Fatalf("first run = %+v, want 3 total / 3 copied", res)
}
for i, k := range keys {
body, ok := stub.get(k)
if !ok {
t.Fatalf("%s not uploaded", k)
}
if want := fmt.Sprintf("photo-bytes-%d", i); string(body) != want {
t.Errorf("%s = %q, want %q", k, body, want)
}
}
// Rerunning is a no-op: everything is already there at the same size.
res, err = Run(ctx, cfg, db, Options{Direction: ToS3})
if err != nil {
t.Fatal(err)
}
if res.Copied != 0 || res.Skipped != 3 {
t.Fatalf("rerun = %+v, want 0 copied / 3 skipped", res)
}
// --overwrite re-uploads them.
before := stub.puts
res, err = Run(ctx, cfg, db, Options{Direction: ToS3, Overwrite: true})
if err != nil {
t.Fatal(err)
}
if res.Copied != 3 || stub.puts != before+3 {
t.Fatalf("overwrite run = %+v, puts %d → %d", res, before, stub.puts)
}
}
func TestRunToLocal(t *testing.T) {
ctx := context.Background()
cfg, db, stub, keys := syncEnv(t)
for i, k := range keys {
stub.objects[k] = []byte(fmt.Sprintf("s3-bytes-%d", i))
}
res, err := Run(ctx, cfg, db, Options{Direction: ToLocal})
if err != nil {
t.Fatal(err)
}
if res.Total != 3 || res.Copied != 3 || res.Failed != 0 {
t.Fatalf("run = %+v, want 3 total / 3 copied", res)
}
for i, k := range keys {
body, err := os.ReadFile(filepath.Join(cfg.StoragePath, k))
if err != nil {
t.Fatalf("read %s: %v", k, err)
}
if want := fmt.Sprintf("s3-bytes-%d", i); string(body) != want {
t.Errorf("%s = %q, want %q", k, body, want)
}
}
}
func TestRunMissingOnSource(t *testing.T) {
cfg, db, _, _ := syncEnv(t)
// Nothing on local disk: every object is reported missing, none fail.
res, err := Run(context.Background(), cfg, db, Options{Direction: ToS3})
if err != nil {
t.Fatal(err)
}
if res.Missing != 3 || res.Copied != 0 || res.Failed != 0 {
t.Fatalf("run = %+v, want 3 missing", res)
}
}
func TestRunRequiresS3Config(t *testing.T) {
cfg, db, _, _ := syncEnv(t)
cfg.S3Endpoint, cfg.S3Bucket = "", ""
if _, err := Run(context.Background(), cfg, db, Options{Direction: ToS3}); err == nil {
t.Fatal("sync without S3 configured should fail")
}
}
+21
View File
@@ -17,6 +17,12 @@ type local struct {
root string
}
// NewLocal builds the local-disk backend explicitly, whichever backend is
// active — `photo-api sync` needs both sides at once.
func NewLocal(root string) (Storage, error) {
return newLocal(root)
}
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)
@@ -74,6 +80,21 @@ func (l *local) Open(_ context.Context, key string) (io.ReadCloser, int64, error
return f, info.Size(), nil
}
func (l *local) Stat(_ context.Context, key string) (int64, error) {
p, err := l.path(key)
if err != nil {
return 0, err
}
info, err := os.Stat(p)
if os.IsNotExist(err) {
return 0, ErrNotExist
}
if err != nil {
return 0, err
}
return info.Size(), nil
}
func (l *local) Delete(_ context.Context, key string) error {
p, err := l.path(key)
if err != nil {
+33
View File
@@ -2,14 +2,19 @@ package storage
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
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"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
)
@@ -23,6 +28,12 @@ type s3Store struct {
bucket string
}
// NewS3 builds the S3 backend explicitly, whichever backend is active —
// `photo-api sync` needs both sides at once.
func NewS3(cfg config.Config) (Storage, error) {
return newS3(cfg)
}
func newS3(cfg config.Config) (*s3Store, error) {
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(cfg.S3Region),
@@ -65,6 +76,28 @@ func (s *s3Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, e
return out.Body, aws.ToInt64(out.ContentLength), nil
}
func (s *s3Store) Stat(ctx context.Context, key string) (int64, error) {
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
// Compatibles differ: some answer NotFound, some NoSuchKey, and a
// HEAD carries no body to parse, so fall back to the status code.
var notFound *types.NotFound
var noKey *types.NoSuchKey
var apiErr smithy.APIError
var respErr *awshttp.ResponseError
if errors.As(err, &notFound) || errors.As(err, &noKey) ||
(errors.As(err, &apiErr) && (apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey")) ||
(errors.As(err, &respErr) && respErr.HTTPStatusCode() == http.StatusNotFound) {
return 0, ErrNotExist
}
return 0, err
}
return 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),
+17 -2
View File
@@ -7,6 +7,7 @@ package storage
import (
"context"
"errors"
"fmt"
"io"
"time"
@@ -17,16 +18,30 @@ import (
// callers then stream the object through the API instead.
var ErrNoPresign = errors.New("presigned URLs not supported")
// ErrNotExist is what Stat reports for a missing object on either backend.
var ErrNotExist = errors.New("object does not exist")
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)
// Stat returns the object's size without reading it, or ErrNotExist.
Stat(ctx context.Context, key string) (int64, error)
Delete(ctx context.Context, key string) error
PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error)
}
// New builds the backend that serves requests (see config.S3Enabled).
func New(cfg config.Config) (Storage, error) {
if cfg.S3Enabled() {
return newS3(cfg)
return NewS3(cfg)
}
return newLocal(cfg.StoragePath)
return NewLocal(cfg.StoragePath)
}
// Name describes a backend for log lines.
func Name(cfg config.Config, s Storage) string {
if _, ok := s.(*s3Store); ok {
return fmt.Sprintf("S3 bucket %s at %s", cfg.S3Bucket, cfg.S3Endpoint)
}
return fmt.Sprintf("local disk at %s", cfg.StoragePath)
}
+49 -12
View File
@@ -12,31 +12,67 @@ import (
"time"
)
type UserAuth struct {
type SessionUser struct {
ID string
Email string
Role string
TokenVersion int
AccountStatus string
Banned bool
ExpiresAt time.Time
}
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 {
// GetSessionUser resolves a Better Auth session token (from the
// spanglish.session_token cookie) to its user. Sessions live in the shared
// auth_sessions table, written by the backend; the row's presence is the
// authoritative validity check, so revocations apply here instantly.
func (db *DB) GetSessionUser(ctx context.Context, token string) (SessionUser, error) {
row := db.QueryRowContext(ctx, db.Rebind(`
SELECT u.id, u.email, u.role, u.account_status, u.banned, s.expires_at
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ?`), token)
var id, email, role, status, banned, expires any
if err := row.Scan(&id, &email, &role, &status, &banned, &expires); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return UserAuth{}, ErrNotFound
return SessionUser{}, ErrNotFound
}
return UserAuth{}, err
return SessionUser{}, err
}
return UserAuth{
return SessionUser{
ID: asString(id),
Email: asString(email),
Role: asString(role),
TokenVersion: int(asInt(tv)),
AccountStatus: asString(status),
Banned: asBool(banned),
ExpiresAt: asTimeOrMillis(expires),
}, nil
}
// asBool normalizes booleans across drivers (pg BOOLEAN, sqlite INTEGER 0/1).
func asBool(v any) bool {
switch x := v.(type) {
case bool:
return x
case int64, int, int32, float64:
return asInt(v) != 0
case []byte:
return len(x) > 0 && x[0] != '0' && x[0] != 'f' && x[0] != 'F'
default:
return false
}
}
// asTimeOrMillis parses a timestamp that is a pg TIMESTAMP on postgres but an
// epoch-milliseconds INTEGER on sqlite (Drizzle timestamp_ms mode).
func asTimeOrMillis(v any) time.Time {
switch v.(type) {
case int64, int, int32, float64:
return time.UnixMilli(asInt(v)).UTC()
default:
return asTime(v)
}
}
// 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).
@@ -99,7 +135,8 @@ func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSu
// 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, email, role, account_status, banned FROM users WHERE 1 = 0",
"SELECT id, user_id, token, expires_at FROM auth_sessions 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",
+13
View File
@@ -54,6 +54,19 @@ func Open(cfg config.Config) (*DB, error) {
}
}
// IsUniqueViolation reports whether err is a duplicate-key error. The two
// drivers wrap it in unrelated types (pgconn.PgError vs sqlite.Error), and
// neither is worth importing here just for one check, so this matches on the
// message: pgx renders "(SQLSTATE 23505)", modernc/sqlite "UNIQUE constraint
// failed: ...".
func IsUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "SQLSTATE 23505") || strings.Contains(msg, "UNIQUE constraint failed")
}
// 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 {
+153 -17
View File
@@ -22,6 +22,9 @@ type Photo struct {
Height int
ThumbKey string
PreviewKey string
// PreviewSizeBytes is the size of the preview object, 0 when it has not
// been measured yet (rows written before the column existed).
PreviewSizeBytes int64
TakenAt time.Time
Status string
Attempts int
@@ -29,14 +32,17 @@ type Photo struct {
LastError string
CreatedAt time.Time
UpdatedAt time.Time
// Checksum is the sha256 of the original bytes, empty when the row has
// not been hashed yet (uploaded before duplicate detection existed).
Checksum string
}
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`
size_bytes, width, height, thumb_key, preview_key, preview_size_bytes, taken_at, status,
attempts, next_attempt_at, last_error, created_at, updated_at, checksum`
func scanPhoto(s scanner) (Photo, error) {
var v [18]any
var v [20]any
dest := make([]any, len(v))
for i := range v {
dest[i] = &v[i]
@@ -56,27 +62,90 @@ func scanPhoto(s scanner) (Photo, error) {
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]),
PreviewSizeBytes: asInt(v[11]),
TakenAt: asTime(v[12]),
Status: asString(v[13]),
Attempts: int(asInt(v[14])),
NextAttemptAt: asTime(v[15]),
LastError: asString(v[16]),
CreatedAt: asTime(v[17]),
UpdatedAt: asTime(v[18]),
Checksum: asString(v[19]),
}, nil
}
// InsertPhoto returns an error satisfying IsUniqueViolation when the gallery
// already holds a photo with the same checksum; callers treat that as a
// duplicate rather than a failure.
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, ?, ?, ?)`),
status, attempts, next_attempt_at, created_at, updated_at, checksum)
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))
p.SizeBytes, db.TimeArg(p.NextAttemptAt), db.TimeArg(p.CreatedAt), db.TimeArg(p.UpdatedAt),
nullable(p.Checksum))
return err
}
// FindPhotoByChecksum looks for an existing photo with the same content in one
// gallery — the duplicate check the upload path runs before storing anything.
// Duplicate scope is per gallery: the same image in another gallery keeps its
// own row and its own stored object.
func (db *DB) FindPhotoByChecksum(ctx context.Context, galleryID, checksum string) (Photo, error) {
if checksum == "" {
return Photo{}, ErrNotFound // never match the not-yet-hashed rows
}
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT "+photoColumns+" FROM photos_photos WHERE gallery_id = ? AND checksum = ?"),
galleryID, checksum)
p, err := scanPhoto(row)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
return p, err
}
// SetPhotoChecksum fills in the hash of an already-stored photo. Used by the
// backfill; returns a unique-violation error when the row turns out to
// duplicate one already hashed in the same gallery.
func (db *DB) SetPhotoChecksum(ctx context.Context, id, checksum string) error {
res, err := db.ExecContext(ctx, db.Rebind(
"UPDATE photos_photos SET checksum = ? WHERE id = ?"), checksum, id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// PhotosMissingChecksum lists rows still lacking a hash (optionally of one
// gallery only), oldest first so backfill keeps the earliest upload of a
// duplicate pair as the one that gets the checksum.
func (db *DB) PhotosMissingChecksum(ctx context.Context, galleryID string) ([]Photo, error) {
q := "SELECT " + photoColumns + " FROM photos_photos WHERE checksum IS NULL"
var args []any
if galleryID != "" {
q += " AND gallery_id = ?"
args = append(args, galleryID)
}
q += " ORDER BY gallery_id, position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
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) GetPhoto(ctx context.Context, id string) (Photo, error) {
row := db.QueryRowContext(ctx,
db.Rebind("SELECT "+photoColumns+" FROM photos_photos WHERE id = ?"), id)
@@ -169,6 +238,64 @@ func (db *DB) PhotoKeys(ctx context.Context, galleryID string) ([]string, error)
return keys, rows.Err()
}
// PhotoObject is one stored object: a photo variant plus the content type it
// should be written with. Used by the storage sync, which treats the rows as
// the inventory of what exists.
type PhotoObject struct {
PhotoID string
GalleryID string
Variant string // original | thumb | preview
Key string
ContentType string
}
// AllPhotoObjects lists every object of every photo (optionally of one
// gallery only), oldest first. Variant keys are empty until the worker has
// processed the photo; those are left out.
func (db *DB) AllPhotoObjects(ctx context.Context, galleryID string) ([]PhotoObject, error) {
q := `SELECT id, gallery_id, content_type, original_key, thumb_key, preview_key
FROM photos_photos`
var args []any
if galleryID != "" {
q += " WHERE gallery_id = ?"
args = append(args, galleryID)
}
q += " ORDER BY gallery_id, position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
objects := []PhotoObject{}
for rows.Next() {
var id, galID, ctype, orig, thumb, preview any
if err := rows.Scan(&id, &galID, &ctype, &orig, &thumb, &preview); err != nil {
return nil, err
}
variants := []struct {
name, key, contentType string
}{
{"original", asString(orig), asString(ctype)},
{"thumb", asString(thumb), "image/jpeg"},
{"preview", asString(preview), "image/jpeg"},
}
for _, v := range variants {
if v.key == "" {
continue
}
objects = append(objects, PhotoObject{
PhotoID: asString(id),
GalleryID: asString(galID),
Variant: v.name,
Key: v.key,
ContentType: v.contentType,
})
}
}
return objects, 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.
@@ -200,17 +327,26 @@ func (db *DB) ClaimNextPhoto(ctx context.Context) (Photo, error) {
return db.GetPhoto(ctx, id)
}
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, width, height int, takenAt time.Time) error {
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, previewSize int64, 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 = ?
SET status = 'ready', thumb_key = ?, preview_key = ?, preview_size_bytes = ?, width = ?,
height = ?, taken_at = ?, last_error = NULL, updated_at = ?
WHERE id = ?`),
thumbKey, previewKey, width, height, takenArg, db.TimeArg(time.Now()), id)
thumbKey, previewKey, previewSize, width, height, takenArg, db.TimeArg(time.Now()), id)
return err
}
// SetPreviewSize records the measured size of an already-generated preview.
// Used to fill in rows processed before the column existed; failure to write
// is not worth failing a read over, so callers may ignore the error.
func (db *DB) SetPreviewSize(ctx context.Context, id string, size int64) error {
_, err := db.ExecContext(ctx, db.Rebind(
"UPDATE photos_photos SET preview_size_bytes = ? WHERE id = ?"), size, id)
return err
}
+14 -7
View File
@@ -146,14 +146,17 @@ func (w *Worker) process(ctx context.Context, p store.Photo) error {
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 {
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 {
// The preview's size is recorded because the gallery response labels the
// mobile save sheet with it; measuring it here costs nothing.
previewSize, err := w.upload(ctx, previewKey, previewPath)
if err != nil {
return fmt.Errorf("store preview: %w", err)
}
return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, res.Width, res.Height, res.TakenAt)
return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, previewSize, res.Width, res.Height, res.TakenAt)
}
func (w *Worker) download(ctx context.Context, key, dst string) error {
@@ -171,15 +174,19 @@ func (w *Worker) download(ctx context.Context, key, dst string) error {
return err
}
func (w *Worker) upload(ctx context.Context, key, src string) error {
// upload stores a generated variant and reports how many bytes it holds.
func (w *Worker) upload(ctx context.Context, key, src string) (int64, error) {
f, err := os.Open(src)
if err != nil {
return err
return 0, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
return 0, err
}
return w.storage.Put(ctx, key, f, info.Size(), "image/jpeg")
if err := w.storage.Put(ctx, key, f, info.Size(), "image/jpeg"); err != nil {
return 0, err
}
return info.Size(), nil
}
@@ -0,0 +1,8 @@
-- Content hash of the stored original, for per-gallery duplicate detection on
-- upload. NULL means "not hashed yet" — rows predating this migration, until
-- `photo-api backfill-checksums` runs. NULLs are distinct under a unique
-- index, so those rows never collide with each other.
-- (No semicolons in these comments: the migration runner splits on them.)
ALTER TABLE photos_photos ADD COLUMN IF NOT EXISTS checksum varchar(64);
CREATE UNIQUE INDEX IF NOT EXISTS photos_photos_gallery_checksum_idx ON photos_photos (gallery_id, checksum);
@@ -0,0 +1,8 @@
-- Content hash of the stored original, for per-gallery duplicate detection on
-- upload. NULL means "not hashed yet" — rows predating this migration, until
-- `photo-api backfill-checksums` runs. NULLs are distinct under a unique
-- index, so those rows never collide with each other.
-- (No semicolons in these comments: the migration runner splits on them.)
ALTER TABLE photos_photos ADD COLUMN checksum text;
CREATE UNIQUE INDEX IF NOT EXISTS photos_photos_gallery_checksum_idx ON photos_photos (gallery_id, checksum);
@@ -0,0 +1,6 @@
-- Byte size of the generated preview variant, so the gallery response can
-- label the mobile save sheet's rows without the client HEADing every file.
-- NULL means "not measured yet" — rows predating this migration, until the
-- read path stats them once (see fillPreviewSizes in httpapi/sizes.go).
-- (No semicolons in these comments: the migration runner splits on them.)
ALTER TABLE photos_photos ADD COLUMN IF NOT EXISTS preview_size_bytes bigint;
@@ -0,0 +1,6 @@
-- Byte size of the generated preview variant, so the gallery response can
-- label the mobile save sheet's rows without the client HEADing every file.
-- NULL means "not measured yet" — rows predating this migration, until the
-- read path stats them once (see fillPreviewSizes in httpapi/sizes.go).
-- (No semicolons in these comments: the migration runner splits on them.)
ALTER TABLE photos_photos ADD COLUMN preview_size_bytes integer;