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
This commit is contained in:
commit
be13179832
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -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
|
||||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -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/
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
auto-install-peers=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
7
apps/api/drizzle.config.ts
Normal file
7
apps/api/drizzle.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { Config } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './migrations',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
} satisfies Config;
|
||||||
44
apps/api/migrations/0001_initial.sql
Normal file
44
apps/api/migrations/0001_initial.sql
Normal file
@ -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)
|
||||||
|
);
|
||||||
35
apps/api/package.json
Normal file
35
apps/api/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/api/src/app.ts
Normal file
28
apps/api/src/app.ts
Normal file
@ -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<Bindings>().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<typeof createApp>;
|
||||||
9
apps/api/src/db/client.ts
Normal file
9
apps/api/src/db/client.ts
Normal file
@ -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<typeof getDb>;
|
||||||
|
|
||||||
|
export function getDb(d1: D1Database) {
|
||||||
|
return drizzle(d1, { schema });
|
||||||
|
}
|
||||||
62
apps/api/src/db/schema.ts
Normal file
62
apps/api/src/db/schema.ts
Normal file
@ -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;
|
||||||
32
apps/api/src/env.ts
Normal file
32
apps/api/src/env.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
91
apps/api/src/lib/auth.ts
Normal file
91
apps/api/src/lib/auth.ts
Normal file
@ -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<Bindings>, 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<AccessJwtPayload | null> {
|
||||||
|
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<JsonWebKey & { kid?: string }> };
|
||||||
|
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));
|
||||||
|
}
|
||||||
21
apps/api/src/lib/ids.ts
Normal file
21
apps/api/src/lib/ids.ts
Normal file
@ -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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
22
apps/api/src/lib/qrcode.ts
Normal file
22
apps/api/src/lib/qrcode.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
export async function generateQrPng(text: string, size = 512): Promise<Uint8Array> {
|
||||||
|
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<string> {
|
||||||
|
return QRCode.toString(text, {
|
||||||
|
type: 'svg',
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
margin: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/api/src/lib/storage-factory.ts
Normal file
15
apps/api/src/lib/storage-factory.ts
Normal file
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
164
apps/api/src/lib/storage-s3.ts
Normal file
164
apps/api/src/lib/storage-s3.ts
Normal file
@ -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<PresignPutResult> {
|
||||||
|
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<InitMultipartResult> {
|
||||||
|
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<void> {
|
||||||
|
const sorted = [...input.parts].sort((a, b) => a.partNumber - b.partNumber);
|
||||||
|
const body =
|
||||||
|
`<CompleteMultipartUpload>` +
|
||||||
|
sorted
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`,
|
||||||
|
)
|
||||||
|
.join('') +
|
||||||
|
`</CompleteMultipartUpload>`;
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' });
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
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<ObjectMeta | null> {
|
||||||
|
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}>([^<]+)</${tag}>`));
|
||||||
|
return m?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
49
apps/api/src/lib/storage.ts
Normal file
49
apps/api/src/lib/storage.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
export interface PresignPutInput {
|
||||||
|
key: string;
|
||||||
|
contentType: string;
|
||||||
|
contentLength?: number;
|
||||||
|
expiresInSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresignPutResult {
|
||||||
|
url: string;
|
||||||
|
method: 'PUT';
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PresignPutResult>;
|
||||||
|
initMultipart(input: InitMultipartInput): Promise<InitMultipartResult>;
|
||||||
|
completeMultipart(input: CompleteMultipartInput): Promise<void>;
|
||||||
|
abortMultipart(input: { key: string; uploadId: string }): Promise<void>;
|
||||||
|
publicUrl(key: string): string;
|
||||||
|
presignGet(key: string, expiresInSec?: number): Promise<string>;
|
||||||
|
exists(key: string): Promise<boolean>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
head(key: string): Promise<ObjectMeta | null>;
|
||||||
|
}
|
||||||
23
apps/api/src/lib/turnstile.ts
Normal file
23
apps/api/src/lib/turnstile.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
interface TurnstileVerifyResponse {
|
||||||
|
success: boolean;
|
||||||
|
'error-codes'?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTurnstile(
|
||||||
|
token: string,
|
||||||
|
secret: string,
|
||||||
|
remoteIp?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
51
apps/api/src/routes/admin.ts
Normal file
51
apps/api/src/routes/admin.ts
Normal file
@ -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<Bindings>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
27
apps/api/src/routes/public.ts
Normal file
27
apps/api/src/routes/public.ts
Normal file
@ -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<Bindings>();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
30
apps/api/src/routes/uploads.ts
Normal file
30
apps/api/src/routes/uploads.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { Bindings } from '../env.js';
|
||||||
|
|
||||||
|
export const uploadsRoutes = new Hono<Bindings>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
8
apps/api/tsconfig.json
Normal file
8
apps/api/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
15
apps/web/.dev.vars.example
Normal file
15
apps/web/.dev.vars.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
S3_ENDPOINT=https://<account-id>.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
|
||||||
7
apps/web/functions/api/[[path]].ts
Normal file
7
apps/web/functions/api/[[path]].ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from '@leetete/api';
|
||||||
|
import type { AppEnv } from '@leetete/api/env';
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
export const onRequest: PagesFunction<AppEnv> = (ctx) =>
|
||||||
|
app.fetch(ctx.request, ctx.env, ctx);
|
||||||
13
apps/web/index.html
Normal file
13
apps/web/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#fdf6ec" />
|
||||||
|
<title>Stefanie & Leandro</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
apps/web/package.json
Normal file
34
apps/web/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
16
apps/web/src/App.tsx
Normal file
16
apps/web/src/App.tsx
Normal file
@ -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 (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/enviar" element={<Upload />} />
|
||||||
|
<Route path="/galeria" element={<Gallery />} />
|
||||||
|
<Route path="/admin/*" element={<AdminDashboard />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/web/src/index.css
Normal file
15
apps/web/src/index.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
20
apps/web/src/lib/api.ts
Normal file
20
apps/web/src/lib/api.ts
Normal file
@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
16
apps/web/src/main.tsx
Normal file
16
apps/web/src/main.tsx
Normal file
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
10
apps/web/src/routes/Gallery.tsx
Normal file
10
apps/web/src/routes/Gallery.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function Gallery() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-full flex items-center justify-center p-6">
|
||||||
|
<div className="text-center text-stone-700">
|
||||||
|
<h2 className="font-display text-3xl mb-3">Galeria</h2>
|
||||||
|
<p>Em breve.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/web/src/routes/Home.tsx
Normal file
48
apps/web/src/routes/Home.tsx
Normal file
@ -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<EventInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api<EventInfo>('/event')
|
||||||
|
.then(setEvent)
|
||||||
|
.catch(() => setEvent(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-full flex flex-col items-center justify-center px-6 py-12 text-center">
|
||||||
|
<h1 className="font-display text-5xl text-stone-800 mb-3">
|
||||||
|
{event?.coupleNames ?? 'Stefanie & Leandro'}
|
||||||
|
</h1>
|
||||||
|
{event?.eventDate ? (
|
||||||
|
<p className="text-stone-600 mb-8">{event.eventDate}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="max-w-md text-stone-700 mb-10">
|
||||||
|
{event?.welcomeMessage ??
|
||||||
|
'Compartilhe suas fotos, vídeos e mensagens deste dia especial.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||||
|
<Link
|
||||||
|
to="/enviar"
|
||||||
|
className="bg-stone-800 text-cream rounded-full py-3 px-6 hover:bg-stone-700 transition"
|
||||||
|
>
|
||||||
|
Enviar fotos e mensagem
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/galeria"
|
||||||
|
className="border border-stone-400 text-stone-700 rounded-full py-3 px-6 hover:bg-white/40 transition"
|
||||||
|
>
|
||||||
|
Ver galeria
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/web/src/routes/Upload.tsx
Normal file
10
apps/web/src/routes/Upload.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function Upload() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-full flex items-center justify-center p-6">
|
||||||
|
<div className="text-center text-stone-700">
|
||||||
|
<h2 className="font-display text-3xl mb-3">Enviar fotos</h2>
|
||||||
|
<p>Em breve.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/web/src/routes/admin/Dashboard.tsx
Normal file
10
apps/web/src/routes/admin/Dashboard.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function AdminDashboard() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-full flex items-center justify-center p-6">
|
||||||
|
<div className="text-center text-stone-700">
|
||||||
|
<h2 className="font-display text-3xl mb-3">Painel dos noivos</h2>
|
||||||
|
<p>Acesso protegido por Cloudflare Access.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/web/tailwind.config.ts
Normal file
19
apps/web/tailwind.config.ts
Normal file
@ -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;
|
||||||
11
apps/web/tsconfig.json
Normal file
11
apps/web/tsconfig.json
Normal file
@ -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" }]
|
||||||
|
}
|
||||||
11
apps/web/tsconfig.node.json
Normal file
11
apps/web/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
19
apps/web/vite.config.ts
Normal file
19
apps/web/vite.config.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
29
apps/web/wrangler.toml
Normal file
29
apps/web/wrangler.toml
Normal file
@ -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 <NAME> --project-name stefanieeleandro`):
|
||||||
|
# S3_ACCESS_KEY_ID
|
||||||
|
# S3_SECRET_ACCESS_KEY
|
||||||
|
# TURNSTILE_SECRET
|
||||||
|
# ALLOWED_ADMIN_EMAILS
|
||||||
22
package.json
Normal file
22
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/shared/package.json
Normal file
21
packages/shared/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './schemas.js';
|
||||||
|
export * from './types.js';
|
||||||
83
packages/shared/src/schemas.ts
Normal file
83
packages/shared/src/schemas.ts
Normal file
@ -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<typeof eventConfigSchema>;
|
||||||
|
|
||||||
|
export const eventConfigUpdateSchema = eventConfigSchema.partial();
|
||||||
|
export type EventConfigUpdate = z.infer<typeof eventConfigUpdateSchema>;
|
||||||
|
|
||||||
|
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<typeof uploadInitSchema>;
|
||||||
|
|
||||||
|
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<typeof uploadInitResponseSchema>;
|
||||||
|
|
||||||
|
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<typeof uploadConfirmSchema>;
|
||||||
|
|
||||||
|
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<typeof galleryItemSchema>;
|
||||||
|
|
||||||
|
export const galleryResponseSchema = z.object({
|
||||||
|
items: z.array(galleryItemSchema),
|
||||||
|
nextCursor: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GalleryResponse = z.infer<typeof galleryResponseSchema>;
|
||||||
4
packages/shared/src/types.ts
Normal file
4
packages/shared/src/types.ts
Normal file
@ -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';
|
||||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
20
tsconfig.base.json
Normal file
20
tsconfig.base.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user