import { withUser, json, ApiError } from "@/lib/api"; import { getDb, messageAttachments } from "@/db"; import { ids } from "@/lib/ids"; import { IMAGE_MIME, isTextLike } from "@/lib/ai/core/content"; import { LIMITS } from "@/lib/rate-limit"; import { and, eq } from "drizzle-orm"; export const dynamic = "force-dynamic"; const MAX_BYTES = 10 * 1024 * 1024; const MAX_TEXT_BYTES = 2 * 1024 * 1024; function kindOf(mime: string, name: string): string | null { if (IMAGE_MIME.has(mime)) return "image"; if (mime === "application/pdf" || /\.pdf$/i.test(name)) return "pdf"; if (mime === "text/csv" || /\.csv$/i.test(name)) return "csv"; if (mime === "application/json" || /\.json$/i.test(name)) return "json"; if (isTextLike(mime, name)) return /\.(md|txt)$/i.test(name) ? "text" : "code"; return null; } function pngSize(buf: Buffer): { width: number; height: number } | null { if (buf.length > 24 && buf.toString("ascii", 1, 4) === "PNG") return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; return null; } /** POST /api/attachments — multipart upload; returns attachment metadata (payload stays server-side). */ export const POST = withUser( async ({ req, user }) => { const len = Number(req.headers.get("content-length") ?? 0); if (len > MAX_BYTES + 4096) throw new ApiError(413, "File too large (max 10 MB)", "PAYLOAD_TOO_LARGE"); const form = await req.formData().catch(() => null); const file = form?.get("file"); if (!(file instanceof File)) throw new ApiError(400, "Missing file", "BAD_REQUEST"); const name = (file.name || "file").slice(0, 200); const mime = (file.type || "application/octet-stream").toLowerCase(); const kind = kindOf(mime, name); if (!kind) throw new ApiError(415, `Unsupported file type (${mime || "unknown"}). Use images, PDF, text, code, CSV or JSON.`, "UNSUPPORTED_TYPE"); if (file.size > MAX_BYTES) throw new ApiError(413, "File too large (max 10 MB)", "PAYLOAD_TOO_LARGE"); if (kind !== "image" && kind !== "pdf" && file.size > MAX_TEXT_BYTES) throw new ApiError(413, "Text files are limited to 2 MB", "PAYLOAD_TOO_LARGE"); const buf = Buffer.from(await file.arrayBuffer()); const dims = kind === "image" ? pngSize(buf) : null; const effectiveMime = kind === "image" ? mime : kind === "pdf" ? "application/pdf" : mime.startsWith("text/") || mime === "application/json" ? mime : "text/plain"; const [row] = await getDb() .insert(messageAttachments) .values({ id: ids.attachment(), userId: user.id, kind, name, mimeType: effectiveMime, sizeBytes: buf.length, dataBase64: buf.toString("base64"), width: dims?.width ?? null, height: dims?.height ?? null }) .returning({ id: messageAttachments.id, kind: messageAttachments.kind, name: messageAttachments.name, mimeType: messageAttachments.mimeType, sizeBytes: messageAttachments.sizeBytes, width: messageAttachments.width, height: messageAttachments.height }); return json({ attachment: row }, { status: 201 }); }, { limit: { ...LIMITS.upload, key: "upload" } }, ); /** GET /api/attachments?id= — streams the payload back to its owner (used for image previews). */ export const GET = withUser(async ({ req, user }) => { const id = new URL(req.url).searchParams.get("id"); if (!id) throw new ApiError(400, "Missing id"); const [row] = await getDb() .select() .from(messageAttachments) .where(and(eq(messageAttachments.id, id), eq(messageAttachments.userId, user.id))) .limit(1); if (!row) throw new ApiError(404, "Not found", "NOT_FOUND"); const buf = Buffer.from(row.dataBase64, "base64"); return new Response(buf, { headers: { "Content-Type": row.mimeType, "Content-Length": String(buf.length), "Cache-Control": "private, max-age=3600", "Content-Disposition": `inline; filename="${encodeURIComponent(row.name)}"` } }); });