// Téléversement de fichiers étudiants : extraction de texte (PDF/DOCX/XLSX/CSV/TXT), // images passées telles quelles aux modèles vision. Espace isolé par utilisateur/conversation. import { NextResponse } from "next/server"; import { mkdirSync, writeFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { join, resolve, extname } from "node:path"; import { apiError } from "@/lib/api.ts"; import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts"; import { run } from "@/lib/db/index.ts"; import { embedPassages, vecToBlob } from "@/lib/rag/embeddings.ts"; import { splitLong } from "@/lib/rag/latex.ts"; const MAX_SIZE = 25 * 1024 * 1024; const ALLOWED: Record = { "application/pdf": [".pdf"], "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"], "text/csv": [".csv"], "text/plain": [".txt", ".md", ".tex"], "text/markdown": [".md"], "image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "image/webp": [".webp"], }; const MAGIC: [string, (b: Buffer) => boolean][] = [ ["application/pdf", (b) => b.subarray(0, 5).toString("latin1") === "%PDF-"], ["image/png", (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))], ["image/jpeg", (b) => b[0] === 0xff && b[1] === 0xd8], ["image/webp", (b) => b.subarray(8, 12).toString("latin1") === "WEBP"], ]; async function extractText(buffer: Buffer, mime: string, filename: string): Promise { try { if (mime === "application/pdf") { const { extractText: pdfText, getDocumentProxy } = await import("unpdf"); const doc = await getDocumentProxy(new Uint8Array(buffer)); const { text } = await pdfText(doc, { mergePages: false }); return (text as string[]).map((p, i) => `[Page ${i + 1}]\n${p}`).join("\n\n"); } if (mime.includes("wordprocessingml")) { const mammoth = await import("mammoth"); const r = await mammoth.extractRawText({ buffer }); return r.value; } if (mime.includes("spreadsheetml") || filename.endsWith(".xlsx")) { const XLSX = await import("xlsx"); const wb = XLSX.read(buffer, { type: "buffer" }); return wb.SheetNames.map((name) => { const csv = XLSX.utils.sheet_to_csv(wb.Sheets[name]); return `[Feuille : ${name}]\n${csv}`; }).join("\n\n"); } if (mime.startsWith("text/") || /\.(txt|md|csv|tex)$/i.test(filename)) { return buffer.toString("utf8"); } } catch (e) { return `(Extraction impossible : ${e instanceof Error ? e.message : "erreur"})`; } return ""; } export async function POST(req: Request) { try { await assertSameOrigin(); const user = await requireUser(); const form = await req.formData(); const file = form.get("file"); const conversationId = parseInt(String(form.get("conversationId") ?? "0"), 10) || null; const persistent = String(form.get("persistent") ?? "") === "1"; if (!(file instanceof File)) return NextResponse.json({ error: "Fichier manquant." }, { status: 400 }); if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 25 Mo)." }, { status: 413 }); const ext = extname(file.name).toLowerCase(); const mime = file.type || "application/octet-stream"; const allowedExts = ALLOWED[mime]; if (!allowedExts || !allowedExts.includes(ext)) { return NextResponse.json({ error: `Type non pris en charge : ${mime || ext}. Formats acceptés : PDF, DOCX, XLSX, CSV, TXT, Markdown, PNG, JPEG, WebP.` }, { status: 415 }); } const buffer = Buffer.from(await file.arrayBuffer()); const magic = MAGIC.find(([m]) => m === mime); if (magic && !magic[1](buffer)) { return NextResponse.json({ error: "Le contenu du fichier ne correspond pas à son type déclaré." }, { status: 415 }); } const uploadsDir = resolve(process.cwd(), process.env.UPLOADS_PATH || "./data/uploads", String(user.id)); mkdirSync(uploadsDir, { recursive: true }); const storedName = `${Date.now()}-${randomBytes(6).toString("hex")}${ext}`; const path = join(uploadsDir, storedName); writeFileSync(path, buffer); const extracted = mime.startsWith("image/") ? "" : (await extractText(buffer, mime, file.name)).slice(0, 400_000); const r = run( "INSERT INTO uploads (user_id, conversation_id, filename, mime, size, path, extracted_text, persistent) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", user.id, conversationId, file.name.slice(0, 200), mime, file.size, path, extracted, persistent ? 1 : 0 ); const uploadId = Number(r.lastInsertRowid); // Indexation RAG du texte extrait (espace étudiant isolé) pour la recherche dans la conversation. if (extracted && extracted.length > 200 && conversationId) { const dr = run( `INSERT INTO documents (course_code, space, path, filename, doc_type, title, category, checksum, status, visible_to_students, ingested_at, chunk_count) VALUES (NULL, 'student-temporary-upload', ?, ?, 'upload', ?, 'upload', ?, 'ok', 0, datetime('now'), 0)`, `upload:${uploadId}`, file.name.slice(0, 200), file.name.slice(0, 200), String(uploadId) ); const docId = Number(dr.lastInsertRowid); const parts = splitLong(extracted, 1800).slice(0, 60); const embeddings = await embedPassages(parts); parts.forEach((p, i) => { const cr = run( `INSERT INTO chunks (document_id, course_code, space, seq, ref_type, ref_number, ref_label, title, content, display_content, owner_user_id, conversation_id, embedding) VALUES (?, NULL, 'student-temporary-upload', ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?)`, docId, i, i + 1, `${file.name} — partie ${i + 1}`, file.name.slice(0, 200), p, p, user.id, conversationId, vecToBlob(embeddings[i]) ); run("INSERT INTO chunks_fts (rowid, title, content) VALUES (?, ?, ?)", Number(cr.lastInsertRowid), file.name, p); }); run("UPDATE documents SET chunk_count = ? WHERE id = ?", parts.length, docId); } return NextResponse.json({ ok: true, upload: { id: uploadId, filename: file.name, mime, size: file.size, hasText: !!extracted }, }); } catch (e) { return apiError(e); } }