TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Téléversement de fichiers étudiants : extraction de texte (PDF/DOCX/XLSX/CSV/TXT),2// images passées telles quelles aux modèles vision. Espace isolé par utilisateur/conversation.3import { NextResponse } from "next/server";4import { mkdirSync, writeFileSync } from "node:fs";5import { randomBytes } from "node:crypto";6import { join, resolve, extname } from "node:path";7import { apiError } from "@/lib/api.ts";8import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts";9import { run } from "@/lib/db/index.ts";10import { embedPassages, vecToBlob } from "@/lib/rag/embeddings.ts";11import { splitLong } from "@/lib/rag/latex.ts";1213const MAX_SIZE = 25 * 1024 * 1024;14const ALLOWED: Record<string, string[]> = {15 "application/pdf": [".pdf"],16 "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],17 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],18 "text/csv": [".csv"],19 "text/plain": [".txt", ".md", ".tex"],20 "text/markdown": [".md"],21 "image/png": [".png"],22 "image/jpeg": [".jpg", ".jpeg"],23 "image/webp": [".webp"],24};2526const MAGIC: [string, (b: Buffer) => boolean][] = [27 ["application/pdf", (b) => b.subarray(0, 5).toString("latin1") === "%PDF-"],28 ["image/png", (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))],29 ["image/jpeg", (b) => b[0] === 0xff && b[1] === 0xd8],30 ["image/webp", (b) => b.subarray(8, 12).toString("latin1") === "WEBP"],31];3233async function extractText(buffer: Buffer, mime: string, filename: string): Promise<string> {34 try {35 if (mime === "application/pdf") {36 const { extractText: pdfText, getDocumentProxy } = await import("unpdf");37 const doc = await getDocumentProxy(new Uint8Array(buffer));38 const { text } = await pdfText(doc, { mergePages: false });39 return (text as string[]).map((p, i) => `[Page ${i + 1}]\n${p}`).join("\n\n");40 }41 if (mime.includes("wordprocessingml")) {42 const mammoth = await import("mammoth");43 const r = await mammoth.extractRawText({ buffer });44 return r.value;45 }46 if (mime.includes("spreadsheetml") || filename.endsWith(".xlsx")) {47 const XLSX = await import("xlsx");48 const wb = XLSX.read(buffer, { type: "buffer" });49 return wb.SheetNames.map((name) => {50 const csv = XLSX.utils.sheet_to_csv(wb.Sheets[name]);51 return `[Feuille : ${name}]\n${csv}`;52 }).join("\n\n");53 }54 if (mime.startsWith("text/") || /\.(txt|md|csv|tex)$/i.test(filename)) {55 return buffer.toString("utf8");56 }57 } catch (e) {58 return `(Extraction impossible : ${e instanceof Error ? e.message : "erreur"})`;59 }60 return "";61}6263export async function POST(req: Request) {64 try {65 await assertSameOrigin();66 const user = await requireUser();67 const form = await req.formData();68 const file = form.get("file");69 const conversationId = parseInt(String(form.get("conversationId") ?? "0"), 10) || null;70 const persistent = String(form.get("persistent") ?? "") === "1";71 if (!(file instanceof File)) return NextResponse.json({ error: "Fichier manquant." }, { status: 400 });72 if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 25 Mo)." }, { status: 413 });7374 const ext = extname(file.name).toLowerCase();75 const mime = file.type || "application/octet-stream";76 const allowedExts = ALLOWED[mime];77 if (!allowedExts || !allowedExts.includes(ext)) {78 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 });79 }80 const buffer = Buffer.from(await file.arrayBuffer());81 const magic = MAGIC.find(([m]) => m === mime);82 if (magic && !magic[1](buffer)) {83 return NextResponse.json({ error: "Le contenu du fichier ne correspond pas à son type déclaré." }, { status: 415 });84 }8586 const uploadsDir = resolve(process.cwd(), process.env.UPLOADS_PATH || "./data/uploads", String(user.id));87 mkdirSync(uploadsDir, { recursive: true });88 const storedName = `${Date.now()}-${randomBytes(6).toString("hex")}${ext}`;89 const path = join(uploadsDir, storedName);90 writeFileSync(path, buffer);9192 const extracted = mime.startsWith("image/") ? "" : (await extractText(buffer, mime, file.name)).slice(0, 400_000);93 const r = run(94 "INSERT INTO uploads (user_id, conversation_id, filename, mime, size, path, extracted_text, persistent) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",95 user.id, conversationId, file.name.slice(0, 200), mime, file.size, path, extracted, persistent ? 1 : 096 );97 const uploadId = Number(r.lastInsertRowid);9899 // Indexation RAG du texte extrait (espace étudiant isolé) pour la recherche dans la conversation.100 if (extracted && extracted.length > 200 && conversationId) {101 const dr = run(102 `INSERT INTO documents (course_code, space, path, filename, doc_type, title, category, checksum, status, visible_to_students, ingested_at, chunk_count)103 VALUES (NULL, 'student-temporary-upload', ?, ?, 'upload', ?, 'upload', ?, 'ok', 0, datetime('now'), 0)`,104 `upload:${uploadId}`, file.name.slice(0, 200), file.name.slice(0, 200), String(uploadId)105 );106 const docId = Number(dr.lastInsertRowid);107 const parts = splitLong(extracted, 1800).slice(0, 60);108 const embeddings = await embedPassages(parts);109 parts.forEach((p, i) => {110 const cr = run(111 `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)112 VALUES (?, NULL, 'student-temporary-upload', ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?)`,113 docId, i, i + 1, `${file.name} — partie ${i + 1}`, file.name.slice(0, 200), p, p, user.id, conversationId, vecToBlob(embeddings[i])114 );115 run("INSERT INTO chunks_fts (rowid, title, content) VALUES (?, ?, ?)", Number(cr.lastInsertRowid), file.name, p);116 });117 run("UPDATE documents SET chunk_count = ? WHERE id = ?", parts.length, docId);118 }119120 return NextResponse.json({121 ok: true,122 upload: { id: uploadId, filename: file.name, mime, size: file.size, hasText: !!extracted },123 });124 } catch (e) {125 return apiError(e);126 }127}128