commit be1317983265b39a869e2072c45b2c5b85d589e1 Author: Claude Date: Fri May 1 23:30:50 2026 +0000 chore: scaffold Cloudflare Pages monorepo for wedding photos app Set up pnpm workspaces with three packages: a shared Zod-schema package, a Hono-based API exported as a library, and a Vite/React/ Tailwind frontend that mounts the API via Cloudflare Pages Functions. Storage is abstracted behind an S3-compatible provider so the project can migrate from R2 to a self-hosted MinIO without code changes. https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63c4366 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +dist/ +build/ +out/ +.cache/ +.turbo/ +*.log +.DS_Store + +.wrangler/ +.dev.vars +worker-configuration.d.ts + +.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/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..b5129be --- /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: './migrations', + dialect: 'sqlite', +} satisfies Config; diff --git a/apps/api/migrations/0001_initial.sql b/apps/api/migrations/0001_initial.sql new file mode 100644 index 0000000..edc539f --- /dev/null +++ b/apps/api/migrations/0001_initial.sql @@ -0,0 +1,44 @@ +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 INTEGER NOT NULL DEFAULT 1, + max_video_seconds INTEGER NOT NULL DEFAULT 300, + welcome_message TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) +); + +INSERT INTO event_config (id, couple_names, max_file_mb, max_video_seconds, allow_video, moderation, gallery_visibility) +VALUES (1, 'Stefanie & Leandro', 500, 300, 1, 'post', 'public'); + +CREATE TABLE uploads ( + id TEXT PRIMARY KEY, + storage_key TEXT NOT NULL, + thumbnail_key TEXT, + mime_type TEXT NOT NULL, + size_bytes INTEGER 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 INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + approved_at INTEGER +); + +CREATE INDEX idx_uploads_status_created ON uploads (status, created_at DESC); +CREATE INDEX idx_uploads_source ON uploads (source); + +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + actor TEXT, + payload TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) +); diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..0c3c3b0 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,35 @@ +{ + "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": { + "typecheck": "tsc --noEmit", + "build": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@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", + "qrcode": "^1.5.4", + "zod": "^3.23.8" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241218.0", + "@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..1e8419c --- /dev/null +++ b/apps/api/src/db/client.ts @@ -0,0 +1,9 @@ +import type { D1Database } from '@cloudflare/workers-types'; +import { drizzle } from 'drizzle-orm/d1'; +import * as schema from './schema.js'; + +export type DB = ReturnType; + +export function getDb(d1: D1Database) { + return drizzle(d1, { schema }); +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..ae9e366 --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -0,0 +1,62 @@ +import { sql } from 'drizzle-orm'; +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const eventConfig = sqliteTable('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: integer('allow_video', { mode: 'boolean' }).notNull().default(true), + maxVideoSeconds: integer('max_video_seconds').notNull().default(300), + welcomeMessage: text('welcome_message'), + updatedAt: integer('updated_at') + .notNull() + .default(sql`(unixepoch() * 1000)`), +}); + +export const uploads = sqliteTable( + 'uploads', + { + id: text('id').primaryKey(), + storageKey: text('storage_key').notNull(), + thumbnailKey: text('thumbnail_key'), + mimeType: text('mime_type').notNull(), + sizeBytes: integer('size_bytes').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: integer('created_at') + .notNull() + .default(sql`(unixepoch() * 1000)`), + approvedAt: integer('approved_at'), + }, + (t) => ({ + statusCreatedIdx: index('idx_uploads_status_created').on(t.status, t.createdAt), + sourceIdx: index('idx_uploads_source').on(t.source), + }), +); + +export const auditLog = sqliteTable('audit_log', { + id: text('id').primaryKey(), + action: text('action').notNull(), + actor: text('actor'), + payload: text('payload'), + createdAt: integer('created_at') + .notNull() + .default(sql`(unixepoch() * 1000)`), +}); + +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..cd183db --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,32 @@ +import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; + +export interface AppEnv { + DB: D1Database; + MEDIA: R2Bucket; + + S3_ENDPOINT: string; + S3_BUCKET: string; + S3_REGION: string; + S3_ACCESS_KEY_ID: string; + S3_SECRET_ACCESS_KEY: string; + S3_PUBLIC_BASE_URL: string; + S3_FORCE_PATH_STYLE?: string; + + COUPLE_NAMES: string; + EVENT_DATE?: string; + PUBLIC_BASE_URL: string; + + TURNSTILE_SECRET: string; + CF_ACCESS_TEAM: string; + CF_ACCESS_AUD: string; + ALLOWED_ADMIN_EMAILS: string; +} + +export interface AppVariables { + adminEmail: string; +} + +export type Bindings = { + Bindings: AppEnv; + Variables: AppVariables; +}; diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts new file mode 100644 index 0000000..8e89836 --- /dev/null +++ b/apps/api/src/lib/auth.ts @@ -0,0 +1,91 @@ +import type { Context, Next } from 'hono'; +import type { Bindings } from '../env.js'; + +interface AccessJwtPayload { + email?: string; + sub?: string; + aud?: string | string[]; + exp?: number; + iat?: number; +} + +interface JwtHeader { + alg: string; + kid: string; + typ?: string; +} + +export async function requireAdmin(c: Context, next: Next) { + const token = c.req.header('cf-access-jwt-assertion'); + if (!token) return c.json({ error: 'unauthorized' }, 401); + + const payload = await verifyAccessJwt(token, c.env.CF_ACCESS_TEAM, c.env.CF_ACCESS_AUD); + if (!payload?.email) return c.json({ error: 'unauthorized' }, 401); + + const allowed = c.env.ALLOWED_ADMIN_EMAILS.split(',').map((s) => s.trim().toLowerCase()); + if (!allowed.includes(payload.email.toLowerCase())) { + return c.json({ error: 'forbidden' }, 403); + } + c.set('adminEmail', payload.email); + await next(); +} + +async function verifyAccessJwt( + token: string, + team: string, + expectedAud: string, +): Promise { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [headerB64, payloadB64, sigB64] = parts as [string, string, string]; + + let header: JwtHeader; + let payload: AccessJwtPayload; + try { + header = JSON.parse(b64urlDecodeString(headerB64)) as JwtHeader; + payload = JSON.parse(b64urlDecodeString(payloadB64)) as AccessJwtPayload; + } catch { + return null; + } + + const audOk = Array.isArray(payload.aud) + ? payload.aud.includes(expectedAud) + : payload.aud === expectedAud; + if (!audOk) return null; + if (payload.exp && payload.exp * 1000 < Date.now()) return null; + + const certsRes = await fetch(`https://${team}/cdn-cgi/access/certs`); + if (!certsRes.ok) return null; + const certs = (await certsRes.json()) as { keys: Array }; + const jwk = certs.keys.find((k) => k.kid === header.kid); + if (!jwk) return null; + + const cryptoKey = await crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, + ['verify'], + ); + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const valid = await crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + cryptoKey, + b64urlDecode(sigB64), + data, + ); + return valid ? payload : null; +} + +function b64urlDecode(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); + const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function b64urlDecodeString(s: string): string { + return new TextDecoder().decode(b64urlDecode(s)); +} 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/qrcode.ts b/apps/api/src/lib/qrcode.ts new file mode 100644 index 0000000..c1fb014 --- /dev/null +++ b/apps/api/src/lib/qrcode.ts @@ -0,0 +1,22 @@ +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] ?? ''; + const bin = atob(base64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +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..da22c74 --- /dev/null +++ b/apps/api/src/lib/storage-factory.ts @@ -0,0 +1,15 @@ +import type { AppEnv } from '../env.js'; +import { S3Storage } from './storage-s3.js'; +import type { StorageProvider } from './storage.js'; + +export function createStorage(env: AppEnv): StorageProvider { + return 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', + }); +} 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/lib/turnstile.ts b/apps/api/src/lib/turnstile.ts new file mode 100644 index 0000000..28206ce --- /dev/null +++ b/apps/api/src/lib/turnstile.ts @@ -0,0 +1,23 @@ +interface TurnstileVerifyResponse { + success: boolean; + 'error-codes'?: string[]; +} + +export async function verifyTurnstile( + token: string, + secret: string, + remoteIp?: string, +): Promise { + const body = new FormData(); + body.append('secret', secret); + body.append('response', token); + if (remoteIp) body.append('remoteip', remoteIp); + + const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + body, + }); + if (!res.ok) return false; + const data = (await res.json()) as TurnstileVerifyResponse; + return data.success === true; +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..ab6e138 --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -0,0 +1,51 @@ +import { Hono } from 'hono'; +import type { Bindings } from '../env.js'; +import { requireAdmin } from '../lib/auth.js'; + +export const adminRoutes = new Hono(); + +adminRoutes.use('*', requireAdmin); + +adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') })); + +// Event configuration +adminRoutes.get('/event', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +adminRoutes.patch('/event', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +// Uploads moderation / management +adminRoutes.get('/uploads', async (c) => { + return c.json({ items: [], nextCursor: null }); +}); + +adminRoutes.post('/uploads/:id/approve', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +adminRoutes.post('/uploads/:id/reject', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +adminRoutes.delete('/uploads/:id', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +// QR code: ?format=png|svg|pdf (PDF = imprimível em A6 com nome do casal) +adminRoutes.get('/qrcode', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +// Bulk import (post-event): pre-signs URLs in batches so the couple +// can upload files from a folder later (works for R2 today, MinIO later). +adminRoutes.post('/imports', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +// Streamed ZIP export of all uploads. +adminRoutes.get('/export.zip', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts new file mode 100644 index 0000000..06afbdb --- /dev/null +++ b/apps/api/src/routes/public.ts @@ -0,0 +1,27 @@ +import { eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { getDb } from '../db/client.js'; +import { eventConfig } from '../db/schema.js'; +import type { Bindings } from '../env.js'; + +export const publicRoutes = new Hono(); + +publicRoutes.get('/event', async (c) => { + const db = getDb(c.env.DB); + const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get(); + if (!cfg) return c.json({ error: 'not_configured' }, 404); + return c.json({ + coupleNames: cfg.coupleNames, + eventDate: cfg.eventDate, + welcomeMessage: cfg.welcomeMessage, + galleryVisibility: cfg.galleryVisibility, + allowVideo: cfg.allowVideo, + maxFileMb: cfg.maxFileMb, + maxVideoSeconds: cfg.maxVideoSeconds, + }); +}); + +publicRoutes.get('/gallery', async (c) => { + // TODO: paginated list of approved uploads with public URLs + return c.json({ items: [], nextCursor: null }); +}); diff --git a/apps/api/src/routes/uploads.ts b/apps/api/src/routes/uploads.ts new file mode 100644 index 0000000..a346d25 --- /dev/null +++ b/apps/api/src/routes/uploads.ts @@ -0,0 +1,30 @@ +import { Hono } from 'hono'; +import type { Bindings } from '../env.js'; + +export const uploadsRoutes = new Hono(); + +// POST /api/uploads/init +// Body: UploadInit (zod schema in @leetete/shared) +// - validates Turnstile +// - reads event_config (rejects if file too big / video disabled / over duration) +// - decides single vs multipart based on sizeBytes (threshold ~50 MB) +// - creates row in `uploads` (status pending) +// - returns presigned PUT or multipart presigned URLs +uploadsRoutes.post('/init', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +// POST /api/uploads/:id/confirm +// Body: UploadConfirm +// - if multipart: completeMultipart on storage +// - verifies object exists via head() +// - sets status based on event_config.moderation +uploadsRoutes.post('/:id/confirm', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); + +// POST /api/uploads/:id/abort +// Cancels a pending multipart upload (cleanup). +uploadsRoutes.post('/:id/abort', async (c) => { + return c.json({ error: 'not_implemented' }, 501); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..bb918f3 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"], + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example new file mode 100644 index 0000000..d9271ff --- /dev/null +++ b/apps/web/.dev.vars.example @@ -0,0 +1,15 @@ +S3_ENDPOINT=https://.r2.cloudflarestorage.com +S3_BUCKET=wedding-media +S3_REGION=auto +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_PUBLIC_BASE_URL=https://media.stefanieeleandro.pages.dev +S3_FORCE_PATH_STYLE=false + +COUPLE_NAMES=Stefanie & Leandro +PUBLIC_BASE_URL=http://localhost:5173 + +TURNSTILE_SECRET=1x0000000000000000000000000000000AA +CF_ACCESS_TEAM=yourteam.cloudflareaccess.com +CF_ACCESS_AUD= +ALLOWED_ADMIN_EMAILS=stefanie@example.com,leandro@example.com diff --git a/apps/web/functions/api/[[path]].ts b/apps/web/functions/api/[[path]].ts new file mode 100644 index 0000000..b0fb97d --- /dev/null +++ b/apps/web/functions/api/[[path]].ts @@ -0,0 +1,7 @@ +import { createApp } from '@leetete/api'; +import type { AppEnv } from '@leetete/api/env'; + +const app = createApp(); + +export const onRequest: PagesFunction = (ctx) => + app.fetch(ctx.request, ctx.env, ctx); 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..3085b9b --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "@leetete/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "deploy": "wrangler pages deploy dist --project-name stefanieeleandro", + "pages:dev": "wrangler pages dev dist --compatibility-flags=nodejs_compat" + }, + "dependencies": { + "@leetete/api": "workspace:*", + "@leetete/shared": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241218.0", + "@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", + "wrangler": "^3.95.0" + } +} 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..2374aff --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,16 @@ +import { Route, Routes } from 'react-router-dom'; +import AdminDashboard from './routes/admin/Dashboard.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..6e275f0 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,20 @@ +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/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..96e4417 --- /dev/null +++ b/apps/web/src/routes/Gallery.tsx @@ -0,0 +1,10 @@ +export default function Gallery() { + return ( +
+
+

Galeria

+

Em breve.

+
+
+ ); +} diff --git a/apps/web/src/routes/Home.tsx b/apps/web/src/routes/Home.tsx new file mode 100644 index 0000000..f03fb9a --- /dev/null +++ b/apps/web/src/routes/Home.tsx @@ -0,0 +1,48 @@ +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; +} + +export default function Home() { + const [event, setEvent] = useState(null); + + useEffect(() => { + api('/event') + .then(setEvent) + .catch(() => setEvent(null)); + }, []); + + return ( +
+

+ {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..89fee8b --- /dev/null +++ b/apps/web/src/routes/Upload.tsx @@ -0,0 +1,10 @@ +export default function Upload() { + return ( +
+
+

Enviar fotos

+

Em breve.

+
+
+ ); +} diff --git a/apps/web/src/routes/admin/Dashboard.tsx b/apps/web/src/routes/admin/Dashboard.tsx new file mode 100644 index 0000000..96a9bc5 --- /dev/null +++ b/apps/web/src/routes/admin/Dashboard.tsx @@ -0,0 +1,10 @@ +export default function AdminDashboard() { + return ( +
+
+

Painel dos noivos

+

Acesso protegido por Cloudflare Access.

+
+
+ ); +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..45855d3 --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,19 @@ +import type { Config } from 'tailwindcss'; + +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + fontFamily: { + display: ['"Cormorant Garamond"', 'Georgia', 'serif'], + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + colors: { + cream: '#fdf6ec', + sage: '#a3b18a', + rose: '#d4a5a5', + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..5a635c3 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "noEmit": true, + "types": ["vite/client", "@cloudflare/workers-types"] + }, + "include": ["src/**/*", "functions/**/*"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 0000000..5cb358c --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..72af113 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,19 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:8788', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: true, + }, +}); diff --git a/apps/web/wrangler.toml b/apps/web/wrangler.toml new file mode 100644 index 0000000..4d42eb6 --- /dev/null +++ b/apps/web/wrangler.toml @@ -0,0 +1,29 @@ +name = "stefanieeleandro" +compatibility_date = "2026-04-01" +compatibility_flags = ["nodejs_compat"] +pages_build_output_dir = "dist" + +[[d1_databases]] +binding = "DB" +database_name = "wedding-db" +database_id = "REPLACE_WITH_D1_ID" +migrations_dir = "../api/migrations" + +[[r2_buckets]] +binding = "MEDIA" +bucket_name = "wedding-media" + +[vars] +COUPLE_NAMES = "Stefanie & Leandro" +PUBLIC_BASE_URL = "https://stefanieeleandro.pages.dev" +S3_REGION = "auto" +S3_BUCKET = "wedding-media" +S3_FORCE_PATH_STYLE = "false" +# S3_ENDPOINT, S3_PUBLIC_BASE_URL, CF_ACCESS_TEAM, CF_ACCESS_AUD are +# set after Cloudflare config (see README of deploy steps). +# +# Secrets (set via `wrangler pages secret put --project-name stefanieeleandro`): +# S3_ACCESS_KEY_ID +# S3_SECRET_ACCESS_KEY +# TURNSTILE_SECRET +# ALLOWED_ADMIN_EMAILS diff --git a/package.json b/package.json new file mode 100644 index 0000000..d483691 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "stefanieeleandro", + "private": true, + "version": "0.1.0", + "packageManager": "pnpm@9.12.0", + "scripts": { + "dev": "pnpm -F @leetete/web dev", + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "deploy": "pnpm -F @leetete/web deploy", + "db:generate": "pnpm -F @leetete/api db:generate", + "db:migrate:local": "pnpm -F @leetete/web exec wrangler d1 migrations apply wedding-db --local", + "db:migrate:remote": "pnpm -F @leetete/web exec wrangler d1 migrations apply wedding-db --remote" + }, + "devDependencies": { + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=20", + "pnpm": ">=9" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..6f0a294 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,21 @@ +{ + "name": "@leetete/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..5065533 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from './schemas.js'; +export * from './types.js'; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts new file mode 100644 index 0000000..2525cbf --- /dev/null +++ b/packages/shared/src/schemas.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +export const eventConfigSchema = z.object({ + coupleNames: z.string().min(1).max(120), + eventDate: z.string().nullable(), + coverKey: z.string().nullable(), + galleryVisibility: z.enum(['public', 'private']), + moderation: z.enum(['pre', 'post']), + maxFileMb: z.number().int().positive(), + allowVideo: z.boolean(), + maxVideoSeconds: z.number().int().positive(), + welcomeMessage: z.string().max(2000).nullable(), +}); + +export type EventConfig = z.infer; + +export const eventConfigUpdateSchema = eventConfigSchema.partial(); +export type EventConfigUpdate = z.infer; + +export const uploadInitSchema = z.object({ + filename: z.string().min(1).max(255), + mimeType: z.string().regex(/^(image|video)\/[a-z0-9.+-]+$/i), + sizeBytes: z.number().int().positive(), + durationSeconds: z.number().int().positive().optional(), + authorName: z.string().max(80).optional(), + message: z.string().max(2000).optional(), + turnstileToken: z.string().min(1), +}); + +export type UploadInit = z.infer; + +export const uploadInitResponseSchema = z.object({ + uploadId: z.string(), + storageKey: z.string(), + mode: z.enum(['single', 'multipart']), + putUrl: z.string().url().optional(), + putHeaders: z.record(z.string()).optional(), + multipart: z + .object({ + providerUploadId: z.string(), + partSize: z.number().int().positive(), + partUrls: z.array(z.string().url()), + }) + .optional(), +}); + +export type UploadInitResponse = z.infer; + +export const uploadConfirmSchema = z.object({ + multipart: z + .object({ + providerUploadId: z.string(), + parts: z.array( + z.object({ + partNumber: z.number().int().positive(), + etag: z.string().min(1), + }), + ), + }) + .optional(), +}); + +export type UploadConfirm = z.infer; + +export const galleryItemSchema = z.object({ + id: z.string(), + url: z.string().url(), + thumbnailUrl: z.string().url().nullable(), + mimeType: z.string(), + isVideo: z.boolean(), + authorName: z.string().nullable(), + message: z.string().nullable(), + createdAt: z.number(), +}); + +export type GalleryItem = z.infer; + +export const galleryResponseSchema = z.object({ + items: z.array(galleryItemSchema), + nextCursor: z.string().nullable(), +}); + +export type GalleryResponse = z.infer; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..ce70867 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,4 @@ +export type UploadStatus = 'pending' | 'approved' | 'rejected'; +export type UploadSource = 'guest' | 'import'; +export type GalleryVisibility = 'public' | 'private'; +export type ModerationMode = 'pre' | 'post'; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..c77769f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..f9426a5 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "allowJs": false, + "declaration": false, + "sourceMap": true + } +}