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)
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'intelligence d'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'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 | |