SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
1 h agolast push
JavaScript 56.7% TypeScript 42.6%

feat(v3.1): connexion par code courriel (Resend), audit fonctionnel (corbeille récursive, partage de dossier, quotas), scripts de réorganisation IA

- auth : /api/auth/otp/request|verify, lib/mailer.ts, lib/login-complete.ts, page de connexion courriel→code (mot de passe en secours)
- audit : lib/trash.ts (corbeille récursive, restauration, purge), lib/shareAccess.ts, partage de dossier (SharedLink.folderId, accessCount), page publique, quotas/chunks/duplication, WebDAV DELETE → corbeille, migration 2026-09-07-audit
- server.ts : purge horaire corbeille + chunks orphelins, SNAPSHOT_DISABLED
- scripts/organize : extract/dupes/plan/apply/verify/rollback (Claude Opus 5)
Simon-Pierre Boucher committed 25 days ago (Sep 7, 2026) parent 7a40938

48 changed files +2,585 −894

modified .env.example +6 −0
@@ -5,3 +5,9 @@ ENCRYPTION_KEY="change-me-32-characters-encryption"
5 5 BACKUP_DIR="./backups"
6 6 UPLOAD_DIR="./uploads"
7 7 MAX_FILE_SIZE="524288000"
8 +
9 +# Connexion par code courriel (Resend) — MAIL_FROM doit appartenir à un domaine vérifié dans Resend
10 +RESEND_API_KEY="re_xxx"
11 +MAIL_FROM="SPB Cloud <no-reply@spboucher.ai>"
12 +# Staging uniquement : journalise les codes côté serveur (ignoré en production)
13 +# AUTH_DEBUG_CODES=1
modified .gitignore +4 −0
@@ -33,5 +33,9 @@ Thumbs.db
33 33 /snapshots/
34 34 *.tsbuildinfo
35 35
36 +# Données de la réorganisation IA (métadonnées personnelles, sauvegardes SQLite)
37 +scripts/organize/data/
38 +scripts/organize/*.log
39 +
36 40 # Builds de sauvegarde
37 41 .next.bak-*/
modified README.md +9 −1
@@ -1,5 +1,5 @@
1 1 <p align="center">
2 − <img src="https://img.shields.io/badge/SPB_Cloud-v3.0-6366f1?style=for-the-badge&logo=cloud&logoColor=white" alt="Version" />
2 + <img src="https://img.shields.io/badge/SPB_Cloud-v3.1-6366f1?style=for-the-badge&logo=cloud&logoColor=white" alt="Version" />
3 3 </p>
4 4
5 5 <h1 align="center">SPB Private Cloud</h1>
@@ -29,6 +29,14 @@
29 29
30 30 ---
31 31
32 +## v3.1 — Connexion par code courriel, audit fonctionnel, réorganisation IA (2026-09-07)
33 +
34 +**Connexion par code courriel (Resend)** — méthode par défaut : saisir son adresse (`spbou4@protonmail.com`, `r-boucher@sympatico.ca`…), recevoir un code à 6 chiffres (10 min, 5 essais, renvoi 30 s), le saisir. Le code est stocké haché dans le cookie de session chiffré (aucune table). Routes `POST /api/auth/otp/request` et `/verify` ; envoi via `lib/mailer.ts` (Resend REST, `RESEND_API_KEY`, `MAIL_FROM`). ⚠ Sans domaine vérifié dans Resend, seul `onboarding@resend.dev` est autorisé et il ne livre qu'au propriétaire du compte : vérifier `spboucher.ai` dans Resend puis mettre `MAIL_FROM="SPB Cloud <no-reply@spboucher.ai>"` pour que Richard reçoive ses codes. Connexion par mot de passe conservée (administration).
35 +
36 +**Audit fonctionnel (11 correctifs)** — suppression de dossier = corbeille récursive (plus de fichiers orphelins ni de contenu effacé du disque), restauration fidèle (dossier d'origine recréé), purge réelle (contenu + versions + vignettes) et purge horaire des > 30 j (`server.ts`), **partage de dossier** (`POST /api/shares {folderId}`, page publique avec navigation, ZIP, mot de passe via cookie signé), sous-dossiers héritant de l'espace partagé, quotas cohérents (chunks, duplication, corbeille exclue), duplication conservant l'extension, validations 404/400, notifications d'upload, WebDAV DELETE → corbeille. Migration : `scripts/migrations/apply-2026-09-07-audit.ts` (idempotente) puis `npx prisma generate` **dans le dossier de l'app** (le client Prisma mémorise le dossier `prisma/` au moment de la génération).
37 +
38 +**Réorganisation IA des espaces partagés** (`scripts/organize/`) — `extract.ts` (Claude Opus 5 : vision pour les photos, PDF natif, texte/Office), `dupes.ts` (SHA-256), `plan.ts` (arborescence + noms par espace, albums photo par ordre de numérisation), `apply.ts --dry-run` puis `apply.ts` (sauvegarde `VACUUM INTO`, journal `data/applied.jsonl`), `verify.ts`, `rollback.ts`. Appliqué le 2026-09-07 : 9 espaces, 1 171 fichiers renommés/déplacés, 85 dossiers créés, 44 anciens supprimés, 78 doublons identiques isolés dans « 99 Doublons identiques ». `data/` est ignoré par git (métadonnées personnelles).
39 +
32 40 ## Nouveautés v3 « Aurora » (2026-09-07)
33 41
34 42 Refonte complète du front-end, déployée sur `cloud.spboucher.ai` (M2U64, PM2 `cloud`).
modified app/admin/shares/page.tsx +15 −6
@@ -8,7 +8,7 @@ import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
8 8 import PageTransition from "@/components/ui/PageTransition";
9 9 import { formatDate, formatFileSize } from "@/lib/utils";
10 10 import { motion } from "framer-motion";
11 −import { Link2, Link2Off, Trash2, Copy, ExternalLink, Lock, Clock, Download, Eye, Share2 } from "lucide-react";
11 +import { Link2, Link2Off, Trash2, Copy, ExternalLink, Lock, Clock, Download, Eye, Share2, Folder, BarChart2 } from "lucide-react";
12 12 import toast from "react-hot-toast";
13 13
14 14 interface SharedLink {
@@ -19,7 +19,11 @@ interface SharedLink {
19 19 expiresAt: string | null;
20 20 passwordHash: string | null;
21 21 createdAt: string;
22 − file: { name: string; mimeType: string; size: number } | null;
22 + type?: "file" | "folder";
23 + accessCount?: number;
24 + lastAccessAt?: string | null;
25 + file: { name: string; mimeType: string; size: number; deletedAt?: string | null } | null;
26 + folder: { name: string; _count?: { files: number; children: number } } | null;
23 27 }
24 28
25 29 export default function SharesPage() {
@@ -91,7 +95,7 @@ export default function SharesPage() {
91 95 ))}
92 96 </div>
93 97 ) : visible.length === 0 ? (
94 − <EmptyState icon={Link2} title={shares.length === 0 ? "Aucun lien de partage" : "Aucun lien dans ce filtre"} description="Créez un lien depuis le menu d'un fichier : expiration, mot de passe, aperçu seul ou téléchargement." />
98 + <EmptyState icon={Link2} title={shares.length === 0 ? "Aucun lien de partage" : "Aucun lien dans ce filtre"} description="Créez un lien depuis le menu d'un fichier ou d'un dossier : expiration, mot de passe, aperçu seul ou téléchargement." />
95 99 ) : (
96 100 <div className="space-y-2">
97 101 {visible.map((share, i) => {
@@ -107,16 +111,21 @@ export default function SharesPage() {
107 111 >
108 112 <div className="flex flex-col sm:flex-row sm:items-center gap-3">
109 113 <div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${on ? "bg-violet-500/10 text-violet-600 dark:text-violet-300" : "bg-gray-100 dark:bg-white/[0.06] text-gray-400"}`}>
110 − {on ? <Link2 className="w-5 h-5" /> : <Link2Off className="w-5 h-5" />}
114 + {share.folder ? <Folder className="w-5 h-5" /> : on ? <Link2 className="w-5 h-5" /> : <Link2Off className="w-5 h-5" />}
111 115 </div>
112 116 <div className="flex-1 min-w-0">
113 − <p className="text-[14px] font-medium text-gray-900 dark:text-white truncate">{share.file?.name || "Fichier supprimé"}</p>
117 + <p className="text-[14px] font-medium text-gray-900 dark:text-white truncate">
118 + {share.folder ? share.folder.name : share.file ? share.file.name : "Cible supprimée"}
119 + {share.folder && <span className="ml-2 text-[11px] font-normal text-blue-600 dark:text-blue-300">Dossier{share.folder._count ? ` · ${share.folder._count.files} fich.` : ""}</span>}
120 + {share.file?.deletedAt && <span className="ml-2 text-[11px] font-normal text-rose-500">dans la corbeille</span>}
121 + </p>
114 122 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1 mt-1 text-[12px] text-gray-500 dark:text-gray-400">
115 123 <span className="inline-flex items-center gap-1">{share.mode === "DOWNLOAD" ? <Download className="w-3 h-3" /> : <Eye className="w-3 h-3" />}{share.mode === "DOWNLOAD" ? "Téléchargement" : "Aperçu seul"}</span>
116 124 {share.file && <span>{formatFileSize(share.file.size)}</span>}
117 125 {share.passwordHash && <span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400"><Lock className="w-3 h-3" /> Protégé</span>}
118 126 {share.expiresAt && <span className={`inline-flex items-center gap-1 ${expired ? "text-rose-500" : ""}`}><Clock className="w-3 h-3" />{expired ? "Expiré le" : "Expire le"} {formatDate(share.expiresAt)}</span>}
119 127 <span className="text-gray-400">Créé le {formatDate(share.createdAt)}</span>
128 + {typeof share.accessCount === "number" && <span className="inline-flex items-center gap-1"><BarChart2 className="w-3 h-3" />{share.accessCount} accès{share.lastAccessAt ? ` · dernier ${formatDate(share.lastAccessAt)}` : ""}</span>}
120 129 </div>
121 130 </div>
122 131 <div className="flex items-center gap-1 shrink-0 -ml-1 sm:ml-0">
@@ -139,7 +148,7 @@ export default function SharesPage() {
139 148 isOpen={!!toDelete}
140 149 onClose={() => setToDelete(null)}
141 150 title="Supprimer ce lien ?"
142 − description={toDelete?.file?.name}
151 + description={toDelete?.folder?.name || toDelete?.file?.name}
143 152 footer={<><Button variant="ghost" onClick={() => setToDelete(null)}>Annuler</Button><Button variant="danger" onClick={deleteShare}><Trash2 className="w-4 h-4" /> Supprimer</Button></>}
144 153 >
145 154 <p className="text-sm text-gray-600 dark:text-gray-300">Le lien cessera immédiatement de fonctionner pour toute personne qui le possède.</p>
added app/api/auth/otp/request/route.ts +70 −0
@@ -0,0 +1,70 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { randomBytes, randomInt } from "crypto";
4 +import { SessionData, sessionOptions } from "@/lib/session";
5 +import { prisma } from "@/lib/prisma";
6 +import { checkLoginRateLimit } from "@/lib/rate-limit";
7 +import { loginCodeEmail, mailerConfigured, sendMail, MailerError } from "@/lib/mailer";
8 +import { clientIp, describeClient } from "@/lib/login-complete";
9 +import { logActivity } from "@/lib/activity";
10 +import { CODE_TTL_MINUTES, hashCode } from "@/lib/otp";
11 +
12 +const RESEND_COOLDOWN_MS = 30_000;
13 +
14 +/**
15 + * POST /api/auth/otp/request { email }
16 + * Envoie un code à 6 chiffres par courriel si l'adresse correspond à un utilisateur.
17 + * Réponse identique que l'adresse existe ou non (pas d'énumération), sauf erreur d'envoi.
18 + */
19 +export async function POST(request: NextRequest) {
20 + const ip = clientIp(request);
21 + if (!(await checkLoginRateLimit(ip))) {
22 + return NextResponse.json({ error: "Trop de tentatives. Réessayez dans 1 minute." }, { status: 429 });
23 + }
24 + if (!mailerConfigured()) {
25 + return NextResponse.json({ error: "L'envoi de courriels n'est pas configuré sur ce serveur." }, { status: 503 });
26 + }
27 +
28 + const body = await request.json().catch(() => ({}));
29 + const email = String(body?.email ?? "").trim().toLowerCase();
30 + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
31 + return NextResponse.json({ error: "Adresse courriel invalide" }, { status: 400 });
32 + }
33 +
34 + const response = NextResponse.json({ success: true, expiresIn: CODE_TTL_MINUTES * 60 });
35 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
36 +
37 + // Anti-spam : un renvoi au plus toutes les 30 s pour la même adresse
38 + if (session.otp && session.otp.email === email && Date.now() - session.otp.sentAt < RESEND_COOLDOWN_MS) {
39 + return NextResponse.json({ success: true, expiresIn: Math.max(0, Math.round((session.otp.expiresAt - Date.now()) / 1000)), throttled: true });
40 + }
41 +
42 + // SQLite : comparaison insensible à la casse faite côté application
43 + const users = await prisma.user.findMany({ where: { email: { not: null } }, select: { id: true, email: true, name: true } });
44 + const user = users.find((u) => (u.email ?? "").trim().toLowerCase() === email);
45 + if (!user) {
46 + await logActivity("LOGIN_CODE_UNKNOWN_EMAIL", null, `Demande de code pour une adresse inconnue depuis ${ip}`);
47 + return response; // même réponse : pas d'énumération d'adresses
48 + }
49 +
50 + const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
51 + const salt = randomBytes(8).toString("hex");
52 + // Débogage hors production uniquement (staging) : AUTH_DEBUG_CODES=1 journalise le code côté serveur.
53 + if (process.env.AUTH_DEBUG_CODES === "1" && process.env.NODE_ENV !== "production") console.log(`[otp:debug] ${email} → ${code}`);
54 + const { label } = describeClient(request);
55 + try {
56 + const mail = loginCodeEmail(code, { name: user.name, minutes: CODE_TTL_MINUTES, ip, device: label });
57 + await sendMail({ to: user.email!, ...mail });
58 + } catch (e) {
59 + const msg = e instanceof MailerError ? e.message : "Erreur d'envoi";
60 + await logActivity("LOGIN_CODE_SEND_FAILED", null, `Envoi du code impossible à ${user.email} : ${msg}`);
61 + return NextResponse.json({ error: `Impossible d'envoyer le courriel (${msg}). Contactez l'administrateur.` }, { status: 502 });
62 + }
63 +
64 + session.otp = { email, userId: user.id, hash: hashCode(code, salt), salt, expiresAt: Date.now() + CODE_TTL_MINUTES * 60_000, attempts: 0, sentAt: Date.now() };
65 + session.pendingTwoFactor = false;
66 + session.pendingUserId = undefined;
67 + await session.save();
68 + await logActivity("LOGIN_CODE_SENT", null, `Code de connexion envoyé à ${user.email} (${label}, ${ip})`);
69 + return response;
70 +}
added app/api/auth/otp/verify/route.ts +55 −0
@@ -0,0 +1,55 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { timingSafeEqual } from "crypto";
4 +import { SessionData, sessionOptions } from "@/lib/session";
5 +import { prisma } from "@/lib/prisma";
6 +import { checkLoginRateLimit } from "@/lib/rate-limit";
7 +import { completeLogin, clientIp } from "@/lib/login-complete";
8 +import { logActivity } from "@/lib/activity";
9 +import { MAX_ATTEMPTS, hashCode } from "@/lib/otp";
10 +
11 +/** POST /api/auth/otp/verify { code } — valide le code envoyé par courriel et ouvre la session. */
12 +export async function POST(request: NextRequest) {
13 + const ip = clientIp(request);
14 + if (!(await checkLoginRateLimit(ip))) {
15 + return NextResponse.json({ error: "Trop de tentatives. Réessayez dans 1 minute." }, { status: 429 });
16 + }
17 + const body = await request.json().catch(() => ({}));
18 + const code = String(body?.code ?? "").replace(/\D/g, "");
19 +
20 + const response = NextResponse.json({ success: true });
21 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
22 + const otp = session.otp;
23 +
24 + if (!otp) return NextResponse.json({ error: "Aucun code en attente. Demandez un nouveau code." }, { status: 400 });
25 + if (Date.now() > otp.expiresAt) {
26 + session.otp = undefined; await session.save();
27 + return NextResponse.json({ error: "Code expiré. Demandez un nouveau code." }, { status: 410 });
28 + }
29 + if (code.length !== 6) return NextResponse.json({ error: "Le code comporte 6 chiffres." }, { status: 400 });
30 +
31 + const expected = Buffer.from(otp.hash, "hex");
32 + const given = Buffer.from(hashCode(code, otp.salt), "hex");
33 + const valid = expected.length === given.length && timingSafeEqual(expected, given);
34 +
35 + if (!valid) {
36 + otp.attempts += 1;
37 + if (otp.attempts >= MAX_ATTEMPTS) {
38 + session.otp = undefined; await session.save();
39 + await logActivity("LOGIN_CODE_LOCKED", null, `Code invalidé après ${MAX_ATTEMPTS} essais (${otp.email}, ${ip})`);
40 + return NextResponse.json({ error: "Trop d'essais : le code a été invalidé. Demandez un nouveau code." }, { status: 423 });
41 + }
42 + session.otp = otp; await session.save();
43 + return NextResponse.json({ error: `Code incorrect (${MAX_ATTEMPTS - otp.attempts} essai${MAX_ATTEMPTS - otp.attempts > 1 ? "s" : ""} restant${MAX_ATTEMPTS - otp.attempts > 1 ? "s" : ""}).` }, { status: 401 });
44 + }
45 +
46 + const user = await prisma.user.findUnique({ where: { id: otp.userId }, select: { id: true, role: true, email: true } });
47 + if (!user || (user.email ?? "").trim().toLowerCase() !== otp.email) {
48 + session.otp = undefined; await session.save();
49 + return NextResponse.json({ error: "Compte introuvable." }, { status: 404 });
50 + }
51 +
52 + const done = await completeLogin(request, session, user, "email_code");
53 + if (!done.ok) return NextResponse.json({ error: done.error }, { status: done.status });
54 + return response;
55 +}
modified app/api/files/[id]/duplicate/route.ts +30 −2
@@ -2,6 +2,19 @@ import { NextRequest, NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 3 import { duplicateFile } from "@/lib/storage";
4 4 import { logActivity } from "@/lib/activity";
5 +import { getSpaceIdForFolder } from "@/lib/trash";
6 +
7 +/** « rapport.pdf » → « rapport (copie).pdf », puis « rapport (copie 2).pdf »… */
8 +async function copyName(original: string, folderId: string | null): Promise<string> {
9 + const m = original.match(/^(.*?)(\.[^.]+)?$/);
10 + const stem = m?.[1] ?? original;
11 + const ext = m?.[2] ?? "";
12 + const siblings = await prisma.file.findMany({ where: { folderId, deletedAt: null }, select: { name: true } });
13 + const taken = new Set(siblings.map((s) => s.name.toLowerCase()));
14 + let candidate = `${stem} (copie)${ext}`;
15 + for (let i = 2; taken.has(candidate.toLowerCase()); i++) candidate = `${stem} (copie ${i})${ext}`;
16 + return candidate;
17 +}
5 18
6 19 export async function POST(
7 20 request: NextRequest,
@@ -9,21 +22,36 @@ export async function POST(
9 22 ) {
10 23 const file = await prisma.file.findUnique({ where: { id: params.id } });
11 24
12 − if (!file) {
25 + if (!file || file.deletedAt) {
13 26 return NextResponse.json({ error: "File not found" }, { status: 404 });
14 27 }
15 28
29 + // Quota de l'espace partagé (hérité via l'arborescence)
30 + const spaceId = await getSpaceIdForFolder(file.folderId);
31 + if (spaceId) {
32 + const space = await prisma.sharedSpace.findUnique({ where: { id: spaceId } });
33 + if (space) {
34 + const agg = await prisma.file.aggregate({ _sum: { size: true }, where: { deletedAt: null, folder: { sharedSpaceId: spaceId } } });
35 + if (Number(agg._sum.size || 0) + Number(file.size) > Number(space.quotaBytes)) {
36 + return NextResponse.json({ error: "Quota de l'espace partagé dépassé" }, { status: 413 });
37 + }
38 + }
39 + }
40 +
16 41 const { storagePath } = await duplicateFile(file.storagePath, file.name);
17 42
18 43 const duplicated = await prisma.file.create({
19 44 data: {
20 − name: `${file.name} (copy)`,
45 + name: await copyName(file.name, file.folderId),
21 46 storagePath,
22 47 // copie octet-à-octet : même état de chiffrement ; on garde la taille en clair d'origine
23 48 size: file.size,
24 49 mimeType: file.mimeType,
25 50 folderId: file.folderId,
51 + userId: file.userId,
26 52 isEncrypted: file.isEncrypted,
53 + aiDescription: file.aiDescription,
54 + aiTags: file.aiTags,
27 55 },
28 56 });
29 57
modified app/api/files/[id]/route.ts +30 −13
@@ -1,6 +1,7 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 3 import { logActivity } from "@/lib/activity";
4 +import { trashFiles } from "@/lib/trash";
4 5
5 6 export async function GET(
6 7 request: NextRequest,
@@ -8,7 +9,7 @@ export async function GET(
8 9 ) {
9 10 const file = await prisma.file.findUnique({
10 11 where: { id: params.id },
11 − include: { folder: true, sharedLinks: true },
12 + include: { folder: true, sharedLinks: true, metadata: true, tags: { include: { tag: true } }, _count: { select: { versions: true } } },
12 13 });
13 14
14 15 if (!file) {
@@ -22,8 +23,8 @@ export async function PATCH(
22 23 request: NextRequest,
23 24 { params }: { params: { id: string } }
24 25 ) {
25 − const body = await request.json();
26 − const { name, folderId } = body;
26 + const body = await request.json().catch(() => ({}));
27 + const { name, folderId } = body as { name?: string; folderId?: string | null };
27 28
28 29 const file = await prisma.file.findUnique({ where: { id: params.id } });
29 30 if (!file) {
@@ -33,13 +34,29 @@ export async function PATCH(
33 34 const updateData: Record<string, unknown> = {};
34 35
35 36 if (name !== undefined) {
36 − updateData.name = name;
37 − await logActivity("RENAME", params.id, `Renamed to ${name}`);
37 + const trimmed = typeof name === "string" ? name.trim() : "";
38 + if (!trimmed) return NextResponse.json({ error: "Nom requis" }, { status: 400 });
39 + if (/[\\/]/.test(trimmed)) return NextResponse.json({ error: "Le nom ne peut pas contenir « / » ou « \\ »" }, { status: 400 });
40 + if (trimmed !== file.name) {
41 + updateData.name = trimmed;
42 + await logActivity("RENAME", params.id, `Renamed ${file.name} -> ${trimmed}`);
43 + }
38 44 }
39 45
40 46 if (folderId !== undefined) {
41 − updateData.folderId = folderId === "root" ? null : folderId;
42 − await logActivity("MOVE", params.id, `Moved to folder ${folderId}`);
47 + const target = folderId === "root" || folderId === null ? null : folderId;
48 + if (target) {
49 + const exists = await prisma.folder.findUnique({ where: { id: target }, select: { id: true } });
50 + if (!exists) return NextResponse.json({ error: "Dossier de destination introuvable" }, { status: 404 });
51 + }
52 + if (target !== file.folderId) {
53 + updateData.folderId = target;
54 + await logActivity("MOVE", params.id, `Moved ${file.name} to folder ${folderId}`);
55 + }
56 + }
57 +
58 + if (Object.keys(updateData).length === 0) {
59 + return NextResponse.json({ ...file, size: Number(file.size) });
43 60 }
44 61
45 62 const updated = await prisma.file.update({
@@ -50,20 +67,20 @@ export async function PATCH(
50 67 return NextResponse.json({ ...updated, size: Number(updated.size) });
51 68 }
52 69
70 +/** Mise à la corbeille (restaurable 30 jours) — l'origine est mémorisée pour la restauration. */
53 71 export async function DELETE(
54 72 request: NextRequest,
55 73 { params }: { params: { id: string } }
56 74 ) {
57 − const file = await prisma.file.findUnique({ where: { id: params.id } });
75 + const file = await prisma.file.findUnique({ where: { id: params.id }, select: { id: true, deletedAt: true } });
58 76 if (!file) {
59 77 return NextResponse.json({ error: "File not found" }, { status: 404 });
60 78 }
79 + if (file.deletedAt) {
80 + return NextResponse.json({ success: true, alreadyTrashed: true });
81 + }
61 82
62 − await prisma.file.update({
63 − where: { id: params.id },
64 − data: { deletedAt: new Date() },
65 − });
66 − await logActivity("TRASH", params.id, `Moved to trash: ${file.name}`);
83 + await trashFiles([file.id]);
67 84
68 85 return NextResponse.json({ success: true });
69 86 }
modified app/api/files/bulk/route.ts +27 −20
@@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
2 2 import { getIronSession } from "iron-session";
3 3 import { SessionData, sessionOptions } from "@/lib/session";
4 4 import { prisma } from "@/lib/prisma";
5 −import { deleteFile, loadFileData } from "@/lib/storage";
5 +import { loadFileData } from "@/lib/storage";
6 6 import { logActivity } from "@/lib/activity";
7 7 import { encryptFile, removeEncryption } from "@/lib/encryption";
8 +import { trashFiles } from "@/lib/trash";
8 9 import JSZip from "jszip";
9 10
10 11 export async function POST(request: NextRequest) {
@@ -12,43 +13,49 @@ export async function POST(request: NextRequest) {
12 13 const session = await getIronSession<SessionData>(request, response, sessionOptions);
13 14 if (!session.isLoggedIn) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
14 15
15 − const body = await request.json();
16 + const body = await request.json().catch(() => ({}));
16 17 const { action, fileIds } = body as { action: string; fileIds: string[] };
17 18
18 − if (!action || !fileIds?.length) return NextResponse.json({ error: "action and fileIds required" }, { status: 400 });
19 + if (!action || !Array.isArray(fileIds) || !fileIds.length) return NextResponse.json({ error: "action and fileIds required" }, { status: 400 });
19 20
20 21 if (action === "delete") {
21 − const files = await prisma.file.findMany({ where: { id: { in: fileIds } } });
22 − await prisma.file.updateMany({
23 − where: { id: { in: fileIds } },
24 − data: { deletedAt: new Date() },
25 − });
26 − for (const file of files) {
27 − await logActivity("TRASH", file.id, `Moved to trash: ${file.name}`);
28 − }
29 − return NextResponse.json({ success: true, count: files.length });
22 + const count = await trashFiles(fileIds);
23 + return NextResponse.json({ success: true, count });
30 24 }
31 25
32 26 if (action === "move") {
33 − const { targetFolderId } = body;
34 − await prisma.file.updateMany({
35 − where: { id: { in: fileIds } },
36 − data: { folderId: targetFolderId === "root" ? null : targetFolderId },
27 + const { targetFolderId } = body as { targetFolderId?: string | null };
28 + const target = targetFolderId === "root" || targetFolderId === null || targetFolderId === undefined ? null : targetFolderId;
29 + if (target) {
30 + const exists = await prisma.folder.findUnique({ where: { id: target }, select: { id: true } });
31 + if (!exists) return NextResponse.json({ error: "Dossier de destination introuvable" }, { status: 404 });
32 + }
33 + const res = await prisma.file.updateMany({
34 + where: { id: { in: fileIds }, deletedAt: null },
35 + data: { folderId: target },
37 36 });
38 − return NextResponse.json({ success: true });
37 + await logActivity("MOVE", null, `Moved ${res.count} file(s) to folder ${target ?? "root"}`);
38 + return NextResponse.json({ success: true, count: res.count });
39 39 }
40 40
41 41 if (action === "download") {
42 − const files = await prisma.file.findMany({ where: { id: { in: fileIds } } });
42 + const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: null } });
43 43 const zip = new JSZip();
44 + const used = new Set<string>();
44 45 for (const file of files) {
45 46 try {
46 − zip.file(file.name, await loadFileData(file.storagePath, file.isEncrypted));
47 + // Noms uniques dans l'archive (deux fichiers homonymes de dossiers différents)
48 + let name = file.name;
49 + let i = 2;
50 + while (used.has(name)) { const m = file.name.match(/^(.*?)(\.[^.]+)?$/); name = `${m?.[1] ?? file.name} (${i})${m?.[2] ?? ""}`; i++; }
51 + used.add(name);
52 + zip.file(name, await loadFileData(file.storagePath, file.isEncrypted));
47 53 } catch {
48 54 // fichier manquant : ignoré
49 55 }
50 56 }
51 − const buffer = await zip.generateAsync({ type: "nodebuffer" });
57 + const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 6 } });
58 + await logActivity("DOWNLOAD", null, `Bulk download (${files.length} files)`);
52 59 return new NextResponse(new Uint8Array(buffer), {
53 60 headers: { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="files.zip"` },
54 61 });
modified app/api/files/route.ts +30 −15
@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 3 import { saveFile } from "@/lib/storage";
4 4 import { logActivity } from "@/lib/activity";
5 +import { getIronSession } from "iron-session";
6 +import { SessionData, sessionOptions } from "@/lib/session";
7 +import { getSpaceIdForFolder } from "@/lib/trash";
8 +import { notifySharedSpaceUpload } from "@/lib/notifications";
5 9 import mime from "mime-types";
6 10
7 11 export async function GET(request: NextRequest) {
@@ -84,6 +88,8 @@ export async function GET(request: NextRequest) {
84 88 }
85 89
86 90 export async function POST(request: NextRequest) {
91 + const response = NextResponse.next();
92 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
87 93 const formData = await request.formData();
88 94 const file = formData.get("file") as File | null;
89 95 const folderId = formData.get("folderId") as string | null;
@@ -91,6 +97,9 @@ export async function POST(request: NextRequest) {
91 97 if (!file) {
92 98 return NextResponse.json({ error: "No file provided" }, { status: 400 });
93 99 }
100 + if (!file.name || /[\\/]/.test(file.name)) {
101 + return NextResponse.json({ error: "Nom de fichier invalide" }, { status: 400 });
102 + }
94 103
95 104 const maxSize = parseInt(process.env.MAX_FILE_SIZE || "104857600");
96 105 if (file.size > maxSize) {
@@ -100,20 +109,24 @@ export async function POST(request: NextRequest) {
100 109 );
101 110 }
102 111
103 − // Quota check for shared spaces
104 − if (folderId && folderId !== "root") {
105 − const folder = await prisma.folder.findUnique({ where: { id: folderId } });
106 − if (folder?.sharedSpaceId) {
107 − const space = await prisma.sharedSpace.findUnique({ where: { id: folder.sharedSpaceId } });
108 − if (space) {
109 − const agg = await prisma.file.aggregate({
110 − _sum: { size: true },
111 − where: { folder: { sharedSpaceId: folder.sharedSpaceId } },
112 − });
113 − const usedBytes = Number(agg._sum.size || 0);
114 − if (usedBytes + file.size > Number(space.quotaBytes)) {
115 − return NextResponse.json({ error: "Quota de l'espace partagé dépassé (500 Go max)" }, { status: 413 });
116 − }
112 + const targetFolder = folderId && folderId !== "root" ? folderId : null;
113 + if (targetFolder) {
114 + const exists = await prisma.folder.findUnique({ where: { id: targetFolder }, select: { id: true } });
115 + if (!exists) return NextResponse.json({ error: "Dossier de destination introuvable" }, { status: 404 });
116 + }
117 +
118 + // Quota de l'espace partagé (hérité via l'arborescence, fichiers en corbeille exclus)
119 + const spaceId = await getSpaceIdForFolder(targetFolder);
120 + if (spaceId) {
121 + const space = await prisma.sharedSpace.findUnique({ where: { id: spaceId } });
122 + if (space) {
123 + const agg = await prisma.file.aggregate({
124 + _sum: { size: true },
125 + where: { deletedAt: null, folder: { sharedSpaceId: spaceId } },
126 + });
127 + const usedBytes = Number(agg._sum.size || 0);
128 + if (usedBytes + file.size > Number(space.quotaBytes)) {
129 + return NextResponse.json({ error: "Quota de l'espace partagé dépassé" }, { status: 413 });
117 130 }
118 131 }
119 132 }
@@ -128,12 +141,14 @@ export async function POST(request: NextRequest) {
128 141 storagePath,
129 142 size: BigInt(size),
130 143 mimeType,
131 − folderId: folderId && folderId !== "root" ? folderId : null,
144 + folderId: targetFolder,
145 + userId: session.userId ?? null,
132 146 isEncrypted: true,
133 147 },
134 148 });
135 149
136 150 await logActivity("UPLOAD", dbFile.id, `Uploaded ${file.name}`);
151 + if (spaceId && session.userId) notifySharedSpaceUpload(spaceId, session.userId, file.name, dbFile.id).catch(() => {});
137 152
138 153 return NextResponse.json(
139 154 { ...dbFile, size: Number(dbFile.size) },
modified app/api/files/upload-chunk/route.ts +45 −3
@@ -1,10 +1,17 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
2 4 import { prisma } from "@/lib/prisma";
3 −import { saveChunk, assembleChunks } from "@/lib/storage";
5 +import { saveChunk, assembleChunks, deleteFile } from "@/lib/storage";
4 6 import { logActivity } from "@/lib/activity";
7 +import { cleanupOrphanChunks, getSpaceIdForFolder } from "@/lib/trash";
8 +import { notifySharedSpaceUpload } from "@/lib/notifications";
5 9 import mime from "mime-types";
6 10
7 11 export async function POST(request: NextRequest) {
12 + const response = NextResponse.next();
13 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
14 +
8 15 const formData = await request.formData();
9 16 const chunk = formData.get("chunk") as File | null;
10 17 const uploadId = formData.get("uploadId") as string;
@@ -12,10 +19,23 @@ export async function POST(request: NextRequest) {
12 19 const totalChunks = parseInt(formData.get("totalChunks") as string);
13 20 const fileName = formData.get("fileName") as string;
14 21 const folderId = formData.get("folderId") as string | null;
22 + const declaredSize = parseInt((formData.get("fileSize") as string) || "0");
15 23
16 − if (!chunk || !uploadId || isNaN(chunkIndex) || isNaN(totalChunks) || !fileName) {
24 + if (!chunk || !uploadId || !/^[A-Za-z0-9_-]{6,80}$/.test(uploadId) || isNaN(chunkIndex) || isNaN(totalChunks) || !fileName) {
17 25 return NextResponse.json({ error: "Missing chunk data" }, { status: 400 });
18 26 }
27 + if (/[\\/]/.test(fileName)) return NextResponse.json({ error: "Nom de fichier invalide" }, { status: 400 });
28 +
29 + const maxSize = parseInt(process.env.MAX_FILE_SIZE || "104857600");
30 + if (declaredSize > maxSize) {
31 + return NextResponse.json({ error: `Fichier trop volumineux (max ${Math.round(maxSize / 1024 / 1024)} Mo)` }, { status: 413 });
32 + }
33 +
34 + const targetFolder = folderId && folderId !== "root" ? folderId : null;
35 + if (targetFolder && chunkIndex === 0) {
36 + const exists = await prisma.folder.findUnique({ where: { id: targetFolder }, select: { id: true } });
37 + if (!exists) return NextResponse.json({ error: "Dossier de destination introuvable" }, { status: 404 });
38 + }
19 39
20 40 const buffer = Buffer.from(await chunk.arrayBuffer());
21 41 await saveChunk(buffer, uploadId, chunkIndex);
@@ -23,6 +43,25 @@ export async function POST(request: NextRequest) {
23 43 // If this is the last chunk, assemble
24 44 if (chunkIndex === totalChunks - 1) {
25 45 const { storagePath, size } = await assembleChunks(uploadId, totalChunks, fileName);
46 +
47 + if (size > maxSize) {
48 + await deleteFile(storagePath);
49 + return NextResponse.json({ error: `Fichier trop volumineux (max ${Math.round(maxSize / 1024 / 1024)} Mo)` }, { status: 413 });
50 + }
51 +
52 + // Quota de l'espace partagé (hérité via l'arborescence)
53 + const spaceId = await getSpaceIdForFolder(targetFolder);
54 + if (spaceId) {
55 + const space = await prisma.sharedSpace.findUnique({ where: { id: spaceId } });
56 + if (space) {
57 + const agg = await prisma.file.aggregate({ _sum: { size: true }, where: { deletedAt: null, folder: { sharedSpaceId: spaceId } } });
58 + if (Number(agg._sum.size || 0) + size > Number(space.quotaBytes)) {
59 + await deleteFile(storagePath);
60 + return NextResponse.json({ error: "Quota de l'espace partagé dépassé" }, { status: 413 });
61 + }
62 + }
63 + }
64 +
26 65 const mimeType = mime.lookup(fileName) || "application/octet-stream";
27 66
28 67 const dbFile = await prisma.file.create({
@@ -31,12 +70,15 @@ export async function POST(request: NextRequest) {
31 70 storagePath,
32 71 size: BigInt(size),
33 72 mimeType,
34 − folderId: folderId && folderId !== "root" ? folderId : null,
73 + folderId: targetFolder,
74 + userId: session.userId ?? null,
35 75 isEncrypted: true,
36 76 },
37 77 });
38 78
39 79 await logActivity("UPLOAD", dbFile.id, `Uploaded ${fileName} (chunked)`);
80 + if (spaceId && session.userId) notifySharedSpaceUpload(spaceId, session.userId, fileName, dbFile.id).catch(() => {});
81 + cleanupOrphanChunks().catch(() => {});
40 82
41 83 return NextResponse.json(
42 84 { ...dbFile, size: Number(dbFile.size), complete: true },
modified app/api/folders/[id]/route.ts +38 −53
@@ -1,7 +1,7 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 −import { deleteFile } from "@/lib/storage";
4 3 import { logActivity } from "@/lib/activity";
4 +import { collectFolderTree, getSpaceIdForFolder, trashFolderRecursive } from "@/lib/trash";
5 5
6 6 export async function GET(
7 7 request: NextRequest,
@@ -14,7 +14,7 @@ export async function GET(
14 14 include: { _count: { select: { files: true, children: true } } },
15 15 orderBy: { name: "asc" },
16 16 },
17 − files: { orderBy: { createdAt: "desc" } },
17 + files: { where: { deletedAt: null }, orderBy: { createdAt: "desc" } },
18 18 parent: true,
19 19 },
20 20 });
@@ -33,95 +33,80 @@ export async function PATCH(
33 33 request: NextRequest,
34 34 { params }: { params: { id: string } }
35 35 ) {
36 − const body = await request.json();
37 − const { name, parentId } = body;
36 + const body = await request.json().catch(() => ({}));
37 + const { name, parentId } = body as { name?: string; parentId?: string | null };
38 +
39 + const current = await prisma.folder.findUnique({ where: { id: params.id } });
40 + if (!current) return NextResponse.json({ error: "Dossier introuvable" }, { status: 404 });
38 41
39 42 const data: Record<string, unknown> = {};
40 43
41 44 if (name !== undefined) {
42 − if (!name || !name.trim()) {
43 − return NextResponse.json({ error: "Name required" }, { status: 400 });
44 − }
45 − data.name = name.trim();
45 + const trimmed = typeof name === "string" ? name.trim() : "";
46 + if (!trimmed) return NextResponse.json({ error: "Nom requis" }, { status: 400 });
47 + if (/[\\/]/.test(trimmed)) return NextResponse.json({ error: "Le nom ne peut pas contenir « / » ou « \\ »" }, { status: 400 });
48 + data.name = trimmed;
49 + if (trimmed !== current.name) await logActivity("RENAME_FOLDER", null, `Renamed folder ${current.name} -> ${trimmed}`);
46 50 }
47 51
48 − // Déplacement de dossier (avec protection anti-cycle)
52 + // Déplacement de dossier (protection anti-cycle + héritage de l'espace partagé de la destination)
53 + let spaceChanged = false;
54 + let newSpaceId: string | null = current.sharedSpaceId;
49 55 if (parentId !== undefined) {
50 56 const target = parentId === "root" || parentId === null ? null : parentId;
51 57 if (target === params.id) {
52 − return NextResponse.json(
53 − { error: "Impossible de déplacer un dossier dans lui-même" },
54 − { status: 400 }
55 − );
58 + return NextResponse.json({ error: "Impossible de déplacer un dossier dans lui-même" }, { status: 400 });
56 59 }
57 60 if (target) {
61 + const targetFolder = await prisma.folder.findUnique({ where: { id: target }, select: { id: true } });
62 + if (!targetFolder) return NextResponse.json({ error: "Dossier de destination introuvable" }, { status: 404 });
58 63 // Remonte la chaîne des parents : si on retombe sur le dossier déplacé,
59 64 // c'est qu'on tenterait de le mettre dans un de ses descendants.
60 65 let cur: string | null = target;
61 66 const visited = new Set<string>();
62 67 while (cur) {
63 68 if (cur === params.id) {
64 − return NextResponse.json(
65 − { error: "Impossible de déplacer un dossier dans son propre sous-dossier" },
66 − { status: 400 }
67 − );
69 + return NextResponse.json({ error: "Impossible de déplacer un dossier dans son propre sous-dossier" }, { status: 400 });
68 70 }
69 71 if (visited.has(cur)) break;
70 72 visited.add(cur);
71 − const p: { parentId: string | null } | null =
72 − await prisma.folder.findUnique({
73 − where: { id: cur },
74 − select: { parentId: true },
75 − });
73 + const p: { parentId: string | null } | null = await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true } });
76 74 cur = p?.parentId ?? null;
77 75 }
76 + newSpaceId = await getSpaceIdForFolder(target);
77 + spaceChanged = newSpaceId !== current.sharedSpaceId;
78 78 }
79 79 data.parentId = target;
80 − await logActivity("MOVE_FOLDER", null, `Moved folder ${params.id} -> ${parentId}`);
80 + if (target !== current.parentId) await logActivity("MOVE_FOLDER", null, `Moved folder ${current.name} -> ${parentId}`);
81 81 }
82 82
83 83 if (Object.keys(data).length === 0) {
84 84 return NextResponse.json({ error: "Rien à mettre à jour" }, { status: 400 });
85 85 }
86 86
87 − const folder = await prisma.folder.update({
88 − where: { id: params.id },
89 − data,
90 − });
87 + if (spaceChanged) {
88 + // Le sous-arbre entier suit l'espace de la destination (quotas, listings, WebDAV)
89 + const ids = await collectFolderTree(params.id);
90 + await prisma.folder.updateMany({ where: { id: { in: ids } }, data: { sharedSpaceId: newSpaceId } });
91 + }
91 92
93 + const folder = await prisma.folder.update({ where: { id: params.id }, data });
92 94 return NextResponse.json(folder);
93 95 }
94 96
97 +/**
98 + * Suppression d'un dossier : tout son contenu (sous-dossiers compris) part à la corbeille,
99 + * restaurable pendant 30 jours ; les dossiers eux-mêmes sont supprimés.
100 + */
95 101 export async function DELETE(
96 102 request: NextRequest,
97 103 { params }: { params: { id: string } }
98 104 ) {
99 − // Recursively delete all files in this folder and subfolders
100 − async function deleteRecursive(folderId: string) {
101 − const folder = await prisma.folder.findUnique({
102 − where: { id: folderId },
103 − include: { files: true, children: true },
104 − });
105 −
106 − if (!folder) return;
107 −
108 − // Delete files
109 − for (const file of folder.files) {
110 − await deleteFile(file.storagePath);
111 − }
112 −
113 − // Recurse into children
114 − for (const child of folder.children) {
115 − await deleteRecursive(child.id);
116 − }
117 − }
118 −
119 − await deleteRecursive(params.id);
120 −
121 − // Prisma cascade will handle DB records
122 − await prisma.folder.delete({ where: { id: params.id } });
105 + const folder = await prisma.folder.findUnique({ where: { id: params.id }, select: { id: true, name: true } });
106 + if (!folder) return NextResponse.json({ error: "Dossier introuvable" }, { status: 404 });
123 107
124 − await logActivity("DELETE_FOLDER", null, `Deleted folder ${params.id}`);
108 + const result = await trashFolderRecursive(folder.id);
109 + await logActivity("DELETE_FOLDER", null, `Deleted folder ${folder.name} (${result.files} fichier(s) mis à la corbeille, ${result.folders} dossier(s))`);
125 110
126 − return NextResponse.json({ success: true });
111 + return NextResponse.json({ success: true, trashedFiles: result.files, deletedFolders: result.folders });
127 112 }
modified app/api/folders/route.ts +14 −2
@@ -1,6 +1,7 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 3 import { logActivity } from "@/lib/activity";
4 +import { getSpaceIdForFolder } from "@/lib/trash";
4 5
5 6 export async function GET(request: NextRequest) {
6 7 const { searchParams } = new URL(request.url);
@@ -56,11 +57,22 @@ export async function POST(request: NextRequest) {
56 57 return NextResponse.json({ error: "Folder name required" }, { status: 400 });
57 58 }
58 59
60 + if (/[\\/]/.test(name)) {
61 + return NextResponse.json({ error: "Le nom ne peut pas contenir « / » ou « \\ »" }, { status: 400 });
62 + }
63 + const parent = parentId && parentId !== "root" ? parentId : null;
64 + if (parent) {
65 + const exists = await prisma.folder.findUnique({ where: { id: parent }, select: { id: true } });
66 + if (!exists) return NextResponse.json({ error: "Dossier parent introuvable" }, { status: 404 });
67 + }
68 + // Un sous-dossier hérite de l'espace partagé de son parent (quotas, listings, WebDAV)
69 + const effectiveSpaceId = sharedSpaceId || (parent ? await getSpaceIdForFolder(parent) : null);
70 +
59 71 const folder = await prisma.folder.create({
60 72 data: {
61 73 name: name.trim(),
62 − parentId: parentId && parentId !== "root" ? parentId : null,
63 − sharedSpaceId: sharedSpaceId || null,
74 + parentId: parent,
75 + sharedSpaceId: effectiveSpaceId,
64 76 },
65 77 });
66 78
modified app/api/shared-spaces/[id]/route.ts +1 −1
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
21 21
22 22 const agg = await prisma.file.aggregate({
23 23 _sum: { size: true },
24 − where: { folder: { sharedSpaceId: params.id } },
24 + where: { deletedAt: null, folder: { sharedSpaceId: params.id } },
25 25 });
26 26
27 27 return NextResponse.json({
modified app/api/shared-spaces/[id]/storage/route.ts +1 −1
@@ -13,7 +13,7 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
13 13
14 14 const agg = await prisma.file.aggregate({
15 15 _sum: { size: true },
16 − where: { folder: { sharedSpaceId: params.id } },
16 + where: { deletedAt: null, folder: { sharedSpaceId: params.id } },
17 17 });
18 18
19 19 const quotaBytes = Number(space.quotaBytes);
modified app/api/shared/[token]/route.ts +103 −43
@@ -1,71 +1,131 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 +import JSZip from "jszip";
2 3 import { prisma } from "@/lib/prisma";
3 4 import { loadFileData } from "@/lib/storage";
5 +import { fileInShare, folderInShare, hasPasswordAccess, loadShare, shareInfo, shareStatusError, touchShare, type ShareWithTargets } from "@/lib/shareAccess";
4 6
5 −export async function GET(
6 − request: NextRequest,
7 − { params }: { params: { token: string } }
8 −) {
9 − const share = await prisma.sharedLink.findUnique({
10 − where: { token: params.token },
11 − include: { file: true },
7 +function fileResponse(buffer: Buffer, name: string, mimeType: string, download: boolean) {
8 + return new NextResponse(new Uint8Array(buffer), {
9 + headers: {
10 + "Content-Type": download ? "application/octet-stream" : mimeType,
11 + "Content-Length": String(buffer.length),
12 + "Content-Disposition": `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(name)}`,
13 + "Cache-Control": "private, no-store",
14 + },
12 15 });
16 +}
13 17
14 − if (!share || !share.active) {
15 − return NextResponse.json({ error: "Lien introuvable ou inactif" }, { status: 404 });
18 +async function addFolderToZip(zip: JSZip, folderId: string, prefix: string) {
19 + const files = await prisma.file.findMany({ where: { folderId, deletedAt: null } });
20 + for (const file of files) {
21 + try {
22 + zip.file(prefix + file.name, await loadFileData(file.storagePath, file.isEncrypted));
23 + } catch { /* fichier illisible : ignoré */ }
16 24 }
25 + const subfolders = await prisma.folder.findMany({ where: { parentId: folderId } });
26 + for (const sub of subfolders) await addFolderToZip(zip, sub.id, `${prefix}${sub.name}/`);
27 +}
17 28
18 − if (share.expiresAt && new Date() > share.expiresAt) {
19 − return NextResponse.json({ error: "Ce lien a expire" }, { status: 410 });
29 +/** Listing d'un dossier du partage : fichiers actifs, sous-dossiers, fil d'Ariane borné à la racine partagée. */
30 +async function folderListing(share: ShareWithTargets, folderId: string) {
31 + const folder = await prisma.folder.findUnique({ where: { id: folderId }, select: { id: true, name: true, parentId: true } });
32 + if (!folder) return null;
33 + const [files, children] = await Promise.all([
34 + prisma.file.findMany({ where: { folderId, deletedAt: null }, orderBy: { name: "asc" }, select: { id: true, name: true, mimeType: true, size: true, updatedAt: true } }),
35 + prisma.folder.findMany({ where: { parentId: folderId }, orderBy: { name: "asc" }, select: { id: true, name: true, _count: { select: { files: true, children: true } } } }),
36 + ]);
37 + const crumbs: { id: string; name: string }[] = [];
38 + let cur: { id: string; name: string; parentId: string | null } | null = folder;
39 + while (cur) {
40 + crumbs.unshift({ id: cur.id, name: cur.name });
41 + if (cur.id === share.folder!.id) break;
42 + cur = cur.parentId ? await prisma.folder.findUnique({ where: { id: cur.parentId }, select: { id: true, name: true, parentId: true } }) : null;
20 43 }
44 + return {
45 + folder: { id: folder.id, name: folder.name },
46 + crumbs,
47 + folders: children.map((c) => ({ id: c.id, name: c.name, files: c._count.files, children: c._count.children })),
48 + files: files.map((f) => ({ ...f, size: Number(f.size) })),
49 + };
50 +}
21 51
22 − if (share.passwordHash) {
52 +export async function GET(
53 + request: NextRequest,
54 + { params }: { params: { token: string } }
55 +) {
56 + const share = await loadShare(params.token);
57 + const err = shareStatusError(share);
58 + if (err || !share) return err!;
59 +
60 + const { searchParams } = new URL(request.url);
61 + const action = searchParams.get("action");
62 + const wantContent = searchParams.get("content") === "true";
63 + const fileId = searchParams.get("fileId");
64 + const folderId = searchParams.get("folderId");
65 +
66 + // Mot de passe non validé (cookie signé absent) : on ne révèle que le minimum
67 + if (!hasPasswordAccess(request, share)) {
68 + const info = shareInfo(share);
23 69 return NextResponse.json({
24 70 requiresPassword: true,
25 − fileName: share.file.name,
26 − fileType: share.file.mimeType,
71 + type: info.type,
72 + name: info.name,
73 + fileName: info.name, // compatibilité ancien client
74 + fileType: share.file?.mimeType ?? "inode/directory",
27 75 mode: share.mode,
76 + expiresAt: info.expiresAt,
28 77 });
29 78 }
30 79
31 − const { searchParams } = new URL(request.url);
32 − const action = searchParams.get("action");
33 − const content = searchParams.get("content");
34 −
35 − // Serve actual file content (binary) when ?content=true
36 − if (content === "true") {
37 − const buffer = await loadFileData(share.file.storagePath, share.file.isEncrypted);
80 + // ---- Partage de FICHIER ----
81 + if (share.file) {
82 + if (wantContent || action === "download") {
83 + const buffer = await loadFileData(share.file.storagePath, share.file.isEncrypted);
84 + const download = action === "download" || share.mode === "DOWNLOAD";
85 + await touchShare(share.id);
86 + return fileResponse(buffer, share.file.name, share.file.mimeType, download);
87 + }
88 + await touchShare(share.id);
89 + return NextResponse.json(shareInfo(share));
90 + }
38 91
39 − const isDownload = action === "download" || share.mode === "DOWNLOAD";
92 + // ---- Partage de DOSSIER ----
93 + const root = share.folder!;
40 94
41 − return new NextResponse(new Uint8Array(buffer), {
42 − headers: {
43 − "Content-Type": isDownload ? "application/octet-stream" : share.file.mimeType,
44 − "Content-Disposition": `${isDownload ? "attachment" : "inline"}; filename="${encodeURIComponent(share.file.name)}"`,
45 − },
46 − });
95 + if (fileId) {
96 + const f = await fileInShare(share, fileId);
97 + if (!f) return NextResponse.json({ error: "Fichier hors du partage" }, { status: 404 });
98 + if (wantContent || action === "download") {
99 + const buffer = await loadFileData(f.storagePath, f.isEncrypted);
100 + await touchShare(share.id);
101 + return fileResponse(buffer, f.name, f.mimeType, action === "download" || share.mode === "DOWNLOAD");
102 + }
103 + return NextResponse.json({ id: f.id, name: f.name, mimeType: f.mimeType, size: Number(f.size), mode: share.mode });
47 104 }
48 105
49 − // Force download
106 + // ZIP du dossier partagé (ou d'un sous-dossier)
50 107 if (action === "download") {
51 − const buffer = await loadFileData(share.file.storagePath, share.file.isEncrypted);
52 −
108 + const target = folderId ?? root.id;
109 + if (!(await folderInShare(share, target))) return NextResponse.json({ error: "Dossier hors du partage" }, { status: 404 });
110 + const folder = await prisma.folder.findUnique({ where: { id: target }, select: { name: true } });
111 + const zip = new JSZip();
112 + await addFolderToZip(zip, target, "");
113 + const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 6 } });
114 + await touchShare(share.id);
53 115 return new NextResponse(new Uint8Array(buffer), {
54 116 headers: {
55 − "Content-Type": "application/octet-stream",
56 − "Content-Disposition": `attachment; filename="${encodeURIComponent(share.file.name)}"`,
117 + "Content-Type": "application/zip",
118 + "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent((folder?.name || root.name) + ".zip")}`,
119 + "Cache-Control": "private, no-store",
57 120 },
58 121 });
59 122 }
60 123
61 − // DOWNLOAD mode: return JSON info (client will show download-only UI)
62 − // PREVIEW mode: return JSON info (client will render rich preview)
63 − return NextResponse.json({
64 − id: share.file.id,
65 − name: share.file.name,
66 − mimeType: share.file.mimeType,
67 − size: Number(share.file.size),
68 − mode: share.mode,
69 − expiresAt: share.expiresAt?.toISOString() || null,
70 − });
124 + // Listing (racine du partage ou sous-dossier)
125 + const target = folderId ?? root.id;
126 + if (!(await folderInShare(share, target))) return NextResponse.json({ error: "Dossier hors du partage" }, { status: 404 });
127 + const listing = await folderListing(share, target);
128 + if (!listing) return NextResponse.json({ error: "Dossier introuvable" }, { status: 404 });
129 + await touchShare(share.id);
130 + return NextResponse.json({ ...shareInfo(share), ...listing });
71 131 }
modified app/api/shared/[token]/verify/route.ts +30 −38
@@ -1,60 +1,52 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 −import { prisma } from "@/lib/prisma";
3 −import { loadFileData } from "@/lib/storage";
4 2 import bcrypt from "bcryptjs";
3 +import { loadFileData } from "@/lib/storage";
4 +import { grantPasswordAccess, loadShare, shareInfo, shareStatusError, touchShare } from "@/lib/shareAccess";
5 5
6 +/**
7 + * Vérifie le mot de passe d'un partage. En cas de succès, pose un cookie signé (6 h) qui autorise
8 + * les GET suivants (aperçu, navigation de dossier, ZIP) sans renvoyer le mot de passe.
9 + * `contentOnly: true` renvoie directement le contenu du fichier (compatibilité ancien client).
10 + */
6 11 export async function POST(
7 12 request: NextRequest,
8 13 { params }: { params: { token: string } }
9 14 ) {
10 − const body = await request.json();
11 − const { password, contentOnly } = body;
15 + const body = await request.json().catch(() => ({}));
16 + const { password, contentOnly } = body as { password?: string; contentOnly?: boolean };
12 17
13 − const share = await prisma.sharedLink.findUnique({
14 − where: { token: params.token },
15 − include: { file: true },
16 − });
17 −
18 − if (!share || !share.active) {
19 − return NextResponse.json({ error: "Lien introuvable" }, { status: 404 });
20 − }
21 −
22 − if (share.expiresAt && new Date() > share.expiresAt) {
23 − return NextResponse.json({ error: "Ce lien a expire" }, { status: 410 });
24 − }
18 + const share = await loadShare(params.token);
19 + const err = shareStatusError(share);
20 + if (err || !share) return err!;
25 21
26 22 if (!share.passwordHash) {
27 23 return NextResponse.json({ error: "Aucun mot de passe requis" }, { status: 400 });
28 24 }
25 + if (!password) {
26 + return NextResponse.json({ error: "Mot de passe requis" }, { status: 400 });
27 + }
29 28
30 29 const valid = await bcrypt.compare(password, share.passwordHash);
31 30 if (!valid) {
32 31 return NextResponse.json({ error: "Mot de passe incorrect" }, { status: 401 });
33 32 }
34 33
35 − // If only verifying password and requesting file info (for preview mode)
36 − if (!contentOnly) {
37 − // Return file info JSON so the client can render the rich preview
38 − return NextResponse.json({
39 − verified: true,
40 − id: share.file.id,
41 − name: share.file.name,
42 − mimeType: share.file.mimeType,
43 − size: Number(share.file.size),
44 − mode: share.mode,
45 − expiresAt: share.expiresAt?.toISOString() || null,
34 + if (contentOnly && share.file) {
35 + const buffer = await loadFileData(share.file.storagePath, share.file.isEncrypted);
36 + const isDownload = share.mode === "DOWNLOAD";
37 + const res = new NextResponse(new Uint8Array(buffer), {
38 + headers: {
39 + "Content-Type": isDownload ? "application/octet-stream" : share.file.mimeType,
40 + "Content-Disposition": `${isDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(share.file.name)}`,
41 + },
46 42 });
43 + grantPasswordAccess(res, share);
44 + await touchShare(share.id);
45 + return res;
47 46 }
48 47
49 − // Serve actual binary content after password verification
50 − const buffer = await loadFileData(share.file.storagePath, share.file.isEncrypted);
51 −
52 − const isDownload = share.mode === "DOWNLOAD";
53 −
54 − return new NextResponse(new Uint8Array(buffer), {
55 − headers: {
56 − "Content-Type": isDownload ? "application/octet-stream" : share.file.mimeType,
57 − "Content-Disposition": `${isDownload ? "attachment" : "inline"}; filename="${encodeURIComponent(share.file.name)}"`,
58 − },
59 − });
48 + const res = NextResponse.json({ verified: true, ...shareInfo(share) });
49 + grantPasswordAccess(res, share);
50 + await touchShare(share.id);
51 + return res;
60 52 }
modified app/api/shares/[id]/route.ts +21 −7
@@ -1,20 +1,34 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 3 import { logActivity } from "@/lib/activity";
4 +import bcrypt from "bcryptjs";
4 5
5 6 export async function PATCH(
6 7 request: NextRequest,
7 8 { params }: { params: { id: string } }
8 9 ) {
9 − const body = await request.json();
10 − const { active } = body;
10 + const body = await request.json().catch(() => ({}));
11 + const { active, expiresAt, password, mode } = body as { active?: boolean; expiresAt?: string | null; password?: string | null; mode?: string };
11 12
12 − const share = await prisma.sharedLink.update({
13 − where: { id: params.id },
14 − data: { active },
15 − });
13 + const existing = await prisma.sharedLink.findUnique({ where: { id: params.id } });
14 + if (!existing) return NextResponse.json({ error: "Share not found" }, { status: 404 });
16 15
17 − if (!active) {
16 + const data: Record<string, unknown> = {};
17 + if (typeof active === "boolean") data.active = active;
18 + if (expiresAt !== undefined) {
19 + if (expiresAt === null || expiresAt === "") data.expiresAt = null;
20 + else {
21 + const d = new Date(expiresAt);
22 + if (isNaN(d.getTime())) return NextResponse.json({ error: "Date invalide" }, { status: 400 });
23 + data.expiresAt = d;
24 + }
25 + }
26 + if (password !== undefined) data.passwordHash = password ? await bcrypt.hash(password, 10) : null;
27 + if (mode === "PREVIEW" || mode === "DOWNLOAD") data.mode = mode;
28 +
29 + const share = await prisma.sharedLink.update({ where: { id: params.id }, data });
30 +
31 + if (active === false) {
18 32 await logActivity("UNSHARE", share.fileId, "Link deactivated");
19 33 }
20 34
modified app/api/shares/route.ts +36 −22
@@ -6,13 +6,15 @@ import bcrypt from "bcryptjs";
6 6 export async function GET() {
7 7 const shares = await prisma.sharedLink.findMany({
8 8 include: {
9 − file: { select: { name: true, mimeType: true, size: true } },
9 + file: { select: { id: true, name: true, mimeType: true, size: true, deletedAt: true } },
10 + folder: { select: { id: true, name: true, _count: { select: { files: true, children: true } } } },
10 11 },
11 12 orderBy: { createdAt: "desc" },
12 13 });
13 14
14 15 const serialized = shares.map((s) => ({
15 16 ...s,
17 + type: s.folderId ? "folder" : "file",
16 18 file: s.file ? { ...s.file, size: Number(s.file.size) } : null,
17 19 }));
18 20
@@ -20,37 +22,49 @@ export async function GET() {
20 22 }
21 23
22 24 export async function POST(request: NextRequest) {
23 − const body = await request.json();
24 − const { fileId, expiresAt, password, mode } = body;
25 + const body = await request.json().catch(() => ({}));
26 + const { fileId, folderId, expiresAt, password, mode } = body as {
27 + fileId?: string; folderId?: string; expiresAt?: string; password?: string; mode?: string;
28 + };
25 29
26 − if (!fileId) {
27 − return NextResponse.json({ error: "fileId required" }, { status: 400 });
30 + if (!fileId && !folderId) {
31 + return NextResponse.json({ error: "fileId ou folderId requis" }, { status: 400 });
28 32 }
29 −
30 − const file = await prisma.file.findUnique({ where: { id: fileId } });
31 − if (!file) {
32 − return NextResponse.json({ error: "File not found" }, { status: 404 });
33 + if (fileId && folderId) {
34 + return NextResponse.json({ error: "Un partage cible soit un fichier, soit un dossier" }, { status: 400 });
33 35 }
34 36
35 − const data: Record<string, unknown> = {
36 − fileId,
37 − mode: mode || "DOWNLOAD",
38 − active: true,
39 − };
40 −
41 − if (expiresAt) {
42 − data.expiresAt = new Date(expiresAt);
37 + let label = "";
38 + if (fileId) {
39 + const file = await prisma.file.findUnique({ where: { id: fileId } });
40 + if (!file || file.deletedAt) return NextResponse.json({ error: "Fichier introuvable" }, { status: 404 });
41 + label = file.name;
42 + } else if (folderId) {
43 + const folder = await prisma.folder.findUnique({ where: { id: folderId } });
44 + if (!folder) return NextResponse.json({ error: "Dossier introuvable" }, { status: 404 });
45 + label = folder.name;
43 46 }
44 47
45 − if (password) {
46 − data.passwordHash = await bcrypt.hash(password, 10);
48 + const safeMode = mode === "PREVIEW" ? "PREVIEW" : "DOWNLOAD";
49 + let expires: Date | null = null;
50 + if (expiresAt) {
51 + expires = new Date(expiresAt);
52 + if (isNaN(expires.getTime())) return NextResponse.json({ error: "Date d'expiration invalide" }, { status: 400 });
53 + if (expires.getTime() < Date.now()) return NextResponse.json({ error: "La date d'expiration est déjà passée" }, { status: 400 });
47 54 }
48 55
49 56 const share = await prisma.sharedLink.create({
50 − data: data as Parameters<typeof prisma.sharedLink.create>[0]["data"],
57 + data: {
58 + fileId: fileId ?? null,
59 + folderId: folderId ?? null,
60 + mode: safeMode,
61 + active: true,
62 + expiresAt: expires,
63 + passwordHash: password ? await bcrypt.hash(password, 10) : null,
64 + },
51 65 });
52 66
53 − await logActivity("SHARE", fileId, `Shared ${file.name}`);
67 + await logActivity("SHARE", fileId ?? null, `Shared ${folderId ? "folder" : "file"} ${label}`);
54 68
55 − return NextResponse.json(share, { status: 201 });
69 + return NextResponse.json({ ...share, type: folderId ? "folder" : "file" }, { status: 201 });
56 70 }
modified app/api/trash/route.ts +27 −36
@@ -1,7 +1,7 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 −import { deleteFile } from "@/lib/storage";
4 3 import { logActivity } from "@/lib/activity";
4 +import { purgeExpiredTrash, purgeFiles, restoreFiles, TRASH_RETENTION_DAYS } from "@/lib/trash";
5 5
6 6 export async function GET() {
7 7 const files = await prisma.file.findMany({
@@ -14,18 +14,21 @@ export async function GET() {
14 14 },
15 15 });
16 16
17 − const serialized = files.map((f) => ({
18 − ...f,
19 − size: Number(f.size),
20 − }));
17 + const serialized = files.map((f) => {
18 + let originPath: string | null = null;
19 + if (f.deletedFromPath) {
20 + try { originPath = (JSON.parse(f.deletedFromPath) as { names?: string[] }).names?.join(" / ") ?? null; } catch { originPath = null; }
21 + }
22 + return { ...f, size: Number(f.size), originPath, retentionDays: TRASH_RETENTION_DAYS };
23 + });
21 24
22 25 return NextResponse.json(serialized);
23 26 }
24 27
25 28 export async function POST(request: NextRequest) {
26 − const body = await request.json();
29 + const body = await request.json().catch(() => ({}));
27 30 const { action, fileIds } = body as {
28 − action: "restore" | "purge" | "empty";
31 + action: "restore" | "purge" | "empty" | "purge-expired";
29 32 fileIds?: string[];
30 33 };
31 34
@@ -33,48 +36,36 @@ export async function POST(request: NextRequest) {
33 36 return NextResponse.json({ error: "action required" }, { status: 400 });
34 37 }
35 38
36 − // Restore files — set deletedAt back to null
39 + // Restauration fidèle : dossier d'origine, chemin recréé au besoin, sinon racine
37 40 if (action === "restore") {
38 41 if (!fileIds?.length) {
39 42 return NextResponse.json({ error: "fileIds required" }, { status: 400 });
40 43 }
41 − await prisma.file.updateMany({
42 − where: { id: { in: fileIds }, deletedAt: { not: null } },
43 − data: { deletedAt: null },
44 − });
45 − for (const id of fileIds) {
46 − await logActivity("RESTORE", id, "Restored from trash");
47 − }
48 − return NextResponse.json({ success: true, count: fileIds.length });
44 + const count = await restoreFiles(fileIds);
45 + return NextResponse.json({ success: true, count });
49 46 }
50 47
51 − // Purge specific files — permanent delete from storage + DB
48 + // Suppression définitive de fichiers précis (disque + versions + vignettes + base)
52 49 if (action === "purge") {
53 50 if (!fileIds?.length) {
54 51 return NextResponse.json({ error: "fileIds required" }, { status: 400 });
55 52 }
56 − const files = await prisma.file.findMany({
57 − where: { id: { in: fileIds }, deletedAt: { not: null } },
58 − });
59 − for (const file of files) {
60 − await deleteFile(file.storagePath);
61 − await logActivity("PURGE", null, `Permanently deleted: ${file.name}`);
62 − await prisma.file.delete({ where: { id: file.id } });
63 − }
64 − return NextResponse.json({ success: true, count: files.length });
53 + const count = await purgeFiles(fileIds);
54 + return NextResponse.json({ success: true, count });
65 55 }
66 56
67 − // Empty entire trash
57 + // Vider la corbeille
68 58 if (action === "empty") {
69 − const files = await prisma.file.findMany({
70 − where: { deletedAt: { not: null } },
71 − });
72 − for (const file of files) {
73 − await deleteFile(file.storagePath);
74 − await prisma.file.delete({ where: { id: file.id } });
75 − }
76 − await logActivity("PURGE", null, `Emptied trash (${files.length} files)`);
77 − return NextResponse.json({ success: true, count: files.length });
59 + const files = await prisma.file.findMany({ where: { deletedAt: { not: null } }, select: { id: true } });
60 + const count = await purgeFiles(files.map((f) => f.id), false);
61 + await logActivity("PURGE", null, `Emptied trash (${count} files)`);
62 + return NextResponse.json({ success: true, count });
63 + }
64 +
65 + // Purge des fichiers au-delà de la rétention (planificateur ou manuel)
66 + if (action === "purge-expired") {
67 + const count = await purgeExpiredTrash();
68 + return NextResponse.json({ success: true, count });
78 69 }
79 70
80 71 return NextResponse.json({ error: "Unknown action" }, { status: 400 });
modified app/dashboard/trash/page.tsx +2 −1
@@ -18,6 +18,7 @@ interface TrashedFile {
18 18 mimeType: string;
19 19 deletedAt: string;
20 20 createdAt: string;
21 + originPath?: string | null;
21 22 }
22 23
23 24 const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -188,7 +189,7 @@ export default function TrashPage() {
188 189 <div className="flex-1 min-w-0">
189 190 <p className="text-[14px] font-medium text-gray-900 dark:text-white truncate">{file.name}</p>
190 191 <p className="text-[12px] text-gray-500 dark:text-gray-400 tabular-nums">
191 − {formatFileSize(file.size)} · supprimé le {new Date(file.deletedAt).toLocaleDateString("fr-CA", { day: "numeric", month: "short" })}
192 + {formatFileSize(file.size)} · supprimé le {new Date(file.deletedAt).toLocaleDateString("fr-CA", { day: "numeric", month: "short" })}{file.originPath ? ` · depuis ${file.originPath}` : ""}
192 193 </p>
193 194 </div>
194 195 <span className={`inline-flex items-center gap-1 px-2 h-6 rounded-full text-[11px] font-medium tabular-nums shrink-0 ${
modified app/login/page.tsx +163 −102
@@ -1,9 +1,9 @@
1 1 "use client";
2 2
3 −import { useState, useEffect } from "react";
3 +import { useState, useEffect, useRef } from "react";
4 4 import { useRouter } from "next/navigation";
5 5 import { motion, AnimatePresence } from "framer-motion";
6 −import { Lock, Eye, EyeOff, Loader2, User, Shield, ShieldCheck, Sparkles, HardDrive, Share2, ArrowRight } from "lucide-react";
6 +import { Lock, Eye, EyeOff, Loader2, User, Shield, ShieldCheck, Sparkles, HardDrive, Share2, ArrowRight, Mail, KeyRound, RefreshCw } from "lucide-react";
7 7 import ThemeToggle from "@/components/theme/ThemeToggle";
8 8 import Logo from "@/components/ui/Logo";
9 9
@@ -14,44 +14,61 @@ const features = [
14 14 { icon: Share2, title: "Partage maîtrisé", text: "Liens à expiration, mot de passe, aperçu seul ou téléchargement." },
15 15 ];
16 16
17 +type Step = "email" | "code" | "password" | "2fa";
18 +const LAST_EMAIL_KEY = "spb-login-email";
19 +
17 20 export default function LoginPage() {
21 + const router = useRouter();
22 + const [step, setStep] = useState<Step>("email");
23 + const [email, setEmail] = useState("");
24 + const [code, setCode] = useState("");
18 25 const [username, setUsername] = useState("");
19 26 const [password, setPassword] = useState("");
20 27 const [totpCode, setTotpCode] = useState("");
21 28 const [error, setError] = useState("");
29 + const [info, setInfo] = useState("");
22 30 const [loading, setLoading] = useState(false);
23 31 const [showPassword, setShowPassword] = useState(false);
24 − const [needs2FA, setNeeds2FA] = useState(false);
25 − const [now, setNow] = useState<string>("");
26 − const router = useRouter();
32 + const [cooldown, setCooldown] = useState(0);
33 + const [now, setNow] = useState("");
34 + const codeRef = useRef<HTMLInputElement>(null);
27 35
28 36 useEffect(() => {
37 + try { const e = localStorage.getItem(LAST_EMAIL_KEY); if (e) setEmail(e); } catch {}
29 38 const fmt = () => setNow(new Intl.DateTimeFormat("fr-CA", { weekday: "long", day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" }).format(new Date()));
30 39 fmt();
31 40 const t = setInterval(fmt, 30_000);
32 41 return () => clearInterval(t);
33 42 }, []);
34 43
44 + useEffect(() => {
45 + if (cooldown <= 0) return;
46 + const t = setTimeout(() => setCooldown((c) => c - 1), 1000);
47 + return () => clearTimeout(t);
48 + }, [cooldown]);
49 +
50 + useEffect(() => { if (step === "code") setTimeout(() => codeRef.current?.focus(), 80); }, [step]);
51 +
35 52 const inputClasses =
36 53 "w-full bg-white dark:bg-white/[0.05] border border-gray-200 dark:border-white/10 rounded-2xl pl-11 pr-11 h-12 text-[15px] text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-blue-500/60 focus:ring-4 focus:ring-blue-500/10 dark:focus:ring-blue-500/15 transition-all duration-200 shadow-card dark:shadow-none";
54 + const primaryBtn =
55 + "group w-full h-12 rounded-2xl text-white font-medium bg-accent-gradient shadow-[inset_0_1px_0_rgb(255_255_255/.2)] hover:brightness-110 hover:shadow-glow transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none flex items-center justify-center gap-2 text-[15px]";
37 56
38 − const handleSubmit = async (e: React.FormEvent) => {
39 − e.preventDefault();
40 − setError("");
41 − setLoading(true);
57 + const go = (s: Step) => { setError(""); setInfo(""); setStep(s); };
58 +
59 + // --- Code par courriel ---
60 + const requestCode = async (e?: React.FormEvent) => {
61 + e?.preventDefault();
62 + setError(""); setInfo(""); setLoading(true);
42 63 try {
43 − const res = await fetch("/api/auth/login", {
44 − method: "POST",
45 − headers: { "Content-Type": "application/json" },
46 − body: JSON.stringify({ password, username: username || undefined }),
47 − });
48 − const data = await res.json();
49 − if (res.ok) {
50 − if (data.requires2FA) setNeeds2FA(true);
51 − else router.push("/dashboard");
52 − } else {
53 − setError(data.error || "Erreur de connexion");
54 − }
64 + const res = await fetch("/api/auth/otp/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: email.trim() }) });
65 + const data = await res.json().catch(() => ({}));
66 + if (!res.ok) { setError(data.error || "Impossible d'envoyer le code"); return; }
67 + try { localStorage.setItem(LAST_EMAIL_KEY, email.trim()); } catch {}
68 + setCode("");
69 + setCooldown(30);
70 + setInfo(data.throttled ? "Un code vient déjà d'être envoyé — vérifiez votre boîte de réception." : `Code envoyé à ${email.trim()}. Il expire dans 10 minutes.`);
71 + setStep("code");
55 72 } catch {
56 73 setError("Erreur réseau");
57 74 } finally {
@@ -59,21 +76,18 @@ export default function LoginPage() {
59 76 }
60 77 };
61 78
62 − const handleVerify2FA = async (e: React.FormEvent) => {
63 − e.preventDefault();
64 − setError("");
65 − setLoading(true);
79 + const verifyCode = async (e?: React.FormEvent, value?: string) => {
80 + e?.preventDefault();
81 + const c = (value ?? code).replace(/\D/g, "");
82 + if (c.length !== 6) return;
83 + setError(""); setLoading(true);
66 84 try {
67 − const res = await fetch("/api/auth/2fa/verify", {
68 − method: "POST",
69 − headers: { "Content-Type": "application/json" },
70 − body: JSON.stringify({ token: totpCode }),
71 − });
72 − if (res.ok) router.push("/dashboard");
73 − else {
74 − const data = await res.json();
75 − setError(data.error || "Code invalide");
76 − }
85 + const res = await fetch("/api/auth/otp/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: c }) });
86 + const data = await res.json().catch(() => ({}));
87 + if (res.ok) { router.push("/dashboard"); return; }
88 + setError(data.error || "Code invalide");
89 + if (res.status === 410 || res.status === 423 || res.status === 400) { setCode(""); }
90 + else setCode("");
77 91 } catch {
78 92 setError("Erreur réseau");
79 93 } finally {
@@ -81,6 +95,45 @@ export default function LoginPage() {
81 95 }
82 96 };
83 97
98 + // --- Mot de passe (administration / secours) ---
99 + const handlePassword = async (e: React.FormEvent) => {
100 + e.preventDefault();
101 + setError(""); setLoading(true);
102 + try {
103 + const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password, username: username || undefined }) });
104 + const data = await res.json();
105 + if (res.ok) { if (data.requires2FA) go("2fa"); else router.push("/dashboard"); }
106 + else setError(data.error || "Erreur de connexion");
107 + } catch { setError("Erreur réseau"); } finally { setLoading(false); }
108 + };
109 +
110 + const handleVerify2FA = async (e: React.FormEvent) => {
111 + e.preventDefault();
112 + setError(""); setLoading(true);
113 + try {
114 + const res = await fetch("/api/auth/2fa/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: totpCode }) });
115 + if (res.ok) router.push("/dashboard");
116 + else { const data = await res.json(); setError(data.error || "Code invalide"); }
117 + } catch { setError("Erreur réseau"); } finally { setLoading(false); }
118 + };
119 +
120 + const titles: Record<Step, [string, string]> = {
121 + email: ["Bon retour", "Entrez votre courriel : un code de sécurité vous sera envoyé."],
122 + code: ["Vérifiez vos courriels", `Saisissez le code à 6 chiffres envoyé à ${email.trim() || "votre adresse"}.`],
123 + password: ["Connexion par mot de passe", "Réservée à l'administration et au dépannage."],
124 + "2fa": ["Vérification en deux étapes", "Entrez le code de votre application d'authentification."],
125 + };
126 +
127 + const ErrorLine = () => (
128 + <AnimatePresence>
129 + {(error || info) && (
130 + <motion.p initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className={`text-[13px] px-1 flex items-start gap-1.5 ${error ? "text-rose-600 dark:text-rose-400" : "text-emerald-600 dark:text-emerald-400"}`} role={error ? "alert" : "status"}>
131 + {error ? <Shield className="w-3.5 h-3.5 mt-0.5 shrink-0" /> : <Mail className="w-3.5 h-3.5 mt-0.5 shrink-0" />} <span>{error || info}</span>
132 + </motion.p>
133 + )}
134 + </AnimatePresence>
135 + );
136 +
84 137 return (
85 138 <div className="min-h-[100dvh] bg-canvas flex transition-colors relative overflow-hidden">
86 139 {/* ===== Panneau de marque (desktop) ===== */}
@@ -90,15 +143,10 @@ export default function LoginPage() {
90 143 <div className="absolute inset-0 bg-grid bg-[size:44px_44px] opacity-[0.12] [mask-image:radial-gradient(ellipse_at_center,black_35%,transparent_75%)]" />
91 144 <motion.div className="absolute -top-32 -left-24 w-[520px] h-[520px] rounded-full bg-blue-500/30 blur-3xl animate-drift" />
92 145 <motion.div className="absolute bottom-[-20%] right-[-10%] w-[560px] h-[560px] rounded-full bg-blue-300/20 blur-3xl animate-drift [animation-delay:-8s]" />
93 −
94 − <div className="relative">
95 − <Logo size={44} wordmark wordmarkClassName="text-xl !text-white" />
96 − </div>
97 −
146 + <div className="relative"><Logo size={44} wordmark wordmarkClassName="text-xl !text-white" /></div>
98 147 <div className="relative max-w-md">
99 148 <motion.h2 initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1, duration: 0.5 }} className="text-4xl xl:text-5xl font-semibold tracking-tight leading-[1.05]">
100 − Votre cloud.<br />
101 − <span className="text-white/70">Vos règles.</span>
149 + Votre cloud.<br /><span className="text-white/70">Vos règles.</span>
102 150 </motion.h2>
103 151 <motion.p initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2, duration: 0.5 }} className="mt-4 text-white/70 text-[15px] leading-relaxed">
104 152 Un espace privé, chiffré et hébergé chez vous — avec l&apos;intelligence d&apos;un assistant qui connaît vos documents.
@@ -106,106 +154,119 @@ export default function LoginPage() {
106 154 <ul className="mt-8 space-y-3.5">
107 155 {features.map((f, i) => (
108 156 <motion.li key={f.title} initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.3 + i * 0.07, duration: 0.4 }} className="flex items-start gap-3">
109 − <span className="mt-0.5 w-8 h-8 rounded-xl bg-white/10 border border-white/15 flex items-center justify-center shrink-0 backdrop-blur">
110 − <f.icon className="w-4 h-4" />
111 − </span>
112 − <span>
113 − <span className="block text-[14px] font-medium">{f.title}</span>
114 − <span className="block text-[13px] text-white/60">{f.text}</span>
115 − </span>
157 + <span className="mt-0.5 w-8 h-8 rounded-xl bg-white/10 border border-white/15 flex items-center justify-center shrink-0 backdrop-blur"><f.icon className="w-4 h-4" /></span>
158 + <span><span className="block text-[14px] font-medium">{f.title}</span><span className="block text-[13px] text-white/60">{f.text}</span></span>
116 159 </motion.li>
117 160 ))}
118 161 </ul>
119 162 </div>
120 −
121 − <div className="relative flex items-center justify-between text-[12px] text-white/50">
122 − <span className="capitalize">{now}</span>
123 − <span>SPB Private Cloud · v3</span>
124 − </div>
163 + <div className="relative flex items-center justify-between text-[12px] text-white/50"><span className="capitalize">{now}</span><span>SPB Private Cloud · v3</span></div>
125 164 </aside>
126 165
127 166 {/* ===== Formulaire ===== */}
128 167 <main className="flex-1 flex items-center justify-center px-4 py-8 sm:px-8 relative">
129 − {/* Halo mobile */}
130 168 <div className="lg:hidden absolute inset-0 pointer-events-none bg-aurora opacity-60 dark:opacity-40" />
131 169 <div className="fixed top-3 right-3 sm:top-5 sm:right-5 z-10"><ThemeToggle /></div>
132 170
133 171 <motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.45 }} className="relative w-full max-w-[400px]">
134 − <div className="lg:hidden flex justify-center mb-6">
135 − <Logo size={56} />
136 − </div>
172 + <div className="lg:hidden flex justify-center mb-6"><Logo size={56} /></div>
137 173
138 174 <div className="mb-7 text-center lg:text-left">
139 − <h1 className="text-[26px] sm:text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
140 − {needs2FA ? "Vérification en deux étapes" : "Bon retour"}
141 − </h1>
142 − <p className="text-gray-500 dark:text-gray-400 mt-1.5 text-[14.5px]">
143 − {needs2FA ? "Entrez le code de votre application d'authentification." : "Connectez-vous à votre cloud personnel."}
144 − </p>
175 + <h1 className="text-[26px] sm:text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{titles[step][0]}</h1>
176 + <p className="text-gray-500 dark:text-gray-400 mt-1.5 text-[14.5px]">{titles[step][1]}</p>
145 177 </div>
146 178
147 179 <AnimatePresence mode="wait">
148 − {!needs2FA ? (
149 − <motion.form key="login-form" initial={{ opacity: 0, x: -16 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 16 }} transition={{ duration: 0.25 }} onSubmit={handleSubmit} className="space-y-3.5">
150 − <div className="relative">
151 − <User className="absolute left-4 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-gray-400 dark:text-gray-500" />
152 − <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Nom d'utilisateur" className={inputClasses} autoComplete="username" autoCapitalize="none" autoCorrect="off" />
153 − </div>
180 + {step === "email" && (
181 + <motion.form key="email" initial={{ opacity: 0, x: -16 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 16 }} transition={{ duration: 0.25 }} onSubmit={requestCode} className="space-y-3.5">
154 182 <div className="relative">
155 − <Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-gray-400 dark:text-gray-500" />
156 − <input type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mot de passe" className={inputClasses} autoComplete="current-password" />
157 − <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-2.5 top-1/2 -translate-y-1/2 w-9 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" aria-label={showPassword ? "Masquer" : "Afficher"}>
158 − {showPassword ? <EyeOff className="w-[18px] h-[18px]" /> : <Eye className="w-[18px] h-[18px]" />}
159 − </button>
183 + <Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-gray-400 dark:text-gray-500" />
184 + <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Adresse courriel" className={inputClasses} autoComplete="email" inputMode="email" autoCapitalize="none" autoCorrect="off" required autoFocus />
160 185 </div>
161 −
162 − <AnimatePresence>
163 − {error && (
164 − <motion.p initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="text-rose-600 dark:text-rose-400 text-[13px] px-1 flex items-center gap-1.5" role="alert">
165 − <Shield className="w-3.5 h-3.5" /> {error}
166 − </motion.p>
167 − )}
168 − </AnimatePresence>
169 −
170 − <motion.button whileTap={{ scale: 0.98 }} type="submit" disabled={loading || !password} className="group w-full h-12 rounded-2xl text-white font-medium bg-accent-gradient shadow-[inset_0_1px_0_rgb(255_255_255/.2)] hover:brightness-110 hover:shadow-glow transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none flex items-center justify-center gap-2 text-[15px]">
171 − {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <>Se connecter <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" /></>}
186 + <ErrorLine />
187 + <motion.button whileTap={{ scale: 0.98 }} type="submit" disabled={loading || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)} className={primaryBtn}>
188 + {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <>Recevoir un code <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" /></>}
172 189 </motion.button>
190 + <p className="text-center text-[12.5px] text-gray-400 pt-1">Un code à usage unique valide 10 minutes sera envoyé à cette adresse.</p>
191 + <button type="button" onClick={() => go("password")} className="w-full flex items-center justify-center gap-1.5 text-[13px] text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 py-2 transition-colors">
192 + <KeyRound className="w-3.5 h-3.5" /> Connexion par mot de passe
193 + </button>
173 194 </motion.form>
174 − ) : (
175 − <motion.form key="2fa-form" initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -16 }} transition={{ duration: 0.25 }} onSubmit={handleVerify2FA} className="space-y-4">
195 + )}
196 +
197 + {step === "code" && (
198 + <motion.form key="code" initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -16 }} transition={{ duration: 0.25 }} onSubmit={(e) => verifyCode(e)} className="space-y-4">
176 199 <motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.05, type: "spring", stiffness: 220 }} className="flex justify-center">
177 − <div className="w-16 h-16 rounded-3xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
178 − <ShieldCheck className="w-8 h-8 text-blue-600 dark:text-blue-300" />
179 − </div>
200 + <div className="w-16 h-16 rounded-3xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center"><Mail className="w-8 h-8 text-blue-600 dark:text-blue-300" /></div>
180 201 </motion.div>
181 202 <input
203 + ref={codeRef}
182 204 type="text"
183 205 inputMode="numeric"
184 206 pattern="[0-9]*"
185 − value={totpCode}
186 − onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
207 + value={code}
208 + onChange={(e) => { const v = e.target.value.replace(/\D/g, "").slice(0, 6); setCode(v); if (v.length === 6) verifyCode(undefined, v); }}
209 + onPaste={(e) => { const v = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6); if (v.length === 6) { e.preventDefault(); setCode(v); verifyCode(undefined, v); } }}
187 210 placeholder="000000"
188 211 maxLength={6}
189 212 className="w-full bg-white dark:bg-white/[0.05] border border-gray-200 dark:border-white/10 rounded-2xl h-14 text-gray-900 dark:text-white text-center text-[28px] tracking-[0.4em] font-mono focus:outline-none focus:border-blue-500/60 focus:ring-4 focus:ring-blue-500/10 transition-all shadow-card dark:shadow-none"
190 − autoFocus
191 213 autoComplete="one-time-code"
214 + aria-label="Code à 6 chiffres"
192 215 />
193 − <AnimatePresence>
194 − {error && <motion.p initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="text-rose-600 dark:text-rose-400 text-[13px] text-center" role="alert">{error}</motion.p>}
195 − </AnimatePresence>
196 − <motion.button whileTap={{ scale: 0.98 }} type="submit" disabled={loading || totpCode.length !== 6} className="w-full h-12 rounded-2xl text-white font-medium bg-accent-gradient hover:brightness-110 hover:shadow-glow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-[15px]">
197 − {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Vérifier"}
216 + <ErrorLine />
217 + <motion.button whileTap={{ scale: 0.98 }} type="submit" disabled={loading || code.length !== 6} className={primaryBtn}>
218 + {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Se connecter"}
219 + </motion.button>
220 + <div className="flex items-center justify-between text-[13px]">
221 + <button type="button" onClick={() => go("email")} className="text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 py-2 transition-colors">← Changer d&apos;adresse</button>
222 + <button type="button" disabled={cooldown > 0 || loading} onClick={() => requestCode()} className="flex items-center gap-1.5 text-blue-600 dark:text-blue-300 disabled:text-gray-400 py-2 transition-colors">
223 + <RefreshCw className="w-3.5 h-3.5" /> {cooldown > 0 ? `Renvoyer (${cooldown} s)` : "Renvoyer le code"}
224 + </button>
225 + </div>
226 + </motion.form>
227 + )}
228 +
229 + {step === "password" && (
230 + <motion.form key="password" initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -16 }} transition={{ duration: 0.25 }} onSubmit={handlePassword} className="space-y-3.5">
231 + <div className="relative">
232 + <User className="absolute left-4 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-gray-400 dark:text-gray-500" />
233 + <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Nom d'utilisateur ou courriel" className={inputClasses} autoComplete="username" autoCapitalize="none" autoCorrect="off" autoFocus />
234 + </div>
235 + <div className="relative">
236 + <Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-gray-400 dark:text-gray-500" />
237 + <input type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mot de passe" className={inputClasses} autoComplete="current-password" />
238 + <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-2.5 top-1/2 -translate-y-1/2 w-9 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" aria-label={showPassword ? "Masquer" : "Afficher"}>
239 + {showPassword ? <EyeOff className="w-[18px] h-[18px]" /> : <Eye className="w-[18px] h-[18px]" />}
240 + </button>
241 + </div>
242 + <ErrorLine />
243 + <motion.button whileTap={{ scale: 0.98 }} type="submit" disabled={loading || !password} className={primaryBtn}>
244 + {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <>Se connecter <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" /></>}
198 245 </motion.button>
199 − <button type="button" onClick={() => { setNeeds2FA(false); setTotpCode(""); setError(""); }} className="w-full text-[13px] text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 py-2 transition-colors">
200 − ← Retour
246 + <button type="button" onClick={() => go("email")} className="w-full flex items-center justify-center gap-1.5 text-[13px] text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 py-2 transition-colors">
247 + <Mail className="w-3.5 h-3.5" /> Recevoir un code par courriel
201 248 </button>
202 249 </motion.form>
203 250 )}
251 +
252 + {step === "2fa" && (
253 + <motion.form key="2fa" initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -16 }} transition={{ duration: 0.25 }} onSubmit={handleVerify2FA} className="space-y-4">
254 + <motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.05, type: "spring", stiffness: 220 }} className="flex justify-center">
255 + <div className="w-16 h-16 rounded-3xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center"><ShieldCheck className="w-8 h-8 text-blue-600 dark:text-blue-300" /></div>
256 + </motion.div>
257 + <input type="text" inputMode="numeric" pattern="[0-9]*" value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="000000" maxLength={6} className="w-full bg-white dark:bg-white/[0.05] border border-gray-200 dark:border-white/10 rounded-2xl h-14 text-gray-900 dark:text-white text-center text-[28px] tracking-[0.4em] font-mono focus:outline-none focus:border-blue-500/60 focus:ring-4 focus:ring-blue-500/10 transition-all shadow-card dark:shadow-none" autoFocus autoComplete="one-time-code" />
258 + <ErrorLine />
259 + <motion.button whileTap={{ scale: 0.98 }} type="submit" disabled={loading || totpCode.length !== 6} className={primaryBtn}>
260 + {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Vérifier"}
261 + </motion.button>
262 + <button type="button" onClick={() => { go("password"); setTotpCode(""); }} className="w-full text-[13px] text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 py-2 transition-colors">← Retour</button>
263 + </motion.form>
264 + )}
204 265 </AnimatePresence>
205 266
206 267 <div className="mt-8 flex flex-col items-center gap-1.5 text-[12px] text-gray-400 dark:text-gray-500">
207 268 <span className="flex items-center gap-1.5"><Shield className="w-3 h-3" /> Connexions analysées par IA · appareils de confiance</span>
208 − <span>SPB Private Cloud · v3.0</span>
269 + <span>SPB Private Cloud · v3.1</span>
209 270 </div>
210 271 </motion.div>
211 272 </main>
modified app/shared/[token]/page.tsx +221 −370
@@ -4,87 +4,63 @@ import { useEffect, useState, useCallback } from "react";
4 4 import { useParams } from "next/navigation";
5 5 import { motion } from "framer-motion";
6 6 import {
7 − Download,
8 − Lock,
9 − AlertCircle,
10 − Loader2,
11 − FileText,
12 − Image as ImageIcon,
13 − Film,
14 − Music,
15 − FileCode,
16 − File as FileIcon,
17 − ZoomIn,
18 − ZoomOut,
19 − Clock,
7 + Download, Lock, AlertCircle, Loader2, FileText, Image as ImageIcon, Film, Music, FileCode, File as FileIcon,
8 + ZoomIn, ZoomOut, Clock, Folder, ChevronRight, Home, Eye, FileSpreadsheet, Archive,
20 9 } from "lucide-react";
21 10 import Button from "@/components/ui/Button";
22 11 import Logo from "@/components/ui/Logo";
23 12 import ThemeToggle from "@/components/theme/ThemeToggle";
24 13
25 −interface FileInfo {
26 − id: string;
27 − name: string;
28 − mimeType: string;
29 − size: number;
30 − mode: string;
31 − expiresAt: string | null;
14 +interface FileInfo { id: string; name: string; mimeType: string; size: number; mode: string; expiresAt: string | null }
15 +interface FolderListing {
16 + id: string; name: string; mode: string; expiresAt: string | null;
17 + folder: { id: string; name: string };
18 + crumbs: { id: string; name: string }[];
19 + folders: { id: string; name: string; files: number; children: number }[];
20 + files: { id: string; name: string; mimeType: string; size: number; updatedAt: string }[];
32 21 }
33 22
34 23 function formatSize(bytes: number): string {
35 − if (bytes === 0) return "0 o";
24 + if (!bytes) return "0 o";
36 25 const k = 1024;
37 26 const sizes = ["o", "Ko", "Mo", "Go", "To"];
38 − const i = Math.floor(Math.log(bytes) / Math.log(k));
27 + const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
39 28 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
40 29 }
41 30
42 31 function formatExpiry(expiresAt: string | null): string | null {
43 32 if (!expiresAt) return null;
44 − const date = new Date(expiresAt);
45 − const now = new Date();
46 − const diff = date.getTime() - now.getTime();
33 + const diff = new Date(expiresAt).getTime() - Date.now();
47 34 if (diff <= 0) return "Expiré";
48 − const hours = Math.floor(diff / (1000 * 60 * 60));
35 + const hours = Math.floor(diff / 3600000);
49 36 const days = Math.floor(hours / 24);
50 − if (days > 0) return `Expire dans ${days}j`;
51 − if (hours > 0) return `Expire dans ${hours}h`;
52 − const minutes = Math.floor(diff / (1000 * 60));
53 − return `Expire dans ${minutes}min`;
37 + if (days > 0) return `Expire dans ${days} j`;
38 + if (hours > 0) return `Expire dans ${hours} h`;
39 + return `Expire dans ${Math.floor(diff / 60000)} min`;
54 40 }
55 41
56 −function getFileCategory(mimeType: string): string {
42 +type Category = "image" | "pdf" | "video" | "audio" | "text" | "spreadsheet" | "archive" | "other";
43 +function getFileCategory(mimeType: string): Category {
57 44 if (mimeType.startsWith("image/")) return "image";
58 45 if (mimeType === "application/pdf") return "pdf";
59 46 if (mimeType.startsWith("video/")) return "video";
60 47 if (mimeType.startsWith("audio/")) return "audio";
61 − if (
62 − mimeType.startsWith("text/") ||
63 − mimeType === "application/json" ||
64 − mimeType === "application/xml" ||
65 − mimeType === "application/javascript" ||
66 − mimeType === "application/x-yaml" ||
67 − mimeType === "application/x-sh"
68 − )
69 − return "text";
70 − if (mimeType.includes("markdown")) return "text";
48 + if (mimeType.includes("spreadsheet") || mimeType === "text/csv" || mimeType.includes("excel")) return "spreadsheet";
49 + if (mimeType.includes("zip") || mimeType.includes("compressed") || mimeType.includes("tar")) return "archive";
50 + if (mimeType.startsWith("text/") || /json|xml|javascript|yaml|x-sh|markdown/.test(mimeType)) return "text";
71 51 return "other";
72 52 }
73 53
74 −function getFileIcon(category: string) {
54 +function CategoryIcon({ category, className = "w-12 h-12" }: { category: Category; className?: string }) {
75 55 switch (category) {
76 − case "image":
77 − return <ImageIcon className="w-12 h-12 text-emerald-500" />;
78 − case "pdf":
79 − return <FileText className="w-12 h-12 text-rose-500" />;
80 − case "video":
81 − return <Film className="w-12 h-12 text-purple-500" />;
82 − case "audio":
83 − return <Music className="w-12 h-12 text-pink-500" />;
84 − case "text":
85 − return <FileCode className="w-12 h-12 text-amber-500" />;
86 − default:
87 − return <FileIcon className="w-12 h-12 text-gray-400" />;
56 + case "image": return <ImageIcon className={`${className} text-emerald-500`} />;
57 + case "pdf": return <FileText className={`${className} text-rose-500`} />;
58 + case "video": return <Film className={`${className} text-purple-500`} />;
59 + case "audio": return <Music className={`${className} text-pink-500`} />;
60 + case "text": return <FileCode className={`${className} text-amber-500`} />;
61 + case "spreadsheet": return <FileSpreadsheet className={`${className} text-green-600`} />;
62 + case "archive": return <Archive className={`${className} text-orange-500`} />;
63 + default: return <FileIcon className={`${className} text-gray-400`} />;
88 64 }
89 65 }
90 66
@@ -92,169 +68,100 @@ export default function SharedPage() {
92 68 const params = useParams();
93 69 const token = params.token as string;
94 70
95 − const [state, setState] = useState<
96 − "loading" | "password" | "preview" | "download" | "error"
97 − >("loading");
71 + const [state, setState] = useState<"loading" | "password" | "preview" | "download" | "folder" | "error">("loading");
98 72 const [error, setError] = useState("");
99 73 const [errorType, setErrorType] = useState<"expired" | "invalid" | "network">("invalid");
100 74 const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);
75 + const [listing, setListing] = useState<FolderListing | null>(null);
76 + const [protectedName, setProtectedName] = useState("");
77 + const [protectedType, setProtectedType] = useState<"file" | "folder">("file");
101 78 const [password, setPassword] = useState("");
102 79 const [passwordLoading, setPasswordLoading] = useState(false);
103 − const [passwordVerified, setPasswordVerified] = useState(false);
104 80 const [textContent, setTextContent] = useState<string | null>(null);
105 81 const [imageZoom, setImageZoom] = useState(1);
82 + const [currentFolder, setCurrentFolder] = useState<string | null>(null);
83 + const [folderLoading, setFolderLoading] = useState(false);
106 84
107 − const contentUrl = useCallback(
108 − (forDownload?: boolean) => {
109 − const base = `/api/shared/${token}?content=true`;
110 − return forDownload ? `${base}&action=download` : base;
111 − },
112 − [token]
113 − );
114 −
115 − // For password-protected files, we need to fetch content via POST
116 − const fetchProtectedContent = useCallback(
117 − async (pw: string, asBlob?: boolean) => {
118 − const res = await fetch(`/api/shared/${token}/verify`, {
119 − method: "POST",
120 − headers: { "Content-Type": "application/json" },
121 − body: JSON.stringify({ password: pw, contentOnly: true }),
122 − });
123 − if (!res.ok) throw new Error("Erreur de chargement");
124 − if (asBlob) return res.blob();
125 − return res.text();
126 − },
127 − [token]
128 − );
129 −
130 − // Load text content for text/code/markdown previews
131 − useEffect(() => {
132 − if (!fileInfo || state !== "preview") return;
133 − const category = getFileCategory(fileInfo.mimeType);
134 − if (category !== "text") return;
85 + const base = `/api/shared/${token}`;
86 + const fileContentUrl = (download?: boolean, fileId?: string) =>
87 + `${base}?content=true${fileId ? `&fileId=${fileId}` : ""}${download ? "&action=download" : ""}`;
88 + const zipUrl = (folderId?: string | null) => `${base}?action=download${folderId ? `&folderId=${folderId}` : ""}`;
135 89
136 − if (passwordVerified) {
137 − fetchProtectedContent(password).then((text) => {
138 − setTextContent(text as string);
139 − }).catch(() => {
140 − setTextContent("Impossible de charger le contenu.");
141 − });
142 − } else {
143 − fetch(contentUrl())
144 − .then((res) => res.text())
145 − .then(setTextContent)
146 − .catch(() => setTextContent("Impossible de charger le contenu."));
90 + /** Charge l'état du partage (le cookie posé après vérification du mot de passe suffit). */
91 + const load = useCallback(async (folderId?: string | null) => {
92 + try {
93 + const res = await fetch(`${base}${folderId ? `?folderId=${folderId}` : ""}`, { cache: "no-store" });
94 + if (!res.ok) {
95 + const data = await res.json().catch(() => ({}));
96 + setErrorType(res.status === 410 ? "expired" : "invalid");
97 + setError(data.error || (res.status === 410 ? "Ce lien a expiré" : "Lien invalide"));
98 + setState("error");
99 + return;
100 + }
101 + const data = await res.json();
102 + if (data.requiresPassword) {
103 + setProtectedName(data.name || data.fileName || "");
104 + setProtectedType(data.type === "folder" ? "folder" : "file");
105 + setState("password");
106 + return;
107 + }
108 + if (data.type === "folder") {
109 + setListing(data);
110 + setCurrentFolder(data.folder.id);
111 + setState("folder");
112 + return;
113 + }
114 + setFileInfo(data);
115 + setState(data.mode === "DOWNLOAD" ? "download" : "preview");
116 + } catch {
117 + setErrorType("network");
118 + setError("Erreur réseau");
119 + setState("error");
147 120 }
148 − }, [fileInfo, state, contentUrl, passwordVerified, password, fetchProtectedContent]);
121 + }, [base]);
149 122
150 − // Initial fetch
151 − useEffect(() => {
152 − fetch(`/api/shared/${token}`)
153 − .then(async (res) => {
154 − if (!res.ok) {
155 − const data = await res.json();
156 − if (res.status === 410) {
157 − setErrorType("expired");
158 − setError(data.error || "Ce lien a expiré");
159 − } else {
160 − setErrorType("invalid");
161 − setError(data.error || "Lien invalide");
162 − }
163 − setState("error");
164 − return;
165 − }
123 + useEffect(() => { load(); }, [load]);
166 124
167 − const data = await res.json();
168 −
169 − if (data.requiresPassword) {
170 − setFileInfo({
171 − id: "",
172 − name: data.fileName,
173 − mimeType: data.fileType,
174 − size: 0,
175 − mode: data.mode,
176 − expiresAt: null,
177 − });
178 − setState("password");
179 − return;
180 − }
181 −
182 − // Got file info JSON
183 − setFileInfo(data);
184 − if (data.mode === "DOWNLOAD") {
185 − setState("download");
186 − } else {
187 − setState("preview");
188 − }
189 − })
190 − .catch(() => {
191 − setErrorType("network");
192 − setError("Erreur réseau");
193 − setState("error");
194 − });
195 − }, [token]);
125 + // Texte : chargement du contenu pour l'aperçu
126 + useEffect(() => {
127 + if (!fileInfo || state !== "preview" || getFileCategory(fileInfo.mimeType) !== "text") return;
128 + fetch(fileContentUrl()).then((r) => r.text()).then(setTextContent).catch(() => setTextContent("Impossible de charger le contenu."));
129 + // eslint-disable-next-line react-hooks/exhaustive-deps
130 + }, [fileInfo, state]);
196 131
197 132 const handlePasswordSubmit = async (e: React.FormEvent) => {
198 133 e.preventDefault();
199 134 setPasswordLoading(true);
200 135 setError("");
201 136 try {
202 − const res = await fetch(`/api/shared/${token}/verify`, {
203 − method: "POST",
204 − headers: { "Content-Type": "application/json" },
205 − body: JSON.stringify({ password }),
206 − });
207 −
208 − if (!res.ok) {
209 − const data = await res.json();
210 − setError(data.error || "Mot de passe incorrect");
211 − setPasswordLoading(false);
212 − return;
213 − }
214 −
215 − const data = await res.json();
216 − setPasswordVerified(true);
217 − setFileInfo({
218 − id: data.id,
219 − name: data.name,
220 − mimeType: data.mimeType,
221 − size: data.size,
222 − mode: data.mode,
223 − expiresAt: data.expiresAt,
224 − });
225 −
226 − if (data.mode === "DOWNLOAD") {
227 − setState("download");
228 − } else {
229 − setState("preview");
230 − }
231 − setPasswordLoading(false);
137 + const res = await fetch(`${base}/verify`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
138 + const data = await res.json().catch(() => ({}));
139 + if (!res.ok) { setError(data.error || "Mot de passe incorrect"); return; }
140 + // Le cookie signé est posé : on recharge normalement
141 + setState("loading");
142 + await load();
232 143 } catch {
233 144 setError("Erreur réseau");
145 + } finally {
234 146 setPasswordLoading(false);
235 147 }
236 148 };
237 149
238 − const handleDownload = async () => {
239 − if (passwordVerified) {
240 − // Fetch via POST for password-protected files
241 − try {
242 − const blob = await fetchProtectedContent(password, true) as Blob;
243 − const url = URL.createObjectURL(blob);
244 − const a = document.createElement("a");
245 − a.href = url;
246 − a.download = fileInfo?.name || "file";
247 − a.click();
248 − URL.revokeObjectURL(url);
249 − } catch {
250 − setError("Erreur lors du téléchargement");
150 + const openFolder = async (folderId: string) => {
151 + setFolderLoading(true);
152 + try {
153 + const res = await fetch(`${base}?folderId=${folderId}`, { cache: "no-store" });
154 + if (res.ok) {
155 + const data = await res.json();
156 + if (data.type === "folder") { setListing(data); setCurrentFolder(data.folder.id); }
251 157 }
252 − } else {
253 − window.open(contentUrl(true), "_blank");
158 + } finally {
159 + setFolderLoading(false);
254 160 }
255 161 };
256 162
257 − // Loading state
163 + const handleDownload = () => window.open(fileContentUrl(true), "_blank");
164 +
258 165 if (state === "loading") {
259 166 return (
260 167 <Shell centered>
@@ -267,7 +174,6 @@ export default function SharedPage() {
267 174 );
268 175 }
269 176
270 − // Error state
271 177 if (state === "error") {
272 178 return (
273 179 <Shell centered>
@@ -276,22 +182,15 @@ export default function SharedPage() {
276 182 {errorType === "expired" ? <Clock className="w-8 h-8" /> : <AlertCircle className="w-8 h-8" />}
277 183 </div>
278 184 <h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
279 − {errorType === "expired"
280 − ? "Lien expiré"
281 − : errorType === "network"
282 − ? "Erreur de connexion"
283 − : "Lien invalide"}
185 + {errorType === "expired" ? "Lien expiré" : errorType === "network" ? "Erreur de connexion" : "Lien invalide"}
284 186 </h1>
285 187 <p className="text-gray-500 dark:text-gray-400 text-sm">{error}</p>
286 − {errorType === "expired" && (
287 − <p className="text-gray-400 text-xs mt-3">Demandez un nouveau lien au propriétaire du fichier.</p>
288 − )}
188 + {errorType === "expired" && <p className="text-gray-400 text-xs mt-3">Demandez un nouveau lien au propriétaire.</p>}
289 189 </motion.div>
290 190 </Shell>
291 191 );
292 192 }
293 193
294 − // Password state
295 194 if (state === "password") {
296 195 return (
297 196 <Shell>
@@ -303,49 +202,120 @@ export default function SharedPage() {
303 202 <div className="w-16 h-16 rounded-3xl bg-blue-500/10 text-blue-600 dark:text-blue-300 flex items-center justify-center mx-auto mb-4">
304 203 <Lock className="w-8 h-8" />
305 204 </div>
306 − <h1 className="text-lg font-semibold text-gray-900 dark:text-white">Fichier protégé</h1>
307 − <p className="text-sm text-gray-500 mt-1 truncate max-w-[280px] mx-auto">{fileInfo?.name}</p>
205 + <h1 className="text-lg font-semibold text-gray-900 dark:text-white">{protectedType === "folder" ? "Dossier protégé" : "Fichier protégé"}</h1>
206 + <p className="text-sm text-gray-500 mt-1 truncate max-w-[280px] mx-auto">{protectedName}</p>
308 207 </div>
309 −
310 208 <form onSubmit={handlePasswordSubmit} className="space-y-3">
311 − <input
312 − type="password"
313 − value={password}
314 − onChange={(e) => { setPassword(e.target.value); setError(""); }}
315 − placeholder="Mot de passe du partage"
316 − className="field h-12 text-center"
317 − autoFocus
318 − />
209 + <input type="password" value={password} onChange={(e) => { setPassword(e.target.value); setError(""); }} placeholder="Mot de passe du partage" className="field h-12 text-center" autoFocus />
319 210 {error && <p className="text-rose-500 text-sm text-center" role="alert">{error}</p>}
320 − <Button type="submit" variant="accent" size="lg" className="w-full" loading={passwordLoading} disabled={!password}>
321 − Accéder
322 − </Button>
211 + <Button type="submit" variant="accent" size="lg" className="w-full" loading={passwordLoading} disabled={!password}>Accéder</Button>
323 212 </form>
324 213 </div>
325 214 </motion.div>
326 215 </div>
327 − <Footer expiresAt={fileInfo?.expiresAt || null} />
216 + <Footer expiresAt={null} />
217 + </Shell>
218 + );
219 + }
220 +
221 + /* ================= DOSSIER PARTAGÉ ================= */
222 + if (state === "folder" && listing) {
223 + const isRoot = currentFolder === listing.id;
224 + const previewMode = listing.mode === "PREVIEW";
225 + return (
226 + <Shell>
227 + <Header
228 + title={listing.folder.name}
229 + subtitle={`${listing.files.length} fichier${listing.files.length !== 1 ? "s" : ""}${listing.folders.length ? ` · ${listing.folders.length} sous-dossier${listing.folders.length !== 1 ? "s" : ""}` : ""}`}
230 + isFolder
231 + onDownload={() => window.open(zipUrl(isRoot ? null : currentFolder), "_blank")}
232 + downloadLabel="Télécharger en ZIP"
233 + />
234 + <main className="flex-1 w-full max-w-5xl mx-auto px-3 sm:px-6 py-4 sm:py-6">
235 + {/* Fil d'Ariane borné à la racine du partage */}
236 + <nav className="flex items-center gap-1 text-sm mb-4 overflow-x-auto scrollbar-none whitespace-nowrap" aria-label="Fil d'Ariane">
237 + {listing.crumbs.map((c, i) => {
238 + const last = i === listing.crumbs.length - 1;
239 + return (
240 + <span key={c.id} className="flex items-center gap-1">
241 + {i > 0 && <ChevronRight className="w-3.5 h-3.5 text-gray-300 dark:text-gray-600" />}
242 + <button
243 + onClick={() => !last && openFolder(c.id)}
244 + className={`flex items-center gap-1.5 px-2 py-1 rounded-lg transition-colors ${last ? "font-semibold text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5"}`}
245 + disabled={last}
246 + >
247 + {i === 0 && <Home className="w-4 h-4" />}
248 + {c.name}
249 + </button>
250 + </span>
251 + );
252 + })}
253 + </nav>
254 +
255 + <motion.div key={currentFolder} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className={`card overflow-hidden ${folderLoading ? "opacity-60" : ""}`}>
256 + {listing.folders.length === 0 && listing.files.length === 0 ? (
257 + <div className="py-16 text-center text-gray-500">
258 + <Folder className="w-10 h-10 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
259 + <p className="text-sm">Ce dossier est vide.</p>
260 + </div>
261 + ) : (
262 + <ul className="divide-y divide-line dark:divide-white/[0.06]">
263 + {listing.folders.map((f) => (
264 + <li key={f.id}>
265 + <button onClick={() => openFolder(f.id)} className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors">
266 + <span className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0"><Folder className="w-[18px] h-[18px] text-blue-500 fill-blue-500/20" /></span>
267 + <span className="flex-1 min-w-0">
268 + <span className="block text-[14px] font-medium text-gray-900 dark:text-white truncate">{f.name}</span>
269 + <span className="block text-[12px] text-gray-500">{f.files} fichier{f.files !== 1 ? "s" : ""}{f.children ? ` · ${f.children} sous-dossier${f.children !== 1 ? "s" : ""}` : ""}</span>
270 + </span>
271 + <a href={zipUrl(f.id)} onClick={(e) => e.stopPropagation()} className="hidden sm:inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10" title="Télécharger ce dossier en ZIP" aria-label="ZIP">
272 + <Download className="w-4 h-4" />
273 + </a>
274 + <ChevronRight className="w-4 h-4 text-gray-300 dark:text-gray-600" />
275 + </button>
276 + </li>
277 + ))}
278 + {listing.files.map((f) => {
279 + const cat = getFileCategory(f.mimeType);
280 + return (
281 + <li key={f.id} className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors">
282 + <span className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center shrink-0"><CategoryIcon category={cat} className="w-[18px] h-[18px]" /></span>
283 + <span className="flex-1 min-w-0">
284 + <span className="block text-[14px] text-gray-900 dark:text-white truncate">{f.name}</span>
285 + <span className="block text-[12px] text-gray-500 tabular-nums">{formatSize(f.size)}</span>
286 + </span>
287 + {previewMode && ["image", "pdf", "video", "audio", "text"].includes(cat) && (
288 + <a href={fileContentUrl(false, f.id)} target="_blank" rel="noopener" className="inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-500/10" title="Aperçu" aria-label="Aperçu">
289 + <Eye className="w-4 h-4" />
290 + </a>
291 + )}
292 + <a href={fileContentUrl(true, f.id)} className="inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10" title="Télécharger" aria-label="Télécharger">
293 + <Download className="w-4 h-4" />
294 + </a>
295 + </li>
296 + );
297 + })}
298 + </ul>
299 + )}
300 + </motion.div>
301 + </main>
302 + <Footer expiresAt={listing.expiresAt} />
328 303 </Shell>
329 304 );
330 305 }
331 306
332 − // Download-only mode
307 + /* ================= FICHIER : téléchargement seul ================= */
333 308 if (state === "download") {
334 309 const category = fileInfo ? getFileCategory(fileInfo.mimeType) : "other";
335 310 return (
336 311 <Shell>
337 − <Header fileName={fileInfo?.name} fileSize={fileInfo?.size} onDownload={handleDownload} />
312 + <Header title={fileInfo?.name} subtitle={fileInfo ? formatSize(fileInfo.size) : undefined} onDownload={handleDownload} />
338 313 <div className="flex-1 flex items-center justify-center p-4">
339 314 <motion.div initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} className="card p-8 sm:p-10 text-center max-w-md w-full">
340 − <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6">
341 − {getFileIcon(category)}
342 − </div>
315 + <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6"><CategoryIcon category={category} /></div>
343 316 <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 break-words">{fileInfo?.name}</h2>
344 317 <p className="text-sm text-gray-500 mb-6 tabular-nums">{fileInfo ? formatSize(fileInfo.size) : ""}</p>
345 − <Button onClick={handleDownload} variant="accent" size="lg" className="w-full sm:w-auto">
346 − <Download className="w-5 h-5" />
347 − Télécharger
348 − </Button>
318 + <Button onClick={handleDownload} variant="accent" size="lg" className="w-full sm:w-auto"><Download className="w-5 h-5" /> Télécharger</Button>
349 319 </motion.div>
350 320 </div>
351 321 <Footer expiresAt={fileInfo?.expiresAt || null} />
@@ -353,135 +323,66 @@ export default function SharedPage() {
353 323 );
354 324 }
355 325
356 − // Preview mode
326 + /* ================= FICHIER : aperçu ================= */
357 327 const category = fileInfo ? getFileCategory(fileInfo.mimeType) : "other";
358 −
359 328 return (
360 329 <Shell>
361 − <Header fileName={fileInfo?.name} fileSize={fileInfo?.size} onDownload={handleDownload} />
362 −
330 + <Header title={fileInfo?.name} subtitle={fileInfo ? formatSize(fileInfo.size) : undefined} onDownload={handleDownload} />
363 331 <main className="flex-1 flex items-center justify-center p-3 sm:p-6 overflow-auto">
364 − <motion.div
365 − initial={{ opacity: 0 }}
366 − animate={{ opacity: 1 }}
367 − transition={{ duration: 0.3 }}
368 − className="w-full h-full flex items-center justify-center"
369 − >
332 + <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="w-full h-full flex items-center justify-center">
370 333 {category === "image" && fileInfo && (
371 334 <div className="relative flex flex-col items-center gap-3 max-w-full">
372 335 <div className="overflow-auto max-h-[75vh] max-w-full rounded-2xl shadow-pop">
373 − {passwordVerified ? (
374 − <ProtectedImage
375 − token={token}
376 − password={password}
377 − alt={fileInfo.name}
378 − zoom={imageZoom}
379 − fetchContent={fetchProtectedContent}
380 − />
381 − ) : (
382 − <img
383 − src={contentUrl()}
384 − alt={fileInfo.name}
385 − className="max-w-full object-contain rounded-2xl transition-transform duration-200"
386 − style={{ transform: `scale(${imageZoom})`, transformOrigin: "center" }}
387 − />
388 − )}
336 + <img src={fileContentUrl()} alt={fileInfo.name} className="max-w-full object-contain rounded-2xl transition-transform duration-200" style={{ transform: `scale(${imageZoom})`, transformOrigin: "center" }} />
389 337 </div>
390 338 <div className="flex items-center gap-1 glass rounded-full px-1.5 py-1">
391 − <button onClick={() => setImageZoom((z) => Math.max(0.25, z - 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors" aria-label="Réduire">
392 − <ZoomOut className="w-4 h-4" />
393 − </button>
339 + <button onClick={() => setImageZoom((z) => Math.max(0.25, z - 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10" aria-label="Réduire"><ZoomOut className="w-4 h-4" /></button>
394 340 <span className="text-xs text-gray-600 dark:text-gray-300 min-w-[3rem] text-center tabular-nums">{Math.round(imageZoom * 100)}%</span>
395 − <button onClick={() => setImageZoom((z) => Math.min(4, z + 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors" aria-label="Agrandir">
396 − <ZoomIn className="w-4 h-4" />
397 − </button>
341 + <button onClick={() => setImageZoom((z) => Math.min(4, z + 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10" aria-label="Agrandir"><ZoomIn className="w-4 h-4" /></button>
398 342 </div>
399 343 </div>
400 344 )}
401 −
402 − {category === "pdf" && fileInfo && (
403 − <iframe
404 − src={passwordVerified ? undefined : contentUrl()}
405 − className="w-full h-[80vh] border-0 rounded-2xl bg-white shadow-pop"
406 − title={fileInfo.name}
407 − />
408 − )}
409 −
345 + {category === "pdf" && fileInfo && <iframe src={fileContentUrl()} className="w-full h-[80vh] border-0 rounded-2xl bg-white shadow-pop" title={fileInfo.name} />}
410 346 {category === "video" && fileInfo && (
411 − <video
412 − controls
413 − className="max-w-full max-h-[75vh] rounded-2xl shadow-pop bg-black"
414 − preload="metadata"
415 − >
416 − {!passwordVerified && (
417 − <source src={contentUrl()} type={fileInfo.mimeType} />
418 − )}
347 + <video controls className="max-w-full max-h-[75vh] rounded-2xl shadow-pop bg-black" preload="metadata">
348 + <source src={fileContentUrl()} type={fileInfo.mimeType} />
419 349 Votre navigateur ne supporte pas la lecture vidéo.
420 350 </video>
421 351 )}
422 −
423 352 {category === "audio" && fileInfo && (
424 353 <div className="w-full max-w-lg">
425 354 <div className="card p-8 text-center">
426 − <div className="w-20 h-20 rounded-3xl bg-pink-500/10 flex items-center justify-center mx-auto mb-6">
427 − <Music className="w-10 h-10 text-pink-500" />
428 − </div>
429 − <h3 className="text-gray-900 dark:text-white font-medium mb-4 truncate">
430 − {fileInfo.name}
431 − </h3>
432 − <audio
433 − controls
434 − className="w-full"
435 − preload="metadata"
436 − >
437 − {!passwordVerified && (
438 − <source src={contentUrl()} type={fileInfo.mimeType} />
439 − )}
440 − Votre navigateur ne supporte pas la lecture audio.
441 − </audio>
355 + <div className="w-20 h-20 rounded-3xl bg-pink-500/10 flex items-center justify-center mx-auto mb-6"><Music className="w-10 h-10 text-pink-500" /></div>
356 + <h3 className="text-gray-900 dark:text-white font-medium mb-4 truncate">{fileInfo.name}</h3>
357 + <audio controls className="w-full" preload="metadata"><source src={fileContentUrl()} type={fileInfo.mimeType} />Votre navigateur ne supporte pas la lecture audio.</audio>
442 358 </div>
443 359 </div>
444 360 )}
445 −
446 361 {category === "text" && (
447 362 <div className="w-full max-w-4xl max-h-[75vh] overflow-auto">
448 363 <div className="card p-4 sm:p-6">
449 − {textContent === null ? (
450 − <div className="flex items-center justify-center py-12">
451 − <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
452 − </div>
453 − ) : (
454 − <pre className="text-[13px] text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words font-mono leading-relaxed">
455 − {textContent}
456 − </pre>
457 − )}
364 + {textContent === null ? <div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 text-blue-500 animate-spin" /></div>
365 + : <pre className="text-[13px] text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words font-mono leading-relaxed">{textContent}</pre>}
458 366 </div>
459 367 </div>
460 368 )}
461 −
462 − {category === "other" && fileInfo && (
369 + {(category === "other" || category === "spreadsheet" || category === "archive") && fileInfo && (
463 370 <div className="card p-8 sm:p-10 text-center max-w-md w-full">
464 − <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6">
465 − {getFileIcon(category)}
466 − </div>
371 + <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6"><CategoryIcon category={category} /></div>
467 372 <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 break-words">{fileInfo.name}</h2>
468 373 <p className="text-sm text-gray-500 mb-2 tabular-nums">{formatSize(fileInfo.size)}</p>
469 374 <p className="text-xs text-gray-400 mb-6">Aperçu non disponible pour ce type de fichier.</p>
470 − <Button onClick={handleDownload} variant="accent">
471 − <Download className="w-4 h-4" />
472 − Télécharger
473 − </Button>
375 + <Button onClick={handleDownload} variant="accent"><Download className="w-4 h-4" /> Télécharger</Button>
474 376 </div>
475 377 )}
476 378 </motion.div>
477 379 </main>
478 −
479 380 <Footer expiresAt={fileInfo?.expiresAt || null} />
480 381 </Shell>
481 382 );
482 383 }
483 384
484 −// --- Sub-components ---
385 +// --- Sous-composants ---
485 386
486 387 function Shell({ children, centered }: { children: React.ReactNode; centered?: boolean }) {
487 388 return (
@@ -499,24 +400,19 @@ function Shell({ children, centered }: { children: React.ReactNode; centered?: b
499 400 );
500 401 }
501 402
502 −function Header({
503 − fileName,
504 − fileSize,
505 − onDownload,
506 −}: {
507 − fileName?: string;
508 − fileSize?: number;
509 − onDownload?: () => void;
510 −}) {
403 +function Header({ title, subtitle, isFolder, onDownload, downloadLabel = "Télécharger" }: { title?: string; subtitle?: string; isFolder?: boolean; onDownload?: () => void; downloadLabel?: string }) {
511 404 return (
512 405 <header className="sticky top-0 z-10 glass border-x-0 border-t-0 rounded-none">
513 406 <div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-4 h-16">
514 407 <div className="flex items-center gap-3 min-w-0">
515 − <Logo size={34} wordmark={!fileName} wordmarkClassName="text-[15px]" />
516 − {fileName && (
517 − <div className="min-w-0 border-l border-line dark:border-white/10 pl-3">
518 − <p className="text-[14px] text-gray-900 dark:text-white font-medium truncate max-w-[200px] sm:max-w-[420px]">{fileName}</p>
519 − {fileSize !== undefined && fileSize > 0 && <p className="text-[11.5px] text-gray-500 tabular-nums">{formatSize(fileSize)}</p>}
408 + <Logo size={34} wordmark={!title} wordmarkClassName="text-[15px]" />
409 + {title && (
410 + <div className="min-w-0 border-l border-line dark:border-white/10 pl-3 flex items-center gap-2">
411 + {isFolder && <Folder className="w-5 h-5 text-blue-500 fill-blue-500/20 shrink-0" />}
412 + <div className="min-w-0">
413 + <p className="text-[14px] text-gray-900 dark:text-white font-medium truncate max-w-[200px] sm:max-w-[420px]">{title}</p>
414 + {subtitle && <p className="text-[11.5px] text-gray-500 tabular-nums">{subtitle}</p>}
415 + </div>
520 416 </div>
521 417 )}
522 418 </div>
@@ -525,7 +421,7 @@ function Header({
525 421 {onDownload && (
526 422 <Button size="sm" variant="accent" onClick={onDownload}>
527 423 <Download className="w-4 h-4" />
528 − <span className="hidden sm:inline">Télécharger</span>
424 + <span className="hidden sm:inline">{downloadLabel}</span>
529 425 </Button>
530 426 )}
531 427 </div>
@@ -549,48 +445,3 @@ function Footer({ expiresAt }: { expiresAt: string | null }) {
549 445 </footer>
550 446 );
551 447 }
552 −
553 −function ProtectedImage({
554 − token,
555 − password,
556 − alt,
557 − zoom,
558 − fetchContent,
559 −}: {
560 − token: string;
561 − password: string;
562 − alt: string;
563 − zoom: number;
564 − fetchContent: (pw: string, asBlob?: boolean) => Promise<Blob | string>;
565 −}) {
566 − const [src, setSrc] = useState<string | null>(null);
567 −
568 − useEffect(() => {
569 − let revoke = "";
570 − fetchContent(password, true).then((blob) => {
571 − const url = URL.createObjectURL(blob as Blob);
572 − revoke = url;
573 − setSrc(url);
574 − });
575 − return () => {
576 − if (revoke) URL.revokeObjectURL(revoke);
577 − };
578 − }, [token, password, fetchContent]);
579 −
580 − if (!src) {
581 − return (
582 − <div className="flex items-center justify-center py-24">
583 − <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
584 − </div>
585 − );
586 − }
587 −
588 − return (
589 − <img
590 − src={src}
591 − alt={alt}
592 − className="max-w-full object-contain rounded-2xl transition-transform duration-200"
593 − style={{ transform: `scale(${zoom})`, transformOrigin: "center" }}
594 − />
595 − );
596 −}
modified components/files/FileActions.tsx +6 −140
@@ -2,11 +2,12 @@
2 2
3 3 import { useEffect, useLayoutEffect, useRef, useState } from "react";
4 4 import { motion } from "framer-motion";
5 −import { Download, Pencil, Trash2, Copy, Share2, FolderInput, Star, Eye, Link2, Check } from "lucide-react";
5 +import { Download, Pencil, Trash2, Copy, Share2, FolderInput, Star, Eye } from "lucide-react";
6 6 import toast from "react-hot-toast";
7 7 import Modal from "../ui/Modal";
8 8 import Button from "../ui/Button";
9 9 import MoveModal from "./MoveModal";
10 +import ShareModal from "./ShareModal";
10 11
11 12 interface FileData {
12 13 id: string;
@@ -33,12 +34,7 @@ export default function FileActions({ file, onClose, onRefresh, anchor, onPrevie
33 34 const [moveModal, setMoveModal] = useState(false);
34 35 const [deleteModal, setDeleteModal] = useState(false);
35 36 const [newName, setNewName] = useState(file.name);
36 − const [sharePassword, setSharePassword] = useState("");
37 − const [shareExpiry, setShareExpiry] = useState("");
38 − const [shareMode, setShareMode] = useState<"DOWNLOAD" | "PREVIEW">("DOWNLOAD");
39 37 const [loading, setLoading] = useState(false);
40 − const [shareLink, setShareLink] = useState<string | null>(null);
41 − const [copied, setCopied] = useState(false);
42 38 const menuRef = useRef<HTMLDivElement>(null);
43 39 const [pos, setPos] = useState<{ x: number; y: number } | null>(anchor ?? null);
44 40
@@ -142,58 +138,6 @@ export default function FileActions({ file, onClose, onRefresh, anchor, onPrevie
142 138 }
143 139 };
144 140
145 − const copyToClipboard = async (text: string) => {
146 − try {
147 − await navigator.clipboard.writeText(text);
148 − return true;
149 − } catch {
150 − const textarea = document.createElement("textarea");
151 − textarea.value = text;
152 − textarea.style.position = "fixed";
153 − textarea.style.opacity = "0";
154 − document.body.appendChild(textarea);
155 − textarea.select();
156 − try {
157 − document.execCommand("copy");
158 − return true;
159 − } catch {
160 − return false;
161 − } finally {
162 − document.body.removeChild(textarea);
163 − }
164 − }
165 − };
166 −
167 − const handleShare = async () => {
168 − setLoading(true);
169 − try {
170 − const res = await fetch("/api/shares", {
171 − method: "POST",
172 − headers: { "Content-Type": "application/json" },
173 − body: JSON.stringify({
174 − fileId: file.id,
175 − password: sharePassword || undefined,
176 − expiresAt: shareExpiry || undefined,
177 − mode: shareMode,
178 − }),
179 − });
180 − if (res.ok) {
181 − const data = await res.json();
182 − const url = `${window.location.origin}/shared/${data.token}`;
183 − setShareLink(url);
184 − const ok = await copyToClipboard(url);
185 − toast.success(ok ? "Lien copié dans le presse-papiers" : "Lien de partage créé");
186 − } else {
187 − const data = await res.json().catch(() => ({}));
188 − toast.error(data.error || "Erreur lors du partage");
189 − }
190 − } catch (err) {
191 − toast.error("Erreur réseau : " + (err as Error).message);
192 − } finally {
193 − setLoading(false);
194 − }
195 − };
196 −
197 141 const handleMove = async (targetFolderId: string) => {
198 142 try {
199 143 const res = await fetch(`/api/files/${file.id}`, {
@@ -296,89 +240,11 @@ export default function FileActions({ file, onClose, onRefresh, anchor, onPrevie
296 240 </div>
297 241 </Modal>
298 242
299 − <Modal
243 + <ShareModal
300 244 isOpen={shareModal}
301 − onClose={() => { setShareModal(false); setShareLink(null); onClose(); }}
302 − title="Partager"
303 − description={file.name}
304 − >
305 − {shareLink ? (
306 − <div className="space-y-4">
307 − <div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 font-medium">
308 − <Check className="w-4 h-4" /> Lien de partage créé
309 − </div>
310 − <div className="flex items-center gap-2">
311 − <div className="relative flex-1">
312 − <Link2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
313 − <input
314 − type="text"
315 − value={shareLink}
316 − readOnly
317 − className="field pl-9 text-xs font-mono"
318 − onClick={(e) => (e.target as HTMLInputElement).select()}
319 − />
320 − </div>
321 − <Button
322 − size="sm"
323 − variant={copied ? "secondary" : "accent"}
324 − onClick={async () => {
325 − await copyToClipboard(shareLink);
326 − setCopied(true);
327 − toast.success("Lien copié");
328 − setTimeout(() => setCopied(false), 1500);
329 − }}
330 − >
331 − {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
332 − {copied ? "Copié" : "Copier"}
333 − </Button>
334 − </div>
335 − <Button variant="ghost" onClick={() => { setShareModal(false); setShareLink(null); onClose(); }} className="w-full">
336 − Fermer
337 − </Button>
338 − </div>
339 − ) : (
340 − <div className="space-y-4">
341 − <div>
342 − <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Mode</label>
343 − <div className="grid grid-cols-2 gap-2">
344 − {([["DOWNLOAD", "Téléchargement", Download], ["PREVIEW", "Aperçu seulement", Eye]] as const).map(([v, label, Icon]) => (
345 − <button
346 − key={v}
347 − type="button"
348 − onClick={() => setShareMode(v)}
349 − className={`flex items-center gap-2 px-3 h-11 rounded-xl border text-sm transition-colors ${
350 − shareMode === v
351 − ? "border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300 font-medium"
352 − : "border-line dark:border-white/10 text-gray-600 dark:text-gray-300 hover:border-gray-300 dark:hover:border-white/20"
353 − }`}
354 − >
355 − <Icon className="w-4 h-4" /> {label}
356 − </button>
357 − ))}
358 − </div>
359 − </div>
360 − <div>
361 − <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Mot de passe (optionnel)</label>
362 − <input
363 − type="password"
364 − value={sharePassword}
365 − onChange={(e) => setSharePassword(e.target.value)}
366 − placeholder="Laisser vide pour aucun mot de passe"
367 − className="field"
368 − autoComplete="new-password"
369 − />
370 − </div>
371 − <div>
372 − <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Expiration (optionnel)</label>
373 − <input type="datetime-local" value={shareExpiry} onChange={(e) => setShareExpiry(e.target.value)} className="field" />
374 − </div>
375 − <div className="flex gap-2 justify-end pt-1">
376 − <Button variant="ghost" onClick={() => { setShareModal(false); onClose(); }}>Annuler</Button>
377 − <Button variant="accent" onClick={handleShare} loading={loading}><Share2 className="w-4 h-4" /> Créer le lien</Button>
378 − </div>
379 − </div>
380 − )}
381 − </Modal>
245 + onClose={() => { setShareModal(false); onClose(); }}
246 + target={{ type: "file", id: file.id, name: file.name }}
247 + />
382 248
383 249 <MoveModal
384 250 isOpen={moveModal}
added components/files/ShareModal.tsx +151 −0
@@ -0,0 +1,151 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import { Share2, Download, Eye, Link2, Copy, Check, Folder } from "lucide-react";
5 +import toast from "react-hot-toast";
6 +import Modal from "../ui/Modal";
7 +import Button from "../ui/Button";
8 +
9 +export interface ShareTarget {
10 + type: "file" | "folder";
11 + id: string;
12 + name: string;
13 +}
14 +
15 +interface ShareModalProps {
16 + isOpen: boolean;
17 + onClose: () => void;
18 + target: ShareTarget;
19 +}
20 +
21 +export async function copyToClipboard(text: string): Promise<boolean> {
22 + try {
23 + await navigator.clipboard.writeText(text);
24 + return true;
25 + } catch {
26 + const textarea = document.createElement("textarea");
27 + textarea.value = text;
28 + textarea.style.position = "fixed";
29 + textarea.style.opacity = "0";
30 + document.body.appendChild(textarea);
31 + textarea.select();
32 + try {
33 + document.execCommand("copy");
34 + return true;
35 + } catch {
36 + return false;
37 + } finally {
38 + document.body.removeChild(textarea);
39 + }
40 + }
41 +}
42 +
43 +/** Création d'un lien de partage public (fichier ou dossier complet). */
44 +export default function ShareModal({ isOpen, onClose, target }: ShareModalProps) {
45 + const [password, setPassword] = useState("");
46 + const [expiry, setExpiry] = useState("");
47 + const [mode, setMode] = useState<"DOWNLOAD" | "PREVIEW">("DOWNLOAD");
48 + const [loading, setLoading] = useState(false);
49 + const [link, setLink] = useState<string | null>(null);
50 + const [copied, setCopied] = useState(false);
51 +
52 + const close = () => { setLink(null); setPassword(""); setExpiry(""); onClose(); };
53 +
54 + const create = async () => {
55 + setLoading(true);
56 + try {
57 + const res = await fetch("/api/shares", {
58 + method: "POST",
59 + headers: { "Content-Type": "application/json" },
60 + body: JSON.stringify({
61 + [target.type === "folder" ? "folderId" : "fileId"]: target.id,
62 + password: password || undefined,
63 + expiresAt: expiry ? new Date(expiry).toISOString() : undefined,
64 + mode,
65 + }),
66 + });
67 + const data = await res.json().catch(() => ({}));
68 + if (res.ok) {
69 + const url = `${window.location.origin}/shared/${data.token}`;
70 + setLink(url);
71 + const ok = await copyToClipboard(url);
72 + toast.success(ok ? "Lien copié dans le presse-papiers" : "Lien de partage créé");
73 + } else {
74 + toast.error(data.error || "Erreur lors du partage");
75 + }
76 + } catch (err) {
77 + toast.error("Erreur réseau : " + (err as Error).message);
78 + } finally {
79 + setLoading(false);
80 + }
81 + };
82 +
83 + const isFolder = target.type === "folder";
84 +
85 + return (
86 + <Modal isOpen={isOpen} onClose={close} title={isFolder ? "Partager le dossier" : "Partager"} description={target.name}>
87 + {link ? (
88 + <div className="space-y-4">
89 + <div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 font-medium">
90 + <Check className="w-4 h-4" /> Lien de partage créé
91 + </div>
92 + <div className="flex items-center gap-2">
93 + <div className="relative flex-1">
94 + <Link2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
95 + <input type="text" value={link} readOnly className="field pl-9 text-xs font-mono" onClick={(e) => (e.target as HTMLInputElement).select()} />
96 + </div>
97 + <Button
98 + size="sm"
99 + variant={copied ? "secondary" : "accent"}
100 + onClick={async () => { await copyToClipboard(link); setCopied(true); toast.success("Lien copié"); setTimeout(() => setCopied(false), 1500); }}
101 + >
102 + {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
103 + {copied ? "Copié" : "Copier"}
104 + </Button>
105 + </div>
106 + {isFolder && (
107 + <p className="text-[12.5px] text-gray-500 dark:text-gray-400 flex items-start gap-2">
108 + <Folder className="w-4 h-4 shrink-0 mt-0.5 text-blue-500" />
109 + Les visiteurs verront le contenu du dossier et de ses sous-dossiers (fichiers ajoutés plus tard inclus) et pourront tout télécharger en ZIP.
110 + </p>
111 + )}
112 + <Button variant="ghost" onClick={close} className="w-full">Fermer</Button>
113 + </div>
114 + ) : (
115 + <div className="space-y-4">
116 + <div>
117 + <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Mode</label>
118 + <div className="grid grid-cols-2 gap-2">
119 + {([["DOWNLOAD", "Téléchargement", Download], ["PREVIEW", "Aperçu seulement", Eye]] as const).map(([v, label, Icon]) => (
120 + <button
121 + key={v}
122 + type="button"
123 + onClick={() => setMode(v)}
124 + className={`flex items-center gap-2 px-3 h-11 rounded-xl border text-sm transition-colors ${
125 + mode === v
126 + ? "border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300 font-medium"
127 + : "border-line dark:border-white/10 text-gray-600 dark:text-gray-300 hover:border-gray-300 dark:hover:border-white/20"
128 + }`}
129 + >
130 + <Icon className="w-4 h-4" /> {label}
131 + </button>
132 + ))}
133 + </div>
134 + </div>
135 + <div>
136 + <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Mot de passe (optionnel)</label>
137 + <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Laisser vide pour aucun mot de passe" className="field" autoComplete="new-password" />
138 + </div>
139 + <div>
140 + <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Expiration (optionnel)</label>
141 + <input type="datetime-local" value={expiry} onChange={(e) => setExpiry(e.target.value)} className="field" min={new Date(Date.now() + 60000).toISOString().slice(0, 16)} />
142 + </div>
143 + <div className="flex gap-2 justify-end pt-1">
144 + <Button variant="ghost" onClick={close}>Annuler</Button>
145 + <Button variant="accent" onClick={create} loading={loading}><Share2 className="w-4 h-4" /> Créer le lien</Button>
146 + </div>
147 + </div>
148 + )}
149 + </Modal>
150 + );
151 +}
modified components/folders/FolderCard.tsx +8 −2
@@ -2,13 +2,14 @@
2 2
3 3 import { useRouter } from "next/navigation";
4 4 import { motion } from "framer-motion";
5 −import { Folder, FolderOpen, MoreVertical, Pencil, Trash2, FolderInput, ChevronRight } from "lucide-react";
5 +import { Folder, FolderOpen, MoreVertical, Pencil, Trash2, FolderInput, ChevronRight, Share2, Download } from "lucide-react";
6 6 import { useState, useRef, useEffect, memo } from "react";
7 7 import toast from "react-hot-toast";
8 8 import Modal from "../ui/Modal";
9 9 import Button from "../ui/Button";
10 10 import SharedBadge from "../ui/SharedBadge";
11 11 import MoveModal from "../files/MoveModal";
12 +import ShareModal from "../files/ShareModal";
12 13 import { DND_FOLDER, handleInternalDrop, hasInternalDrag, notifyFoldersChanged, readFolderId, setDragGhost } from "../files/dnd";
13 14
14 15 interface FolderData {
@@ -34,6 +35,7 @@ function FolderCardInner({ folder, onRefresh, viewMode, onOpen, focusable }: Fol
34 35 const [renameModal, setRenameModal] = useState(false);
35 36 const [deleteModal, setDeleteModal] = useState(false);
36 37 const [moveModal, setMoveModal] = useState(false);
38 + const [shareModal, setShareModal] = useState(false);
37 39 const [newName, setNewName] = useState(folder.name);
38 40 const [loading, setLoading] = useState(false);
39 41 const [dragOver, setDragOver] = useState(false);
@@ -164,6 +166,8 @@ function FolderCardInner({ folder, onRefresh, viewMode, onOpen, focusable }: Fol
164 166 } as const;
165 167
166 168 const menuItems = [
169 + { icon: Download, label: "Télécharger en ZIP", onClick: () => window.open(`/api/folders/${folder.id}/download`, "_blank") },
170 + { icon: Share2, label: "Partager…", onClick: () => setShareModal(true) },
167 171 { icon: Pencil, label: "Renommer…", onClick: () => setRenameModal(true) },
168 172 { icon: FolderInput, label: "Déplacer vers…", onClick: () => setMoveModal(true) },
169 173 { icon: Trash2, label: "Supprimer", onClick: () => setDeleteModal(true), danger: true },
@@ -206,8 +210,9 @@ function FolderCardInner({ folder, onRefresh, viewMode, onOpen, focusable }: Fol
206 210 </Modal>
207 211 <Modal isOpen={deleteModal} onClose={() => setDeleteModal(false)} title="Supprimer le dossier">
208 212 <p className="text-gray-600 dark:text-gray-300 text-sm mb-5">
209 − Supprimer <strong className="text-gray-900 dark:text-white">{folder.name}</strong> et tout son contenu
213 + Supprimer <strong className="text-gray-900 dark:text-white">{folder.name}</strong>
210 214 {folder._count ? <> ({folder._count.files} fichier{folder._count.files !== 1 ? "s" : ""}, {folder._count.children} sous-dossier{folder._count.children !== 1 ? "s" : ""})</> : null} ?
215 + Les fichiers qu'il contient seront placés dans la corbeille et restaurables pendant 30 jours.
211 216 </p>
212 217 <div className="flex gap-2 justify-end">
213 218 <Button variant="ghost" onClick={() => setDeleteModal(false)}>Annuler</Button>
@@ -215,6 +220,7 @@ function FolderCardInner({ folder, onRefresh, viewMode, onOpen, focusable }: Fol
215 220 </div>
216 221 </Modal>
217 222 <MoveModal isOpen={moveModal} onClose={() => setMoveModal(false)} onSelect={handleMove} excludeId={folder.id} itemName={folder.name} currentFolderId={folder.parentId ?? "root"} />
223 + <ShareModal isOpen={shareModal} onClose={() => setShareModal(false)} target={{ type: "folder", id: folder.id, name: folder.name }} />
218 224 </>
219 225 );
220 226
added lib/login-complete.ts +65 −0
@@ -0,0 +1,65 @@
1 +/**
2 + * Finalisation d'une connexion (partagée par mot de passe, 2FA et code courriel) :
3 + * analyse IA de sécurité, enregistrement LoginSession, écriture de la session iron-session.
4 + */
5 +import { NextRequest } from "next/server";
6 +import { randomBytes } from "crypto";
7 +import type { IronSession } from "iron-session";
8 +import { prisma } from "./prisma";
9 +import { logActivity } from "./activity";
10 +import { parseUserAgent } from "./ua-parser";
11 +import { getGeoFromIp } from "./geo";
12 +import { analyzeLogin } from "./ai-security";
13 +import type { SessionData } from "./session";
14 +
15 +export function clientIp(request: NextRequest): string {
16 + return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "127.0.0.1";
17 +}
18 +
19 +export function describeClient(request: NextRequest) {
20 + const ua = request.headers.get("user-agent") || "";
21 + const parsed = parseUserAgent(ua);
22 + return { ua, parsed, label: `${parsed.browser} / ${parsed.os}${parsed.device ? ` (${parsed.device})` : ""}` };
23 +}
24 +
25 +export type LoginMethod = "password" | "2fa" | "email_code";
26 +
27 +/**
28 + * Termine la connexion pour `user`. Retourne { ok:false, error, status } si l'agent IA refuse.
29 + * La `session` est sauvegardée en cas de succès.
30 + */
31 +export async function completeLogin(
32 + request: NextRequest,
33 + session: IronSession<SessionData>,
34 + user: { id: string; role: string },
35 + method: LoginMethod
36 +): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
37 + const ip = clientIp(request);
38 + const { ua, parsed } = describeClient(request);
39 + const geo = await getGeoFromIp(ip);
40 +
41 + const aiResult = await analyzeLogin({ userId: user.id, ip, country: geo.country, city: geo.city, device: parsed.device, browser: parsed.browser, os: parsed.os });
42 +
43 + const base = { userId: user.id, ip, userAgent: ua, device: parsed.device, browser: parsed.browser, os: parsed.os, country: geo.country, city: geo.city };
44 + if (aiResult.decision === "refuse") {
45 + await prisma.loginSession.create({ data: { ...base, status: "refused_by_ai", aiDecision: "refused", aiReason: aiResult.reason, aiRiskLevel: aiResult.riskLevel, endedAt: new Date() } });
46 + await logActivity("LOGIN_REFUSED_AI", null, `Connexion (${method}) refusée par IA depuis ${ip} (${geo.country}) — ${aiResult.reason}`);
47 + return { ok: false, error: `Connexion refusée par l'agent IA de sécurité. ${aiResult.reason}`, status: 403 };
48 + }
49 +
50 + const loginSession = await prisma.loginSession.create({ data: { ...base, status: "active", aiDecision: "allowed", aiReason: aiResult.reason, aiRiskLevel: aiResult.riskLevel } });
51 +
52 + session.isLoggedIn = true;
53 + session.userId = user.id;
54 + session.userRole = user.role;
55 + session.pendingTwoFactor = false;
56 + session.pendingUserId = undefined;
57 + session.otp = undefined;
58 + session.sessionToken = loginSession.sessionToken;
59 + session.csrfToken = randomBytes(32).toString("hex");
60 + await session.save();
61 +
62 + const methodLabel = method === "email_code" ? "code courriel" : method === "2fa" ? "2FA" : "mot de passe";
63 + await logActivity("LOGIN", null, `Connexion (${methodLabel}) depuis ${ip} (${parsed.browser} / ${parsed.os}) — IA : ${aiResult.riskLevel}`);
64 + return { ok: true };
65 +}
added lib/mailer.ts +64 −0
@@ -0,0 +1,64 @@
1 +/**
2 + * Envoi de courriels via Resend (API REST, aucune dépendance).
3 + * Variables : RESEND_API_KEY (obligatoire), MAIL_FROM (ex. « SPB Cloud <no-reply@spboucher.ai> »).
4 + * ⚠ Sans domaine vérifié dans Resend, seul « onboarding@resend.dev » est accepté comme expéditeur,
5 + * et il ne livre qu'à l'adresse propriétaire du compte Resend.
6 + */
7 +
8 +export interface MailInput {
9 + to: string;
10 + subject: string;
11 + html: string;
12 + text: string;
13 +}
14 +
15 +export class MailerError extends Error {
16 + constructor(message: string, public status?: number, public code?: string) { super(message); }
17 +}
18 +
19 +export function mailerConfigured(): boolean {
20 + return !!process.env.RESEND_API_KEY;
21 +}
22 +
23 +export function mailFrom(): string {
24 + return process.env.MAIL_FROM || "SPB Cloud <onboarding@resend.dev>";
25 +}
26 +
27 +export async function sendMail(input: MailInput): Promise<{ id: string }> {
28 + const key = process.env.RESEND_API_KEY;
29 + if (!key) throw new MailerError("RESEND_API_KEY manquante", 500, "not_configured");
30 + const res = await fetch("https://api.resend.com/emails", {
31 + method: "POST",
32 + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
33 + body: JSON.stringify({ from: mailFrom(), to: [input.to], subject: input.subject, html: input.html, text: input.text }),
34 + });
35 + const body = (await res.json().catch(() => ({}))) as { id?: string; message?: string; name?: string; statusCode?: number };
36 + if (!res.ok) throw new MailerError(body.message || `Resend HTTP ${res.status}`, res.status, body.name);
37 + return { id: body.id || "" };
38 +}
39 +
40 +/** Gabarit du code de connexion (clair, lisible sur mobile, marque SPB Cloud). */
41 +export function loginCodeEmail(code: string, opts: { name?: string | null; minutes: number; ip?: string; device?: string }): { subject: string; html: string; text: string } {
42 + const spaced = code.split("").join(" ");
43 + const greeting = opts.name ? `Bonjour ${opts.name},` : "Bonjour,";
44 + const subject = `${code} — votre code de connexion SPB Cloud`;
45 + const text = `${greeting}\n\nVoici votre code de connexion à SPB Cloud : ${code}\n\nIl expire dans ${opts.minutes} minutes et ne peut être utilisé qu'une fois.\nSi vous n'êtes pas à l'origine de cette demande, ignorez ce courriel.\n\n${opts.device ? `Appareil : ${opts.device}\n` : ""}${opts.ip ? `Adresse IP : ${opts.ip}\n` : ""}\n— SPB Cloud · cloud.spboucher.ai`;
46 + const html = `<!doctype html><html lang="fr"><body style="margin:0;background:#f5f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#0f172a">
47 + <div style="max-width:520px;margin:0 auto;padding:32px 20px">
48 + <div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
49 + <div style="width:36px;height:36px;border-radius:11px;background:linear-gradient(135deg,#6366f1,#4338ca);display:inline-block;vertical-align:middle"></div>
50 + <span style="font-size:17px;font-weight:600;vertical-align:middle;margin-left:10px">SPB <span style="color:#4f46e5">Cloud</span></span>
51 + </div>
52 + <div style="background:#fff;border:1px solid #e2e6eb;border-radius:20px;padding:28px 24px">
53 + <p style="margin:0 0 10px;font-size:15px">${greeting}</p>
54 + <p style="margin:0 0 18px;font-size:15px;color:#3a404b">Voici votre code de connexion. Il expire dans <strong>${opts.minutes} minutes</strong> et ne sert qu'une fois.</p>
55 + <div style="text-align:center;margin:22px 0">
56 + <span style="display:inline-block;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:34px;letter-spacing:8px;font-weight:700;padding:14px 22px;border-radius:14px;background:#eef0f3;color:#0f172a">${spaced}</span>
57 + </div>
58 + <p style="margin:18px 0 0;font-size:13px;color:#6b7280">Si vous n'êtes pas à l'origine de cette demande, ignorez simplement ce courriel — personne ne peut se connecter sans ce code.</p>
59 + ${opts.device || opts.ip ? `<p style="margin:12px 0 0;font-size:12px;color:#9aa1ad">${opts.device ? `Appareil : ${opts.device}` : ""}${opts.device && opts.ip ? " · " : ""}${opts.ip ? `IP : ${opts.ip}` : ""}</p>` : ""}
60 + </div>
61 + <p style="margin:18px 0 0;text-align:center;font-size:12px;color:#9aa1ad">SPB Cloud · <a href="https://cloud.spboucher.ai" style="color:#6b7280">cloud.spboucher.ai</a> · cloud personnel privé et chiffré</p>
62 + </div></body></html>`;
63 + return { subject, html, text };
64 +}
added lib/otp.ts +8 −0
@@ -0,0 +1,8 @@
1 +import { createHash } from "crypto";
2 +
3 +export const CODE_TTL_MINUTES = 10;
4 +export const MAX_ATTEMPTS = 5;
5 +
6 +export function hashCode(code: string, salt: string): string {
7 + return createHash("sha256").update(`${salt}:${code}`).digest("hex");
8 +}
modified lib/session.ts +12 −0
@@ -1,5 +1,16 @@
1 1 import { SessionOptions } from "iron-session";
2 2
3 +/** Code de connexion par courriel en attente (stocké chiffré dans le cookie — aucune table nécessaire). */
4 +export interface PendingOtp {
5 + email: string; // adresse normalisée (minuscules)
6 + userId: string;
7 + hash: string; // SHA-256(sel:code)
8 + salt: string;
9 + expiresAt: number; // epoch ms
10 + attempts: number;
11 + sentAt: number; // epoch ms — anti-spam de renvoi
12 +}
13 +
3 14 export interface SessionData {
4 15 isLoggedIn: boolean;
5 16 userId?: string;
@@ -8,6 +19,7 @@ export interface SessionData {
8 19 pendingTwoFactor?: boolean;
9 20 pendingUserId?: string;
10 21 sessionToken?: string; // ties the cookie to a LoginSession record
22 + otp?: PendingOtp;
11 23 }
12 24
13 25 export const sessionOptions: SessionOptions = {
added lib/shareAccess.ts +80 −0
@@ -0,0 +1,80 @@
1 +/**
2 + * Accès aux liens de partage publics : validité, mot de passe (cookie signé après vérification),
3 + * compteur d'accès, portée (fichier ou dossier + descendants).
4 + */
5 +import crypto from "crypto";
6 +import { NextRequest, NextResponse } from "next/server";
7 +import { prisma } from "./prisma";
8 +import { isDescendantOf } from "./trash";
9 +
10 +export type ShareWithTargets = NonNullable<Awaited<ReturnType<typeof loadShare>>>;
11 +
12 +export async function loadShare(token: string) {
13 + return prisma.sharedLink.findUnique({
14 + where: { token },
15 + include: { file: true, folder: true },
16 + });
17 +}
18 +
19 +/** null si valide, sinon une réponse d'erreur prête à renvoyer. */
20 +export function shareStatusError(share: ShareWithTargets | null): NextResponse | null {
21 + if (!share || !share.active) return NextResponse.json({ error: "Lien introuvable ou inactif" }, { status: 404 });
22 + if (share.expiresAt && new Date() > share.expiresAt) return NextResponse.json({ error: "Ce lien a expiré" }, { status: 410 });
23 + if (share.file && share.file.deletedAt) return NextResponse.json({ error: "Le fichier partagé n'existe plus" }, { status: 404 });
24 + if (!share.file && !share.folder) return NextResponse.json({ error: "La cible du partage n'existe plus" }, { status: 404 });
25 + return null;
26 +}
27 +
28 +const SECRET = process.env.SESSION_PASSWORD || "spb-share-secret";
29 +
30 +export function shareCookieName(token: string): string {
31 + return `spb_share_${token.slice(0, 24)}`;
32 +}
33 +
34 +/** Signature liée au lien ET au hash du mot de passe (changer le mot de passe invalide les cookies). */
35 +export function shareCookieValue(share: { token: string; passwordHash: string | null }): string {
36 + return crypto.createHmac("sha256", SECRET).update(`${share.token}:${share.passwordHash ?? ""}`).digest("hex");
37 +}
38 +
39 +/** Le visiteur a-t-il déjà validé le mot de passe (cookie signé) ? */
40 +export function hasPasswordAccess(request: NextRequest, share: { token: string; passwordHash: string | null }): boolean {
41 + if (!share.passwordHash) return true;
42 + const c = request.cookies.get(shareCookieName(share.token))?.value;
43 + if (!c) return false;
44 + const expected = shareCookieValue(share);
45 + return c.length === expected.length && crypto.timingSafeEqual(Buffer.from(c), Buffer.from(expected));
46 +}
47 +
48 +export function grantPasswordAccess(response: NextResponse, share: { token: string; passwordHash: string | null }) {
49 + response.cookies.set(shareCookieName(share.token), shareCookieValue(share), {
50 + httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 6,
51 + });
52 +}
53 +
54 +/** Informations publiques d'un partage (sans contenu). */
55 +export function shareInfo(share: ShareWithTargets) {
56 + const base = { token: share.token, mode: share.mode, expiresAt: share.expiresAt?.toISOString() || null };
57 + if (share.file) {
58 + return { ...base, type: "file" as const, id: share.file.id, name: share.file.name, mimeType: share.file.mimeType, size: Number(share.file.size) };
59 + }
60 + return { ...base, type: "folder" as const, id: share.folder!.id, name: share.folder!.name };
61 +}
62 +
63 +export async function touchShare(id: string) {
64 + await prisma.sharedLink.update({ where: { id }, data: { accessCount: { increment: 1 }, lastAccessAt: new Date() } }).catch(() => {});
65 +}
66 +
67 +/** Un dossier demandé appartient-il au sous-arbre partagé ? */
68 +export async function folderInShare(share: ShareWithTargets, folderId: string): Promise<boolean> {
69 + if (!share.folder) return false;
70 + return isDescendantOf(folderId, share.folder.id);
71 +}
72 +
73 +/** Un fichier demandé appartient-il au partage (fichier lui-même ou fichier du sous-arbre partagé) ? */
74 +export async function fileInShare(share: ShareWithTargets, fileId: string) {
75 + if (share.file) return share.file.id === fileId ? share.file : null;
76 + if (!share.folder) return null;
77 + const f = await prisma.file.findUnique({ where: { id: fileId } });
78 + if (!f || f.deletedAt || !f.folderId) return null;
79 + return (await isDescendantOf(f.folderId, share.folder.id)) ? f : null;
80 +}
added lib/trash.ts +191 −0
@@ -0,0 +1,191 @@
1 +/**
2 + * Corbeille cohérente : mise à la corbeille (fichiers et dossiers récursifs), restauration fidèle,
3 + * purge réelle (disque + versions + vignettes), purge automatique 30 jours, utilitaires d'arborescence.
4 + */
5 +import { existsSync } from "fs";
6 +import { readdir, rm, stat } from "fs/promises";
7 +import path from "path";
8 +import { prisma } from "./prisma";
9 +import { deleteFile, deleteThumbnails, deleteVersion } from "./storage";
10 +import { logActivity } from "./activity";
11 +
12 +export const TRASH_RETENTION_DAYS = 30;
13 +
14 +export interface OriginPath { spaceId: string | null; userId: string | null; names: string[] }
15 +
16 +/** Identifiant d'espace partagé effectif d'un dossier (hérité en remontant l'arborescence). */
17 +export async function getSpaceIdForFolder(folderId: string | null | undefined): Promise<string | null> {
18 + let cur = folderId ?? null;
19 + const seen = new Set<string>();
20 + while (cur && !seen.has(cur)) {
21 + seen.add(cur);
22 + const f: { parentId: string | null; sharedSpaceId: string | null } | null =
23 + await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true, sharedSpaceId: true } });
24 + if (!f) return null;
25 + if (f.sharedSpaceId) return f.sharedSpaceId;
26 + cur = f.parentId;
27 + }
28 + return null;
29 +}
30 +
31 +/** Chemin (noms) d'un dossier depuis la racine + espace/propriétaire. */
32 +export async function getOriginPath(folderId: string): Promise<OriginPath | null> {
33 + const names: string[] = [];
34 + let spaceId: string | null = null;
35 + let userId: string | null = null;
36 + let cur: string | null = folderId;
37 + const seen = new Set<string>();
38 + while (cur && !seen.has(cur)) {
39 + seen.add(cur);
40 + const f: { name: string; parentId: string | null; sharedSpaceId: string | null; userId: string | null } | null =
41 + await prisma.folder.findUnique({ where: { id: cur }, select: { name: true, parentId: true, sharedSpaceId: true, userId: true } });
42 + if (!f) return null;
43 + names.unshift(f.name);
44 + if (f.sharedSpaceId && !spaceId) spaceId = f.sharedSpaceId;
45 + if (f.userId && !userId) userId = f.userId;
46 + cur = f.parentId;
47 + }
48 + return { spaceId, userId, names };
49 +}
50 +
51 +/** Tous les identifiants de dossiers du sous-arbre (racine incluse), en largeur. */
52 +export async function collectFolderTree(rootId: string): Promise<string[]> {
53 + const ids = [rootId];
54 + const seen = new Set(ids);
55 + for (let i = 0; i < ids.length; i++) {
56 + const kids = await prisma.folder.findMany({ where: { parentId: ids[i] }, select: { id: true } });
57 + for (const k of kids) if (!seen.has(k.id)) { seen.add(k.id); ids.push(k.id); }
58 + }
59 + return ids;
60 +}
61 +
62 +/** Vérifie que `folderId` est `ancestorId` ou l'un de ses descendants. */
63 +export async function isDescendantOf(folderId: string, ancestorId: string): Promise<boolean> {
64 + let cur: string | null = folderId;
65 + const seen = new Set<string>();
66 + while (cur && !seen.has(cur)) {
67 + if (cur === ancestorId) return true;
68 + seen.add(cur);
69 + const f: { parentId: string | null } | null = await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true } });
70 + cur = f?.parentId ?? null;
71 + }
72 + return false;
73 +}
74 +
75 +/** Met des fichiers à la corbeille en mémorisant leur dossier d'origine. */
76 +export async function trashFiles(fileIds: string[], reason = "TRASH"): Promise<number> {
77 + if (!fileIds.length) return 0;
78 + const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: null }, select: { id: true, name: true, folderId: true } });
79 + const pathCache = new Map<string, string | null>();
80 + const now = new Date();
81 + for (const f of files) {
82 + let originPath: string | null = null;
83 + if (f.folderId) {
84 + if (!pathCache.has(f.folderId)) {
85 + const p = await getOriginPath(f.folderId);
86 + pathCache.set(f.folderId, p ? JSON.stringify(p) : null);
87 + }
88 + originPath = pathCache.get(f.folderId) ?? null;
89 + }
90 + await prisma.file.update({
91 + where: { id: f.id },
92 + data: { deletedAt: now, deletedFromFolderId: f.folderId, deletedFromPath: originPath },
93 + });
94 + await logActivity(reason, f.id, `Moved to trash: ${f.name}`);
95 + }
96 + return files.length;
97 +}
98 +
99 +/**
100 + * Supprime un dossier : tous les fichiers du sous-arbre partent à la corbeille (restaurables 30 jours),
101 + * puis les dossiers sont supprimés. Les fichiers déjà en corbeille dans ce sous-arbre conservent leur origine.
102 + */
103 +export async function trashFolderRecursive(folderId: string): Promise<{ files: number; folders: number }> {
104 + const ids = await collectFolderTree(folderId);
105 + const active = await prisma.file.findMany({ where: { folderId: { in: ids }, deletedAt: null }, select: { id: true } });
106 + const trashed = await trashFiles(active.map((f) => f.id), "TRASH");
107 + // Fichiers déjà en corbeille : mémoriser l'origine avant que la cascade ne mette folderId à NULL
108 + const alreadyTrashed = await prisma.file.findMany({ where: { folderId: { in: ids }, deletedAt: { not: null } }, select: { id: true, folderId: true, deletedFromPath: true } });
109 + for (const f of alreadyTrashed) {
110 + if (!f.deletedFromPath && f.folderId) {
111 + const p = await getOriginPath(f.folderId);
112 + await prisma.file.update({ where: { id: f.id }, data: { deletedFromFolderId: f.folderId, deletedFromPath: p ? JSON.stringify(p) : null } });
113 + }
114 + }
115 + // Suppression des dossiers (cascade Prisma sur les sous-dossiers ; File.folderId → NULL)
116 + await prisma.folder.delete({ where: { id: folderId } });
117 + return { files: trashed, folders: ids.length };
118 +}
119 +
120 +/** Recrée (ou retrouve) une chaîne de dossiers par noms dans un espace / pour un propriétaire. */
121 +async function ensureFolderPath(origin: OriginPath): Promise<string | null> {
122 + let parentId: string | null = null;
123 + for (const name of origin.names) {
124 + const where: Record<string, unknown> = { name, parentId };
125 + if (parentId === null) {
126 + // au niveau racine, distinguer par espace
127 + if (origin.spaceId) where.sharedSpaceId = origin.spaceId; else where.sharedSpaceId = null;
128 + }
129 + let folder = await prisma.folder.findFirst({ where });
130 + if (!folder) {
131 + folder = await prisma.folder.create({ data: { name, parentId, sharedSpaceId: origin.spaceId, userId: origin.userId } });
132 + }
133 + parentId = folder.id;
134 + }
135 + return parentId;
136 +}
137 +
138 +/** Restaure des fichiers : dossier d'origine s'il existe, sinon chemin recréé, sinon racine. */
139 +export async function restoreFiles(fileIds: string[]): Promise<number> {
140 + const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: { not: null } } });
141 + for (const f of files) {
142 + let target: string | null = null;
143 + const candidate = f.deletedFromFolderId ?? f.folderId;
144 + if (candidate && (await prisma.folder.findUnique({ where: { id: candidate }, select: { id: true } }))) target = candidate;
145 + else if (f.deletedFromPath) {
146 + try { target = await ensureFolderPath(JSON.parse(f.deletedFromPath) as OriginPath); } catch { target = null; }
147 + }
148 + await prisma.file.update({ where: { id: f.id }, data: { deletedAt: null, folderId: target, deletedFromFolderId: null, deletedFromPath: null } });
149 + await logActivity("RESTORE", f.id, `Restored from trash: ${f.name}`);
150 + }
151 + return files.length;
152 +}
153 +
154 +/** Suppression définitive : contenu, versions, vignettes, enregistrement. */
155 +export async function purgeFiles(fileIds: string[], log = true): Promise<number> {
156 + const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: { not: null } }, include: { versions: true } });
157 + for (const f of files) {
158 + try { await deleteFile(f.storagePath); } catch { /* déjà absent */ }
159 + for (const v of f.versions) { try { await deleteVersion(v.storagePath); } catch { /* ignore */ } }
160 + await deleteThumbnails(f.id);
161 + await prisma.file.delete({ where: { id: f.id } });
162 + if (log) await logActivity("PURGE", null, `Permanently deleted: ${f.name}`);
163 + }
164 + return files.length;
165 +}
166 +
167 +/** Purge automatique des fichiers en corbeille depuis plus de `days` jours. */
168 +export async function purgeExpiredTrash(days = TRASH_RETENTION_DAYS): Promise<number> {
169 + const limit = new Date(Date.now() - days * 86400000);
170 + const expired = await prisma.file.findMany({ where: { deletedAt: { not: null, lt: limit } }, select: { id: true } });
171 + if (!expired.length) return 0;
172 + const n = await purgeFiles(expired.map((f) => f.id), false);
173 + await logActivity("PURGE", null, `Auto-purge: ${n} fichier(s) en corbeille depuis plus de ${days} jours`);
174 + return n;
175 +}
176 +
177 +/** Nettoie les dossiers de morceaux d'upload orphelins (uploads interrompus). */
178 +export async function cleanupOrphanChunks(maxAgeHours = 24): Promise<number> {
179 + const dir = path.join(process.env.UPLOAD_DIR || "./uploads", "chunks");
180 + if (!existsSync(dir)) return 0;
181 + let removed = 0;
182 + const limit = Date.now() - maxAgeHours * 3600000;
183 + for (const entry of await readdir(dir)) {
184 + const p = path.join(dir, entry);
185 + try {
186 + const s = await stat(p);
187 + if (s.isDirectory() && s.mtimeMs < limit) { await rm(p, { recursive: true, force: true }); removed++; }
188 + } catch { /* ignore */ }
189 + }
190 + return removed;
191 +}
modified lib/webdav.ts +7 −14
@@ -12,6 +12,7 @@ import {
12 12 duplicateFile,
13 13 } from "./storage";
14 14 import { logActivity } from "./activity";
15 +import { trashFiles, trashFolderRecursive } from "./trash";
15 16
16 17 // ---------------------------------------------------------------------------
17 18 // Constants
@@ -795,19 +796,15 @@ async function handleDelete(
795 796 // Delete folder
796 797 const folderId = await resolvePersonalFolder(user.id, ctx.folderParts);
797 798 if (!folderId) { res.writeHead(403); res.end("Cannot delete root"); return; }
798 − const files = await prisma.file.findMany({ where: { folderId } });
799 − await Promise.all(files.map((f) => deleteFile(f.storagePath)));
800 − await prisma.folder.delete({ where: { id: folderId } });
801 − await logActivity("DELETE", undefined, `WebDAV delete folder: ${ctx.folderParts.join("/")}`);
799 + await trashFolderRecursive(folderId);
800 + await logActivity("DELETE_FOLDER", undefined, `WebDAV delete folder: ${ctx.folderParts.join("/")}`);
802 801 } else {
803 802 const folderId = await resolvePersonalFolder(user.id, ctx.folderParts);
804 803 const file = await prisma.file.findFirst({
805 804 where: { userId: user.id, folderId, name: ctx.filename },
806 805 });
807 806 if (!file) { res.writeHead(404); res.end("Not Found"); return; }
808 − await deleteFile(file.storagePath);
809 − await prisma.file.delete({ where: { id: file.id } });
810 − await logActivity("DELETE", file.id, `WebDAV delete: ${ctx.filename}`);
807 + await trashFiles([file.id]);
811 808 }
812 809 }
813 810
@@ -818,18 +815,14 @@ async function handleDelete(
818 815 if (!ctx.filename || ctx.isDir) {
819 816 const folderId = await resolveSharedFolder(space.id, ctx.folderParts);
820 817 if (!folderId) { res.writeHead(403); res.end("Cannot delete space root"); return; }
821 − const files = await prisma.file.findMany({ where: { folderId } });
822 − await Promise.all(files.map((f) => deleteFile(f.storagePath)));
823 − await prisma.folder.delete({ where: { id: folderId } });
824 − await logActivity("DELETE", undefined, `WebDAV delete shared folder: ${ctx.folderParts.join("/")} in ${ctx.spaceName}`);
818 + await trashFolderRecursive(folderId);
819 + await logActivity("DELETE_FOLDER", undefined, `WebDAV delete shared folder: ${ctx.folderParts.join("/")} in ${ctx.spaceName}`);
825 820 } else {
826 821 const folderId = await resolveSharedFolder(space.id, ctx.folderParts);
827 822 if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; }
828 823 const file = await prisma.file.findFirst({ where: { folderId, name: ctx.filename } });
829 824 if (!file) { res.writeHead(404); res.end("Not Found"); return; }
830 − await deleteFile(file.storagePath);
831 − await prisma.file.delete({ where: { id: file.id } });
832 − await logActivity("DELETE", file.id, `WebDAV shared delete: ${ctx.filename} in ${ctx.spaceName}`);
825 + await trashFiles([file.id]);
833 826 }
834 827 }
835 828
modified middleware.ts +2 −0
@@ -6,6 +6,7 @@ const publicPaths = [
6 6 "/login",
7 7 "/api/auth/login",
8 8 "/api/auth/2fa/verify",
9 + "/api/auth/otp/",
9 10 "/shared",
10 11 "/api/shared",
11 12 "/api/v1/",
@@ -19,6 +20,7 @@ const publicPaths = [
19 20 // CSRF-exempt paths (public endpoints that accept POST without session)
20 21 const csrfExemptPaths = [
21 22 "/api/auth/login",
23 + "/api/auth/otp/",
22 24 "/api/shared/",
23 25 "/api/v1/",
24 26 ];
modified prisma/dev.db-shm +0 −0

Binary file not shown.

modified prisma/dev.db-wal +0 −0

Binary file not shown.

added prisma/dev.db.bak-pre-audit-20260907-050648 +0 −0

Binary file not shown.

modified prisma/schema.prisma +14 −2
@@ -58,6 +58,10 @@ model File {
58 58 aiEmbedding String?
59 59
60 60 deletedAt DateTime?
61 + /// Dossier d'origine au moment de la mise à la corbeille (restauration fidèle)
62 + deletedFromFolderId String?
63 + /// Chemin d'origine sérialisé JSON {spaceId, names[]} si le dossier a disparu
64 + deletedFromPath String?
61 65 createdAt DateTime @default(now())
62 66 updatedAt DateTime @updatedAt
63 67 sharedLinks SharedLink[]
@@ -87,6 +91,7 @@ model Folder {
87 91 sharedSpace SharedSpace? @relation(fields: [sharedSpaceId], references: [id], onDelete: SetNull)
88 92 files File[]
89 93 permissions Permission[]
94 + sharedLinks SharedLink[]
90 95 createdAt DateTime @default(now())
91 96
92 97 @@index([parentId])
@@ -98,17 +103,24 @@ model Folder {
98 103
99 104 model SharedLink {
100 105 id String @id @default(cuid())
101 − fileId String
102 − file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
106 + /// Partage d'un fichier (exclusif avec folderId)
107 + fileId String?
108 + file File? @relation(fields: [fileId], references: [id], onDelete: Cascade)
109 + /// Partage d'un dossier complet (navigation + ZIP)
110 + folderId String?
111 + folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
103 112 token String @unique @default(cuid())
104 113 expiresAt DateTime?
105 114 passwordHash String?
106 115 mode String @default("DOWNLOAD")
107 116 active Boolean @default(true)
117 + accessCount Int @default(0)
118 + lastAccessAt DateTime?
108 119 createdAt DateTime @default(now())
109 120
110 121 @@index([token])
111 122 @@index([fileId])
123 + @@index([folderId])
112 124 }
113 125
114 126 // ==================== PERMISSIONS ====================
added scripts/migrations/2026-09-07-audit.sql +34 −0
@@ -0,0 +1,34 @@
1 +-- Migration audit fonctionnel 2026-09-07 (SQLite, mode WAL).
2 +-- Idempotente si appliquée via scripts/migrations/apply-2026-09-07-audit.ts (vérifie les colonnes avant).
3 +-- Équivalent : `npx prisma db push --skip-generate` puis `npx prisma generate`.
4 +
5 +-- 1. Corbeille : origine des fichiers supprimés
6 +ALTER TABLE "File" ADD COLUMN "deletedFromFolderId" TEXT;
7 +ALTER TABLE "File" ADD COLUMN "deletedFromPath" TEXT;
8 +
9 +-- 2. Liens de partage : fileId optionnel + partage de dossier + compteur d'accès
10 +PRAGMA foreign_keys=OFF;
11 +CREATE TABLE "new_SharedLink" (
12 + "id" TEXT NOT NULL PRIMARY KEY,
13 + "fileId" TEXT,
14 + "folderId" TEXT,
15 + "token" TEXT NOT NULL,
16 + "expiresAt" DATETIME,
17 + "passwordHash" TEXT,
18 + "mode" TEXT NOT NULL DEFAULT 'DOWNLOAD',
19 + "active" BOOLEAN NOT NULL DEFAULT true,
20 + "accessCount" INTEGER NOT NULL DEFAULT 0,
21 + "lastAccessAt" DATETIME,
22 + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
23 + CONSTRAINT "SharedLink_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
24 + CONSTRAINT "SharedLink_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder" ("id") ON DELETE CASCADE ON UPDATE CASCADE
25 +);
26 +INSERT INTO "new_SharedLink" ("id","fileId","token","expiresAt","passwordHash","mode","active","createdAt")
27 + SELECT "id","fileId","token","expiresAt","passwordHash","mode","active","createdAt" FROM "SharedLink";
28 +DROP TABLE "SharedLink";
29 +ALTER TABLE "new_SharedLink" RENAME TO "SharedLink";
30 +CREATE UNIQUE INDEX "SharedLink_token_key" ON "SharedLink"("token");
31 +CREATE INDEX "SharedLink_token_idx" ON "SharedLink"("token");
32 +CREATE INDEX "SharedLink_fileId_idx" ON "SharedLink"("fileId");
33 +CREATE INDEX "SharedLink_folderId_idx" ON "SharedLink"("folderId");
34 +PRAGMA foreign_keys=ON;
added scripts/migrations/apply-2026-09-07-audit.ts +64 −0
@@ -0,0 +1,64 @@
1 +/**
2 + * Applique la migration 2026-09-07-audit.sql de façon idempotente sur prisma/dev.db.
3 + * npx tsx --env-file=.env scripts/migrations/apply-2026-09-07-audit.ts
4 + * À exécuter AVANT de démarrer le nouveau build (le client Prisma régénéré lit les nouvelles colonnes).
5 + * Ensuite : `npx prisma generate` (si node_modules/.prisma n'a pas été régénéré).
6 + */
7 +import { PrismaClient } from "@prisma/client";
8 +
9 +const prisma = new PrismaClient();
10 +
11 +async function columns(table: string): Promise<string[]> {
12 + const rows = await prisma.$queryRawUnsafe<{ name: string }[]>(`PRAGMA table_info("${table}")`);
13 + return rows.map((r) => r.name);
14 +}
15 +
16 +async function main() {
17 + const fileCols = await columns("File");
18 + if (!fileCols.includes("deletedFromFolderId")) {
19 + await prisma.$executeRawUnsafe(`ALTER TABLE "File" ADD COLUMN "deletedFromFolderId" TEXT`);
20 + console.log("+ File.deletedFromFolderId");
21 + }
22 + if (!fileCols.includes("deletedFromPath")) {
23 + await prisma.$executeRawUnsafe(`ALTER TABLE "File" ADD COLUMN "deletedFromPath" TEXT`);
24 + console.log("+ File.deletedFromPath");
25 + }
26 +
27 + const shareCols = await columns("SharedLink");
28 + if (!shareCols.includes("folderId")) {
29 + const stmts = [
30 + `PRAGMA foreign_keys=OFF`,
31 + `CREATE TABLE "new_SharedLink" (
32 + "id" TEXT NOT NULL PRIMARY KEY,
33 + "fileId" TEXT,
34 + "folderId" TEXT,
35 + "token" TEXT NOT NULL,
36 + "expiresAt" DATETIME,
37 + "passwordHash" TEXT,
38 + "mode" TEXT NOT NULL DEFAULT 'DOWNLOAD',
39 + "active" BOOLEAN NOT NULL DEFAULT true,
40 + "accessCount" INTEGER NOT NULL DEFAULT 0,
41 + "lastAccessAt" DATETIME,
42 + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
43 + CONSTRAINT "SharedLink_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
44 + CONSTRAINT "SharedLink_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder" ("id") ON DELETE CASCADE ON UPDATE CASCADE
45 + )`,
46 + `INSERT INTO "new_SharedLink" ("id","fileId","token","expiresAt","passwordHash","mode","active","createdAt")
47 + SELECT "id","fileId","token","expiresAt","passwordHash","mode","active","createdAt" FROM "SharedLink"`,
48 + `DROP TABLE "SharedLink"`,
49 + `ALTER TABLE "new_SharedLink" RENAME TO "SharedLink"`,
50 + `CREATE UNIQUE INDEX "SharedLink_token_key" ON "SharedLink"("token")`,
51 + `CREATE INDEX "SharedLink_token_idx" ON "SharedLink"("token")`,
52 + `CREATE INDEX "SharedLink_fileId_idx" ON "SharedLink"("fileId")`,
53 + `CREATE INDEX "SharedLink_folderId_idx" ON "SharedLink"("folderId")`,
54 + `PRAGMA foreign_keys=ON`,
55 + ];
56 + for (const s of stmts) await prisma.$executeRawUnsafe(s);
57 + console.log("~ SharedLink reconstruite (fileId nullable, folderId, accessCount, lastAccessAt)");
58 + }
59 + const n = await prisma.$queryRawUnsafe<{ n: number }[]>(`SELECT COUNT(*) as n FROM "SharedLink"`);
60 + console.log(`Migration OK · ${Number(n[0].n)} liens de partage conservés`);
61 + await prisma.$disconnect();
62 +}
63 +
64 +main().catch((e) => { console.error(e); process.exit(1); });
added scripts/organize/apply.ts +119 −0
@@ -0,0 +1,119 @@
1 +/**
2 + * Étape 3 — Application du plan (data/plan.json) : renommage des espaces, création de l'arborescence,
3 + * déplacement + renommage des fichiers, description IA, suppression des anciens dossiers vides.
4 + * Journal complet dans data/applied.jsonl (rollback.ts). Sauvegarde SQLite avant toute écriture.
5 + * npx tsx --env-file=.env scripts/organize/apply.ts --dry-run
6 + * npx tsx --env-file=.env scripts/organize/apply.ts
7 + */
8 +import fs from "fs";
9 +import path from "path";
10 +import { prisma } from "../../lib/prisma";
11 +import { logActivity } from "../../lib/activity";
12 +import { APPLIED_PATH, DATA_DIR, PLAN_PATH, appendJsonl } from "./common";
13 +import type { Plan, PlanSpace } from "./plan";
14 +
15 +const dry = process.argv.includes("--dry-run");
16 +
17 +interface Applied {
18 + kind: "file" | "space" | "folder-created" | "folder-deleted";
19 + fileId?: string; oldName?: string; newName?: string;
20 + oldFolder?: { id: string; name: string; parentId: string | null; sharedSpaceId: string | null } | null;
21 + newFolderId?: string;
22 + spaceId?: string; folderId?: string; folder?: { id: string; name: string; parentId: string | null; sharedSpaceId: string | null };
23 + at: string;
24 +}
25 +
26 +function printTree(ps: PlanSpace) {
27 + const byFolder = new Map<string, string[]>();
28 + for (const f of ps.files) { if (!byFolder.has(f.folder)) byFolder.set(f.folder, []); byFolder.get(f.folder)!.push(f.name); }
29 + console.log(`\n📁 ${ps.oldName} → « ${ps.newName} » (${ps.files.length} fichiers, ${byFolder.size} dossiers)`);
30 + for (const folder of Array.from(byFolder.keys()).sort()) {
31 + const names = byFolder.get(folder)!.sort();
32 + console.log(` ${folder} (${names.length})`);
33 + for (const n of names.slice(0, dry ? 8 : 0)) console.log(` · ${n}`);
34 + if (dry && names.length > 8) console.log(` … +${names.length - 8}`);
35 + }
36 + if (ps.notes) console.log(` ℹ ${ps.notes}`);
37 +}
38 +
39 +async function ensureFolder(spaceId: string, pathStr: string, cache: Map<string, string>): Promise<string> {
40 + const parts = pathStr.split("/").filter(Boolean);
41 + let parentId: string | null = null;
42 + let acc = "";
43 + for (const part of parts) {
44 + acc = acc ? `${acc}/${part}` : part;
45 + if (cache.has(acc)) { parentId = cache.get(acc)!; continue; }
46 + let folder: { id: string; name: string; parentId: string | null; sharedSpaceId: string | null } | null = await prisma.folder.findFirst({ where: { name: part, parentId, sharedSpaceId: spaceId } });
47 + if (!folder) {
48 + folder = await prisma.folder.create({ data: { name: part, parentId, sharedSpaceId: spaceId } });
49 + appendJsonl(APPLIED_PATH, { kind: "folder-created", folderId: folder.id, folder: { id: folder.id, name: folder.name, parentId: folder.parentId, sharedSpaceId: folder.sharedSpaceId }, at: new Date().toISOString() } satisfies Applied);
50 + }
51 + cache.set(acc, folder.id);
52 + parentId = folder.id;
53 + }
54 + return parentId!;
55 +}
56 +
57 +async function main() {
58 + const plan: Plan = JSON.parse(fs.readFileSync(PLAN_PATH, "utf8"));
59 + const total = plan.spaces.reduce((n, s) => n + s.files.length, 0);
60 + console.log(`Plan du ${plan.generatedAt} · ${plan.spaces.length} espaces · ${total} fichiers · ${dry ? "SIMULATION" : "APPLICATION"}`);
61 + for (const ps of plan.spaces) printTree(ps);
62 + if (dry) { await prisma.$disconnect(); return; }
63 +
64 + // Sauvegarde cohérente de la base (fonctionne en mode WAL)
65 + const bak = path.join(DATA_DIR, `dev.db.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
66 + await prisma.$executeRawUnsafe(`VACUUM INTO '${bak.replace(/'/g, "''")}'`);
67 + console.log(`\n💾 Sauvegarde SQLite : ${bak}`);
68 +
69 + let moved = 0, renamed = 0, createdFolders = 0, deletedFolders = 0;
70 + for (const ps of plan.spaces) {
71 + const cache = new Map<string, string>();
72 + const oldFolderIds = new Set<string>();
73 + // Espace
74 + const space = await prisma.sharedSpace.findUnique({ where: { id: ps.spaceId } });
75 + if (!space) { console.warn(`Espace introuvable ${ps.spaceId}`); continue; }
76 + if (space.name !== ps.newName) {
77 + await prisma.sharedSpace.update({ where: { id: ps.spaceId }, data: { name: ps.newName } });
78 + appendJsonl(APPLIED_PATH, { kind: "space", spaceId: ps.spaceId, oldName: space.name, newName: ps.newName, at: new Date().toISOString() } satisfies Applied);
79 + }
80 + // Fichiers
81 + for (const pf of ps.files) {
82 + const file = await prisma.file.findUnique({ where: { id: pf.id }, include: { folder: true } });
83 + if (!file) { console.warn(`Fichier introuvable ${pf.id} (${pf.oldName})`); continue; }
84 + if (file.folderId) oldFolderIds.add(file.folderId);
85 + const before = cache.size;
86 + const targetId = await ensureFolder(ps.spaceId, pf.folder, cache);
87 + if (cache.size > before) createdFolders += cache.size - before;
88 + const changes: { folderId?: string; name?: string; aiDescription?: string } = {};
89 + if (file.folderId !== targetId) { changes.folderId = targetId; moved++; }
90 + if (file.name !== pf.name) { changes.name = pf.name; renamed++; }
91 + if (pf.summary && pf.summary !== file.aiDescription) changes.aiDescription = pf.summary;
92 + if (Object.keys(changes).length) {
93 + await prisma.file.update({ where: { id: pf.id }, data: { ...changes, updatedAt: file.updatedAt } });
94 + appendJsonl(APPLIED_PATH, {
95 + kind: "file", fileId: pf.id, oldName: file.name, newName: pf.name,
96 + oldFolder: file.folder ? { id: file.folder.id, name: file.folder.name, parentId: file.folder.parentId, sharedSpaceId: file.folder.sharedSpaceId } : null,
97 + newFolderId: targetId, at: new Date().toISOString(),
98 + } satisfies Applied);
99 + }
100 + }
101 + // Anciens dossiers désormais vides (aucun fichier, même en corbeille, aucun sous-dossier)
102 + const reused = new Set(Array.from(cache.values()));
103 + for (const fid of Array.from(oldFolderIds)) {
104 + if (reused.has(fid)) continue; // réutilisé par le plan
105 + const f = await prisma.folder.findUnique({ where: { id: fid }, include: { _count: { select: { files: true, children: true } } } });
106 + if (f && f._count.files === 0 && f._count.children === 0) {
107 + appendJsonl(APPLIED_PATH, { kind: "folder-deleted", folderId: f.id, folder: { id: f.id, name: f.name, parentId: f.parentId, sharedSpaceId: f.sharedSpaceId }, at: new Date().toISOString() } satisfies Applied);
108 + await prisma.folder.delete({ where: { id: f.id } });
109 + deletedFolders++;
110 + }
111 + }
112 + await logActivity("ORGANIZE", null, `Réorganisation IA de l'espace « ${ps.newName} » (${ps.files.length} fichiers)`);
113 + console.log(`✓ ${ps.newName}`);
114 + }
115 + console.log(`\nTerminé : ${renamed} renommés · ${moved} déplacés · ${createdFolders} dossiers créés · ${deletedFolders} anciens dossiers vides supprimés · journal ${APPLIED_PATH}`);
116 + await prisma.$disconnect();
117 +}
118 +
119 +main().catch((e) => { console.error(e); process.exit(1); });
added scripts/organize/common.ts +131 −0
@@ -0,0 +1,131 @@
1 +/**
2 + * Réorganisation intelligente des espaces partagés Richard ↔ Simon-Pierre.
3 + * Utilitaires communs (périmètre, cache JSONL, nettoyage de noms).
4 + *
5 + * Exécution sur le nœud, depuis la racine du projet :
6 + * npx tsx --env-file=.env scripts/organize/extract.ts
7 + * npx tsx --env-file=.env scripts/organize/plan.ts
8 + * npx tsx --env-file=.env scripts/organize/apply.ts [--dry-run]
9 + */
10 +import fs from "fs";
11 +import path from "path";
12 +import { prisma } from "../../lib/prisma";
13 +
14 +export const DATA_DIR = path.resolve("scripts/organize/data");
15 +export const EXTRACT_PATH = path.join(DATA_DIR, "extract.jsonl");
16 +export const PLAN_PATH = path.join(DATA_DIR, "plan.json");
17 +export const APPLIED_PATH = path.join(DATA_DIR, "applied.jsonl");
18 +fs.mkdirSync(DATA_DIR, { recursive: true });
19 +
20 +export const MODEL = "claude-opus-5";
21 +
22 +/** Utilisateurs dont la co-appartenance définit le périmètre. */
23 +export const MEMBERS = ["rboucher", "spboucher"];
24 +
25 +export interface ScopeFile {
26 + id: string;
27 + name: string;
28 + mimeType: string;
29 + size: number;
30 + storagePath: string;
31 + isEncrypted: boolean;
32 + folderId: string | null;
33 + folderName: string;
34 + spaceId: string;
35 + spaceName: string;
36 + createdAt: string;
37 +}
38 +
39 +export interface Extraction {
40 + id: string;
41 + name: string;
42 + spaceId: string;
43 + spaceName: string;
44 + folderName: string;
45 + mimeType: string;
46 + kind: "photo" | "pdf" | "text" | "office" | "other";
47 + ok: boolean;
48 + error?: string;
49 + data?: Record<string, unknown>;
50 + at: string;
51 +}
52 +
53 +/** Espaces partagés où TOUS les membres requis sont présents. */
54 +export async function sharedSpacesInScope() {
55 + const spaces = await prisma.sharedSpace.findMany({
56 + include: { members: { include: { user: { select: { username: true } } } } },
57 + orderBy: { name: "asc" },
58 + });
59 + return spaces.filter((s) => MEMBERS.every((u) => s.members.some((m) => m.user.username === u)));
60 +}
61 +
62 +export async function filesInScope(): Promise<ScopeFile[]> {
63 + const spaces = await sharedSpacesInScope();
64 + const spaceById = new Map(spaces.map((s) => [s.id, s]));
65 + const folders = await prisma.folder.findMany({ where: { sharedSpaceId: { in: spaces.map((s) => s.id) } } });
66 + const folderById = new Map(folders.map((f) => [f.id, f]));
67 + const files = await prisma.file.findMany({
68 + where: { deletedAt: null, folderId: { in: folders.map((f) => f.id) } },
69 + orderBy: { name: "asc" },
70 + });
71 + return files.map((f) => {
72 + const folder = folderById.get(f.folderId!)!;
73 + const space = spaceById.get(folder.sharedSpaceId!)!;
74 + return {
75 + id: f.id, name: f.name, mimeType: f.mimeType, size: Number(f.size), storagePath: f.storagePath,
76 + isEncrypted: f.isEncrypted, folderId: f.folderId, folderName: folder.name, spaceId: space.id, spaceName: space.name,
77 + createdAt: f.createdAt.toISOString(),
78 + };
79 + });
80 +}
81 +
82 +export function readJsonl<T>(p: string): T[] {
83 + if (!fs.existsSync(p)) return [];
84 + return fs.readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l) as T);
85 +}
86 +export function appendJsonl(p: string, obj: unknown) {
87 + fs.appendFileSync(p, JSON.stringify(obj) + "\n");
88 +}
89 +
90 +/** Extrait le premier objet JSON d'une réponse texte. */
91 +export function parseJson(text: string): Record<string, unknown> | null {
92 + const m = text.match(/\{[\s\S]*\}/);
93 + if (!m) return null;
94 + try { return JSON.parse(m[0]); } catch { return null; }
95 +}
96 +
97 +/** Nettoie un nom de fichier/dossier : accents conservés, caractères interdits retirés, espaces normalisés. */
98 +export function cleanName(s: string, maxLen = 120): string {
99 + let n = s.normalize("NFC").replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, " ").trim();
100 + n = n.replace(/^[.\s]+|[.\s]+$/g, "");
101 + if (n.length > maxLen) n = n.slice(0, maxLen).trim();
102 + return n || "Sans nom";
103 +}
104 +
105 +export function extOf(name: string): string {
106 + const m = name.match(/\.([A-Za-z0-9]{1,6})$/);
107 + return m ? m[1].toLowerCase() : "";
108 +}
109 +
110 +/** Slug ASCII compact pour les photos (Titre_court). */
111 +export function slugTitle(s: string, maxLen = 48): string {
112 + const t = s.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
113 + const cut = t.slice(0, maxLen).replace(/_+$/g, "");
114 + return cut || "Photo";
115 +}
116 +
117 +export function pad(n: number, w = 3) { return String(n).padStart(w, "0"); }
118 +
119 +export function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); }
120 +
121 +/** Exécute des tâches avec une concurrence bornée. */
122 +export async function pool<T>(items: T[], limit: number, fn: (item: T, idx: number) => Promise<void>) {
123 + let i = 0;
124 + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
125 + while (i < items.length) {
126 + const idx = i++;
127 + await fn(items[idx], idx);
128 + }
129 + });
130 + await Promise.all(workers);
131 +}
added scripts/organize/dupes.ts +44 −0
@@ -0,0 +1,44 @@
1 +/**
2 + * Détection des doublons exacts (SHA-256 du contenu déchiffré) dans le périmètre → data/dupes.json
3 + * npx tsx --env-file=.env scripts/organize/dupes.ts
4 + */
5 +import crypto from "crypto";
6 +import fs from "fs";
7 +import path from "path";
8 +import { loadFileData } from "../../lib/storage";
9 +import { prisma } from "../../lib/prisma";
10 +import { DATA_DIR, filesInScope, pool } from "./common";
11 +
12 +export const DUPES_PATH = path.join(DATA_DIR, "dupes.json");
13 +
14 +async function main() {
15 + const files = await filesInScope();
16 + const hashes = new Map<string, string>(); // fileId → sha256
17 + let n = 0;
18 + await pool(files, 8, async (f) => {
19 + try {
20 + const buf = await loadFileData(f.storagePath, f.isEncrypted);
21 + hashes.set(f.id, crypto.createHash("sha256").update(buf).digest("hex"));
22 + } catch (e) {
23 + console.warn("hash impossible", f.name, e instanceof Error ? e.message : e);
24 + }
25 + if (++n % 200 === 0) console.log(`${n}/${files.length}`);
26 + });
27 + const groups = new Map<string, string[]>();
28 + for (const [id, h] of Array.from(hashes.entries())) { if (!groups.has(h)) groups.set(h, []); groups.get(h)!.push(id); }
29 + const byId = new Map(files.map((f) => [f.id, f]));
30 + const dupGroups = Array.from(groups.values()).filter((ids) => ids.length > 1).map((ids) => {
31 + // le « maître » = le plus ancien (createdAt), puis nom le plus court
32 + const sorted = ids.map((id) => byId.get(id)!).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.name.length - b.name.length);
33 + return { master: sorted[0].id, duplicates: sorted.slice(1).map((f) => f.id), name: sorted[0].name, space: sorted[0].spaceName, size: sorted[0].size };
34 + });
35 + const out = { generatedAt: new Date().toISOString(), hashed: hashes.size, groups: dupGroups, duplicateIds: dupGroups.flatMap((g) => g.duplicates) };
36 + fs.writeFileSync(DUPES_PATH, JSON.stringify(out, null, 2));
37 + const bySpace = new Map<string, number>();
38 + for (const g of dupGroups) bySpace.set(g.space, (bySpace.get(g.space) ?? 0) + g.duplicates.length);
39 + console.log(`${hashes.size} fichiers hachés · ${dupGroups.length} groupes de doublons · ${out.duplicateIds.length} copies redondantes`);
40 + for (const [s, c] of Array.from(bySpace.entries())) console.log(` ${s}: ${c}`);
41 + await prisma.$disconnect();
42 +}
43 +
44 +main().catch((e) => { console.error(e); process.exit(1); });
added scripts/organize/extract.ts +238 −0
@@ -0,0 +1,238 @@
1 +/**
2 + * Étape 1 — Extraction : décrit chaque fichier du périmètre avec Claude Opus 5
3 + * (vision pour les photos, document PDF natif, texte pour le reste).
4 + * Résultats mis en cache dans data/extract.jsonl (idempotent : relancer reprend où ça s'est arrêté).
5 + */
6 +import Anthropic from "@anthropic-ai/sdk";
7 +import fs from "fs";
8 +import os from "os";
9 +import path from "path";
10 +import { execFile } from "child_process";
11 +import { promisify } from "util";
12 +import sharp from "sharp";
13 +import { PDFDocument } from "pdf-lib";
14 +import mammoth from "mammoth";
15 +import * as XLSX from "xlsx";
16 +import { loadFileData } from "../../lib/storage";
17 +import { prisma } from "../../lib/prisma";
18 +import {
19 + EXTRACT_PATH, MODEL, appendJsonl, filesInScope, parseJson, pool, readJsonl, sleep,
20 + type Extraction, type ScopeFile,
21 +} from "./common";
22 +
23 +const client = new Anthropic();
24 +const execFileP = promisify(execFile);
25 +const CONCURRENCY = Number(process.env.ORG_CONCURRENCY || 6);
26 +const MAX_PDF_PAGES = 24;
27 +
28 +const CONTEXT = `Contexte : cloud familial privé (Québec, Canada) partagé entre Richard Boucher et son fils Simon-Pierre Boucher.
29 +Sylvie Defoy (conjointe de Richard, mère de Simon-Pierre) est décédée en février 2026 ; les documents concernent surtout
30 +sa succession, ses placements, les impôts (Sylvie, Simon-Pierre, Richard), des assurances, un condo à Hallandale Beach (Floride),
31 +des factures Hydro-Québec, et des photos de famille numérisées (FastFoto). Réponds en français, avec accents.`;
32 +
33 +const DOC_PROMPT = `${CONTEXT}
34 +
35 +Analyse ce document et réponds UNIQUEMENT avec un objet JSON (aucun texte autour) :
36 +{
37 + "doc_type": "type précis (ex. Relevé de compte, Feuillet T5, Relevé 3, T4, Relevé 1, Facture, Certificat d'assurance, Acte de décès, Contrat, Formulaire fiscal, Rapport de performance, Sommaire des transactions, Lettre, Reçu, Avis de décès…)",
38 + "issuer": "émetteur (institution / entreprise / organisme, forme courte et courante ex. RBC, Banque Nationale, Desjardins, TD, Scotia, Hydro-Québec, Revenu Québec, ARC, Wealthsimple…) ou null",
39 + "person": "personne principale concernée : Sylvie Defoy | Simon-Pierre Boucher | Richard Boucher | Succession Sylvie Defoy | autre nom | null",
40 + "date": "date du document AAAA-MM-JJ (date d'émission/fin de période) ou AAAA-MM ou AAAA ou null",
41 + "period": "période couverte si relevé (ex. 2025-12, 2026-01 à 2026-02, année 2025) ou null",
42 + "tax_year": "année d'imposition si feuillet/formulaire fiscal (ex. 2025) ou null",
43 + "account": "identifiant de compte/police/contrat abrégé (4-8 derniers caractères, ex. 66NRVKE, …1210, CELI) ou null",
44 + "amount": "montant principal si pertinent (ex. 1 234,56 $) ou null",
45 + "language": "fr | en",
46 + "summary": "une phrase (≤ 30 mots) décrivant précisément le contenu",
47 + "filename_stem": "nom de fichier proposé SANS extension, format AAAA-MM-JJ_Émetteur_Type-de-document_Détail (accents permis, espaces remplacés par _ ou -), ≤ 90 caractères"
48 +}`;
49 +
50 +const PHOTO_PROMPT = `${CONTEXT}
51 +
52 +Ceci est une photo de famille numérisée (tirage papier scanné). Réponds UNIQUEMENT avec un objet JSON :
53 +{
54 + "title": "titre court en français (3 à 7 mots, sans article initial, ex. Baignade dans les vagues, Souper de gala au Château Frontenac)",
55 + "description": "1 phrase descriptive (≤ 25 mots)",
56 + "scene": "plage | piscine | ville | nature | maison | fête | mariage | gala | restaurant | école | sport | voyage | portrait | hiver | autre",
57 + "event": "hypothèse d'événement/série (ex. Vacances à Virginia Beach, Gala professionnel, Noël en famille, Portrait studio, Baptême, Voyage…) ",
58 + "location_hint": "lieu identifiable (enseigne, panneau, texte visible, architecture) ou null",
59 + "people_count": 0,
60 + "people": "description brève des personnes (ex. mère et jeune garçon, groupe d'hommes en costume) ou null",
61 + "child_present": true,
62 + "indoor": true,
63 + "era": "décennie estimée d'après vêtements/qualité (ex. 1990s, 2000s, 1980s) ou null",
64 + "season": "été | hiver | automne | printemps | null",
65 + "text_visible": "texte lisible dans l'image ou null",
66 + "orientation_issue": "upside_down | rotated_left | rotated_right | ok",
67 + "quality": "bonne | moyenne | faible"
68 +}`;
69 +
70 +const TEXT_PROMPT = `${CONTEXT}
71 +
72 +Voici le contenu (possiblement tronqué) d'un fichier texte/données. Réponds UNIQUEMENT avec un objet JSON :
73 +{
74 + "doc_type": "type (ex. Fiche de compétence IA (skill), Export CSV de transactions, Analyse immobilière, Script Python, Configuration JSON, Note Markdown…)",
75 + "issuer": "source/outil/institution ou null",
76 + "person": "personne concernée ou null",
77 + "date": "AAAA-MM-JJ ou AAAA-MM ou AAAA ou null",
78 + "topic": "sujet principal en 3-6 mots",
79 + "summary": "une phrase (≤ 30 mots)",
80 + "filename_stem": "nom de fichier proposé SANS extension, clair et descriptif (≤ 80 caractères, espaces → _ ou -)"
81 +}`;
82 +
83 +function kindOf(f: ScopeFile): Extraction["kind"] {
84 + const m = f.mimeType;
85 + if (m.startsWith("image/")) return "photo";
86 + if (m === "application/pdf") return "pdf";
87 + if (m.startsWith("text/") || m.includes("json") || m.includes("python")) return "text";
88 + if (m.includes("word") || m.includes("sheet") || m.includes("excel") || m.includes("presentation")) return "office";
89 + return "other";
90 +}
91 +
92 +async function callClaude(content: Anthropic.MessageParam["content"]): Promise<Record<string, unknown>> {
93 + let lastErr: unknown;
94 + for (let attempt = 0; attempt < 5; attempt++) {
95 + try {
96 + const res = await client.messages.create({
97 + model: MODEL,
98 + max_tokens: 1200,
99 + output_config: { effort: "low" },
100 + messages: [{ role: "user", content }],
101 + });
102 + if (res.stop_reason === "refusal") throw new Error(`refusal: ${(res as unknown as { stop_details?: { explanation?: string } }).stop_details?.explanation ?? ""}`);
103 + const text = res.content.filter((b): b is Anthropic.TextBlock => b.type === "text").map((b) => b.text).join("\n");
104 + const json = parseJson(text);
105 + if (!json) throw new Error("JSON introuvable dans la réponse : " + text.slice(0, 200));
106 + return json;
107 + } catch (e) {
108 + lastErr = e;
109 + if (e instanceof Anthropic.RateLimitError || (e instanceof Anthropic.APIError && (e.status ?? 0) >= 500) || e instanceof Anthropic.APIConnectionError) {
110 + await sleep(2000 * (attempt + 1) + Math.random() * 1000);
111 + continue;
112 + }
113 + if (e instanceof Error && e.message.startsWith("JSON introuvable") && attempt < 2) continue;
114 + throw e;
115 + }
116 + }
117 + throw lastErr;
118 +}
119 +
120 +async function pdfForClaude(buf: Buffer): Promise<{ data: Buffer; pages: number; truncated: boolean }> {
121 + // pdf-lib sert uniquement à compter/tronquer ; s'il échoue (PDF atypique), on envoie le fichier brut.
122 + try {
123 + const doc = await PDFDocument.load(buf, { ignoreEncryption: true, updateMetadata: false });
124 + const pages = doc.getPageCount();
125 + if (pages <= MAX_PDF_PAGES && buf.length < 20 * 1024 * 1024) return { data: buf, pages, truncated: false };
126 + const out = await PDFDocument.create();
127 + const idx = Array.from({ length: Math.min(MAX_PDF_PAGES, pages) }, (_, i) => i);
128 + const copied = await out.copyPages(doc, idx);
129 + copied.forEach((p) => out.addPage(p));
130 + return { data: Buffer.from(await out.save()), pages, truncated: true };
131 + } catch {
132 + return { data: buf, pages: 0, truncated: false };
133 + }
134 +}
135 +
136 +/** Secours : rend les premières pages en images via pdftoppm (poppler) ou sips (macOS, 1re page) quand l'API refuse le PDF. */
137 +async function pdfPagesAsImages(buf: Buffer, maxPages = 6): Promise<Buffer[]> {
138 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "spb-pdf-"));
139 + const src = path.join(dir, "in.pdf");
140 + fs.writeFileSync(src, buf);
141 + const out: Buffer[] = [];
142 + try {
143 + try {
144 + await execFileP("pdftoppm", ["-r", "110", "-l", String(maxPages), "-jpeg", src, path.join(dir, "p")]);
145 + for (const f of fs.readdirSync(dir).filter((x) => x.startsWith("p") && x.endsWith(".jpg")).sort()) out.push(fs.readFileSync(path.join(dir, f)));
146 + } catch {
147 + const jpg = path.join(dir, "p1.jpg");
148 + await execFileP("sips", ["-s", "format", "jpeg", "-Z", "1600", src, "--out", jpg]);
149 + out.push(fs.readFileSync(jpg));
150 + }
151 + } catch { /* aucun convertisseur disponible */ }
152 + fs.rmSync(dir, { recursive: true, force: true });
153 + return out;
154 +}
155 +
156 +async function extractOne(f: ScopeFile): Promise<Extraction> {
157 + const base: Extraction = { id: f.id, name: f.name, spaceId: f.spaceId, spaceName: f.spaceName, folderName: f.folderName, mimeType: f.mimeType, kind: kindOf(f), ok: false, at: new Date().toISOString() };
158 + const hint = `Nom actuel : ${f.name}\nDossier actuel : ${f.folderName}\nEspace : ${f.spaceName}`;
159 + try {
160 + const buf = await loadFileData(f.storagePath, f.isEncrypted);
161 + let data: Record<string, unknown>;
162 + if (base.kind === "photo") {
163 + if (!/jpeg|png|webp|gif/.test(f.mimeType)) throw new Error("format image non pris en charge : " + f.mimeType);
164 + const img = await sharp(buf).rotate().resize({ width: 1280, height: 1280, fit: "inside", withoutEnlargement: true }).jpeg({ quality: 82 }).toBuffer();
165 + const meta = await sharp(buf).metadata();
166 + data = await callClaude([
167 + { type: "image", source: { type: "base64", media_type: "image/jpeg", data: img.toString("base64") } },
168 + { type: "text", text: `${PHOTO_PROMPT}\n\n${hint}` },
169 + ]);
170 + data._width = meta.width; data._height = meta.height;
171 + } else if (base.kind === "pdf") {
172 + const { data: pdf, pages, truncated } = await pdfForClaude(buf);
173 + try {
174 + data = await callClaude([
175 + { type: "document", source: { type: "base64", media_type: "application/pdf", data: pdf.toString("base64") } },
176 + { type: "text", text: `${DOC_PROMPT}\n\n${hint}${truncated ? `\n(Seules les ${MAX_PDF_PAGES} premières pages sur ${pages} sont fournies.)` : ""}` },
177 + ]);
178 + } catch (e) {
179 + if (!(e instanceof Anthropic.BadRequestError)) throw e;
180 + const imgs = await pdfPagesAsImages(buf);
181 + if (!imgs.length) throw e;
182 + data = await callClaude([
183 + ...imgs.map((b) => ({ type: "image" as const, source: { type: "base64" as const, media_type: "image/jpeg" as const, data: b.toString("base64") } })),
184 + { type: "text", text: `${DOC_PROMPT}\n\n${hint}\n(PDF fourni sous forme d'images des ${imgs.length} premières pages.)` },
185 + ]);
186 + data._rendered = true;
187 + }
188 + data._pages = pages;
189 + } else if (base.kind === "office") {
190 + let text = "";
191 + if (f.mimeType.includes("word")) text = (await mammoth.extractRawText({ buffer: buf })).value;
192 + else {
193 + const wb = XLSX.read(buf, { type: "buffer" });
194 + text = wb.SheetNames.slice(0, 3).map((n) => `## ${n}\n` + XLSX.utils.sheet_to_csv(wb.Sheets[n]).split("\n").slice(0, 150).join("\n")).join("\n\n");
195 + }
196 + data = await callClaude([{ type: "text", text: `${DOC_PROMPT}\n\n${hint}\n\n--- CONTENU ---\n${text.slice(0, 12000)}` }]);
197 + } else if (base.kind === "text") {
198 + const text = buf.toString("utf8").slice(0, 8000);
199 + data = await callClaude([{ type: "text", text: `${TEXT_PROMPT}\n\n${hint}\n\n--- CONTENU ---\n${text}` }]);
200 + } else {
201 + throw new Error("type non analysé : " + f.mimeType);
202 + }
203 + return { ...base, ok: true, data };
204 + } catch (e) {
205 + return { ...base, ok: false, error: e instanceof Error ? e.message : String(e) };
206 + }
207 +}
208 +
209 +async function main() {
210 + const files = await filesInScope();
211 + const done = new Map(readJsonl<Extraction>(EXTRACT_PATH).map((e) => [e.id, e]));
212 + const retryErrors = process.argv.includes("--retry-errors");
213 + const limitArg = process.argv.find((a) => a.startsWith("--limit="));
214 + let todo = files.filter((f) => !done.has(f.id) || (retryErrors && !done.get(f.id)!.ok));
215 + if (limitArg) {
216 + // Échantillon varié : quelques fichiers de chaque type
217 + const n = Number(limitArg.split("=")[1]);
218 + const byKind = new Map<string, ScopeFile[]>();
219 + for (const f of todo) { const k = kindOf(f); if (!byKind.has(k)) byKind.set(k, []); byKind.get(k)!.push(f); }
220 + todo = Array.from(byKind.values()).flatMap((arr) => arr.slice(0, Math.max(1, Math.ceil(n / byKind.size))));
221 + }
222 + console.log(`Périmètre : ${files.length} fichiers · déjà extraits : ${done.size} · à traiter : ${todo.length} · modèle ${MODEL} · concurrence ${CONCURRENCY}`);
223 + let n = 0, ok = 0, ko = 0;
224 + const t0 = Date.now();
225 + await pool(todo, CONCURRENCY, async (f) => {
226 + const ext = await extractOne(f);
227 + appendJsonl(EXTRACT_PATH, ext);
228 + n++; if (ext.ok) ok++; else ko++;
229 + if (n % 25 === 0 || !ext.ok) {
230 + const rate = n / ((Date.now() - t0) / 60000);
231 + console.log(`[${n}/${todo.length}] ok=${ok} ko=${ko} · ${rate.toFixed(1)}/min · ${ext.ok ? "✓" : "✗ " + ext.error} ${f.spaceName} / ${f.name}`);
232 + }
233 + });
234 + console.log(`Terminé : ${ok} ok, ${ko} erreurs, ${((Date.now() - t0) / 60000).toFixed(1)} min`);
235 + await prisma.$disconnect();
236 +}
237 +
238 +main().catch((e) => { console.error(e); process.exit(1); });
added scripts/organize/plan.ts +262 −0
@@ -0,0 +1,262 @@
1 +/**
2 + * Étape 2 — Plan : à partir des extractions, Claude Opus 5 propose par espace une arborescence
3 + * logique + un nom explicite pour chaque fichier. Les photos sont regroupées en albums.
4 + * Sortie : data/plan.json (validé : chaque fichier exactement une fois, noms uniques, extensions conservées).
5 + */
6 +import Anthropic from "@anthropic-ai/sdk";
7 +import fs from "fs";
8 +import path from "path";
9 +import { prisma } from "../../lib/prisma";
10 +import {
11 + EXTRACT_PATH, MODEL, PLAN_PATH, cleanName, extOf, filesInScope, pad, parseJson, readJsonl, sharedSpacesInScope, slugTitle,
12 + type Extraction, DATA_DIR,
13 +} from "./common";
14 +
15 +interface DupesFile { groups: { master: string; duplicates: string[] }[]; duplicateIds: string[] }
16 +function loadDupes(): DupesFile {
17 + const p = path.join(DATA_DIR, "dupes.json");
18 + return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : { groups: [], duplicateIds: [] };
19 +}
20 +
21 +/** Les copies strictement identiques (même SHA-256) rejoignent « 99 Doublons identiques » avec le nom du fichier maître. */
22 +function applyDupes(files: PlanFile[], dupes: DupesFile) {
23 + const byId = new Map(files.map((f) => [f.id, f]));
24 + for (const g of dupes.groups) {
25 + const master = byId.get(g.master);
26 + if (!master) continue;
27 + g.duplicates.forEach((id, i) => {
28 + const d = byId.get(id);
29 + if (!d) return;
30 + const ext = extOf(master.name);
31 + const stem = ext ? master.name.slice(0, -(ext.length + 1)) : master.name;
32 + d.folder = "99 Doublons identiques";
33 + d.name = `${stem}_doublon${g.duplicates.length > 1 ? "-" + (i + 1) : ""}${ext ? "." + ext : ""}`;
34 + d.summary = `Copie identique (même contenu) de « ${master.folder}/${master.name} ».`;
35 + });
36 + }
37 +}
38 +
39 +const client = new Anthropic();
40 +
41 +export interface PlanFile { id: string; oldName: string; oldFolder: string; folder: string; name: string; summary?: string }
42 +export interface PlanSpace { spaceId: string; oldName: string; newName: string; files: PlanFile[]; notes?: string }
43 +export interface Plan { generatedAt: string; spaces: PlanSpace[] }
44 +
45 +const CONTEXT = `Contexte : cloud familial privé (Québec) partagé entre Richard Boucher et son fils Simon-Pierre Boucher.
46 +Sylvie Defoy (conjointe de Richard, mère de Simon-Pierre) est décédée en février 2026. Les documents concernent sa succession,
47 +ses placements, les impôts (Sylvie, Simon-Pierre, Richard), des assurances, un condo à Hallandale Beach (Floride), Hydro-Québec, etc.`;
48 +
49 +const DOC_RULES = `Tu es un archiviste méticuleux. Propose une organisation ULTRA STRUCTURÉE, logique et lisible pour un humain, en français avec accents.
50 +
51 +RÈGLES DES DOSSIERS
52 +- Arborescence de 1 à 2 niveaux maximum (« Niveau 1/Niveau 2 »), séparateur « / ».
53 +- Dossiers de niveau 1 numérotés « 01 », « 02 »… dans un ordre logique (officiel → financier → fiscal → autres). Niveau 2 non numéroté.
54 +- Regrouper par NATURE puis par INSTITUTION ou par ANNÉE (ex. « 02 Comptes bancaires/RBC », « 01 Année d'imposition 2025/Feuillets T5 et Relevés 3 »).
55 +- Fusionner les doublons de dossiers actuels (ex. « Banque Royale » ×2, « RBC Placement Direct » / « RBC placement Direct », « Caisse Desjardins » / « Caisse Desjardins »).
56 +- Pas de dossier pour 1 seul fichier si un dossier frère peut l'accueillir logiquement ; pas de dossier vide.
57 +- Noms de dossiers : mots entiers, accents, espaces (pas de _), ≤ 60 caractères.
58 +
59 +RÈGLES DES NOMS DE FICHIERS
60 +- Format : AAAA-MM-JJ_Émetteur_Type-de-document_Détail.ext (si la date exacte est inconnue : AAAA-MM_ ou AAAA_ ; si aucune date : omettre).
61 +- Composants séparés par « _ », mots d'un même composant séparés par « - ». Accents permis. ≤ 100 caractères. Conserver l'extension d'origine en minuscules.
62 +- Le nom doit permettre de comprendre le contenu SANS ouvrir le fichier (institution, type exact, compte/police abrégé, période, personne si ambigu).
63 +- Deux fichiers identiques (doublons évidents : même document, même compte, même période) : garder les deux mais suffixer « _copie-2 » le second.
64 +- Ne jamais inventer de données : n'utilise que les métadonnées fournies.
65 +
66 +FORMAT DE RÉPONSE : UNIQUEMENT un objet JSON :
67 +{
68 + "space_name": "nom propre et lisible pour l'espace partagé (ex. « Succession — Sylvie Defoy », « Impôts — Simon-Pierre Boucher »), accents, ≤ 50 caractères",
69 + "files": [ { "id": "identifiant fourni", "folder": "01 Dossier/Sous-dossier", "name": "AAAA-MM-JJ_Émetteur_Type_Détail.ext" } ],
70 + "notes": "2-3 phrases : logique retenue, doublons repérés, points d'attention"
71 +}
72 +Chaque identifiant fourni doit apparaître EXACTEMENT une fois dans "files".`;
73 +
74 +const PHOTO_RULES = `Tu es un archiviste de photos de famille. On te fournit, dans l'ORDRE DE NUMÉRISATION (les tirages d'un même album/événement ont été scannés à la suite), la description de chaque photo : index #n, titre, scène, hypothèse d'événement, indice de lieu, époque estimée, personnes, saison.
75 +
76 +Regroupe les photos en ALBUMS cohérents (événement, voyage, période, thème). Règles :
77 +- Utilise fortement l'ordre de numérisation : des indices consécutifs appartiennent presque toujours au même album ; découpe aux vrais changements de contexte.
78 +- 6 à 25 albums au total ; un album a en général ≥ 8 photos (fusionne les petits fragments dans l'album voisin le plus logique ; un album « Divers » est acceptable pour les orphelins).
79 +- Nom d'album : « AAAA? Événement ou thème — Lieu » quand l'époque est raisonnablement estimable (ex. « Années 1990 — Vacances à Virginia Beach », « Gala et soupers professionnels — Château Frontenac », « Portraits de famille », « Hiver et fêtes de Noël »). Français, accents, ≤ 60 caractères, sans « / ».
80 +- Ordonne les albums chronologiquement (approximatif), numérotés « 01 », « 02 »…
81 +FORMAT DE RÉPONSE : UNIQUEMENT un objet JSON :
82 +{ "albums": [ { "folder": "01 Années 1990 — Vacances à Virginia Beach", "indices": ["1-59", "61", "63-70"] } ], "notes": "logique retenue" }
83 +Les "indices" sont des index #n ou des plages "a-b" (inclusives). Chaque index de 1 à N doit apparaître exactement une fois.`;
84 +
85 +function v(x: unknown): string { return x === null || x === undefined || x === "" ? "—" : String(x); }
86 +
87 +async function ask(system: string, user: string, maxTokens: number): Promise<Record<string, unknown>> {
88 + for (let attempt = 0; attempt < 3; attempt++) {
89 + const stream = client.messages.stream({
90 + model: MODEL,
91 + max_tokens: maxTokens,
92 + system,
93 + thinking: { type: "adaptive" },
94 + output_config: { effort: "high" },
95 + messages: [{ role: "user", content: user }],
96 + });
97 + const res = await stream.finalMessage();
98 + if (res.stop_reason === "refusal") throw new Error("refusal: " + ((res as unknown as { stop_details?: { explanation?: string } }).stop_details?.explanation ?? ""));
99 + const text = res.content.filter((b): b is Anthropic.TextBlock => b.type === "text").map((b) => b.text).join("\n");
100 + const json = parseJson(text);
101 + if (json) return json;
102 + console.warn("Réponse sans JSON exploitable, nouvel essai…", text.slice(0, 200));
103 + }
104 + throw new Error("Impossible d'obtenir un JSON valide");
105 +}
106 +
107 +function uniqueNames(files: PlanFile[]) {
108 + const seen = new Map<string, number>();
109 + for (const f of files) {
110 + const key = `${f.folder}::${f.name.toLowerCase()}`;
111 + const n = seen.get(key) ?? 0;
112 + seen.set(key, n + 1);
113 + if (n > 0) {
114 + const ext = extOf(f.name);
115 + const stem = ext ? f.name.slice(0, -(ext.length + 1)) : f.name;
116 + f.name = `${stem}_copie-${n + 1}${ext ? "." + ext : ""}`;
117 + }
118 + }
119 +}
120 +
121 +function normalizeFolder(p: string): string {
122 + return p.split("/").map((s) => cleanName(s, 60)).filter(Boolean).slice(0, 2).join("/") || "01 Documents";
123 +}
124 +
125 +function normalizeFileName(proposed: string, oldName: string): string {
126 + const oldExt = extOf(oldName);
127 + let stem = proposed.replace(/\.[A-Za-z0-9]{1,6}$/, "");
128 + stem = stem.normalize("NFC").replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, "_").replace(/_{2,}/g, "_").replace(/^[_\-.]+|[_\-.]+$/g, "");
129 + if (stem.length > 100) stem = stem.slice(0, 100).replace(/[_\-.]+$/g, "");
130 + if (!stem) stem = oldName.replace(/\.[A-Za-z0-9]{1,6}$/, "");
131 + return oldExt ? `${stem}.${oldExt}` : stem;
132 +}
133 +
134 +function photoSeq(name: string): number {
135 + // FastFoto_2026_02_0998_a.jpeg → 0998 ; 01_Baignade… → 1 (série déjà nommée : placée avant les FastFoto)
136 + const m = name.match(/^FastFoto_(\d{4})_(\d{2})_(\d{2,4})(?:_(\d{2,4}))?/);
137 + if (m) {
138 + const n = m[4] ? Number(m[3]) * 10000 + Number(m[4]) : Number(m[3]);
139 + return 1_000_000 + n;
140 + }
141 + const k = name.match(/^(\d{1,4})[_\- ]/);
142 + return k ? Number(k[1]) : 5_000_000;
143 +}
144 +
145 +function expandIndices(spec: unknown[], n: number): number[] {
146 + const out: number[] = [];
147 + for (const s of spec) {
148 + const str = String(s).trim();
149 + const m = str.match(/^#?(\d+)\s*[-–]\s*#?(\d+)$/);
150 + if (m) { const a = Number(m[1]), b = Number(m[2]); for (let i = Math.min(a, b); i <= Math.max(a, b); i++) if (i >= 1 && i <= n) out.push(i); }
151 + else { const i = Number(str.replace("#", "")); if (i >= 1 && i <= n) out.push(i); }
152 + }
153 + return out;
154 +}
155 +
156 +async function planDocSpace(space: { id: string; name: string }, exts: Extraction[]): Promise<PlanSpace> {
157 + const lines = exts.map((e) => {
158 + const d = (e.data ?? {}) as Record<string, unknown>;
159 + return `ID=${e.id} | actuel="${e.name}" | dossier="${e.folderName}" | type=${v(d.doc_type)} | émetteur=${v(d.issuer)} | personne=${v(d.person)} | date=${v(d.date)} | période=${v(d.period)} | année-fiscale=${v(d.tax_year)} | compte=${v(d.account)} | sujet=${v(d.topic)} | résumé=${v(d.summary)} | proposition=${v(d.filename_stem)}${e.ok ? "" : " | ⚠ analyse impossible : " + e.error}`;
160 + });
161 + const user = `Espace partagé actuel : « ${space.name} » (${exts.length} fichiers).\n\nFICHIERS :\n${lines.join("\n")}`;
162 + const json = await ask(`${CONTEXT}\n\n${DOC_RULES}`, user, 32000);
163 + const byId = new Map(exts.map((e) => [e.id, e]));
164 + const proposed = Array.isArray(json.files) ? (json.files as Array<Record<string, unknown>>) : [];
165 + const files: PlanFile[] = [];
166 + const seen = new Set<string>();
167 + for (const p of proposed) {
168 + const id = String(p.id ?? "");
169 + const e = byId.get(id);
170 + if (!e || seen.has(id)) continue;
171 + seen.add(id);
172 + files.push({ id, oldName: e.name, oldFolder: e.folderName, folder: normalizeFolder(String(p.folder ?? "")), name: normalizeFileName(String(p.name ?? e.name), e.name), summary: (e.data as Record<string, unknown> | undefined)?.summary as string | undefined });
173 + }
174 + for (const e of exts) if (!seen.has(e.id)) {
175 + const d = (e.data ?? {}) as Record<string, unknown>;
176 + files.push({ id: e.id, oldName: e.name, oldFolder: e.folderName, folder: "99 À classer", name: normalizeFileName(String(d.filename_stem ?? e.name), e.name), summary: d.summary as string | undefined });
177 + }
178 + applyDupes(files, loadDupes());
179 + uniqueNames(files);
180 + return { spaceId: space.id, oldName: space.name, newName: cleanName(String(json.space_name ?? space.name), 50), files, notes: typeof json.notes === "string" ? json.notes : undefined };
181 +}
182 +
183 +async function planPhotoSpace(space: { id: string; name: string }, exts: Extraction[]): Promise<PlanSpace> {
184 + const ordered = [...exts].sort((a, b) => photoSeq(a.name) - photoSeq(b.name) || a.name.localeCompare(b.name));
185 + const lines = ordered.map((e, i) => {
186 + const d = (e.data ?? {}) as Record<string, unknown>;
187 + return `#${i + 1} | ${v(d.title)} | scène=${v(d.scene)} | événement=${v(d.event)} | lieu=${v(d.location_hint)} | époque=${v(d.era)} | personnes=${v(d.people)} | saison=${v(d.season)} | ${d.indoor ? "intérieur" : "extérieur"}`;
188 + });
189 + const json = await ask(`${CONTEXT}\n\n${PHOTO_RULES}`, `${ordered.length} photos dans l'ordre de numérisation :\n${lines.join("\n")}`, 24000);
190 + const albums = Array.isArray(json.albums) ? (json.albums as Array<Record<string, unknown>>) : [];
191 + const assigned = new Map<number, string>();
192 + const albumOrder: string[] = [];
193 + for (const a of albums) {
194 + const folder = cleanName(String(a.folder ?? "Divers"), 60);
195 + albumOrder.push(folder);
196 + for (const i of expandIndices(Array.isArray(a.indices) ? a.indices : [], ordered.length)) if (!assigned.has(i)) assigned.set(i, folder);
197 + }
198 + // Orphelins → album voisin précédent, sinon « Divers »
199 + let last = albumOrder[0] ?? "99 Divers";
200 + for (let i = 1; i <= ordered.length; i++) {
201 + if (assigned.has(i)) last = assigned.get(i)!;
202 + else assigned.set(i, last);
203 + }
204 + // Deux scans portant le même nom d'origine (FastFoto : original + version retouchée) partagent numéro et titre : « _version-2 ».
205 + const counters = new Map<string, number>();
206 + const firstByOldName = new Map<string, { n: number; titlePart: string; folder: string; versions: number }>();
207 + const files: PlanFile[] = ordered.map((e, idx) => {
208 + let folder = assigned.get(idx + 1)!;
209 + const d = (e.data ?? {}) as Record<string, unknown>;
210 + const title = typeof d.title === "string" && d.title.trim() ? d.title.trim() : "Photo";
211 + let titlePart = title.normalize("NFC").replace(/[\\/:*?"<>|]/g, "").replace(/\s+/g, "_").replace(/^_+|_+$/g, "").slice(0, 60) || slugTitle(title);
212 + const ext = extOf(e.name) || "jpeg";
213 + const prev = firstByOldName.get(e.name);
214 + let n: number; let suffix = "";
215 + if (prev) {
216 + prev.versions++; n = prev.n; titlePart = prev.titlePart; folder = prev.folder; suffix = `_version-${prev.versions}`;
217 + } else {
218 + n = (counters.get(folder) ?? 0) + 1; counters.set(folder, n);
219 + firstByOldName.set(e.name, { n, titlePart, folder, versions: 1 });
220 + }
221 + return { id: e.id, oldName: e.name, oldFolder: e.folderName, folder, name: `${pad(n)}_${titlePart}${suffix}.${ext}`, summary: typeof d.description === "string" ? d.description : undefined };
222 + });
223 + applyDupes(files, loadDupes());
224 + uniqueNames(files);
225 + return { spaceId: space.id, oldName: space.name, newName: "Photos de famille", files, notes: typeof json.notes === "string" ? json.notes : undefined };
226 +}
227 +
228 +async function main() {
229 + const exts = readJsonl<Extraction>(EXTRACT_PATH);
230 + const latest = new Map<string, Extraction>();
231 + for (const e of exts) latest.set(e.id, e); // dernière extraction par fichier
232 + const scope = await filesInScope();
233 + const spaces = await sharedSpacesInScope();
234 + const only = process.argv.find((a) => a.startsWith("--space="))?.split("=")[1];
235 + const missing = scope.filter((f) => !latest.has(f.id));
236 + if (missing.length) console.warn(`⚠ ${missing.length} fichiers sans extraction — ils seront classés avec leurs métadonnées de base.`);
237 + for (const f of missing) latest.set(f.id, { id: f.id, name: f.name, spaceId: f.spaceId, spaceName: f.spaceName, folderName: f.folderName, mimeType: f.mimeType, kind: "other", ok: false, error: "non extrait", at: "" });
238 +
239 + const existing: Plan = fs.existsSync(PLAN_PATH) ? JSON.parse(fs.readFileSync(PLAN_PATH, "utf8")) : { generatedAt: "", spaces: [] };
240 + const plan: Plan = { generatedAt: new Date().toISOString(), spaces: [] };
241 + for (const s of spaces) {
242 + const inSpace = scope.filter((f) => f.spaceId === s.id).map((f) => latest.get(f.id)!);
243 + if (inSpace.length === 0) continue;
244 + if (only && s.name !== only && s.id !== only) {
245 + const prev = existing.spaces.find((p) => p.spaceId === s.id);
246 + if (prev) plan.spaces.push(prev);
247 + continue;
248 + }
249 + console.log(`→ ${s.name} : ${inSpace.length} fichiers…`);
250 + const isPhoto = inSpace.filter((e) => e.kind === "photo").length > inSpace.length * 0.8;
251 + const ps = isPhoto ? await planPhotoSpace(s, inSpace) : await planDocSpace(s, inSpace);
252 + plan.spaces.push(ps);
253 + const folders = new Set(ps.files.map((f) => f.folder));
254 + console.log(` « ${ps.newName} » · ${folders.size} dossiers · ${ps.files.length} fichiers\n ${ps.notes ?? ""}`);
255 + fs.writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
256 + }
257 + fs.writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
258 + console.log(`Plan écrit : ${PLAN_PATH}`);
259 + await prisma.$disconnect();
260 +}
261 +
262 +main().catch((e) => { console.error(e); process.exit(1); });
added scripts/organize/rollback.ts +52 −0
@@ -0,0 +1,52 @@
1 +/**
2 + * Annule la réorganisation en rejouant data/applied.jsonl à l'envers
3 + * (noms/dossiers d'origine des fichiers, anciens dossiers recréés avec leur id, nouveaux dossiers vides supprimés, noms d'espaces).
4 + * npx tsx --env-file=.env scripts/organize/rollback.ts
5 + */
6 +import fs from "fs";
7 +import { prisma } from "../../lib/prisma";
8 +import { APPLIED_PATH, readJsonl } from "./common";
9 +
10 +interface Applied {
11 + kind: "file" | "space" | "folder-created" | "folder-deleted";
12 + fileId?: string; oldName?: string; newName?: string;
13 + oldFolder?: { id: string; name: string; parentId: string | null; sharedSpaceId: string | null } | null;
14 + newFolderId?: string; spaceId?: string; folderId?: string;
15 + folder?: { id: string; name: string; parentId: string | null; sharedSpaceId: string | null };
16 +}
17 +
18 +async function ensureOldFolder(f: { id: string; name: string; parentId: string | null; sharedSpaceId: string | null }) {
19 + const exists = await prisma.folder.findUnique({ where: { id: f.id } });
20 + if (exists) return;
21 + if (f.parentId && !(await prisma.folder.findUnique({ where: { id: f.parentId } }))) f = { ...f, parentId: null };
22 + await prisma.folder.create({ data: { id: f.id, name: f.name, parentId: f.parentId, sharedSpaceId: f.sharedSpaceId } });
23 +}
24 +
25 +async function main() {
26 + const entries = readJsonl<Applied>(APPLIED_PATH).reverse();
27 + let files = 0, spaces = 0, recreated = 0, removed = 0;
28 + for (const e of entries) {
29 + if (e.kind === "file" && e.fileId) {
30 + if (e.oldFolder) { await ensureOldFolder(e.oldFolder); recreated++; }
31 + await prisma.file.update({ where: { id: e.fileId }, data: { name: e.oldName!, folderId: e.oldFolder?.id ?? null } }).catch(() => {});
32 + files++;
33 + } else if (e.kind === "space" && e.spaceId) {
34 + await prisma.sharedSpace.update({ where: { id: e.spaceId }, data: { name: e.oldName! } }).catch(() => {});
35 + spaces++;
36 + } else if (e.kind === "folder-deleted" && e.folder) {
37 + await ensureOldFolder(e.folder); recreated++;
38 + }
39 + }
40 + // Supprimer les dossiers créés par le plan s'ils sont vides (enfants d'abord : ordre inverse de création)
41 + for (const e of entries) {
42 + if (e.kind === "folder-created" && e.folderId) {
43 + const f = await prisma.folder.findUnique({ where: { id: e.folderId }, include: { _count: { select: { files: true, children: true } } } });
44 + if (f && f._count.files === 0 && f._count.children === 0) { await prisma.folder.delete({ where: { id: f.id } }); removed++; }
45 + }
46 + }
47 + fs.renameSync(APPLIED_PATH, APPLIED_PATH + `.rolled-back-${Date.now()}`);
48 + console.log(`Rollback : ${files} fichiers restaurés · ${spaces} espaces · ${recreated} dossiers d'origine vérifiés · ${removed} dossiers du plan supprimés`);
49 + await prisma.$disconnect();
50 +}
51 +
52 +main().catch((e) => { console.error(e); process.exit(1); });
added scripts/organize/verify.ts +46 −0
@@ -0,0 +1,46 @@
1 +/**
2 + * Vérifications après application : aucun fichier perdu, aucun orphelin, tout dossier rattaché au bon espace,
3 + * pas de dossier vide, unicité des noms par dossier. Affiche l'arborescence finale.
4 + * npx tsx --env-file=.env scripts/organize/verify.ts [--tree]
5 + */
6 +import { prisma } from "../../lib/prisma";
7 +import { sharedSpacesInScope } from "./common";
8 +
9 +async function main() {
10 + const spaces = await sharedSpacesInScope();
11 + let problems = 0;
12 + const totalFiles = await prisma.file.count({ where: { deletedAt: null } });
13 + console.log(`Fichiers actifs (toute la base) : ${totalFiles}`);
14 + for (const s of spaces) {
15 + const folders = await prisma.folder.findMany({ where: { sharedSpaceId: s.id }, include: { _count: { select: { files: true, children: true } } } });
16 + const fids = folders.map((f) => f.id);
17 + const files = await prisma.file.findMany({ where: { deletedAt: null, folderId: { in: fids } }, select: { id: true, name: true, folderId: true } });
18 + if (!files.length && !folders.length) continue;
19 + // Dossiers vides
20 + for (const f of folders) if (f._count.files === 0 && f._count.children === 0) { console.log(` ⚠ dossier vide : ${s.name} / ${f.name}`); problems++; }
21 + // Parents dans le même espace
22 + const byId = new Map(folders.map((f) => [f.id, f]));
23 + for (const f of folders) if (f.parentId && !byId.has(f.parentId)) { console.log(` ⚠ parent hors espace : ${f.name}`); problems++; }
24 + // Unicité des noms
25 + const seen = new Set<string>();
26 + for (const f of files) { const k = `${f.folderId}::${f.name.toLowerCase()}`; if (seen.has(k)) { console.log(` ⚠ nom en double : ${f.name}`); problems++; } seen.add(k); }
27 + // Arbre
28 + const roots = folders.filter((f) => !f.parentId).sort((a, b) => a.name.localeCompare(b.name, "fr"));
29 + console.log(`\n📁 ${s.name} — ${files.length} fichiers, ${folders.length} dossiers`);
30 + for (const r of roots) {
31 + console.log(` ${r.name} (${r._count.files})`);
32 + const kids = folders.filter((f) => f.parentId === r.id).sort((a, b) => a.name.localeCompare(b.name, "fr"));
33 + for (const k of kids) {
34 + console.log(` └ ${k.name} (${k._count.files})`);
35 + if (process.argv.includes("--tree")) for (const fi of files.filter((x) => x.folderId === k.id).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 6)) console.log(` · ${fi.name}`);
36 + }
37 + if (process.argv.includes("--tree")) for (const fi of files.filter((x) => x.folderId === r.id).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 6)) console.log(` · ${fi.name}`);
38 + }
39 + }
40 + const orphans = await prisma.file.count({ where: { deletedAt: null, folderId: null } });
41 + console.log(`\nFichiers à la racine (hors dossier) : ${orphans} · problèmes détectés : ${problems}`);
42 + await prisma.$disconnect();
43 + process.exit(problems ? 1 : 0);
44 +}
45 +
46 +main().catch((e) => { console.error(e); process.exit(1); });
modified server.ts +9 −0
@@ -2,6 +2,7 @@ import { createServer } from "http";
2 2 import next from "next";
3 3 import { handleWebDAV } from "./lib/webdav";
4 4 import { createDailySnapshotIfNeeded } from "./lib/snapshot";
5 +import { purgeExpiredTrash, cleanupOrphanChunks } from "./lib/trash";
5 6
6 7 const dev = process.env.NODE_ENV !== "production";
7 8 const port = parseInt(process.env.PORT || "5001", 10);
@@ -29,6 +30,14 @@ app.prepare().then(() => {
29 30 .then((created) => { if (created) console.log("> [Snapshot] Snapshot quotidien créé"); })
30 31 .catch((err) => console.error("> [Snapshot] Erreur:", err));
31 32 };
33 + // Entretien horaire : purge de la corbeille (> 30 jours) + chunks d'upload orphelins
34 + const runMaintenance = () => {
35 + purgeExpiredTrash().then((n) => { if (n) console.log(`> [Corbeille] ${n} fichier(s) purgé(s) (> 30 j)`); }).catch((err) => console.error("> [Corbeille] Erreur:", err));
36 + cleanupOrphanChunks().catch((err) => console.error("> [Upload] Nettoyage chunks:", err));
37 + };
38 + setTimeout(runMaintenance, 60 * 1000);
39 + setInterval(runMaintenance, 60 * 60 * 1000);
40 +
32 41 if (process.env.SNAPSHOT_DISABLED === "1") {
33 42 console.log("> [Snapshot] désactivé (SNAPSHOT_DISABLED=1)");
34 43 } else {
35 44