From 00baf9bd89a90a2f93e5f4c9b2afa470657dcdca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:11:10 +0000 Subject: [PATCH] feat: full Docker stack on a VPS with improved admin editing Self-hosted rewrite of the wedding photo app: Node + Hono replaces Cloudflare Workers, Postgres 16 replaces D1, MinIO replaces R2, Caddy fronts the stack with automatic Let's Encrypt TLS. Same routes and feature set as before; storage abstraction is the same S3 client so MinIO drops in without code changes. Architecture: - docker-compose.yml: postgres, minio, minio-init (creates bucket + anonymous read + CORS), app, caddy (reverse proxy + media subdomain). - Dockerfile: multi-stage pnpm build, single runtime image serving the API and the SPA dist as static assets from one process. - Caddyfile: primary domain proxies to app; media subdomain proxies to MinIO so guests upload directly and signatures match Host. - app: tsx runtime, runs SQL migrations idempotently at startup via a schema_migrations(name, sha256, applied_at) table. Admin upgrades requested: - PATCH /api/admin/uploads/:id to edit author/message. - POST /api/admin/uploads/bulk for bulk approve/reject/delete. - POST /api/admin/uploads/:id/cover and DELETE /api/admin/event/cover to set/clear a featured image (rendered on Home when set). - GET /api/admin/uploads gains ?q= text search across author and message and ?kind=photo|video filter. - Dashboard: bulk select checkboxes with a toolbar, edit modal that rewrites author and message, search input, kind filter, set-cover button per item, cover preview + clear in the event card. Singletons replace the per-request bindings pattern: initDb() and initStorage() run once in server.ts; routes call getDb()/getStorage() directly rather than threading env.DB / env.MEDIA through. env.ts uses zod to parse process.env and fails fast if anything mandatory is missing. .env.example documents every variable and flags the hairpin tradeoff for MinIO access from the app container. --- .dockerignore | 16 + .env.example | 34 + .gitignore | 15 + .npmrc | 2 + Caddyfile | 25 + Dockerfile | 35 + apps/api/drizzle.config.ts | 7 + apps/api/package.json | 40 + apps/api/src/app.ts | 28 + apps/api/src/db/client.ts | 30 + apps/api/src/db/migrate.ts | 71 + apps/api/src/db/migrations/0001_initial.sql | 46 + apps/api/src/db/schema.ts | 62 + apps/api/src/env.ts | 48 + apps/api/src/lib/auth.ts | 56 + apps/api/src/lib/ids.ts | 21 + apps/api/src/lib/qr-pdf.ts | 84 + apps/api/src/lib/qrcode.ts | 19 + apps/api/src/lib/storage-factory.ts | 26 + apps/api/src/lib/storage-s3.ts | 164 + apps/api/src/lib/storage.ts | 49 + apps/api/src/routes/admin.ts | 322 ++ apps/api/src/routes/public.ts | 131 + apps/api/src/routes/uploads.ts | 220 ++ apps/api/src/server.ts | 65 + apps/api/tsconfig.json | 8 + apps/web/index.html | 13 + apps/web/package.json | 29 + apps/web/postcss.config.js | 6 + apps/web/src/App.tsx | 18 + apps/web/src/index.css | 15 + apps/web/src/lib/api.ts | 23 + apps/web/src/lib/upload.ts | 143 + apps/web/src/main.tsx | 16 + apps/web/src/routes/Gallery.tsx | 157 + apps/web/src/routes/Home.tsx | 58 + apps/web/src/routes/Upload.tsx | 262 ++ apps/web/src/routes/admin/Dashboard.tsx | 834 ++++++ apps/web/src/routes/admin/Login.tsx | 97 + apps/web/tailwind.config.ts | 19 + apps/web/tsconfig.json | 10 + apps/web/vite.config.ts | 19 + docker-compose.yml | 109 + infra/minio-cors.json | 11 + package.json | 20 + packages/shared/package.json | 21 + packages/shared/src/index.ts | 2 + packages/shared/src/schemas.ts | 135 + packages/shared/src/types.ts | 6 + packages/shared/tsconfig.json | 8 + pnpm-lock.yaml | 2956 +++++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 20 + 53 files changed, 6634 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 apps/api/drizzle.config.ts create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/db/client.ts create mode 100644 apps/api/src/db/migrate.ts create mode 100644 apps/api/src/db/migrations/0001_initial.sql create mode 100644 apps/api/src/db/schema.ts create mode 100644 apps/api/src/env.ts create mode 100644 apps/api/src/lib/auth.ts create mode 100644 apps/api/src/lib/ids.ts create mode 100644 apps/api/src/lib/qr-pdf.ts create mode 100644 apps/api/src/lib/qrcode.ts create mode 100644 apps/api/src/lib/storage-factory.ts create mode 100644 apps/api/src/lib/storage-s3.ts create mode 100644 apps/api/src/lib/storage.ts create mode 100644 apps/api/src/routes/admin.ts create mode 100644 apps/api/src/routes/public.ts create mode 100644 apps/api/src/routes/uploads.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/upload.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/routes/Gallery.tsx create mode 100644 apps/web/src/routes/Home.tsx create mode 100644 apps/web/src/routes/Upload.tsx create mode 100644 apps/web/src/routes/admin/Dashboard.tsx create mode 100644 apps/web/src/routes/admin/Login.tsx create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 infra/minio-cors.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9465ada --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +**/node_modules +.git +.gitignore +.dockerignore +.env +.env.local +.env.*.local +dist +**/dist +.cache +.turbo +*.log +.DS_Store +.vscode +.idea diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33ab0cf --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# ============================================================================= +# Edite essas variáveis e salve como .env (mesmo diretório do docker-compose.yml) +# ============================================================================= + +# ----- Domínios (apontar DNS A/AAAA pra IP do VPS antes do `docker compose up`) +DOMAIN=casamento.exemplo.com.br +MEDIA_DOMAIN=midia.exemplo.com.br +ACME_EMAIL=voce@exemplo.com + +# ----- Postgres +POSTGRES_USER=wedding +POSTGRES_PASSWORD=troque-isso-por-uma-senha-forte +POSTGRES_DB=wedding + +# ----- MinIO (root credentials = também usadas pelo app como S3 access keys) +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=troque-isso-por-uma-senha-forte +S3_BUCKET=wedding-media +S3_REGION=us-east-1 +# Endpoint INTERNO usado pelo app pra falar com o MinIO. Use o do Caddy +# (https://${MEDIA_DOMAIN}) — necessário porque a assinatura S3 inclui o host +# e o browser faz upload direto pra MEDIA_DOMAIN. Hairpin via DNS público é OK. +# Pra evitar hairpin, ajuste extra_hosts no compose pra resolver MEDIA_DOMAIN +# pra IP interno e set S3_INTERNAL_ENDPOINT=http://minio:9000 (avançado). +S3_INTERNAL_ENDPOINT= + +# ----- App +COUPLE_NAMES=Stefanie & Leandro +EVENT_DATE=07 de junho de 2026 + +# ----- Admin (painel dos noivos) +ADMIN_PASSWORD=senha-forte-compartilhada-entre-os-dois +SESSION_SECRET=gere-um-uuid-longo-aqui-min-32-chars +ALLOWED_ADMIN_EMAILS=voce@exemplo.com,outro@exemplo.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a303ab8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +build/ +.cache/ +.turbo/ +*.log +.DS_Store + +.env +.env.local +.env.*.local +!.env.example + +.vscode/ +.idea/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4c2f52b --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +strict-peer-dependencies=false diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..2774d45 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,25 @@ +{ + email {$ACME_EMAIL} +} + +{$DOMAIN} { + encode zstd gzip + + request_body { + max_size 600MB + } + + reverse_proxy app:3000 { + transport http { + response_header_timeout 10m + dial_timeout 30s + } + } +} + +{$MEDIA_DOMAIN} { + encode gzip + reverse_proxy minio:9000 { + header_up Host {upstream_hostport} + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2cf2d0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:22-alpine AS base +RUN apk add --no-cache libc6-compat +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +FROM base AS deps +WORKDIR /app +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./ +COPY packages/shared/package.json packages/shared/ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +RUN pnpm install --frozen-lockfile + +FROM deps AS build +WORKDIR /app +COPY tsconfig.base.json ./ +COPY packages packages +COPY apps apps +RUN pnpm -F @leetete/web build + +FROM base AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV STATIC_DIR=/app/public + +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/pnpm-workspace.yaml /app/package.json /app/pnpm-lock.yaml* ./ +COPY --from=build /app/tsconfig.base.json ./ +COPY --from=build /app/packages packages +COPY --from=build /app/apps/api apps/api +COPY --from=build /app/apps/web/dist public + +EXPOSE 3000 + +CMD ["pnpm", "--filter", "@leetete/api", "start"] diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..1931818 --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,7 @@ +import type { Config } from 'drizzle-kit'; + +export default { + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'postgresql', +} satisfies Config; diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..c9071ab --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,40 @@ +{ + "name": "@leetete/api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/app.ts", + "types": "./src/app.ts", + "exports": { + ".": "./src/app.ts", + "./env": "./src/env.ts", + "./db/schema": "./src/db/schema.ts" + }, + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts", + "migrate": "tsx src/db/migrate.ts", + "typecheck": "tsc --noEmit", + "build": "tsc --noEmit", + "db:generate": "drizzle-kit generate" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "@leetete/shared": "workspace:*", + "aws4fetch": "^1.0.20", + "drizzle-orm": "^0.36.4", + "hono": "^4.6.14", + "nanoid": "^5.0.9", + "pdf-lib": "^1.17.1", + "postgres": "^3.4.5", + "qrcode": "^1.5.4", + "tsx": "^4.19.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@types/qrcode": "^1.5.5", + "drizzle-kit": "^0.28.1", + "typescript": "^5.6.3" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..f960b76 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,28 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import type { Bindings } from './env.js'; +import { adminRoutes } from './routes/admin.js'; +import { publicRoutes } from './routes/public.js'; +import { uploadsRoutes } from './routes/uploads.js'; + +export function createApp() { + const app = new Hono().basePath('/api'); + + app.use('*', cors({ origin: (origin) => origin ?? '*', credentials: true })); + + app.get('/health', (c) => c.json({ ok: true, ts: Date.now() })); + + app.route('/', publicRoutes); + app.route('/uploads', uploadsRoutes); + app.route('/admin', adminRoutes); + + app.notFound((c) => c.json({ error: 'not_found' }, 404)); + app.onError((err, c) => { + console.error('unhandled', err); + return c.json({ error: 'internal_error' }, 500); + }); + + return app; +} + +export type App = ReturnType; diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts new file mode 100644 index 0000000..74b0867 --- /dev/null +++ b/apps/api/src/db/client.ts @@ -0,0 +1,30 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema.js'; + +export type DB = ReturnType>; + +let dbInstance: DB | null = null; +let pgInstance: ReturnType | null = null; + +export function initDb(databaseUrl: string): DB { + if (dbInstance) return dbInstance; + pgInstance = postgres(databaseUrl, { max: 10, idle_timeout: 30 }); + dbInstance = drizzle(pgInstance, { schema }); + return dbInstance; +} + +export function getDb(): DB { + if (!dbInstance) { + throw new Error('DB not initialized. Call initDb() before getDb().'); + } + return dbInstance; +} + +export async function closeDb(): Promise { + if (pgInstance) { + await pgInstance.end({ timeout: 5 }); + pgInstance = null; + dbInstance = null; + } +} diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..5a8ed32 --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import postgres from 'postgres'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function sha256(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} + +export async function runMigrations(databaseUrl: string): Promise { + const sql = postgres(databaseUrl, { max: 1 }); + try { + await sql` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + sha256 TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + const dir = join(__dirname, 'migrations'); + const files = readdirSync(dir) + .filter((f) => f.endsWith('.sql')) + .sort(); + + for (const file of files) { + const content = readFileSync(join(dir, file), 'utf-8'); + const hash = sha256(content); + + const existing = await sql<{ sha256: string }[]>` + SELECT sha256 FROM schema_migrations WHERE name = ${file} + `; + + if (existing.length) { + if (existing[0]!.sha256 !== hash) { + throw new Error( + `Migration ${file} has been modified after being applied. ` + + `Add a new migration file instead.`, + ); + } + console.log(`[migrate] skip ${file}`); + continue; + } + + console.log(`[migrate] apply ${file}`); + await sql.unsafe(content); + await sql` + INSERT INTO schema_migrations (name, sha256) VALUES (${file}, ${hash}) + `; + } + + console.log('[migrate] done'); + } finally { + await sql.end({ timeout: 5 }); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const url = process.env['DATABASE_URL']; + if (!url) { + console.error('DATABASE_URL is required'); + process.exit(1); + } + runMigrations(url).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/apps/api/src/db/migrations/0001_initial.sql b/apps/api/src/db/migrations/0001_initial.sql new file mode 100644 index 0000000..4b31d4b --- /dev/null +++ b/apps/api/src/db/migrations/0001_initial.sql @@ -0,0 +1,46 @@ +CREATE TABLE event_config ( + id INTEGER PRIMARY KEY DEFAULT 1, + couple_names TEXT NOT NULL, + event_date TEXT, + cover_key TEXT, + gallery_visibility TEXT NOT NULL DEFAULT 'public', + moderation TEXT NOT NULL DEFAULT 'post', + max_file_mb INTEGER NOT NULL DEFAULT 500, + allow_video BOOLEAN NOT NULL DEFAULT TRUE, + max_video_seconds INTEGER NOT NULL DEFAULT 300, + welcome_message TEXT, + updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT +); + +INSERT INTO event_config (id, couple_names) +VALUES (1, 'Stefanie & Leandro') +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE uploads ( + id TEXT PRIMARY KEY, + storage_key TEXT NOT NULL, + thumbnail_key TEXT, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + duration_seconds INTEGER, + author_name TEXT, + message TEXT, + status TEXT NOT NULL DEFAULT 'approved', + source TEXT NOT NULL DEFAULT 'guest', + ip_hash TEXT, + provider_upload_id TEXT, + created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, + approved_at BIGINT +); + +CREATE INDEX idx_uploads_status_created ON uploads (status, created_at DESC); +CREATE INDEX idx_uploads_source ON uploads (source); +CREATE INDEX idx_uploads_author_lower ON uploads (LOWER(author_name)); + +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + actor TEXT, + payload TEXT, + created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT +); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..73a5702 --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -0,0 +1,62 @@ +import { sql } from 'drizzle-orm'; +import { bigint, boolean, index, integer, pgTable, text } from 'drizzle-orm/pg-core'; + +export const eventConfig = pgTable('event_config', { + id: integer('id').primaryKey().default(1), + coupleNames: text('couple_names').notNull(), + eventDate: text('event_date'), + coverKey: text('cover_key'), + galleryVisibility: text('gallery_visibility', { enum: ['public', 'private'] }) + .notNull() + .default('public'), + moderation: text('moderation', { enum: ['pre', 'post'] }).notNull().default('post'), + maxFileMb: integer('max_file_mb').notNull().default(500), + allowVideo: boolean('allow_video').notNull().default(true), + maxVideoSeconds: integer('max_video_seconds').notNull().default(300), + welcomeMessage: text('welcome_message'), + updatedAt: bigint('updated_at', { mode: 'number' }) + .notNull() + .default(sql`(extract(epoch from now()) * 1000)::bigint`), +}); + +export const uploads = pgTable( + 'uploads', + { + id: text('id').primaryKey(), + storageKey: text('storage_key').notNull(), + thumbnailKey: text('thumbnail_key'), + mimeType: text('mime_type').notNull(), + sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(), + durationSeconds: integer('duration_seconds'), + authorName: text('author_name'), + message: text('message'), + status: text('status', { enum: ['pending', 'approved', 'rejected'] }) + .notNull() + .default('approved'), + source: text('source', { enum: ['guest', 'import'] }).notNull().default('guest'), + ipHash: text('ip_hash'), + providerUploadId: text('provider_upload_id'), + createdAt: bigint('created_at', { mode: 'number' }) + .notNull() + .default(sql`(extract(epoch from now()) * 1000)::bigint`), + approvedAt: bigint('approved_at', { mode: 'number' }), + }, + (t) => ({ + statusCreatedIdx: index('idx_uploads_status_created').on(t.status, t.createdAt), + sourceIdx: index('idx_uploads_source').on(t.source), + }), +); + +export const auditLog = pgTable('audit_log', { + id: text('id').primaryKey(), + action: text('action').notNull(), + actor: text('actor'), + payload: text('payload'), + createdAt: bigint('created_at', { mode: 'number' }) + .notNull() + .default(sql`(extract(epoch from now()) * 1000)::bigint`), +}); + +export type EventConfigRow = typeof eventConfig.$inferSelect; +export type UploadRow = typeof uploads.$inferSelect; +export type NewUploadRow = typeof uploads.$inferInsert; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..01c0433 --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + DATABASE_URL: z.string().min(1), + + S3_ENDPOINT: z.string().min(1), + S3_BUCKET: z.string().min(1), + S3_REGION: z.string().default('us-east-1'), + S3_ACCESS_KEY_ID: z.string().min(1), + S3_SECRET_ACCESS_KEY: z.string().min(1), + S3_PUBLIC_BASE_URL: z.string().min(1), + S3_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'), + + COUPLE_NAMES: z.string().default('Stefanie & Leandro'), + EVENT_DATE: z.string().optional(), + PUBLIC_BASE_URL: z.string().optional(), + + ADMIN_PASSWORD: z.string().min(1), + SESSION_SECRET: z.string().min(16), + ALLOWED_ADMIN_EMAILS: z.string().min(1), + + PORT: z.coerce.number().int().positive().default(3000), + STATIC_DIR: z.string().default('./public'), + NODE_ENV: z.enum(['development', 'production']).default('production'), + AUTO_MIGRATE: z.enum(['true', 'false']).default('true'), + TRUST_PROXY: z.enum(['true', 'false']).default('true'), +}); + +export type AppEnv = z.infer; + +export interface AppVariables { + adminEmail: string; +} + +export type Bindings = { + Bindings: AppEnv; + Variables: AppVariables; +}; + +export function loadEnv(): AppEnv { + const result = envSchema.safeParse(process.env); + if (!result.success) { + console.error('Invalid environment variables:'); + console.error(result.error.flatten().fieldErrors); + process.exit(1); + } + return result.data; +} diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts new file mode 100644 index 0000000..454044a --- /dev/null +++ b/apps/api/src/lib/auth.ts @@ -0,0 +1,56 @@ +import type { Context, Next } from 'hono'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import { sign, verify } from 'hono/jwt'; +import type { Bindings } from '../env.js'; + +const COOKIE = 'wedding_admin'; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; + +interface SessionPayload { + email: string; + exp: number; +} + +export async function requireAdmin(c: Context, next: Next) { + const token = getCookie(c, COOKIE); + if (!token) return c.json({ error: 'unauthorized' }, 401); + try { + const payload = (await verify( + token, + c.env.SESSION_SECRET, + 'HS256', + )) as unknown as SessionPayload; + if (!payload.email) return c.json({ error: 'unauthorized' }, 401); + c.set('adminEmail', payload.email); + await next(); + } catch { + return c.json({ error: 'unauthorized' }, 401); + } +} + +export async function createSession(c: Context, email: string) { + const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; + const token = await sign({ email, exp }, c.env.SESSION_SECRET, 'HS256'); + const secure = c.env.NODE_ENV === 'production'; + setCookie(c, COOKIE, token, { + httpOnly: true, + secure, + sameSite: 'Lax', + path: '/', + maxAge: SESSION_TTL_SECONDS, + }); +} + +export function destroySession(c: Context) { + deleteCookie(c, COOKIE, { path: '/' }); +} + +export async function timingSafeEqual(a: string, b: string): Promise { + if (a.length !== b.length) return false; + const enc = new TextEncoder(); + const aBytes = enc.encode(a); + const bBytes = enc.encode(b); + let result = 0; + for (let i = 0; i < aBytes.length; i++) result |= aBytes[i]! ^ bBytes[i]!; + return result === 0; +} diff --git a/apps/api/src/lib/ids.ts b/apps/api/src/lib/ids.ts new file mode 100644 index 0000000..5f74bf3 --- /dev/null +++ b/apps/api/src/lib/ids.ts @@ -0,0 +1,21 @@ +import { customAlphabet } from 'nanoid'; + +const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; +const newId = customAlphabet(alphabet, 16); + +export function uploadId(): string { + return `up_${newId()}`; +} + +export function auditId(): string { + return `au_${newId()}`; +} + +export async function hashIp(ip: string, salt: string): Promise { + const data = new TextEncoder().encode(`${salt}:${ip}`); + const digest = await crypto.subtle.digest('SHA-256', data); + const bytes = new Uint8Array(digest); + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + return hex.slice(0, 32); +} diff --git a/apps/api/src/lib/qr-pdf.ts b/apps/api/src/lib/qr-pdf.ts new file mode 100644 index 0000000..55b3361 --- /dev/null +++ b/apps/api/src/lib/qr-pdf.ts @@ -0,0 +1,84 @@ +import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import { generateQrPng } from './qrcode.js'; + +export interface QrPdfOptions { + url: string; + coupleNames: string; + eventDate?: string; + callToAction?: string; +} + +const PT_PER_MM = 2.834645669; +const A6_WIDTH = 105 * PT_PER_MM; +const A6_HEIGHT = 148 * PT_PER_MM; + +function centerX( + text: string, + fontSize: number, + font: import('pdf-lib').PDFFont, +): number { + const w = font.widthOfTextAtSize(text, fontSize); + return (A6_WIDTH - w) / 2; +} + +export async function generateQrPdf(opts: QrPdfOptions): Promise { + const pdf = await PDFDocument.create(); + const page = pdf.addPage([A6_WIDTH, A6_HEIGHT]); + + const titleFont = await pdf.embedFont(StandardFonts.TimesRomanItalic); + const bodyFont = await pdf.embedFont(StandardFonts.Helvetica); + const boldFont = await pdf.embedFont(StandardFonts.HelveticaBold); + + const ink = rgb(0.18, 0.16, 0.14); + const muted = rgb(0.45, 0.42, 0.38); + + const titleSize = 28; + const titleY = A6_HEIGHT - 28 * PT_PER_MM; + page.drawText(opts.coupleNames, { + x: centerX(opts.coupleNames, titleSize, titleFont), + y: titleY, + size: titleSize, + font: titleFont, + color: ink, + }); + + if (opts.eventDate) { + const dateSize = 11; + page.drawText(opts.eventDate, { + x: centerX(opts.eventDate, dateSize, bodyFont), + y: titleY - 7 * PT_PER_MM, + size: dateSize, + font: bodyFont, + color: muted, + }); + } + + const qrPng = await generateQrPng(opts.url, 600); + const qrImage = await pdf.embedPng(qrPng); + const qrSize = 70 * PT_PER_MM; + const qrX = (A6_WIDTH - qrSize) / 2; + const qrY = (A6_HEIGHT - qrSize) / 2 - 8 * PT_PER_MM; + page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize }); + + const cta = opts.callToAction ?? 'Aponte a câmera do celular'; + const ctaSize = 12; + page.drawText(cta, { + x: centerX(cta, ctaSize, boldFont), + y: qrY - 9 * PT_PER_MM, + size: ctaSize, + font: boldFont, + color: ink, + }); + + const sub = 'para enviar fotos e mensagem'; + const subSize = 10; + page.drawText(sub, { + x: centerX(sub, subSize, bodyFont), + y: qrY - 14 * PT_PER_MM, + size: subSize, + font: bodyFont, + color: muted, + }); + + return await pdf.save(); +} diff --git a/apps/api/src/lib/qrcode.ts b/apps/api/src/lib/qrcode.ts new file mode 100644 index 0000000..891968a --- /dev/null +++ b/apps/api/src/lib/qrcode.ts @@ -0,0 +1,19 @@ +import QRCode from 'qrcode'; + +export async function generateQrPng(text: string, size = 512): Promise { + const dataUrl = await QRCode.toDataURL(text, { + errorCorrectionLevel: 'M', + margin: 2, + width: size, + }); + const base64 = dataUrl.split(',')[1] ?? ''; + return Uint8Array.from(Buffer.from(base64, 'base64')); +} + +export function generateQrSvg(text: string): Promise { + return QRCode.toString(text, { + type: 'svg', + errorCorrectionLevel: 'M', + margin: 2, + }); +} diff --git a/apps/api/src/lib/storage-factory.ts b/apps/api/src/lib/storage-factory.ts new file mode 100644 index 0000000..268859c --- /dev/null +++ b/apps/api/src/lib/storage-factory.ts @@ -0,0 +1,26 @@ +import type { AppEnv } from '../env.js'; +import { S3Storage } from './storage-s3.js'; +import type { StorageProvider } from './storage.js'; + +let storageInstance: StorageProvider | null = null; + +export function initStorage(env: AppEnv): StorageProvider { + if (storageInstance) return storageInstance; + storageInstance = new S3Storage({ + endpoint: env.S3_ENDPOINT, + bucket: env.S3_BUCKET, + region: env.S3_REGION, + accessKeyId: env.S3_ACCESS_KEY_ID, + secretAccessKey: env.S3_SECRET_ACCESS_KEY, + publicBaseUrl: env.S3_PUBLIC_BASE_URL, + forcePathStyle: env.S3_FORCE_PATH_STYLE === 'true', + }); + return storageInstance; +} + +export function getStorage(): StorageProvider { + if (!storageInstance) { + throw new Error('Storage not initialized. Call initStorage() before getStorage().'); + } + return storageInstance; +} diff --git a/apps/api/src/lib/storage-s3.ts b/apps/api/src/lib/storage-s3.ts new file mode 100644 index 0000000..011d132 --- /dev/null +++ b/apps/api/src/lib/storage-s3.ts @@ -0,0 +1,164 @@ +import { AwsClient } from 'aws4fetch'; +import type { + CompleteMultipartInput, + InitMultipartInput, + InitMultipartResult, + ObjectMeta, + PresignPutInput, + PresignPutResult, + StorageProvider, +} from './storage.js'; + +export interface S3StorageConfig { + endpoint: string; + bucket: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + publicBaseUrl: string; + forcePathStyle?: boolean; +} + +const DEFAULT_PART_SIZE = 10 * 1024 * 1024; + +export class S3Storage implements StorageProvider { + private aws: AwsClient; + + constructor(private config: S3StorageConfig) { + this.aws = new AwsClient({ + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + service: 's3', + region: config.region, + }); + } + + private objectUrl(key: string, query?: string): string { + const base = this.config.endpoint.replace(/\/$/, ''); + const path = this.config.forcePathStyle + ? `${base}/${this.config.bucket}/${encodeKey(key)}` + : `${base}/${encodeKey(key)}`; + return query ? `${path}?${query}` : path; + } + + async presignPut(input: PresignPutInput): Promise { + const expires = input.expiresInSec ?? 600; + const url = this.objectUrl(input.key, `X-Amz-Expires=${expires}`); + const signed = await this.aws.sign( + new Request(url, { + method: 'PUT', + headers: { 'content-type': input.contentType }, + }), + { aws: { signQuery: true } }, + ); + return { + url: signed.url, + method: 'PUT', + headers: { 'content-type': input.contentType }, + }; + } + + async initMultipart(input: InitMultipartInput): Promise { + const initUrl = this.objectUrl(input.key, 'uploads='); + const init = await this.aws.fetch(initUrl, { + method: 'POST', + headers: { 'content-type': input.contentType }, + }); + if (!init.ok) { + throw new Error(`initMultipart failed: ${init.status} ${await init.text()}`); + } + const xml = await init.text(); + const uploadId = matchXml(xml, 'UploadId'); + if (!uploadId) throw new Error('multipart upload init: missing UploadId'); + + const expires = input.expiresInSec ?? 3600; + const partUrls: string[] = []; + for (let i = 1; i <= input.partCount; i++) { + const url = this.objectUrl( + input.key, + `partNumber=${i}&uploadId=${encodeURIComponent(uploadId)}&X-Amz-Expires=${expires}`, + ); + const signed = await this.aws.sign(new Request(url, { method: 'PUT' }), { + aws: { signQuery: true }, + }); + partUrls.push(signed.url); + } + + return { uploadId, partSize: DEFAULT_PART_SIZE, partUrls }; + } + + async completeMultipart(input: CompleteMultipartInput): Promise { + const sorted = [...input.parts].sort((a, b) => a.partNumber - b.partNumber); + const body = + `` + + sorted + .map( + (p) => + `${p.partNumber}${escapeXml(p.etag)}`, + ) + .join('') + + ``; + const url = this.objectUrl(input.key, `uploadId=${encodeURIComponent(input.uploadId)}`); + const res = await this.aws.fetch(url, { method: 'POST', body }); + if (!res.ok) { + throw new Error(`completeMultipart failed: ${res.status} ${await res.text()}`); + } + } + + async abortMultipart(input: { key: string; uploadId: string }): Promise { + const url = this.objectUrl(input.key, `uploadId=${encodeURIComponent(input.uploadId)}`); + await this.aws.fetch(url, { method: 'DELETE' }); + } + + publicUrl(key: string): string { + return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${encodeKey(key)}`; + } + + async presignGet(key: string, expiresInSec = 3600): Promise { + const url = this.objectUrl(key, `X-Amz-Expires=${expiresInSec}`); + const signed = await this.aws.sign(new Request(url, { method: 'GET' }), { + aws: { signQuery: true }, + }); + return signed.url; + } + + async exists(key: string): Promise { + const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' }); + return res.ok; + } + + async delete(key: string): Promise { + const res = await this.aws.fetch(this.objectUrl(key), { method: 'DELETE' }); + if (!res.ok && res.status !== 404) { + throw new Error(`delete failed: ${res.status}`); + } + } + + async head(key: string): Promise { + const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`head failed: ${res.status}`); + return { + contentType: res.headers.get('content-type') ?? 'application/octet-stream', + contentLength: Number(res.headers.get('content-length') ?? 0), + etag: res.headers.get('etag') ?? '', + }; + } +} + +function encodeKey(key: string): string { + return key.split('/').map(encodeURIComponent).join('/'); +} + +function matchXml(xml: string, tag: string): string | null { + const m = xml.match(new RegExp(`<${tag}>([^<]+)`)); + return m?.[1] ?? null; +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/api/src/lib/storage.ts b/apps/api/src/lib/storage.ts new file mode 100644 index 0000000..9e2c828 --- /dev/null +++ b/apps/api/src/lib/storage.ts @@ -0,0 +1,49 @@ +export interface PresignPutInput { + key: string; + contentType: string; + contentLength?: number; + expiresInSec?: number; +} + +export interface PresignPutResult { + url: string; + method: 'PUT'; + headers: Record; +} + +export interface InitMultipartInput { + key: string; + contentType: string; + partCount: number; + expiresInSec?: number; +} + +export interface InitMultipartResult { + uploadId: string; + partSize: number; + partUrls: string[]; +} + +export interface CompleteMultipartInput { + key: string; + uploadId: string; + parts: { partNumber: number; etag: string }[]; +} + +export interface ObjectMeta { + contentType: string; + contentLength: number; + etag: string; +} + +export interface StorageProvider { + presignPut(input: PresignPutInput): Promise; + initMultipart(input: InitMultipartInput): Promise; + completeMultipart(input: CompleteMultipartInput): Promise; + abortMultipart(input: { key: string; uploadId: string }): Promise; + publicUrl(key: string): string; + presignGet(key: string, expiresInSec?: number): Promise; + exists(key: string): Promise; + delete(key: string): Promise; + head(key: string): Promise; +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..e524a4d --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -0,0 +1,322 @@ +import { and, desc, eq, ilike, inArray, like, lt, or } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { + adminBulkSchema, + adminUploadUpdateSchema, + eventConfigUpdateSchema, + type AdminStats, + type AdminUploadsResponse, +} from '@leetete/shared'; +import { getDb } from '../db/client.js'; +import { eventConfig, uploads } from '../db/schema.js'; +import type { Bindings } from '../env.js'; +import { + createSession, + destroySession, + requireAdmin, + timingSafeEqual, +} from '../lib/auth.js'; +import { getStorage } from '../lib/storage-factory.js'; + +export const adminRoutes = new Hono(); + +const ADMIN_PAGE_SIZE = 30; + +const loginSchema = z.object({ + email: z.string().email().toLowerCase(), + password: z.string().min(1), +}); + +adminRoutes.post('/login', async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = loginSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body' }, 400); + } + + const allowed = c.env.ALLOWED_ADMIN_EMAILS.split(',').map((e) => e.trim().toLowerCase()); + const emailOk = allowed.includes(parsed.data.email); + const passwordOk = await timingSafeEqual(parsed.data.password, c.env.ADMIN_PASSWORD); + if (!emailOk || !passwordOk) { + return c.json({ error: 'unauthorized' }, 401); + } + + await createSession(c, parsed.data.email); + return c.json({ ok: true, email: parsed.data.email }); +}); + +adminRoutes.post('/logout', async (c) => { + destroySession(c); + return c.json({ ok: true }); +}); + +adminRoutes.use('*', requireAdmin); + +adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') })); + +adminRoutes.get('/event', async (c) => { + const db = getDb(); + const rows = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + const row = rows[0]; + if (!row) return c.json({ error: 'not_configured' }, 404); + const storage = getStorage(); + return c.json({ + coupleNames: row.coupleNames, + eventDate: row.eventDate, + coverKey: row.coverKey, + coverUrl: row.coverKey ? storage.publicUrl(row.coverKey) : null, + galleryVisibility: row.galleryVisibility, + moderation: row.moderation, + maxFileMb: row.maxFileMb, + allowVideo: row.allowVideo, + maxVideoSeconds: row.maxVideoSeconds, + welcomeMessage: row.welcomeMessage, + updatedAt: row.updatedAt, + }); +}); + +adminRoutes.patch('/event', async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = eventConfigUpdateSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400); + } + const db = getDb(); + await db + .update(eventConfig) + .set({ ...parsed.data, updatedAt: Date.now() }) + .where(eq(eventConfig.id, 1)); + return c.json({ ok: true }); +}); + +adminRoutes.get('/uploads', async (c) => { + const db = getDb(); + const status = c.req.query('status') as 'pending' | 'approved' | 'rejected' | undefined; + const kind = c.req.query('kind') as 'photo' | 'video' | undefined; + const q = c.req.query('q')?.trim(); + const cursorParam = c.req.query('cursor'); + const cursor = cursorParam ? Number(cursorParam) : null; + const limit = Math.min(Number(c.req.query('limit') ?? ADMIN_PAGE_SIZE), 100); + + const filters = []; + if (status) filters.push(eq(uploads.status, status)); + if (cursor !== null && Number.isFinite(cursor)) { + filters.push(lt(uploads.createdAt, cursor)); + } + if (kind === 'photo') filters.push(like(uploads.mimeType, 'image/%')); + if (kind === 'video') filters.push(like(uploads.mimeType, 'video/%')); + if (q && q.length > 0) { + const pattern = `%${q.replace(/[%_]/g, (m) => `\\${m}`)}%`; + const cond = or( + ilike(uploads.authorName, pattern), + ilike(uploads.message, pattern), + ); + if (cond) filters.push(cond); + } + const where = filters.length ? and(...filters) : undefined; + + const rows = await db + .select() + .from(uploads) + .where(where) + .orderBy(desc(uploads.createdAt)) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const slice = hasMore ? rows.slice(0, limit) : rows; + const storage = getStorage(); + + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + const coverKey = cfg[0]?.coverKey ?? null; + + const items = slice.map((r) => ({ + id: r.id, + url: storage.publicUrl(r.storageKey), + thumbnailUrl: r.thumbnailKey ? storage.publicUrl(r.thumbnailKey) : null, + storageKey: r.storageKey, + mimeType: r.mimeType, + isVideo: r.mimeType.startsWith('video/'), + sizeBytes: r.sizeBytes, + durationSeconds: r.durationSeconds, + authorName: r.authorName, + message: r.message, + status: r.status, + source: r.source, + createdAt: r.createdAt, + approvedAt: r.approvedAt, + isCover: coverKey === r.storageKey, + })); + + const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null; + return c.json({ items, nextCursor } satisfies AdminUploadsResponse); +}); + +adminRoutes.patch('/uploads/:id', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json().catch(() => null); + const parsed = adminUploadUpdateSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400); + } + const updates: Record = {}; + if (parsed.data.authorName !== undefined) { + updates['authorName'] = parsed.data.authorName?.trim() || null; + } + if (parsed.data.message !== undefined) { + updates['message'] = parsed.data.message?.trim() || null; + } + if (Object.keys(updates).length === 0) { + return c.json({ ok: true, noop: true }); + } + + const db = getDb(); + const result = await db + .update(uploads) + .set(updates) + .where(eq(uploads.id, id)) + .returning({ id: uploads.id }); + if (!result.length) return c.json({ error: 'not_found' }, 404); + return c.json({ ok: true }); +}); + +adminRoutes.post('/uploads/:id/approve', async (c) => { + const id = c.req.param('id'); + const db = getDb(); + const result = await db + .update(uploads) + .set({ status: 'approved', approvedAt: Date.now() }) + .where(eq(uploads.id, id)) + .returning({ id: uploads.id }); + if (!result.length) return c.json({ error: 'not_found' }, 404); + return c.json({ ok: true }); +}); + +adminRoutes.post('/uploads/:id/reject', async (c) => { + const id = c.req.param('id'); + const db = getDb(); + const result = await db + .update(uploads) + .set({ status: 'rejected', approvedAt: null }) + .where(eq(uploads.id, id)) + .returning({ id: uploads.id }); + if (!result.length) return c.json({ error: 'not_found' }, 404); + return c.json({ ok: true }); +}); + +adminRoutes.post('/uploads/:id/cover', async (c) => { + const id = c.req.param('id'); + const db = getDb(); + const rows = await db.select().from(uploads).where(eq(uploads.id, id)); + const row = rows[0]; + if (!row) return c.json({ error: 'not_found' }, 404); + await db + .update(eventConfig) + .set({ coverKey: row.storageKey, updatedAt: Date.now() }) + .where(eq(eventConfig.id, 1)); + return c.json({ ok: true }); +}); + +adminRoutes.delete('/event/cover', async (c) => { + const db = getDb(); + await db + .update(eventConfig) + .set({ coverKey: null, updatedAt: Date.now() }) + .where(eq(eventConfig.id, 1)); + return c.json({ ok: true }); +}); + +adminRoutes.delete('/uploads/:id', async (c) => { + const id = c.req.param('id'); + const db = getDb(); + const rows = await db.select().from(uploads).where(eq(uploads.id, id)); + const row = rows[0]; + if (!row) return c.json({ error: 'not_found' }, 404); + + const storage = getStorage(); + await storage.delete(row.storageKey).catch((err) => { + console.warn('failed to delete from storage', row.storageKey, err); + }); + if (row.thumbnailKey) { + await storage.delete(row.thumbnailKey).catch(() => {}); + } + await db.delete(uploads).where(eq(uploads.id, id)); + + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + if (cfg[0]?.coverKey === row.storageKey) { + await db.update(eventConfig).set({ coverKey: null }).where(eq(eventConfig.id, 1)); + } + return c.json({ ok: true }); +}); + +adminRoutes.post('/uploads/bulk', async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = adminBulkSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400); + } + + const db = getDb(); + const storage = getStorage(); + + if (parsed.data.action === 'delete') { + const rows = await db + .select() + .from(uploads) + .where(inArray(uploads.id, parsed.data.ids)); + await Promise.all( + rows.map((r) => storage.delete(r.storageKey).catch(() => {})), + ); + await db.delete(uploads).where(inArray(uploads.id, parsed.data.ids)); + + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + if (cfg[0]?.coverKey && rows.some((r) => r.storageKey === cfg[0]!.coverKey)) { + await db.update(eventConfig).set({ coverKey: null }).where(eq(eventConfig.id, 1)); + } + } else { + const status = parsed.data.action === 'approve' ? 'approved' : 'rejected'; + const approvedAt = status === 'approved' ? Date.now() : null; + await db + .update(uploads) + .set({ status, approvedAt }) + .where(inArray(uploads.id, parsed.data.ids)); + } + + return c.json({ ok: true, count: parsed.data.ids.length }); +}); + +adminRoutes.get('/stats', async (c) => { + const db = getDb(); + const all = await db + .select({ + status: uploads.status, + mimeType: uploads.mimeType, + sizeBytes: uploads.sizeBytes, + }) + .from(uploads); + + const stats: AdminStats = { + total: all.length, + approved: 0, + pending: 0, + rejected: 0, + photos: 0, + videos: 0, + totalBytes: 0, + }; + for (const r of all) { + stats[r.status]++; + if (r.mimeType.startsWith('image/')) stats.photos++; + else if (r.mimeType.startsWith('video/')) stats.videos++; + stats.totalBytes += r.sizeBytes; + } + return c.json(stats); +}); + +adminRoutes.get('/export.zip', async (c) => { + return c.json({ error: 'not_implemented_yet' }, 501); +}); + +adminRoutes.post('/imports', async (c) => { + return c.json({ error: 'not_implemented_yet' }, 501); +}); diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts new file mode 100644 index 0000000..bfdc448 --- /dev/null +++ b/apps/api/src/routes/public.ts @@ -0,0 +1,131 @@ +import { and, desc, eq, lt } from 'drizzle-orm'; +import { Hono } from 'hono'; +import type { GalleryResponse } from '@leetete/shared'; +import { getDb } from '../db/client.js'; +import { eventConfig, uploads } from '../db/schema.js'; +import type { Bindings } from '../env.js'; +import { generateQrPdf } from '../lib/qr-pdf.js'; +import { generateQrPng, generateQrSvg } from '../lib/qrcode.js'; +import { getStorage } from '../lib/storage-factory.js'; + +export const publicRoutes = new Hono(); + +const GALLERY_PAGE_SIZE = 24; + +publicRoutes.get('/event', async (c) => { + const db = getDb(); + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + const row = cfg[0]; + if (!row) return c.json({ error: 'not_configured' }, 404); + const storage = getStorage(); + return c.json({ + coupleNames: row.coupleNames, + eventDate: row.eventDate, + welcomeMessage: row.welcomeMessage, + galleryVisibility: row.galleryVisibility, + allowVideo: row.allowVideo, + maxFileMb: row.maxFileMb, + maxVideoSeconds: row.maxVideoSeconds, + coverUrl: row.coverKey ? storage.publicUrl(row.coverKey) : null, + }); +}); + +publicRoutes.get('/gallery', async (c) => { + const db = getDb(); + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + const row = cfg[0]; + if (!row || row.galleryVisibility !== 'public') { + return c.json({ items: [], nextCursor: null } satisfies GalleryResponse); + } + + const cursorParam = c.req.query('cursor'); + const cursor = cursorParam ? Number(cursorParam) : null; + const limit = Math.min(Number(c.req.query('limit') ?? GALLERY_PAGE_SIZE), 60); + + const baseCondition = eq(uploads.status, 'approved'); + const where = + cursor !== null && Number.isFinite(cursor) + ? and(baseCondition, lt(uploads.createdAt, cursor)) + : baseCondition; + + const rows = await db + .select() + .from(uploads) + .where(where) + .orderBy(desc(uploads.createdAt)) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const slice = hasMore ? rows.slice(0, limit) : rows; + const storage = getStorage(); + + const items = slice.map((r) => ({ + id: r.id, + url: storage.publicUrl(r.storageKey), + thumbnailUrl: r.thumbnailKey ? storage.publicUrl(r.thumbnailKey) : null, + mimeType: r.mimeType, + isVideo: r.mimeType.startsWith('video/'), + authorName: r.authorName, + message: r.message, + createdAt: r.createdAt, + })); + + const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null; + return c.json({ items, nextCursor } satisfies GalleryResponse); +}); + +publicRoutes.get('/qrcode', async (c) => { + const format = (c.req.query('format') ?? 'png').toLowerCase(); + const overrideUrl = c.req.query('url'); + + const reqUrl = new URL(c.req.url); + const base = overrideUrl ?? c.env.PUBLIC_BASE_URL ?? `${reqUrl.protocol}//${reqUrl.host}`; + const target = base.replace(/\/$/, '') + '/enviar'; + + if (format === 'svg') { + const svg = await generateQrSvg(target); + return new Response(svg, { + headers: { + 'content-type': 'image/svg+xml; charset=utf-8', + 'cache-control': 'public, max-age=300', + }, + }); + } + + if (format === 'pdf') { + const db = getDb(); + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + const row = cfg[0]; + const pdf = await generateQrPdf({ + url: target, + coupleNames: row?.coupleNames ?? c.env.COUPLE_NAMES, + eventDate: row?.eventDate ?? c.env.EVENT_DATE, + }); + return new Response(pdf, { + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'inline; filename="qr-mesa.pdf"', + 'cache-control': 'no-store', + }, + }); + } + + const png = await generateQrPng(target, 800); + return new Response(png, { + headers: { + 'content-type': 'image/png', + 'cache-control': 'public, max-age=300', + }, + }); +}); + +publicRoutes.get('/stats', async (c) => { + const db = getDb(); + const all = await db + .select({ id: uploads.id, mimeType: uploads.mimeType }) + .from(uploads) + .where(eq(uploads.status, 'approved')); + const photos = all.filter((u) => u.mimeType.startsWith('image/')).length; + const videos = all.filter((u) => u.mimeType.startsWith('video/')).length; + return c.json({ photos, videos, total: all.length }); +}); diff --git a/apps/api/src/routes/uploads.ts b/apps/api/src/routes/uploads.ts new file mode 100644 index 0000000..5ff9381 --- /dev/null +++ b/apps/api/src/routes/uploads.ts @@ -0,0 +1,220 @@ +import { eq } from 'drizzle-orm'; +import { Hono, type Context } from 'hono'; +import { + uploadConfirmSchema, + uploadInitSchema, + type UploadInitResponse, +} from '@leetete/shared'; +import { getDb } from '../db/client.js'; +import { eventConfig, uploads } from '../db/schema.js'; +import type { Bindings } from '../env.js'; +import { hashIp, uploadId } from '../lib/ids.js'; +import { getStorage } from '../lib/storage-factory.js'; + +export const uploadsRoutes = new Hono(); + +const SINGLE_PUT_MAX = 50 * 1024 * 1024; +const PART_SIZE = 10 * 1024 * 1024; +const KEY_PREFIX = 'uploads'; +const IP_HASH_SALT = 'wedding-uploads-v1'; + +function extFromFilename(filename: string, mimeType: string): string { + const dot = filename.lastIndexOf('.'); + if (dot >= 0 && dot < filename.length - 1) { + const ext = filename.slice(dot + 1).toLowerCase(); + if (/^[a-z0-9]{1,6}$/.test(ext)) return ext; + } + const map: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/heic': 'heic', + 'image/heif': 'heif', + 'image/gif': 'gif', + 'video/mp4': 'mp4', + 'video/quicktime': 'mov', + 'video/webm': 'webm', + }; + return map[mimeType.toLowerCase()] ?? 'bin'; +} + +function buildKey(id: string, filename: string, mimeType: string): string { + const now = new Date(); + const yyyy = now.getUTCFullYear(); + const mm = String(now.getUTCMonth() + 1).padStart(2, '0'); + const ext = extFromFilename(filename, mimeType); + return `${KEY_PREFIX}/${yyyy}/${mm}/${id}.${ext}`; +} + +function getClientIp(c: Context): string { + return ( + c.req.header('cf-connecting-ip') ?? + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? + c.req.header('x-real-ip') ?? + 'unknown' + ); +} + +uploadsRoutes.post('/init', async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = uploadInitSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400); + } + const input = parsed.data; + + const db = getDb(); + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + const event = cfg[0]; + if (!event) return c.json({ error: 'not_configured' }, 500); + + const isVideo = input.mimeType.startsWith('video/'); + if (isVideo && !event.allowVideo) { + return c.json({ error: 'video_not_allowed' }, 400); + } + if (input.sizeBytes > event.maxFileMb * 1024 * 1024) { + return c.json({ error: 'file_too_large', maxFileMb: event.maxFileMb }, 400); + } + if ( + isVideo && + input.durationSeconds !== undefined && + input.durationSeconds > event.maxVideoSeconds + ) { + return c.json({ error: 'video_too_long', maxVideoSeconds: event.maxVideoSeconds }, 400); + } + + const id = uploadId(); + const storageKey = buildKey(id, input.filename, input.mimeType); + const storage = getStorage(); + const ipHashValue = await hashIp(getClientIp(c), IP_HASH_SALT); + + if (input.sizeBytes <= SINGLE_PUT_MAX) { + const presigned = await storage.presignPut({ + key: storageKey, + contentType: input.mimeType, + contentLength: input.sizeBytes, + expiresInSec: 900, + }); + + await db.insert(uploads).values({ + id, + storageKey, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + durationSeconds: input.durationSeconds ?? null, + authorName: input.authorName?.trim() || null, + message: input.message?.trim() || null, + status: 'pending', + source: 'guest', + ipHash: ipHashValue, + }); + + const response: UploadInitResponse = { + uploadId: id, + storageKey, + mode: 'single', + putUrl: presigned.url, + putHeaders: presigned.headers, + }; + return c.json(response); + } + + const partCount = Math.ceil(input.sizeBytes / PART_SIZE); + const multipart = await storage.initMultipart({ + key: storageKey, + contentType: input.mimeType, + partCount, + expiresInSec: 3600 * 6, + }); + + await db.insert(uploads).values({ + id, + storageKey, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + durationSeconds: input.durationSeconds ?? null, + authorName: input.authorName?.trim() || null, + message: input.message?.trim() || null, + status: 'pending', + source: 'guest', + ipHash: ipHashValue, + providerUploadId: multipart.uploadId, + }); + + const response: UploadInitResponse = { + uploadId: id, + storageKey, + mode: 'multipart', + multipart: { + providerUploadId: multipart.uploadId, + partSize: multipart.partSize, + partUrls: multipart.partUrls, + }, + }; + return c.json(response); +}); + +uploadsRoutes.post('/:id/confirm', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json().catch(() => ({})); + const parsed = uploadConfirmSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400); + } + + const db = getDb(); + const rows = await db.select().from(uploads).where(eq(uploads.id, id)); + const upload = rows[0]; + if (!upload) return c.json({ error: 'not_found' }, 404); + if (upload.status === 'approved') return c.json({ ok: true, alreadyApproved: true }); + + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)); + const event = cfg[0]; + if (!event) return c.json({ error: 'not_configured' }, 500); + + const storage = getStorage(); + + if (parsed.data.multipart) { + if (parsed.data.multipart.providerUploadId !== upload.providerUploadId) { + return c.json({ error: 'multipart_mismatch' }, 400); + } + await storage.completeMultipart({ + key: upload.storageKey, + uploadId: parsed.data.multipart.providerUploadId, + parts: parsed.data.multipart.parts, + }); + } + + const head = await storage.head(upload.storageKey); + if (!head) { + return c.json({ error: 'not_uploaded' }, 400); + } + + const finalStatus = event.moderation === 'pre' ? 'pending' : 'approved'; + await db + .update(uploads) + .set({ + status: finalStatus, + approvedAt: finalStatus === 'approved' ? Date.now() : null, + }) + .where(eq(uploads.id, id)); + + return c.json({ ok: true, status: finalStatus }); +}); + +uploadsRoutes.post('/:id/abort', async (c) => { + const id = c.req.param('id'); + const db = getDb(); + const rows = await db.select().from(uploads).where(eq(uploads.id, id)); + const upload = rows[0]; + if (!upload) return c.json({ error: 'not_found' }, 404); + + if (upload.providerUploadId) { + const storage = getStorage(); + await storage + .abortMultipart({ key: upload.storageKey, uploadId: upload.providerUploadId }) + .catch(() => {}); + } + await db.delete(uploads).where(eq(uploads.id, id)); + return c.json({ ok: true }); +}); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..1e636c3 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,65 @@ +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { Hono } from 'hono'; +import { createApp } from './app.js'; +import { closeDb, initDb } from './db/client.js'; +import { runMigrations } from './db/migrate.js'; +import type { Bindings } from './env.js'; +import { loadEnv } from './env.js'; +import { initStorage } from './lib/storage-factory.js'; + +async function main() { + const env = loadEnv(); + + if (env.AUTO_MIGRATE === 'true') { + console.log('[startup] running migrations'); + await runMigrations(env.DATABASE_URL); + } + + initDb(env.DATABASE_URL); + initStorage(env); + + const apiApp = createApp(); + + const app = new Hono(); + app.route('/', apiApp); + + app.use( + '/assets/*', + serveStatic({ + root: env.STATIC_DIR, + rewriteRequestPath: (path) => path, + }), + ); + + for (const f of ['/favicon.ico', '/robots.txt']) { + app.get(f, serveStatic({ path: `${env.STATIC_DIR}${f}` })); + } + + app.get('*', serveStatic({ path: `${env.STATIC_DIR}/index.html` })); + + const server = serve( + { + fetch: (req) => app.fetch(req, env), + port: env.PORT, + hostname: '0.0.0.0', + }, + (info) => { + console.log(`[startup] listening on http://0.0.0.0:${info.port}`); + }, + ); + + const shutdown = async (signal: string) => { + console.log(`[shutdown] received ${signal}`); + server.close(); + await closeDb(); + process.exit(0); + }; + process.on('SIGTERM', () => void shutdown('SIGTERM')); + process.on('SIGINT', () => void shutdown('SIGINT')); +} + +main().catch((err) => { + console.error('[startup] failed', err); + process.exit(1); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..5552d15 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..348b426 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Stefanie & Leandro + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d2109a0 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "@leetete/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@leetete/shared": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..35c7fb7 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,18 @@ +import { Route, Routes } from 'react-router-dom'; +import AdminDashboard from './routes/admin/Dashboard.js'; +import AdminLogin from './routes/admin/Login.js'; +import Gallery from './routes/Gallery.js'; +import Home from './routes/Home.js'; +import Upload from './routes/Upload.js'; + +export default function App() { + return ( + + } /> + } /> + } /> + } /> + } /> + + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..2595c20 --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,15 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; +} + +body { + font-family: theme('fontFamily.sans'); + background: theme('colors.cream'); + color: #2c2a26; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..485e7ea --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,23 @@ +const API_BASE = '/api'; + +export class ApiError extends Error { + constructor( + public status: number, + public body: string, + ) { + super(`API ${status}: ${body}`); + } +} + +export async function api(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + credentials: 'include', + ...init, + headers: { + 'content-type': 'application/json', + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) throw new ApiError(res.status, await res.text()); + return (await res.json()) as T; +} diff --git a/apps/web/src/lib/upload.ts b/apps/web/src/lib/upload.ts new file mode 100644 index 0000000..72f74fc --- /dev/null +++ b/apps/web/src/lib/upload.ts @@ -0,0 +1,143 @@ +import type { UploadInit, UploadInitResponse } from '@leetete/shared'; + +export type UploadProgress = (loaded: number, total: number) => void; + +export interface UploadOptions { + signal?: AbortSignal; + onProgress?: UploadProgress; +} + +export interface UploadOk { + uploadId: string; +} + +async function getDuration(file: File): Promise { + if (!file.type.startsWith('video/')) return undefined; + return await new Promise((resolve) => { + const url = URL.createObjectURL(file); + const v = document.createElement('video'); + v.preload = 'metadata'; + v.src = url; + v.onloadedmetadata = () => { + URL.revokeObjectURL(url); + resolve(Number.isFinite(v.duration) ? Math.round(v.duration) : undefined); + }; + v.onerror = () => { + URL.revokeObjectURL(url); + resolve(undefined); + }; + }); +} + +function putWithProgress( + url: string, + body: Blob, + headers: Record, + onProgress?: UploadProgress, + signal?: AbortSignal, +): Promise<{ etag: string }> { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', url); + for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onProgress?.(e.loaded, e.total); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + const etag = xhr.getResponseHeader('etag') ?? xhr.getResponseHeader('ETag') ?? ''; + resolve({ etag: etag.replace(/^"|"$/g, '') }); + } else { + reject(new Error(`PUT failed: ${xhr.status} ${xhr.statusText}`)); + } + }; + xhr.onerror = () => reject(new Error('network error')); + xhr.onabort = () => reject(new DOMException('aborted', 'AbortError')); + if (signal) { + if (signal.aborted) { + xhr.abort(); + return; + } + signal.addEventListener('abort', () => xhr.abort(), { once: true }); + } + xhr.send(body); + }); +} + +export async function uploadFile( + file: File, + meta: { authorName?: string; message?: string }, + opts: UploadOptions = {}, +): Promise { + const duration = await getDuration(file); + + const initBody: UploadInit = { + filename: file.name || 'upload', + mimeType: file.type || 'application/octet-stream', + sizeBytes: file.size, + durationSeconds: duration, + authorName: meta.authorName?.trim() || undefined, + message: meta.message?.trim() || undefined, + }; + + const initRes = await fetch('/api/uploads/init', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(initBody), + signal: opts.signal, + }); + if (!initRes.ok) { + const text = await initRes.text(); + throw new Error(`init failed: ${initRes.status} ${text}`); + } + const init = (await initRes.json()) as UploadInitResponse; + + if (init.mode === 'single') { + if (!init.putUrl) throw new Error('init: missing putUrl'); + await putWithProgress( + init.putUrl, + file, + init.putHeaders ?? { 'content-type': initBody.mimeType }, + opts.onProgress, + opts.signal, + ); + } else if (init.mode === 'multipart' && init.multipart) { + const { partSize, partUrls, providerUploadId } = init.multipart; + const parts: { partNumber: number; etag: string }[] = []; + let uploaded = 0; + for (let i = 0; i < partUrls.length; i++) { + const start = i * partSize; + const end = Math.min(start + partSize, file.size); + const blob = file.slice(start, end); + const partProgress: UploadProgress = (loaded) => { + opts.onProgress?.(uploaded + loaded, file.size); + }; + const url = partUrls[i]; + if (!url) throw new Error(`missing part url for part ${i + 1}`); + const { etag } = await putWithProgress(url, blob, {}, partProgress, opts.signal); + uploaded += blob.size; + opts.onProgress?.(uploaded, file.size); + if (!etag) throw new Error(`part ${i + 1}: missing ETag (CORS exposes ETag?)`); + parts.push({ partNumber: i + 1, etag }); + } + const confirmRes = await fetch(`/api/uploads/${init.uploadId}/confirm`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ multipart: { providerUploadId, parts } }), + signal: opts.signal, + }); + if (!confirmRes.ok) throw new Error(`confirm failed: ${confirmRes.status}`); + return { uploadId: init.uploadId }; + } else { + throw new Error('init: invalid mode'); + } + + const confirmRes = await fetch(`/api/uploads/${init.uploadId}/confirm`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + signal: opts.signal, + }); + if (!confirmRes.ok) throw new Error(`confirm failed: ${confirmRes.status}`); + return { uploadId: init.uploadId }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..da121d4 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App.js'; +import './index.css'; + +const root = document.getElementById('root'); +if (!root) throw new Error('root element not found'); + +ReactDOM.createRoot(root).render( + + + + + , +); diff --git a/apps/web/src/routes/Gallery.tsx b/apps/web/src/routes/Gallery.tsx new file mode 100644 index 0000000..a45d9f3 --- /dev/null +++ b/apps/web/src/routes/Gallery.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { GalleryItem, GalleryResponse } from '@leetete/shared'; + +export default function Gallery() { + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [active, setActive] = useState(null); + + async function loadMore(reset = false) { + if (loading) return; + setLoading(true); + setError(null); + try { + const url = new URL('/api/gallery', window.location.origin); + if (!reset && cursor) url.searchParams.set('cursor', cursor); + const res = await fetch(url); + if (!res.ok) throw new Error(`API ${res.status}`); + const data = (await res.json()) as GalleryResponse; + setItems((prev) => (reset ? data.items : [...prev, ...data.items])); + setCursor(data.nextCursor); + if (!data.nextCursor) setDone(true); + } catch (e) { + setError(e instanceof Error ? e.message : 'erro'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadMore(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ + ← Voltar + + + + Enviar + +
+

Galeria

+ + {items.length === 0 && !loading && !error && ( +
+ Ainda sem fotos. Seja a primeira pessoa a compartilhar! +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ {items.map((item) => ( + + ))} +
+ + {!done && ( +
+ +
+ )} + + {active && ( +
setActive(null)} + > + +
e.stopPropagation()} + > + {active.isVideo ? ( +
+ {(active.authorName || active.message) && ( +
e.stopPropagation()} + > + {active.authorName && ( +

{active.authorName}

+ )} + {active.message &&

{active.message}

} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/routes/Home.tsx b/apps/web/src/routes/Home.tsx new file mode 100644 index 0000000..1dccc43 --- /dev/null +++ b/apps/web/src/routes/Home.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../lib/api.js'; + +interface EventInfo { + coupleNames: string; + eventDate: string | null; + welcomeMessage: string | null; + coverUrl: string | null; +} + +export default function Home() { + const [event, setEvent] = useState(null); + + useEffect(() => { + api('/event') + .then(setEvent) + .catch(() => setEvent(null)); + }, []); + + return ( +
+ {event?.coverUrl && ( +
+ +
+ )} +

+ {event?.coupleNames ?? 'Stefanie & Leandro'} +

+ {event?.eventDate ? ( +

{event.eventDate}

+ ) : null} +

+ {event?.welcomeMessage ?? + 'Compartilhe suas fotos, vídeos e mensagens deste dia especial.'} +

+
+ + Enviar fotos e mensagem + + + Ver galeria + +
+
+ ); +} diff --git a/apps/web/src/routes/Upload.tsx b/apps/web/src/routes/Upload.tsx new file mode 100644 index 0000000..202848b --- /dev/null +++ b/apps/web/src/routes/Upload.tsx @@ -0,0 +1,262 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { uploadFile } from '../lib/upload.js'; + +interface EventInfo { + coupleNames: string; + allowVideo: boolean; + maxFileMb: number; + maxVideoSeconds: number; +} + +type Status = 'idle' | 'preparing' | 'uploading' | 'success' | 'error'; + +const MB = 1024 * 1024; + +function formatBytes(bytes: number): string { + if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / MB).toFixed(1)} MB`; +} + +export default function Upload() { + const [event, setEvent] = useState(null); + const [files, setFiles] = useState([]); + const [authorName, setAuthorName] = useState(''); + const [message, setMessage] = useState(''); + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState({ index: 0, loaded: 0, total: 0 }); + const [error, setError] = useState(null); + const [successCount, setSuccessCount] = useState(0); + const abortRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + fetch('/api/event') + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then(setEvent) + .catch(() => setEvent(null)); + }, []); + + function reset() { + setFiles([]); + setStatus('idle'); + setProgress({ index: 0, loaded: 0, total: 0 }); + setError(null); + setSuccessCount(0); + if (inputRef.current) inputRef.current.value = ''; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!files.length || status === 'uploading') return; + + const ac = new AbortController(); + abortRef.current = ac; + setStatus('uploading'); + setError(null); + setSuccessCount(0); + + try { + for (let i = 0; i < files.length; i++) { + const file = files[i]!; + setProgress({ index: i, loaded: 0, total: file.size }); + await uploadFile( + file, + { authorName, message: i === 0 ? message : undefined }, + { + signal: ac.signal, + onProgress: (loaded, total) => setProgress({ index: i, loaded, total }), + }, + ); + setSuccessCount((n) => n + 1); + } + setStatus('success'); + } catch (err) { + if ((err as DOMException)?.name === 'AbortError') { + setStatus('idle'); + return; + } + console.error(err); + setError(humanError(err)); + setStatus('error'); + } finally { + abortRef.current = null; + } + } + + function cancelUpload() { + abortRef.current?.abort(); + } + + const totalBytes = files.reduce((s, f) => s + f.size, 0); + const overLimit = + event && + files.some( + (f) => + f.size > event.maxFileMb * MB || + (f.type.startsWith('video/') && !event.allowVideo), + ); + + if (status === 'success') { + return ( +
+

Obrigado!

+

+ {successCount === 1 + ? 'Sua foto/vídeo foi enviado com sucesso.' + : `${successCount} arquivos enviados com sucesso.`} + {message ? ' Sua mensagem aos noivos foi guardada.' : ''} +

+
+ + + Ver galeria + +
+
+ ); + } + + return ( +
+ + ← Voltar + +

Enviar fotos

+

+ Compartilhe momentos do casamento de {event?.coupleNames ?? 'Stefanie & Leandro'}. + {event?.allowVideo + ? ` Vídeos curtos (até ${Math.floor(event.maxVideoSeconds / 60)} min) também são bem-vindos.` + : ' Apenas fotos por enquanto.'} +

+ +
+
+ + setFiles(Array.from(e.target.files ?? []))} + className="block w-full text-sm text-stone-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-medium file:bg-stone-200 file:text-stone-700 hover:file:bg-stone-300" + /> + {files.length > 0 && ( +

+ {files.length} arquivo{files.length > 1 ? 's' : ''} ({formatBytes(totalBytes)}) +

+ )} + {overLimit && ( +

+ Algum arquivo excede o limite ({event?.maxFileMb} MB) ou é um vídeo (não permitido). +

+ )} +
+ +
+ + setAuthorName(e.target.value)} + disabled={status === 'uploading'} + maxLength={80} + placeholder="Como você quer aparecer na galeria" + className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2 text-stone-800 focus:border-stone-500 focus:outline-none" + /> +
+ +
+ +