Single-PUT init was inserting rows with status='approved' (skipping the confirmation step), so a failed browser->R2 PUT left ghost entries that the gallery happily listed. Always start as 'pending'; the existing /confirm flow is the only place that should approve. https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
210 lines
6.4 KiB
TypeScript
210 lines
6.4 KiB
TypeScript
import { eq } from 'drizzle-orm';
|
|
import { Hono } from 'hono';
|
|
import {
|
|
uploadConfirmSchema,
|
|
uploadInitSchema,
|
|
type UploadInitResponse,
|
|
} from '@leetete/shared';
|
|
import { getDb } from '../db/client.js';
|
|
import { eventConfig, uploads } from '../db/schema.js';
|
|
import type { Bindings } from '../env.js';
|
|
import { hashIp, uploadId } from '../lib/ids.js';
|
|
import { createStorage } from '../lib/storage-factory.js';
|
|
|
|
export const uploadsRoutes = new Hono<Bindings>();
|
|
|
|
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}`;
|
|
}
|
|
|
|
uploadsRoutes.post('/init', async (c) => {
|
|
const body = await c.req.json().catch(() => null);
|
|
const parsed = uploadInitSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
}
|
|
const input = parsed.data;
|
|
|
|
const db = getDb(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,
|
|
status: 'pending',
|
|
source: 'guest',
|
|
ipHash: ipHashValue,
|
|
});
|
|
|
|
const response: UploadInitResponse = {
|
|
uploadId: id,
|
|
storageKey,
|
|
mode: 'single',
|
|
putUrl: presigned.url,
|
|
putHeaders: presigned.headers,
|
|
};
|
|
return c.json(response);
|
|
}
|
|
|
|
const partCount = Math.ceil(input.sizeBytes / PART_SIZE);
|
|
const multipart = await storage.initMultipart({
|
|
key: storageKey,
|
|
contentType: input.mimeType,
|
|
partCount,
|
|
expiresInSec: 3600 * 6,
|
|
});
|
|
|
|
await db.insert(uploads).values({
|
|
id,
|
|
storageKey,
|
|
mimeType: input.mimeType,
|
|
sizeBytes: input.sizeBytes,
|
|
durationSeconds: input.durationSeconds ?? null,
|
|
authorName: input.authorName?.trim() || null,
|
|
message: input.message?.trim() || null,
|
|
status: 'pending',
|
|
source: 'guest',
|
|
ipHash: ipHashValue,
|
|
providerUploadId: multipart.uploadId,
|
|
});
|
|
|
|
const response: UploadInitResponse = {
|
|
uploadId: id,
|
|
storageKey,
|
|
mode: 'multipart',
|
|
multipart: {
|
|
providerUploadId: multipart.uploadId,
|
|
partSize: multipart.partSize,
|
|
partUrls: multipart.partUrls,
|
|
},
|
|
};
|
|
return c.json(response);
|
|
});
|
|
|
|
uploadsRoutes.post('/:id/confirm', async (c) => {
|
|
const id = c.req.param('id');
|
|
const body = await c.req.json().catch(() => ({}));
|
|
const parsed = uploadConfirmSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
}
|
|
|
|
const db = getDb(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 });
|
|
});
|
|
|
|
uploadsRoutes.post('/:id/abort', async (c) => {
|
|
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 });
|
|
});
|