This repository has been archived on 2026-06-09. You can view files and clone it, but cannot push or open issues or pull requests.
wedding-app/apps/api/src/routes/uploads.ts

210 lines
6.4 KiB
TypeScript
Raw Normal View History

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 });
});