feat: implement guest upload flow and public gallery
Backend:
- POST /api/uploads/init validates against event_config (size, video
policy, duration), creates an uploads row, and returns either a
single presigned PUT or a multipart batch of presigned part URLs
depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
HEADs the object to verify upload, and flips status to approved
(post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
the row.
- GET /api/gallery returns approved uploads in cursor-paginated
reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.
Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
video preview tiles, infinite "load more", and a fullscreen lightbox
showing the author and message.
Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.
https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
2026-05-03 23:08:00 -03:00
|
|
|
import { eq } from 'drizzle-orm';
|
2026-05-01 20:30:50 -03:00
|
|
|
import { Hono } from 'hono';
|
feat: implement guest upload flow and public gallery
Backend:
- POST /api/uploads/init validates against event_config (size, video
policy, duration), creates an uploads row, and returns either a
single presigned PUT or a multipart batch of presigned part URLs
depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
HEADs the object to verify upload, and flips status to approved
(post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
the row.
- GET /api/gallery returns approved uploads in cursor-paginated
reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.
Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
video preview tiles, infinite "load more", and a fullscreen lightbox
showing the author and message.
Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.
https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
2026-05-03 23:08:00 -03:00
|
|
|
import {
|
|
|
|
|
uploadConfirmSchema,
|
|
|
|
|
uploadInitSchema,
|
|
|
|
|
type UploadInitResponse,
|
|
|
|
|
} from '@leetete/shared';
|
|
|
|
|
import { getDb } from '../db/client.js';
|
|
|
|
|
import { eventConfig, uploads } from '../db/schema.js';
|
2026-05-01 20:30:50 -03:00
|
|
|
import type { Bindings } from '../env.js';
|
feat: implement guest upload flow and public gallery
Backend:
- POST /api/uploads/init validates against event_config (size, video
policy, duration), creates an uploads row, and returns either a
single presigned PUT or a multipart batch of presigned part URLs
depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
HEADs the object to verify upload, and flips status to approved
(post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
the row.
- GET /api/gallery returns approved uploads in cursor-paginated
reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.
Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
video preview tiles, infinite "load more", and a fullscreen lightbox
showing the author and message.
Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.
https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
2026-05-03 23:08:00 -03:00
|
|
|
import { hashIp, uploadId } from '../lib/ids.js';
|
|
|
|
|
import { createStorage } from '../lib/storage-factory.js';
|
2026-05-01 20:30:50 -03:00
|
|
|
|
|
|
|
|
export const uploadsRoutes = new Hono<Bindings>();
|
|
|
|
|
|
feat: implement guest upload flow and public gallery
Backend:
- POST /api/uploads/init validates against event_config (size, video
policy, duration), creates an uploads row, and returns either a
single presigned PUT or a multipart batch of presigned part URLs
depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
HEADs the object to verify upload, and flips status to approved
(post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
the row.
- GET /api/gallery returns approved uploads in cursor-paginated
reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.
Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
video preview tiles, infinite "load more", and a fullscreen lightbox
showing the author and message.
Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.
https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
2026-05-03 23:08:00 -03:00
|
|
|
const SINGLE_PUT_MAX = 50 * 1024 * 1024;
|
|
|
|
|
const PART_SIZE = 10 * 1024 * 1024;
|
|
|
|
|
const KEY_PREFIX = 'uploads';
|
|
|
|
|
const IP_HASH_SALT = 'wedding-uploads-v1';
|
|
|
|
|
|
|
|
|
|
function extFromFilename(filename: string, mimeType: string): string {
|
|
|
|
|
const dot = filename.lastIndexOf('.');
|
|
|
|
|
if (dot >= 0 && dot < filename.length - 1) {
|
|
|
|
|
const ext = filename.slice(dot + 1).toLowerCase();
|
|
|
|
|
if (/^[a-z0-9]{1,6}$/.test(ext)) return ext;
|
|
|
|
|
}
|
|
|
|
|
const map: Record<string, string> = {
|
|
|
|
|
'image/jpeg': 'jpg',
|
|
|
|
|
'image/png': 'png',
|
|
|
|
|
'image/webp': 'webp',
|
|
|
|
|
'image/heic': 'heic',
|
|
|
|
|
'image/heif': 'heif',
|
|
|
|
|
'image/gif': 'gif',
|
|
|
|
|
'video/mp4': 'mp4',
|
|
|
|
|
'video/quicktime': 'mov',
|
|
|
|
|
'video/webm': 'webm',
|
|
|
|
|
};
|
|
|
|
|
return map[mimeType.toLowerCase()] ?? 'bin';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildKey(id: string, filename: string, mimeType: string): string {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const yyyy = now.getUTCFullYear();
|
|
|
|
|
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
|
|
|
const ext = extFromFilename(filename, mimeType);
|
|
|
|
|
return `${KEY_PREFIX}/${yyyy}/${mm}/${id}.${ext}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:30:50 -03:00
|
|
|
uploadsRoutes.post('/init', async (c) => {
|
feat: implement guest upload flow and public gallery
Backend:
- POST /api/uploads/init validates against event_config (size, video
policy, duration), creates an uploads row, and returns either a
single presigned PUT or a multipart batch of presigned part URLs
depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
HEADs the object to verify upload, and flips status to approved
(post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
the row.
- GET /api/gallery returns approved uploads in cursor-paginated
reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.
Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
video preview tiles, infinite "load more", and a fullscreen lightbox
showing the author and message.
Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.
https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
2026-05-03 23:08:00 -03:00
|
|
|
const body = await c.req.json().catch(() => null);
|
|
|
|
|
const parsed = uploadInitSchema.safeParse(body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
|
|
|
}
|
|
|
|
|
const input = parsed.data;
|
|
|
|
|
|
|
|
|
|
const db = getDb(c.env.DB);
|
|
|
|
|
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
|
|
|
|
|
if (!cfg) return c.json({ error: 'not_configured' }, 500);
|
|
|
|
|
|
|
|
|
|
const isVideo = input.mimeType.startsWith('video/');
|
|
|
|
|
if (isVideo && !cfg.allowVideo) {
|
|
|
|
|
return c.json({ error: 'video_not_allowed' }, 400);
|
|
|
|
|
}
|
|
|
|
|
if (input.sizeBytes > cfg.maxFileMb * 1024 * 1024) {
|
|
|
|
|
return c.json({ error: 'file_too_large', maxFileMb: cfg.maxFileMb }, 400);
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
isVideo &&
|
|
|
|
|
input.durationSeconds !== undefined &&
|
|
|
|
|
input.durationSeconds > cfg.maxVideoSeconds
|
|
|
|
|
) {
|
|
|
|
|
return c.json({ error: 'video_too_long', maxVideoSeconds: cfg.maxVideoSeconds }, 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const id = uploadId();
|
|
|
|
|
const storageKey = buildKey(id, input.filename, input.mimeType);
|
|
|
|
|
const storage = createStorage(c.env);
|
|
|
|
|
const ip =
|
|
|
|
|
c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown';
|
|
|
|
|
const ipHashValue = await hashIp(ip, IP_HASH_SALT);
|
|
|
|
|
|
|
|
|
|
if (input.sizeBytes <= SINGLE_PUT_MAX) {
|
|
|
|
|
const presigned = await storage.presignPut({
|
|
|
|
|
key: storageKey,
|
|
|
|
|
contentType: input.mimeType,
|
|
|
|
|
contentLength: input.sizeBytes,
|
|
|
|
|
expiresInSec: 900,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(uploads).values({
|
|
|
|
|
id,
|
|
|
|
|
storageKey,
|
|
|
|
|
mimeType: input.mimeType,
|
|
|
|
|
sizeBytes: input.sizeBytes,
|
|
|
|
|
durationSeconds: input.durationSeconds ?? null,
|
|
|
|
|
authorName: input.authorName?.trim() || null,
|
|
|
|
|
message: input.message?.trim() || null,
|
2026-05-03 23:21:27 -03:00
|
|
|
status: 'pending',
|
feat: implement guest upload flow and public gallery
Backend:
- POST /api/uploads/init validates against event_config (size, video
policy, duration), creates an uploads row, and returns either a
single presigned PUT or a multipart batch of presigned part URLs
depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
HEADs the object to verify upload, and flips status to approved
(post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
the row.
- GET /api/gallery returns approved uploads in cursor-paginated
reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.
Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
video preview tiles, infinite "load more", and a fullscreen lightbox
showing the author and message.
Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.
https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
2026-05-03 23:08:00 -03:00
|
|
|
source: 'guest',
|
|
|
|
|
ipHash: ipHashValue,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const response: UploadInitResponse = {
|
|
|
|
|
uploadId: id,
|
|
|
|
|
storageKey,
|
|
|
|
|
mode: 'single',
|
|
|
|
|
putUrl: presigned.url,
|
|
|
|
|
putHeaders: presigned.headers,
|
|
|
|
|
};
|
|
|
|
|
return c.json(response);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const partCount = Math.ceil(input.sizeBytes / PART_SIZE);
|
|
|
|
|
const multipart = await storage.initMultipart({
|
|
|
|
|
key: storageKey,
|
|
|
|
|
contentType: input.mimeType,
|
|
|
|
|
partCount,
|
|
|
|
|
expiresInSec: 3600 * 6,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.insert(uploads).values({
|
|
|
|
|
id,
|
|
|
|
|
storageKey,
|
|
|
|
|
mimeType: input.mimeType,
|
|
|
|
|
sizeBytes: input.sizeBytes,
|
|
|
|
|
durationSeconds: input.durationSeconds ?? null,
|
|
|
|
|
authorName: input.authorName?.trim() || null,
|
|
|
|
|
message: input.message?.trim() || null,
|
|
|
|
|
status: 'pending',
|
|
|
|
|
source: 'guest',
|
|
|
|
|
ipHash: ipHashValue,
|
|
|
|
|
providerUploadId: multipart.uploadId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const response: UploadInitResponse = {
|
|
|
|
|
uploadId: id,
|
|
|
|
|
storageKey,
|
|
|
|
|
mode: 'multipart',
|
|
|
|
|
multipart: {
|
|
|
|
|
providerUploadId: multipart.uploadId,
|
|
|
|
|
partSize: multipart.partSize,
|
|
|
|
|
partUrls: multipart.partUrls,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return c.json(response);
|
2026-05-01 20:30:50 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
uploadsRoutes.post('/:id/confirm', async (c) => {
|
feat: implement guest upload flow and public gallery
Backend:
- POST /api/uploads/init validates against event_config (size, video
policy, duration), creates an uploads row, and returns either a
single presigned PUT or a multipart batch of presigned part URLs
depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
HEADs the object to verify upload, and flips status to approved
(post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
the row.
- GET /api/gallery returns approved uploads in cursor-paginated
reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.
Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
video preview tiles, infinite "load more", and a fullscreen lightbox
showing the author and message.
Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.
https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
2026-05-03 23:08:00 -03:00
|
|
|
const id = c.req.param('id');
|
|
|
|
|
const body = await c.req.json().catch(() => ({}));
|
|
|
|
|
const parsed = uploadConfirmSchema.safeParse(body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const db = getDb(c.env.DB);
|
|
|
|
|
const upload = await db.select().from(uploads).where(eq(uploads.id, id)).get();
|
|
|
|
|
if (!upload) return c.json({ error: 'not_found' }, 404);
|
|
|
|
|
if (upload.status === 'approved') return c.json({ ok: true, alreadyApproved: true });
|
|
|
|
|
|
|
|
|
|
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
|
|
|
|
|
if (!cfg) return c.json({ error: 'not_configured' }, 500);
|
|
|
|
|
|
|
|
|
|
const storage = createStorage(c.env);
|
|
|
|
|
|
|
|
|
|
if (parsed.data.multipart) {
|
|
|
|
|
if (parsed.data.multipart.providerUploadId !== upload.providerUploadId) {
|
|
|
|
|
return c.json({ error: 'multipart_mismatch' }, 400);
|
|
|
|
|
}
|
|
|
|
|
await storage.completeMultipart({
|
|
|
|
|
key: upload.storageKey,
|
|
|
|
|
uploadId: parsed.data.multipart.providerUploadId,
|
|
|
|
|
parts: parsed.data.multipart.parts,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const head = await storage.head(upload.storageKey);
|
|
|
|
|
if (!head) {
|
|
|
|
|
return c.json({ error: 'not_uploaded' }, 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const finalStatus = cfg.moderation === 'pre' ? 'pending' : 'approved';
|
|
|
|
|
await db
|
|
|
|
|
.update(uploads)
|
|
|
|
|
.set({
|
|
|
|
|
status: finalStatus,
|
|
|
|
|
approvedAt: finalStatus === 'approved' ? Date.now() : null,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(uploads.id, id));
|
|
|
|
|
|
|
|
|
|
return c.json({ ok: true, status: finalStatus });
|
2026-05-01 20:30:50 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
uploadsRoutes.post('/:id/abort', async (c) => {
|
feat: implement guest upload flow and public gallery
Backend:
- POST /api/uploads/init validates against event_config (size, video
policy, duration), creates an uploads row, and returns either a
single presigned PUT or a multipart batch of presigned part URLs
depending on file size (50 MB threshold, 10 MB parts).
- POST /api/uploads/:id/confirm completes multipart on R2 if needed,
HEADs the object to verify upload, and flips status to approved
(post-moderation) or pending (pre-moderation).
- POST /api/uploads/:id/abort cancels in-flight multipart and removes
the row.
- GET /api/gallery returns approved uploads in cursor-paginated
reverse chronological order, with public R2 URLs.
- GET /api/stats returns lightweight counts for future home/admin use.
Frontend:
- lib/upload.ts handles single and multipart uploads via XHR with
progress callbacks, video duration extraction, and abort signals.
- /enviar: real form with file picker (image/video), author name,
message, per-file progress, multi-file support, and a success state.
- /galeria: responsive grid of approved items with lazy-loaded images,
video preview tiles, infinite "load more", and a fullscreen lightbox
showing the author and message.
Schema: turnstileToken is now optional so the MVP works without
Turnstile wired up; we layer it back in later.
https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
2026-05-03 23:08:00 -03:00
|
|
|
const id = c.req.param('id');
|
|
|
|
|
const db = getDb(c.env.DB);
|
|
|
|
|
const upload = await db.select().from(uploads).where(eq(uploads.id, id)).get();
|
|
|
|
|
if (!upload) return c.json({ error: 'not_found' }, 404);
|
|
|
|
|
|
|
|
|
|
if (upload.providerUploadId) {
|
|
|
|
|
const storage = createStorage(c.env);
|
|
|
|
|
await storage
|
|
|
|
|
.abortMultipart({ key: upload.storageKey, uploadId: upload.providerUploadId })
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
await db.delete(uploads).where(eq(uploads.id, id));
|
|
|
|
|
return c.json({ ok: true });
|
2026-05-01 20:30:50 -03:00
|
|
|
});
|