import "server-only"; import { and, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; import { getDb, projectFiles, projects, messageAttachments, type ProjectFile } from "@/db"; import { ids, newId } from "@/lib/ids"; import { ApiError } from "@/lib/api"; import { IMAGE_MIME, isTextLike } from "@/lib/ai/core/content"; import { estimateAttachmentTokens } from "@/lib/client/tokens"; export const LIBRARY_MAX_BYTES = 10 * 1024 * 1024; export const LIBRARY_MAX_TEXT_BYTES = 2 * 1024 * 1024; export const LIBRARY_MAX_FILES = 500; /** Metadata only — the base64 payload never leaves the server except through GET /api/library/files/[id]. */ export function toPublicFile(f: ProjectFile) { return { id: f.id, projectId: f.projectId, kind: f.kind, name: f.name, mimeType: f.mimeType, sizeBytes: f.sizeBytes, width: f.width, height: f.height, estimatedTokens: f.estimatedTokens, description: f.description, createdAt: f.createdAt.toISOString(), }; } export type PublicProjectFile = ReturnType; /** Same classification as /api/attachments so anything accepted there is accepted here (and vice versa). */ export 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; } /** JPEG SOF marker scan — enough for the token heuristic; returns null when the stream is unusual. */ function jpegSize(buf: Buffer): { width: number; height: number } | null { if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; let i = 2; while (i + 9 < buf.length) { if (buf[i] !== 0xff) return null; const marker = buf[i + 1]; const len = buf.readUInt16BE(i + 2); if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) { return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }; } i += 2 + len; } return null; } export function imageSize(buf: Buffer, mime: string): { width: number; height: number } | null { if (mime === "image/png") return pngSize(buf); if (mime === "image/jpeg") return jpegSize(buf); return null; } export interface ListFilesFilter { /** A project id, `"none"` for the global library, or undefined for everything the user owns. */ projectId?: string | "none"; q?: string; kind?: string; limit?: number; } export async function listFiles(userId: string, f: ListFilesFilter = {}) { const conds = [eq(projectFiles.userId, userId)]; if (f.projectId === "none") conds.push(isNull(projectFiles.projectId)); else if (f.projectId) conds.push(eq(projectFiles.projectId, f.projectId)); if (f.q) conds.push(ilike(projectFiles.name, `%${f.q.replace(/[%_]/g, "\\$&")}%`)); if (f.kind) conds.push(eq(projectFiles.kind, f.kind)); const rows = await getDb() .select({ id: projectFiles.id, projectId: projectFiles.projectId, kind: projectFiles.kind, name: projectFiles.name, mimeType: projectFiles.mimeType, sizeBytes: projectFiles.sizeBytes, width: projectFiles.width, height: projectFiles.height, estimatedTokens: projectFiles.estimatedTokens, description: projectFiles.description, createdAt: projectFiles.createdAt, }) .from(projectFiles) .where(and(...conds)) .orderBy(desc(projectFiles.createdAt)) .limit(Math.min(f.limit ?? 200, LIBRARY_MAX_FILES)); return rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }) as PublicProjectFile); } export async function getFile(userId: string, id: string): Promise { const [row] = await getDb() .select() .from(projectFiles) .where(and(eq(projectFiles.id, id), eq(projectFiles.userId, userId))) .limit(1); if (!row) throw new ApiError(404, "File not found", "NOT_FOUND"); return row; } export interface SaveFileInput { file: File; projectId?: string | null; description?: string | null; } /** Validates, classifies, measures and stores an uploaded file (mirrors /api/attachments limits). */ export async function saveFile(userId: string, input: SaveFileInput) { const { file } = input; 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 > LIBRARY_MAX_BYTES) throw new ApiError(413, "File too large (max 10 MB)", "PAYLOAD_TOO_LARGE"); if (kind !== "image" && kind !== "pdf" && file.size > LIBRARY_MAX_TEXT_BYTES) throw new ApiError(413, "Text files are limited to 2 MB", "PAYLOAD_TOO_LARGE"); const db = getDb(); if (input.projectId) { const [p] = await db .select({ id: projects.id }) .from(projects) .where(and(eq(projects.id, input.projectId), eq(projects.userId, userId))) .limit(1); if (!p) throw new ApiError(404, "Project not found", "NOT_FOUND"); } const [{ n }] = await db .select({ n: sql`count(*)::int` }) .from(projectFiles) .where(eq(projectFiles.userId, userId)); if (n >= LIBRARY_MAX_FILES) throw new ApiError(409, `Library is full (${LIBRARY_MAX_FILES} files). Delete some files first.`, "LIBRARY_FULL"); const buf = Buffer.from(await file.arrayBuffer()); const dims = kind === "image" ? imageSize(buf, mime) : null; const effectiveMime = kind === "image" ? mime : kind === "pdf" ? "application/pdf" : mime.startsWith("text/") || mime === "application/json" ? mime : "text/plain"; const estimatedTokens = estimateAttachmentTokens({ kind, sizeBytes: buf.length, width: dims?.width ?? null, height: dims?.height ?? null }); const [row] = await db .insert(projectFiles) .values({ id: newId("pfl"), userId, projectId: input.projectId ?? null, kind, name, mimeType: effectiveMime, sizeBytes: buf.length, dataBase64: buf.toString("base64"), width: dims?.width ?? null, height: dims?.height ?? null, estimatedTokens, description: input.description?.trim().slice(0, 500) || null, }) .returning(); return toPublicFile(row); } export async function updateFile(userId: string, id: string, patch: { name?: string; description?: string | null; projectId?: string | null }) { const set: Partial = {}; if (patch.name !== undefined) set.name = patch.name.trim().slice(0, 200) || "file"; if (patch.description !== undefined) set.description = patch.description?.trim().slice(0, 500) || null; if (patch.projectId !== undefined) { if (patch.projectId) { const [p] = await getDb() .select({ id: projects.id }) .from(projects) .where(and(eq(projects.id, patch.projectId), eq(projects.userId, userId))) .limit(1); if (!p) throw new ApiError(404, "Project not found", "NOT_FOUND"); } set.projectId = patch.projectId; } if (!Object.keys(set).length) return toPublicFile(await getFile(userId, id)); const [row] = await getDb() .update(projectFiles) .set(set) .where(and(eq(projectFiles.id, id), eq(projectFiles.userId, userId))) .returning(); if (!row) throw new ApiError(404, "File not found", "NOT_FOUND"); return toPublicFile(row); } export async function deleteFile(userId: string, id: string) { const res = await getDb() .delete(projectFiles) .where(and(eq(projectFiles.id, id), eq(projectFiles.userId, userId))) .returning({ id: projectFiles.id }); if (!res.length) throw new ApiError(404, "File not found", "NOT_FOUND"); } export interface PendingAttachment { id: string; kind: string; name: string; mimeType: string; sizeBytes: number; width: number | null; height: number | null; } /** * Copies library files into `message_attachments` as *pending* attachments (messageId / conversationId null), * exactly like a fresh upload through POST /api/attachments. The chat service links them to the user message on send. */ export async function attachFiles(userId: string, fileIds: string[]): Promise { const uniq = Array.from(new Set(fileIds)); if (!uniq.length) return []; const db = getDb(); const rows = await db .select() .from(projectFiles) .where(and(eq(projectFiles.userId, userId), inArray(projectFiles.id, uniq))); if (rows.length !== uniq.length) throw new ApiError(404, "Some files were not found", "NOT_FOUND"); const byId = new Map(rows.map((r) => [r.id, r])); const values = uniq.map((fid) => { const f = byId.get(fid)!; return { id: ids.attachment(), userId, kind: f.kind, name: f.name, mimeType: f.mimeType, sizeBytes: f.sizeBytes, dataBase64: f.dataBase64, width: f.width, height: f.height }; }); const inserted = await db .insert(messageAttachments) .values(values) .returning({ id: messageAttachments.id, kind: messageAttachments.kind, name: messageAttachments.name, mimeType: messageAttachments.mimeType, sizeBytes: messageAttachments.sizeBytes, width: messageAttachments.width, height: messageAttachments.height }); // Preserve the caller's order. const order = new Map(values.map((v, i) => [v.id, i])); return inserted.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); }