docs(storage): add readme for the storage module
This commit is contained in:
@@ -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';
|
||||
```
|
||||
Reference in New Issue
Block a user