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