Construire une plateforme de gestion de fichiers complète — upload, authentification, analytics, sécurité fail2ban et déploiement Docker.
Deux fonctionnalités qui semblent simples mais demandent de la rigueur : la corbeille (suppression en deux étapes) et les liens de partage (accès public temporaire avec tracking).
La suppression dans OpenFiler ne supprime jamais directement. Le fichier passe d'abord en corbeille — déplacé sur le disque et en base.
import { rename } from 'fs/promises'
import { join } from 'path'
import db from '@/lib/db'
export async function moveToTrash(fileId: number, userId: string) {
const file = db.prepare('SELECT * FROM file_metadata WHERE id = ?').get(fileId) as FileMetadata
if (!file) throw new Error('Fichier introuvable')
const src = join(process.cwd(), 'upload', file.folder, file.filename)
const dest = join(process.cwd(), 'upload', 'trash', file.filename)
await rename(src, dest)
const transfer = db.transaction(() => {
db.prepare(`
INSERT INTO trash (filename, original_name, folder, size, deleted_by)
VALUES (?, ?, ?, ?, ?)
`).run(file.filename, file.original_name, file.folder, file.size, userId)
db.prepare('DELETE FROM file_metadata WHERE id = ?').run(fileId)
})
transfer()
}
export async function restoreFromTrash(trashId: number) {
const item = db.prepare('SELECT * FROM trash WHERE id = ?').get(trashId) as TrashItem
if (!item) throw new Error('Introuvable en corbeille')
const src = join(process.cwd(), 'upload', 'trash', item.filename)
const dest = join(process.cwd(), 'upload', item.folder, item.filename)
await rename(src, dest)
const transfer = db.transaction(() => {
db.prepare(`
INSERT INTO file_metadata (filename, original_name, folder, size, mime_type)
VALUES (?, ?, ?, ?, '')
`).run(item.filename, item.original_name, item.folder, item.size)
db.prepare('DELETE FROM trash WHERE id = ?').run(trashId)
})
transfer()
}La transaction garantit que si le déplacement disque réussit mais l'écriture SQL échoue, on ne perd pas de cohérence — ou plutôt, que les deux opérations réussissent ensemble.
import { randomBytes } from 'crypto'
import db from '@/lib/db'
export function createShareLink(
filename: string,
folder: string,
durationHours: number
): string {
const token = randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + durationHours * 3600 * 1000).toISOString()
db.prepare(`
INSERT INTO share_link (token, filename, folder, expires_at)
VALUES (?, ?, ?, ?)
`).run(token, filename, folder, expiresAt)
return token
}
export function resolveShareLink(token: string) {
const link = db.prepare(`
SELECT * FROM share_link
WHERE token = ? AND expires_at > datetime('now')
`).get(token) as ShareLink | undefined
return link ?? null
}import { resolveShareLink } from '@/lib/share'
import { notFound } from 'next/navigation'
export default async function SharePage({ params }: { params: { token: string } }) {
const link = resolveShareLink(params.token)
if (!link) notFound()
// Logger l'accès dans file_view
// Afficher le fichier ou proposer le téléchargement
return (
<main>
<h1>{link.filename}</h1>
<a href={`/api/download/${link.folder}/${link.filename}?token=${params.token}`}>
Télécharger
</a>
</main>
)
}La route est publique — pas de session requise. L'expiration est vérifiée en base avec expires_at > datetime('now'). Un lien expiré retourne une page 404.
const DURATIONS = [
{ label: '1 heure', hours: 1 },
{ label: '24 heures', hours: 24 },
{ label: '7 jours', hours: 168 },
{ label: '30 jours', hours: 720 },
]Présenté comme un sélecteur dans la modal de partage — l'utilisateur choisit la durée, le token est généré immédiatement et affiché une seule fois.