Stocker des fichiers

Valider et téléverser un fichier, déclarer une policy d'accès et servir le contenu via HTTP.

Tout accès fichier passe par core/storage, qui parle à un provider S3-compatible (Backblaze B2 ou Cloudflare R2).

Pour attacher un fichier à un contenu éditorial, avec médiathèque et suivi des références, voir plutôt Gérer les médias.

Configuration

LRVO_STORAGE_PROVIDER=backblaze
LRVO_STORAGE_ENDPOINT=s3.<region>.backblazeb2.com
LRVO_STORAGE_REGION=<region>
LRVO_STORAGE_BUCKET=mon-bucket
LRVO_STORAGE_ACCESS_KEY=…
LRVO_STORAGE_SECRET_KEY=…

Backblaze B2 est le fournisseur par défaut, LRVO_STORAGE_REGION y est obligatoire. Pour Cloudflare R2, poser LRVO_STORAGE_PROVIDER=r2 et un endpoint de la forme <accountId>.r2.cloudflarestorage.com, la région devenant facultative.

Téléverser

Toujours valider avant de téléverser.

import {
  uploadImage, validateUpload, generateUniqueFilename, getMimeType,
  FILE_TYPE_PRESETS, FILE_SIZE_LIMITS,
} from '@lrvo/core/storage';
import { apiError, apiSuccess } from '@lrvo/core/api';

export async function uploadAvatar(userId, file, buffer) {
  const { valid, errors } = validateUpload({
    filename: file.name,
    size: buffer.byteLength,
    allowedTypes: FILE_TYPE_PRESETS.IMAGES,
    maxSize: FILE_SIZE_LIMITS.AVATAR,
    buffer,
  });
  if (!valid) return apiError('Bad Request', errors[0]);

  const filename = generateUniqueFilename(file.name, 'avatar');
  const key = `users/${userId}/profile/${filename}`;

  const result = await uploadImage({
    key,
    body: buffer,
    contentType: getMimeType(file.name),
  });
  if (!result.success) return apiError('Internal Server Error', 'Le fichier n\'a pas pu être enregistré.');

  return apiSuccess({ success: true, key });
}

Passer buffer à validateUpload dès qu'on l'a. Sans lui, deux des six couches de validation ne s'exécutent pas : la vérification des magic bytes et le scan anti-polyglotte. Un fichier simplement renommé passerait alors la validation.

uploadImage est uploadFile avec un cache d'un an. Pour un fichier qui peut changer à clé constante, utiliser uploadFile et un cacheControl adapté.

Choisir la clé

La clé porte le contrôle d'accès. Sa forme n'est donc pas cosmétique.

users/{userId}/profile/avatar_1736.webp    → policy 'owner' sur 'users'
invoices/{invoiceId}/facture.pdf           → policy 'admin' sur 'invoices'
products/{productId}/photo.webp            → préfixe public 'products'

Le premier segment détermine la policy, le second l'identifiant de propriété. Un chemin qui ne suit pas cette forme sera refusé au service.

Déclarer l'accès

Tout est privé par défaut. Un fichier n'est servi par /lrvo/api/storage/** que si son chemin correspond à un préfixe public ou à une policy déclarée.

// src/storage-policies.js
export const storageAccessPolicies = [
  { prefix: 'users',    type: 'owner' },  // users/{userId}/…
  { prefix: 'invoices', type: 'admin' },  // invoices/…
];

export const storagePublicPrefixes = ['products'];
// register-server.js d'un module, ou init du projet
import { registerStoragePolicies, registerStoragePublicPrefixes } from '@lrvo/core/storage';
import { storageAccessPolicies, storagePublicPrefixes } from './storage-policies.js';

registerStoragePolicies(storageAccessPolicies);
registerStoragePublicPrefixes(storagePublicPrefixes);
TypeRègle
ownerLe deuxième segment du chemin doit être l'identifiant de l'utilisateur connecté. Les admins accèdent à tout.
adminL'utilisateur doit être admin.

Un préfixe public exige au minimum la forme {prefixe}/{id}/{fichier} : déclarer products n'expose pas products/secret.pdf.

L'enregistrement est additif. Plusieurs modules déclarent leurs policies indépendamment.

Servir un fichier

Le fichier est accessible à /lrvo/api/storage/<clé>. La route applique elle-même les règles ci-dessus, puis streame le contenu.

<img src={`/lrvo/api/storage/${user.avatar_key}`} alt="" />

Lien temporaire

Pour un partage hors session, quand le destinataire n'a pas de compte sur le site :

import { getPresignedUrl } from '@lrvo/core/storage';

const { data } = await getPresignedUrl({ key: 'invoices/42/facture.pdf', expiresIn: 300 });
// data.url — valide 5 minutes

expiresIn est en secondes, 3600 par défaut, 604 800 au maximum. Une URL présignée contourne les policies : la choisir en connaissance de cause, et la garder courte.

Ce n'est pas la voie pour faire téléverser un gros fichier depuis un navigateur. Voir la section suivante.

Téléverser un gros fichier

uploadFile signe le corps de la requête, donc il le tient entier en mémoire : un fichier de deux gigaoctets coûterait deux gigaoctets. Et il ne passerait de toute façon pas, puisqu'un proxy plafonne le corps d'une requête, 100 Mo chez Cloudflare sur le forfait gratuit.

La réponse est le téléversement en plusieurs parties : le navigateur découpe le fichier avec File.slice(), chaque tranche part dans sa propre requête vers votre route, et le serveur la pousse comme une part.

import { createMultipartUpload, uploadPart, completeMultipartUpload } from '@lrvo/core/storage';

La mémoire du serveur vaut alors une tranche, quelle que soit la taille du fichier, et le navigateur ne parle jamais au bucket, donc il n'y a aucune règle CORS à poser. Les contraintes de taille de part et le détail des quatre fonctions sont dans la référence.

Servir un gros fichier

getFile charge l'objet entier. Pour le rendre à un client sans le tenir en mémoire, utilisez getFileStream, qui rend le flux de la réponse et accepte une plage d'octets.

import { getFileStream } from '@lrvo/core/storage';

const { data } = await getFileStream(key);
return { success: true, file: { body: data.body, contentType, filename } };

Le gestionnaire de routes reconnaît cette enveloppe et streame la réponse. Répondre aux plages d'octets n'est pas un luxe sur une vidéo : Safari refuse de lire une vidéo dont le serveur ne répond pas 206 à une requête Range.

Remplacer un fichier

Toujours supprimer l'ancien fichier avant d'en écrire un nouveau à la même place.

import { deleteFile, uploadImage } from '@lrvo/core/storage';

if (user.avatar_key) await deleteFile(user.avatar_key);
await uploadImage({ key: newKey, body: buffer, contentType });

Sans cette discipline, le bucket accumule des fichiers fantômes : présents dans le stockage, plus référencés par l'application, et impossibles à distinguer des fichiers utiles.

Même chose à la suppression d'une ressource : libérer ses fichiers dans la même opération.

Gérer les erreurs

Toutes les opérations retournent { success, data, error } et ne lèvent jamais. L'erreur détaillée est journalisée côté serveur ; le message renvoyé au client reste générique.

const result = await uploadFile({ key, body, contentType });
if (!result.success) {
  return apiError('Internal Server Error', 'Le fichier n\'a pas pu être enregistré.');
}

Pièges

Le SVG est refusé. C'est un document exécutable, pas une image inerte. Il n'y a pas d'option pour l'autoriser.

listFiles est plafonné à 1 000 entrées par appel. Utiliser data.nextContinuationToken pour paginer, sinon le listage est silencieusement tronqué.

moveFile n'est pas atomique. Il copie puis supprime. Si la suppression échoue, la copie est conservée et l'échec n'est pas fatal.

Ne jamais passer une image privée par /_next/image. L'optimiseur met la réponse en cache par URL et l'exposerait à tous les visiteurs après le premier accès.

Sur cette page