Stockage

Couche d'accès aux providers S3-compatibles, upload, validation de fichiers, URL présignées et contrôle d'accès par chemin.

core/storage fournit un accès générique aux providers S3-compatibles (Backblaze B2, Cloudflare R2) : upload, téléchargement, suppression, listage, URL présignées, validation de fichiers. Il gère aussi le contrôle d'accès des fichiers servis en HTTP.

Règle absolue : tout accès fichier passe par ce module.

Import

import {
  uploadFile, uploadImage, deleteFile, deleteFiles, getFile, getFileStream,
  getFileMetadata, fileExists, listFiles, getPresignedUrl, copyFile, moveFile, proxyFile,
  createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload,
  listMultipartParts, MULTIPART_MIN_PART_SIZE, MULTIPART_MAX_PARTS, MULTIPART_MAX_PART_SIZE,
  validateUpload, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS,
  generateUniqueFilename, getFileExtension, getMimeType,
  validateFileType, validateFileSize, formatFileSize, sanitizeFilename,
  registerStoragePolicies, registerStoragePublicPrefixes, clearStorageConfig,
} from '@lrvo/core/storage';

Module serveur.

Variables d'environnement

Un seul jeu de variables sert les deux providers. Le provider actif est choisi par LRVO_STORAGE_PROVIDER.

VariableDéfautRôle
LRVO_STORAGE_PROVIDERbackblazebackblaze ou r2.
LRVO_STORAGE_ENDPOINT—Backblaze : s3.<region>.backblazeb2.com. R2 : <accountId>.r2.cloudflarestorage.com.
LRVO_STORAGE_REGIONauto pour R2Obligatoire pour Backblaze, optionnelle pour R2.
LRVO_STORAGE_BUCKET—Nom du bucket.
LRVO_STORAGE_ACCESS_KEY—Clé d'accès.
LRVO_STORAGE_SECRET_KEY—Clé secrète.

Contrôle d'accès

Tout est privé par défaut

Aucun fichier n'est servi sans configuration explicite. Pour qu'un fichier passe par /lrvo/api/storage/**, son chemin doit correspondre à un préfixe public ou à une policy déclarée pendant initializeLrvo().

Préfixes publics

Les fichiers dont le chemin commence par un préfixe public sont servis sans session. Le chemin doit avoir au minimum la forme {prefixe}/{id}/{fichier} : un préfixe seul n'expose rien.

// storage-policies.js d'une feature ou d'un module
export const storagePublicPrefixes = ['products'];
// products/abc123/photo.webp → accessible sans session

Policies d'accès

Pour un chemin privé, une policy doit correspondre au premier segment du chemin.

TypeRègle
ownerLe deuxième segment doit être l'identifiant de l'utilisateur connecté. Les admins accèdent à tout.
adminL'utilisateur doit être admin.
export const storageAccessPolicies = [
  { prefix: 'users',    type: 'owner' },  // users/{userId}/...
  { prefix: 'invoices', type: 'admin' },  // invoices/...
];

Enregistrement

import { registerStoragePolicies, registerStoragePublicPrefixes } from '@lrvo/core/storage';
import { storageAccessPolicies, storagePublicPrefixes } from './storage-policies.js';

registerStoragePolicies(storageAccessPolicies);
registerStoragePublicPrefixes(storagePublicPrefixes);

L'enregistrement est additif : plusieurs features et modules appellent ces fonctions indépendamment.

Opérations

Toutes les opérations retournent { success, data, error } et ne lèvent jamais. Les erreurs sont journalisées côté serveur et encapsulées dans error.

uploadFile({ key, body, contentType, metadata?, cacheControl? })

const result = await uploadFile({
  key: 'users/abc123/profile/avatar_xxx.webp',
  body: buffer,
  contentType: 'image/webp',
  metadata: { userId: 'abc123' },
});
if (!result.success) throw new Error(result.error);

uploadImage({ key, body, contentType, metadata? })

Identique à uploadFile avec Cache-Control: public, max-age=31536000.

deleteFile(key) et deleteFiles(keys[])

Suppression unitaire ou en lot (S3 DeleteObjects).

const { data } = await deleteFiles(['a.jpg', 'b.jpg']);
// data.deleted, data.errors

getFile(key)

Retourne data.body, data.contentType, data.contentLength, data.lastModified, data.metadata.

getFileStream(key, { range? })

Comme getFile, mais sans charger l'objet en mémoire : data.body est le flux de la réponse. La mémoire du processus ne dépend donc pas de la taille de l'objet, ce qui est la seule façon de servir un fichier de plusieurs gigaoctets à travers l'application.

range demande une plage d'octets, bornes comprises. La réponse porte alors data.status à 206 et data.contentRange donne la position dans l'objet complet.

const { data } = await getFileStream('videos/demo.mp4', { range: { start: 0, end: 1023 } });
// data.body → ReadableStream
// data.status → 206, data.contentRange → 'bytes 0-1023/8452301'

Deux points sur lesquels cette fonction se fait mal utiliser :

  • l'appelant doit consommer le flux ou appeler cancel() dessus, sinon la socket reste ouverte jusqu'à expiration ;
  • sur une réponse 206, data.contentLength est la longueur de la tranche renvoyée, pas la taille de l'objet. Le total se lit dans data.contentRange.

getFileMetadata(key)

Requête HEAD : métadonnées seules, sans télécharger le contenu.

fileExists(key)

Retourne true si le fichier existe.

listFiles({ prefix?, maxKeys?, continuationToken? })

Listage paginé, 1 000 entrées maximum par appel.

const { data } = await listFiles({ prefix: 'users/abc123/', maxKeys: 100 });
// data.files → [{ key, size, lastModified, etag }]
// data.isTruncated, data.nextContinuationToken

getPresignedUrl({ key, expiresIn?, operation? })

URL signée temporaire. expiresIn en secondes, défaut 3600, maximum 604 800 (7 jours). operation vaut 'get' (défaut) ou 'put'.

const { data } = await getPresignedUrl({ key: 'docs/contrat.pdf', expiresIn: 300 });

Ce n'est plus la voie à prendre pour faire téléverser un gros fichier depuis un navigateur : voir Téléversement en plusieurs parties, qui n'ouvre pas le bucket au navigateur et ne demande aucune règle CORS.

copyFile({ sourceKey, destinationKey }) et moveFile({ sourceKey, destinationKey })

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

proxyFile(key, { filename? })

Récupère un fichier pour le streamer directement au client.

Téléversement en plusieurs parties

uploadFile signe le corps de la requête, ce qui l'oblige à le tenir entier en mémoire. C'est sans conséquence pour un avatar et impossible pour un fichier de deux gigaoctets. Le téléversement en plusieurs parties découpe l'objet en parts signées et envoyées une par une : la mémoire de pointe vaut une part, quelle que soit la taille de l'objet.

C'est aussi la seule façon de recevoir un gros fichier depuis un navigateur derrière un proxy. Cloudflare plafonne le corps d'une requête à 100 Mo sur le forfait gratuit, et refuse la requête avant qu'elle n'atteigne l'application : le navigateur doit donc découper de son côté, et chaque tranche devient une part.

Rien n'apparaît à la clé de destination tant que completeMultipartUpload n'a pas réussi. Un téléversement laissé ouvert garde ses parts stockées et facturées : celui qui l'ouvre s'engage à le conclure ou à l'interrompre.

createMultipartUpload({ key, contentType?, metadata?, cacheControl? })

Ouvre le téléversement et rend data.uploadId. Le type de contenu et les métadonnées se posent ici : l'objet assemblé hérite de ce qui est déclaré à cette étape, pas de ce que portent les parts.

uploadPart({ key, uploadId, partNumber, body })

Envoie une part. partNumber commence à 1. Rend data.etag, à conserver pour l'assemblage. Renvoyer le même numéro remplace la part précédente, ce qui rend une reprise sûre.

completeMultipartUpload({ key, uploadId, parts })

Assemble. parts est une liste de { partNumber, etag }, dans n'importe quel ordre : la fonction la trie avant de l'envoyer.

abortMultipartUpload({ key, uploadId })

Renonce et libère les parts. Un téléversement déjà disparu compte comme un succès, pour qu'un balayage puisse être rejoué sans se bloquer.

listMultipartParts({ key, uploadId, maxParts? })

Les parts que le stockage a réellement reçues, pour reprendre en se fiant au bucket plutôt qu'à sa propre comptabilité.

Exemple

const { data: open } = await createMultipartUpload({ key, contentType: 'application/zip' });

const parts = [];
for (const [index, slice] of slices.entries()) {
  const { data } = await uploadPart({ key, uploadId: open.uploadId, partNumber: index + 1, body: slice });
  parts.push({ partNumber: index + 1, etag: data.etag });
}

const done = await completeMultipartUpload({ key, uploadId: open.uploadId, parts });
if (!done.success) await abortMultipartUpload({ key, uploadId: open.uploadId });

Contraintes du stockage

ContrainteValeurConstante
Taille minimale d'une part, la dernière exceptée5 MioMULTIPART_MIN_PART_SIZE
Nombre maximal de parts10 000MULTIPART_MAX_PARTS
Taille maximale d'une part5 GioMULTIPART_MAX_PART_SIZE

R2 va plus loin que S3 : toutes les parts sauf la dernière doivent faire exactement la même taille. Choisissez donc une taille de part par téléversement et gardez-la, plutôt que de dimensionner chaque part indépendamment. Une reprise doit reprendre la taille d'origine.

Validation

validateUpload({ filename, size, allowedTypes, maxSize, buffer? })

Retourne { valid, errors[] }. À appeler avant tout upload.

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]);

Couches appliquées dans l'ordre :

  1. Nom de fichier requis.
  2. SVG explicitement interdit : risque d'exécution de script.
  3. Extension contre la liste blanche allowedTypes.
  4. Taille contre maxSize.
  5. Si buffer est fourni : magic bytes, assertion positive que l'extension correspond au contenu réel.
  6. Si buffer est fourni : scan des 512 premiers octets pour HTML, SVG et XML, défense contre les fichiers polyglots.

Passer buffer dès qu'on l'a. Sans lui, les deux dernières couches ne s'exécutent pas et un fichier renommé passe la validation.

FILE_TYPE_PRESETS

ConstanteExtensions
IMAGES.jpg .jpeg .png .gif .webp
IMAGES_NO_GIF.jpg .jpeg .png .webp
DOCUMENTS.pdf .doc .docx .xls .xlsx .ppt .pptx .txt .csv
PDF_ONLY.pdf
VIDEOS.mp4 .avi .mov .wmv
AUDIO.mp3 .wav
ARCHIVES.zip .rar .7z .tar .gz

FILE_SIZE_LIMITS

ConstanteLimite
AVATAR5 Mo
IMAGE10 Mo
DOCUMENT50 Mo
VIDEO500 Mo
LARGE_FILE1 Go

Utilitaires

FonctionRetour
generateUniqueFilename(originalName, prefix?){prefix}_{timestamp}_{hash}{ext}
getFileExtension(filename)Extension avec le point, ou ''.
getMimeType(filename)Type MIME déduit de l'extension.
validateFileType(filename, allowedTypes)boolean
validateFileSize(size, maxSize)boolean
formatFileSize(bytes)Chaîne lisible, 1.5 MB.
sanitizeFilename(filename)Normalise en NFD, retire les accents, passe en minuscules, convertit _ en - et remplace tout autre caractère par -. Sortie : [a-z0-9-] plus extension.

Notes

Toujours supprimer l'ancien fichier avant d'en écrire un nouveau à la même place. Sinon le stockage accumule des fichiers fantômes, présents dans le bucket mais plus référencés par l'application.

completeMultipartUpload peut répondre 200 avec un corps d'erreur. Le stockage envoie des espaces pendant qu'il assemble, donc il a déjà répondu 200 quand il découvre le problème. Le statut seul ne prouve rien, et la fonction lit le corps avant de conclure.

La route /lrvo/api/storage/** est déclarée auth: 'public' et applique elle-même les règles décrites plus haut, parce que la décision dépend du chemin demandé, pas de la route. Voir API.

Sur cette page