/** * Accès aux liens de partage publics : validité, mot de passe (cookie signé après vérification), * compteur d'accès, portée (fichier ou dossier + descendants). */ import crypto from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { prisma } from "./prisma"; import { isDescendantOf } from "./trash"; export type ShareWithTargets = NonNullable>>; export async function loadShare(token: string) { return prisma.sharedLink.findUnique({ where: { token }, include: { file: true, folder: true }, }); } /** null si valide, sinon une réponse d'erreur prête à renvoyer. */ export function shareStatusError(share: ShareWithTargets | null): NextResponse | null { if (!share || !share.active) return NextResponse.json({ error: "Lien introuvable ou inactif" }, { status: 404 }); if (share.expiresAt && new Date() > share.expiresAt) return NextResponse.json({ error: "Ce lien a expiré" }, { status: 410 }); if (share.file && share.file.deletedAt) return NextResponse.json({ error: "Le fichier partagé n'existe plus" }, { status: 404 }); if (!share.file && !share.folder) return NextResponse.json({ error: "La cible du partage n'existe plus" }, { status: 404 }); return null; } const SECRET = process.env.SESSION_PASSWORD || "spb-share-secret"; export function shareCookieName(token: string): string { return `spb_share_${token.slice(0, 24)}`; } /** Signature liée au lien ET au hash du mot de passe (changer le mot de passe invalide les cookies). */ export function shareCookieValue(share: { token: string; passwordHash: string | null }): string { return crypto.createHmac("sha256", SECRET).update(`${share.token}:${share.passwordHash ?? ""}`).digest("hex"); } /** Le visiteur a-t-il déjà validé le mot de passe (cookie signé) ? */ export function hasPasswordAccess(request: NextRequest, share: { token: string; passwordHash: string | null }): boolean { if (!share.passwordHash) return true; const c = request.cookies.get(shareCookieName(share.token))?.value; if (!c) return false; const expected = shareCookieValue(share); return c.length === expected.length && crypto.timingSafeEqual(Buffer.from(c), Buffer.from(expected)); } export function grantPasswordAccess(response: NextResponse, share: { token: string; passwordHash: string | null }) { response.cookies.set(shareCookieName(share.token), shareCookieValue(share), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 6, }); } /** Informations publiques d'un partage (sans contenu). */ export function shareInfo(share: ShareWithTargets) { const base = { token: share.token, mode: share.mode, expiresAt: share.expiresAt?.toISOString() || null }; if (share.file) { return { ...base, type: "file" as const, id: share.file.id, name: share.file.name, mimeType: share.file.mimeType, size: Number(share.file.size) }; } return { ...base, type: "folder" as const, id: share.folder!.id, name: share.folder!.name }; } export async function touchShare(id: string) { await prisma.sharedLink.update({ where: { id }, data: { accessCount: { increment: 1 }, lastAccessAt: new Date() } }).catch(() => {}); } /** Un dossier demandé appartient-il au sous-arbre partagé ? */ export async function folderInShare(share: ShareWithTargets, folderId: string): Promise { if (!share.folder) return false; return isDescendantOf(folderId, share.folder.id); } /** Un fichier demandé appartient-il au partage (fichier lui-même ou fichier du sous-arbre partagé) ? */ export async function fileInShare(share: ShareWithTargets, fileId: string) { if (share.file) return share.file.id === fileId ? share.file : null; if (!share.folder) return null; const f = await prisma.file.findUnique({ where: { id: fileId } }); if (!f || f.deletedAt || !f.folderId) return null; return (await isDescendantOf(f.folderId, share.folder.id)) ? f : null; }