From af8da2aa86dc6b59c6642a7a20321a64a0d47d6c Mon Sep 17 00:00:00 2001 From: Hyko Date: Sun, 19 Apr 2026 16:09:31 -0400 Subject: [PATCH] docs(storage): add readme for the storage module --- src/core/storage/README.md | 283 +++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 src/core/storage/README.md diff --git a/src/core/storage/README.md b/src/core/storage/README.md new file mode 100644 index 0000000..4cd03a6 --- /dev/null +++ b/src/core/storage/README.md @@ -0,0 +1,283 @@ +# Storage + +Ce répertoire est le **module de stockage de fichiers**. Il fournit une couche d'accès générique aux providers S3-compatibles (Cloudflare R2, Backblaze B2) : upload, téléchargement, suppression, listage, URL présignées et validation de fichiers. Il gère également le système de contrôle d'accès aux fichiers servis via HTTP. + +Il ne connaît aucune feature spécifique — les features l'importent pour leurs propres besoins et enregistrent leurs propres policies d'accès. + +--- + +## Structure + +``` +src/core/storage/ +├── index.js Exports publics (uploadFile, deleteFile, validateUpload…) +├── api.js Route HTTP /zen/api/storage/** avec contrôle d'accès par chemin +├── storage-config.js Enregistrement des policies et préfixes publics +├── utils.js Validation de fichiers, MIME types, magic bytes +├── signing.js Signature AWS Signature V4 pour requêtes S3-compatibles +├── cloudflare-r2.js Configuration provider Cloudflare R2 +└── backblaze.js Configuration provider Backblaze B2 +``` + +--- + +## Variables d'environnement + +| Variable | Rôle | +|----------|------| +| `ZEN_STORAGE_PROVIDER` | Provider à utiliser : `r2` (défaut) ou `backblaze` | +| `ZEN_R2_ACCOUNT_ID` | ID de compte Cloudflare (R2) | +| `ZEN_R2_ACCESS_KEY_ID` | Clé d'accès R2 | +| `ZEN_R2_SECRET_ACCESS_KEY` | Secret R2 | +| `ZEN_R2_BUCKET_NAME` | Nom du bucket R2 | +| `ZEN_B2_KEY_ID` | ID de clé Backblaze B2 | +| `ZEN_B2_APPLICATION_KEY` | Clé d'application Backblaze B2 | +| `ZEN_B2_BUCKET_NAME` | Nom du bucket Backblaze B2 | +| `ZEN_B2_ENDPOINT` | Endpoint S3 Backblaze (ex: `s3.us-west-004.backblazeb2.com`) | + +--- + +## Système de contrôle d'accès + +### Principe : tout est privé par défaut + +Aucun fichier n'est accessible sans configuration explicite. Pour qu'un fichier soit servi via `/zen/api/storage/**`, son chemin doit correspondre à l'un des deux mécanismes déclarés pendant `initializeZen()` : un préfixe public ou une policy d'accès. + +### Préfixes publics + +Les fichiers dont le chemin commence par un préfixe public sont servis **sans authentification**. Utile pour les assets publics (logos, images de produits…). + +Le chemin doit avoir au minimum la forme `{prefixe}/{id}/{fichier}` — un préfixe seul ne suffit pas à exposer des fichiers. + +```js +// src/features/myfeature/storage-policies.js +export const storagePublicPrefixes = ['products']; +// products/abc123/photo.webp → accessible sans session +``` + +### Policies d'accès (chemins privés) + +Pour les chemins privés, une policy doit correspondre au premier segment du chemin (`pathParts[0]`). Deux types sont disponibles : + +| Type | Règle | +|------|-------| +| `owner` | `pathParts[1]` doit être l'ID de l'utilisateur connecté. Les admins ont accès à tout. | +| `admin` | L'utilisateur doit avoir le rôle `admin`. | + +```js +// src/features/myfeature/storage-policies.js +export const storageAccessPolicies = [ + { prefix: 'users', type: 'owner' }, // users/{userId}/... + { prefix: 'invoices', type: 'admin' }, // invoices/... +]; +``` + +### Enregistrement dans initializeZen() + +```js +// src/shared/lib/init.js +import { registerStoragePolicies, registerStoragePublicPrefixes } from '@zen/core/storage'; +import { storageAccessPolicies, storagePublicPrefixes } from '../../features/myfeature/storage-policies.js'; + +registerStoragePolicies(storageAccessPolicies); +registerStoragePublicPrefixes(storagePublicPrefixes); // si nécessaire +``` + +L'enregistrement est **additif** — plusieurs features peuvent appeler `registerStoragePolicies` indépendamment. + +--- + +## Fonctions d'opération + +Toutes les fonctions retournent un objet `{ success, data, error }`. Elles ne lèvent jamais d'exception — les erreurs sont journalisées côté serveur et encapsulées dans `error`. + +### `uploadFile({ key, body, contentType, metadata?, cacheControl? })` + +Upload générique. + +```js +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? })` + +Équivalent à `uploadFile` avec `Cache-Control: public, max-age=31536000` (1 an). + +### `deleteFile(key)` + +Supprime un fichier. + +```js +await deleteFile('users/abc123/profile/avatar_xxx.webp'); +``` + +### `deleteFiles(keys[])` + +Suppression en lot (S3 DeleteObjects). Retourne les clés supprimées et les erreurs éventuelles. + +```js +const { data } = await deleteFiles(['a.jpg', 'b.jpg']); +// data.deleted → [{ Key: 'a.jpg' }, …] +// data.errors → [] +``` + +### `getFile(key)` + +Récupère le contenu et les métadonnées d'un fichier. + +```js +const { data } = await getFile('users/abc123/profile/avatar.webp'); +// data.body, data.contentType, data.contentLength, data.lastModified, data.metadata +``` + +### `getFileMetadata(key)` + +Requête HEAD : métadonnées uniquement, sans télécharger le contenu. + +### `fileExists(key)` + +Retourne `true` si le fichier existe. + +```js +if (await fileExists('users/abc123/profile/avatar.webp')) { … } +``` + +### `listFiles({ prefix?, maxKeys?, continuationToken? })` + +Liste les fichiers avec pagination (max 1 000 par appel). + +```js +const { data } = await listFiles({ prefix: 'users/abc123/', maxKeys: 100 }); +// data.files → [{ key, size, lastModified, etag }, …] +// data.isTruncated, data.nextContinuationToken +``` + +### `getPresignedUrl({ key, expiresIn?, operation? })` + +Génère une URL signée temporaire. `expiresIn` en secondes (défaut 3600, max 604 800 = 7 jours). `operation` : `'get'` (défaut) ou `'put'`. + +```js +const { data } = await getPresignedUrl({ key: 'docs/contrat.pdf', expiresIn: 300 }); +// data.url → URL signée valide 5 minutes +``` + +### `copyFile({ sourceKey, destinationKey })` + +Copie un fichier dans le même bucket. + +### `moveFile({ sourceKey, destinationKey })` + +Copie puis supprime la source. Si la suppression échoue, le fichier copié est conservé (non-fatal). + +### `proxyFile(key, { filename? })` + +Récupère un fichier pour le streamer directement au client. + +--- + +## Validation de fichiers + +### `validateUpload({ filename, size, allowedTypes, maxSize, buffer? })` + +Validation complète avant upload. Retourne `{ valid, errors[] }`. + +```js +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 de validation appliquées dans l'ordre : +1. Nom de fichier requis +2. SVG explicitement interdit (risque d'exécution de scripts) +3. Extension contre la liste blanche `allowedTypes` +4. Taille contre `maxSize` +5. *(si buffer fourni)* Magic bytes — assertion positive que l'extension correspond au contenu réel +6. *(si buffer fourni)* Scan des 512 premiers octets pour HTML/SVG/XML (défense contre les fichiers polyglots) + +### `FILE_TYPE_PRESETS` + +| Constante | Extensions | +|-----------|-----------| +| `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` + +| Constante | Limite | +|-----------|--------| +| `AVATAR` | 5 MB | +| `IMAGE` | 10 MB | +| `DOCUMENT` | 50 MB | +| `VIDEO` | 500 MB | +| `LARGE_FILE` | 1 GB | + +### Fonctions utilitaires + +| Fonction | Description | +|----------|-------------| +| `generateUniqueFilename(originalName, prefix?)` | Génère `{prefix}_{timestamp}_{hash}{ext}` | +| `getFileExtension(filename)` | Retourne l'extension avec le point (`.jpg`) ou `''` | +| `getMimeType(filename)` | Retourne le MIME type depuis l'extension | +| `validateFileType(filename, allowedTypes)` | Vérifie l'extension contre une liste | +| `validateFileSize(size, maxSize)` | Vérifie la taille | +| `formatFileSize(bytes)` | Formate en lisible humain (`1.5 MB`) | +| `sanitizeFilename(filename)` | Supprime les caractères spéciaux | + +--- + +## Usage depuis une feature + +```js +// src/features/media/api.js +import { uploadImage, deleteFile, validateUpload, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, generateUniqueFilename } from '../../core/storage/index.js'; + +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) throw new Error(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) throw new Error('Upload failed'); + + return key; +} +``` + +```js +// src/features/media/storage-policies.js ← la feature déclare ses propres policies +export const storageAccessPolicies = [ + { prefix: 'users', type: 'owner' }, +]; +``` + +## Usage depuis un module + +```js +// src/modules/mymodule/actions.js +import { uploadFile, deleteFile, generateUniqueFilename } from '@zen/core/storage'; +```