Compare commits
10
Commits
backup-prod13
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4dd5b47f | ||
|
|
390e1dc0ea | ||
|
|
fa8686276d | ||
|
|
498d7d8a7d | ||
|
|
dafa3711f8 | ||
|
|
733d2459df | ||
|
|
4afa5d6fa0 | ||
|
|
617c884012 | ||
|
|
93476ac72a | ||
|
|
c4094630d9 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declaration": false,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 that’s 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 you’re 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 what’s 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 />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="" />
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¬Found) || 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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user