the one the sweep missed. an image or a PDF dropped into a chat went straight to its final name in the attachments directory, and the mkdir above it was redundant once Bun.write creates the parent chain anyway. no `Bun.write` left under src/servers/api that is not writeAtomic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
70 lines
2.7 KiB
TypeScript
70 lines
2.7 KiB
TypeScript
import { createRouter } from '../../create-router';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
|
import { writeAtomic } from '@@/write-atomic';
|
|
|
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
/** PDFs never enter a prompt whole — the agent reads them from disk — so the ceiling is storage, not context. */
|
|
const PDF_MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
|
|
|
export const uploadRouter = createRouter();
|
|
|
|
uploadRouter.post('/', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.get('body') as Record<string, unknown>;
|
|
|
|
const file = body.file as File | null;
|
|
const sessionId = (body.sessionId as string) || null;
|
|
|
|
if (!file || !(file instanceof File)) {
|
|
return ctx.json({ error: 'file is required' }, 400);
|
|
}
|
|
|
|
// Images and PDFs. The two are handled very differently downstream and it is worth knowing which is
|
|
// which here: an image travels to the model as base64 in the message, while a PDF is left on disk and
|
|
// the agent is told where it is, because reading a document is something its own tools already do well
|
|
// (page ranges, extraction) and a 10 MB base64 blob in a prompt is not.
|
|
const isImage = file.type.startsWith('image/');
|
|
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
|
|
if (!isImage && !isPdf) {
|
|
return ctx.json({ error: 'Only images and PDFs are allowed' }, 400);
|
|
}
|
|
|
|
// A PDF is allowed to be bigger: 5 MB is a generous photo and a small report. The agent reads it from
|
|
// disk in page ranges, so size costs storage rather than context.
|
|
const limit = isPdf ? PDF_MAX_FILE_SIZE : MAX_FILE_SIZE;
|
|
if (file.size > limit) {
|
|
return ctx.json({ error: `File exceeds ${Math.round(limit / 1024 / 1024)} MB limit` }, 400);
|
|
}
|
|
|
|
try {
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
const ext = file.name.split('.').pop() || 'png';
|
|
const filename = `${crypto.randomUUID()}.${ext}`;
|
|
|
|
let saveDir: string;
|
|
if (sessionId) {
|
|
saveDir = getAttachmentsDir(user.email, sessionId);
|
|
} else {
|
|
saveDir = getTmpAttachmentsDir(user.email);
|
|
}
|
|
|
|
await writeAtomic(join(saveDir, filename), buffer);
|
|
|
|
// Only an image gets a data URL. Building one for a PDF would mean carrying megabytes through the
|
|
// browser for a preview nothing renders.
|
|
const dataUrl = isImage ? `data:${file.type};base64,${buffer.toString('base64')}` : '';
|
|
|
|
return ctx.json({
|
|
filename: file.name,
|
|
dataUrl,
|
|
attachmentId: filename,
|
|
kind: isPdf ? 'pdf' : 'image',
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Upload failed';
|
|
return ctx.json({ error: message }, 500);
|
|
}
|
|
});
|