TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import { withUser, json, ApiError } from "@/lib/api";2import { getDb, messageAttachments } from "@/db";3import { ids } from "@/lib/ids";4import { IMAGE_MIME, isTextLike } from "@/lib/ai/core/content";5import { LIMITS } from "@/lib/rate-limit";6import { and, eq } from "drizzle-orm";78export const dynamic = "force-dynamic";910const MAX_BYTES = 10 * 1024 * 1024;11const MAX_TEXT_BYTES = 2 * 1024 * 1024;1213function kindOf(mime: string, name: string): string | null {14 if (IMAGE_MIME.has(mime)) return "image";15 if (mime === "application/pdf" || /\.pdf$/i.test(name)) return "pdf";16 if (mime === "text/csv" || /\.csv$/i.test(name)) return "csv";17 if (mime === "application/json" || /\.json$/i.test(name)) return "json";18 if (isTextLike(mime, name)) return /\.(md|txt)$/i.test(name) ? "text" : "code";19 return null;20}2122function pngSize(buf: Buffer): { width: number; height: number } | null {23 if (buf.length > 24 && buf.toString("ascii", 1, 4) === "PNG") return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };24 return null;25}2627/** POST /api/attachments — multipart upload; returns attachment metadata (payload stays server-side). */28export const POST = withUser(29 async ({ req, user }) => {30 const len = Number(req.headers.get("content-length") ?? 0);31 if (len > MAX_BYTES + 4096) throw new ApiError(413, "File too large (max 10 MB)", "PAYLOAD_TOO_LARGE");32 const form = await req.formData().catch(() => null);33 const file = form?.get("file");34 if (!(file instanceof File)) throw new ApiError(400, "Missing file", "BAD_REQUEST");35 const name = (file.name || "file").slice(0, 200);36 const mime = (file.type || "application/octet-stream").toLowerCase();37 const kind = kindOf(mime, name);38 if (!kind) throw new ApiError(415, `Unsupported file type (${mime || "unknown"}). Use images, PDF, text, code, CSV or JSON.`, "UNSUPPORTED_TYPE");39 if (file.size > MAX_BYTES) throw new ApiError(413, "File too large (max 10 MB)", "PAYLOAD_TOO_LARGE");40 if (kind !== "image" && kind !== "pdf" && file.size > MAX_TEXT_BYTES) throw new ApiError(413, "Text files are limited to 2 MB", "PAYLOAD_TOO_LARGE");41 const buf = Buffer.from(await file.arrayBuffer());42 const dims = kind === "image" ? pngSize(buf) : null;43 const effectiveMime = kind === "image" ? mime : kind === "pdf" ? "application/pdf" : mime.startsWith("text/") || mime === "application/json" ? mime : "text/plain";44 const [row] = await getDb()45 .insert(messageAttachments)46 .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 })47 .returning({ id: messageAttachments.id, kind: messageAttachments.kind, name: messageAttachments.name, mimeType: messageAttachments.mimeType, sizeBytes: messageAttachments.sizeBytes, width: messageAttachments.width, height: messageAttachments.height });48 return json({ attachment: row }, { status: 201 });49 },50 { limit: { ...LIMITS.upload, key: "upload" } },51);5253/** GET /api/attachments?id= — streams the payload back to its owner (used for image previews). */54export const GET = withUser(async ({ req, user }) => {55 const id = new URL(req.url).searchParams.get("id");56 if (!id) throw new ApiError(400, "Missing id");57 const [row] = await getDb()58 .select()59 .from(messageAttachments)60 .where(and(eq(messageAttachments.id, id), eq(messageAttachments.userId, user.id)))61 .limit(1);62 if (!row) throw new ApiError(404, "Not found", "NOT_FOUND");63 const buf = Buffer.from(row.dataBase64, "base64");64 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)}"` } });65});66