diff --git a/content/recipes/file-storage.md b/content/recipes/file-storage.md
new file mode 100644
index 0000000000..c89d2bd9eb
--- /dev/null
+++ b/content/recipes/file-storage.md
@@ -0,0 +1,1166 @@
+### File Storage
+
+[`@fozooni/nestjs-storage`](https://github.com/fozooni/nestjs-storage) is a driver-based storage module for NestJS. It exposes a single unified `FilesystemContract` interface that works identically across all storage backends — switch providers by changing config, not code. The module supports 9 built-in drivers: Local, S3, R2 (Cloudflare), GCS (Google Cloud), Azure Blob Storage, MinIO, Backblaze B2, DigitalOcean Spaces, and Wasabi.
+
+> info **Note** `@fozooni/nestjs-storage` is a third-party package and is not officially maintained by the NestJS core team. If you encounter any issues, please report them in the [official repository](https://github.com/fozooni/nestjs-storage).
+
+#### Installation
+
+To get started, install the core package:
+
+```bash
+$ npm install @fozooni/nestjs-storage
+```
+
+Then install the peer dependency for the storage backend(s) you plan to use:
+
+| Package | When to install |
+|---------|----------------|
+| `@aws-sdk/client-s3 @aws-sdk/s3-request-presigner` | S3, R2, MinIO, B2, DigitalOcean, Wasabi |
+| `@aws-sdk/s3-presigned-post` | Presigned POST uploads on S3 / R2 |
+| `@aws-sdk/cloudfront-signer` | CloudFront signed URLs with `CdnDisk` |
+| `@google-cloud/storage` | GCS driver |
+| `@azure/storage-blob` | Azure Blob Storage driver |
+| `@opentelemetry/api` | `OtelDisk` tracing spans |
+| `multer` / `@types/multer` | `StorageFileInterceptor` / `StorageFilesInterceptor` |
+| `archiver` / `@types/archiver` | `StorageArchiver` (ZIP / TAR) |
+| `zod` | Schema validation in `json()` |
+| `@nestjs/terminus` | `StorageHealthIndicator` |
+
+> info **Hint** You only need to install the peer dependencies for the drivers and features you actually use. The module provides graceful errors if a required peer dependency is missing.
+
+#### Module setup
+
+Import `StorageModule` into your root `AppModule` and configure it using `forRoot()`:
+
+```typescript
+@@filename(app.module)
+import { Module } from '@nestjs/common';
+import { StorageModule } from '@fozooni/nestjs-storage';
+
+@Module({
+ imports: [
+ StorageModule.forRoot({
+ default: 'local',
+ disks: {
+ local: {
+ driver: 'local',
+ root: './storage',
+ url: 'http://localhost:3000/storage',
+ },
+ s3: {
+ driver: 's3',
+ bucket: 'my-bucket',
+ region: 'us-east-1',
+ key: process.env.AWS_ACCESS_KEY_ID,
+ secret: process.env.AWS_SECRET_ACCESS_KEY,
+ },
+ },
+ }),
+ ],
+})
+export class AppModule {}
+```
+
+The `default` property specifies which disk to use when none is explicitly provided. Setting `isGlobal: true` (which is the default) makes the module available throughout your application without needing to re-import it.
+
+##### Async configuration
+
+For applications that use the `ConfigModule` or need to resolve dependencies at runtime, use `forRootAsync()`:
+
+```typescript
+@@filename(app.module)
+import { Module } from '@nestjs/common';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import { StorageModule } from '@fozooni/nestjs-storage';
+
+@Module({
+ imports: [
+ ConfigModule.forRoot(),
+ StorageModule.forRootAsync({
+ useFactory: (config: ConfigService) => ({
+ default: config.get('STORAGE_DRIVER'),
+ disks: {
+ local: {
+ driver: 'local',
+ root: './storage',
+ url: config.get('APP_URL') + '/storage',
+ },
+ s3: {
+ driver: 's3',
+ bucket: config.get('AWS_BUCKET'),
+ region: config.get('AWS_REGION'),
+ key: config.get('AWS_ACCESS_KEY_ID'),
+ secret: config.get('AWS_SECRET_ACCESS_KEY'),
+ },
+ },
+ }),
+ inject: [ConfigService],
+ imports: [ConfigModule],
+ injectDisks: ['local', 's3'],
+ }),
+ ],
+})
+export class AppModule {}
+```
+
+> info **Hint** The `injectDisks` array is required when using `forRootAsync()` if you plan to use the `@InjectDisk()` decorator to inject specific disks.
+
+#### Storage drivers
+
+The module ships with 9 built-in drivers. Each driver implements the same `FilesystemContract` interface, so your application code stays identical regardless of which backend you choose.
+
+| Driver | `driver` value | Required peer dependency |
+|--------|---------------|--------------------------|
+| Local filesystem | `'local'` | None |
+| Amazon S3 | `'s3'` | `@aws-sdk/client-s3` |
+| Cloudflare R2 | `'r2'` | `@aws-sdk/client-s3` |
+| Google Cloud Storage | `'gcs'` | `@google-cloud/storage` |
+| Azure Blob Storage | `'azure'` | `@azure/storage-blob` |
+| MinIO | `'minio'` | `@aws-sdk/client-s3` |
+| Backblaze B2 | `'b2'` | `@aws-sdk/client-s3` |
+| DigitalOcean Spaces | `'digitalocean'` | `@aws-sdk/client-s3` |
+| Wasabi | `'wasabi'` | `@aws-sdk/client-s3` |
+
+Expand to see configuration examples for each driver
+
+##### Local
+
+```typescript
+{
+ driver: 'local',
+ root: './storage',
+ url: 'http://localhost:3000/storage',
+ signSecret: 'my-32-char-secret-for-signed-urls',
+ visibility: 'private',
+}
+```
+
+##### S3
+
+```typescript
+{
+ driver: 's3',
+ bucket: 'my-bucket',
+ region: 'us-east-1',
+ key: 'AKIAIOSFODNN7EXAMPLE',
+ secret: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
+}
+```
+
+##### Cloudflare R2
+
+```typescript
+{
+ driver: 'r2',
+ bucket: 'my-bucket',
+ accountId: 'your-cloudflare-account-id',
+ key: 'access-key',
+ secret: 'secret-key',
+}
+```
+
+##### Google Cloud Storage
+
+```typescript
+{
+ driver: 'gcs',
+ bucket: 'my-bucket',
+ projectId: 'my-project',
+ keyFilename: '/path/to/service-account.json',
+}
+```
+
+##### Azure Blob Storage
+
+```typescript
+{
+ driver: 'azure',
+ containerName: 'my-container',
+ accountName: 'myaccount',
+ accountKey: 'base64-encoded-key',
+}
+```
+
+##### MinIO
+
+```typescript
+{
+ driver: 'minio',
+ bucket: 'my-bucket',
+ endpoint: 'http://localhost:9000',
+ key: 'minioadmin',
+ secret: 'minioadmin',
+ region: 'us-east-1',
+}
+```
+
+##### Backblaze B2
+
+```typescript
+{
+ driver: 'b2',
+ bucket: 'my-bucket',
+ endpoint: 'https://s3.us-west-004.backblazeb2.com',
+ key: 'keyId',
+ secret: 'applicationKey',
+ region: 'us-west-004',
+}
+```
+
+##### DigitalOcean Spaces
+
+```typescript
+{
+ driver: 'digitalocean',
+ bucket: 'my-space',
+ region: 'nyc3',
+ endpoint: 'https://nyc3.digitaloceanspaces.com',
+ key: 'access-key',
+ secret: 'secret-key',
+}
+```
+
+##### Wasabi
+
+```typescript
+{
+ driver: 'wasabi',
+ bucket: 'my-bucket',
+ region: 'us-east-1',
+ endpoint: 'https://s3.wasabisys.com',
+ key: 'access-key',
+ secret: 'secret-key',
+}
+```
+
+
+
+#### Basic usage
+
+Inject `StorageService` into any provider to start reading and writing files. All operations use the default disk unless you specify otherwise.
+
+```typescript
+@@filename(files.service)
+import { Injectable } from '@nestjs/common';
+import { StorageService } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class FilesService {
+ constructor(private readonly storage: StorageService) {}
+
+ async createFile(path: string, content: string): Promise {
+ return this.storage.put(path, content);
+ }
+
+ async readFile(path: string): Promise {
+ const buffer = await this.storage.get(path);
+ return buffer.toString();
+ }
+
+ async deleteFile(path: string): Promise {
+ return this.storage.delete(path);
+ }
+
+ async fileExists(path: string): Promise {
+ return this.storage.exists(path);
+ }
+}
+```
+
+##### Core operations
+
+The `FilesystemContract` interface provides the following core operations:
+
+```typescript
+// Write & read
+await storage.put('reports/q1.txt', 'Quarterly report content');
+const content = await storage.get('reports/q1.txt');
+const stream = await storage.get('reports/q1.txt', { responseType: 'stream' });
+
+// Check existence
+const exists = await storage.exists('reports/q1.txt');
+
+// Copy & move
+await storage.copy('reports/q1.txt', 'archive/q1.txt');
+await storage.move('temp/draft.txt', 'reports/final.txt');
+
+// Delete
+await storage.delete('temp/draft.txt');
+await storage.deleteMany(['temp/a.txt', 'temp/b.txt']);
+
+// File info
+const bytes = await storage.size('reports/q1.txt');
+const modified = await storage.lastModified('reports/q1.txt');
+const mime = await storage.mimeType('reports/q1.txt');
+const metadata = await storage.getMetadata('reports/q1.txt');
+const hash = await storage.checksum('reports/q1.txt', 'sha256');
+
+// Append & prepend
+await storage.append('logs/app.log', 'New log entry\n');
+await storage.prepend('logs/app.log', 'Header line\n');
+
+// JSON with optional Zod validation
+const data = await storage.json('config.json');
+```
+
+##### Directory operations
+
+```typescript
+// List files
+const files = await storage.files('uploads/');
+const allFiles = await storage.allFiles('uploads/'); // recursive
+
+// List directories
+const dirs = await storage.directories('uploads/');
+const allDirs = await storage.allDirectories('uploads/'); // recursive
+
+// Create & delete directories
+await storage.makeDirectory('uploads/photos');
+await storage.deleteDirectory('uploads/temp');
+
+// Directory size
+const totalBytes = await storage.directorySize('uploads/');
+```
+
+##### Visibility
+
+```typescript
+await storage.setVisibility('reports/q1.txt', 'public');
+const visibility = await storage.getVisibility('reports/q1.txt');
+// → 'public' | 'private'
+```
+
+#### Working with disks
+
+##### Switching disks at runtime
+
+Use the `disk()` method to switch between configured disks:
+
+```typescript
+@@filename(files.service)
+import { Injectable } from '@nestjs/common';
+import { StorageService } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class FilesService {
+ constructor(private readonly storage: StorageService) {}
+
+ async backupToCloud(path: string): Promise {
+ const content = await this.storage.disk('local').get(path);
+ await this.storage.disk('s3').put(path, content);
+ }
+}
+```
+
+##### Injecting specific disks
+
+Use the `@InjectDisk()` decorator to inject a specific disk directly:
+
+```typescript
+@@filename(photos.service)
+import { Injectable } from '@nestjs/common';
+import { InjectDisk, FilesystemContract } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class PhotosService {
+ constructor(
+ @InjectDisk('s3') private readonly photoDisk: FilesystemContract,
+ ) {}
+
+ async upload(photo: Express.Multer.File): Promise {
+ const result = await this.photoDisk.putFile('photos', photo);
+ return result || '';
+ }
+}
+```
+
+##### Scoped disks
+
+A scoped disk transparently prepends a prefix to all paths, which is useful for multi-tenant applications:
+
+```typescript
+@@filename(tenant-files.service)
+import { Injectable } from '@nestjs/common';
+import { StorageService, FilesystemContract } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class TenantFilesService {
+ constructor(private readonly storage: StorageService) {}
+
+ getDiskForTenant(tenantId: string): FilesystemContract {
+ return this.storage.scope(`tenants/${tenantId}`);
+ }
+}
+```
+
+Nested scopes chain correctly — `scoped.scope('photos')` produces paths like `tenants/123/photos/...`.
+
+#### File uploads
+
+The module provides interceptors that handle file uploads and automatically store them to disk, replacing the standard Multer file object with a `StoredFile` containing the storage path and URL.
+
+> info **Hint** Install the `multer` package and its type definitions before using storage interceptors: `npm install multer` and `npm install -D @types/multer`.
+
+##### Single file upload
+
+```typescript
+@@filename(avatar.controller)
+import { Controller, Post, UseInterceptors, UploadedFile } from '@nestjs/common';
+import { StorageFileInterceptor, StoredFile } from '@fozooni/nestjs-storage';
+
+@Controller('users')
+export class AvatarController {
+ @Post('avatar')
+ @UseInterceptors(StorageFileInterceptor('avatar', {
+ disk: 's3',
+ path: 'avatars/',
+ }))
+ async uploadAvatar(@UploadedFile() file: StoredFile) {
+ return {
+ url: file.url,
+ path: file.path,
+ size: file.size,
+ };
+ }
+}
+```
+
+##### Multiple file upload
+
+```typescript
+@@filename(gallery.controller)
+import { Controller, Post, UseInterceptors, UploadedFiles } from '@nestjs/common';
+import { StorageFilesInterceptor, StoredFile } from '@fozooni/nestjs-storage';
+
+@Controller('gallery')
+export class GalleryController {
+ @Post('photos')
+ @UseInterceptors(StorageFilesInterceptor('photos', 10, {
+ disk: 's3',
+ path: 'gallery/',
+ }))
+ async uploadPhotos(@UploadedFiles() files: StoredFile[]) {
+ return files.map(f => ({ url: f.url, size: f.size }));
+ }
+}
+```
+
+The `StoredFile` object contains `path`, `url`, `size`, `mimetype`, `originalname`, and `disk`.
+
+#### File validation
+
+The module ships with two file validators that extend `FileValidator` from `@nestjs/common`. They can be combined with `ParseFilePipe` to validate uploads.
+
+```typescript
+@@filename(upload.controller)
+import { Controller, Post, UploadedFile, ParseFilePipe, UseInterceptors } from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { FileExtensionValidator, MagicBytesValidator } from '@fozooni/nestjs-storage';
+
+@Controller('upload')
+export class UploadController {
+ @Post()
+ @UseInterceptors(FileInterceptor('file'))
+ async upload(
+ @UploadedFile(
+ new ParseFilePipe({
+ validators: [
+ new FileExtensionValidator({ allowedExtensions: ['.jpg', '.png', '.webp'] }),
+ new MagicBytesValidator(),
+ ],
+ }),
+ )
+ file: Express.Multer.File,
+ ) {
+ return { filename: file.originalname };
+ }
+}
+```
+
+`FileExtensionValidator` checks the file extension (case-insensitive), while `MagicBytesValidator` reads the first bytes of the file buffer and compares them against known magic byte signatures. This prevents file extension spoofing without requiring any external dependencies.
+
+#### Naming strategies
+
+Naming strategies control how uploaded files are named when using `putFile()` or the storage interceptors. Four built-in strategies are available:
+
+| Strategy | Output example | Description |
+|----------|---------------|-------------|
+| `UuidNamingStrategy` | `a4f3b2c1-xxxx-xxxx.jpg` | Random UUID + original extension |
+| `HashNamingStrategy` | `d41d8cd98f00b204.jpg` | MD5 hash of content + extension (deterministic) |
+| `DatePathNamingStrategy` | `2024/01/15/a4f3b2c1.jpg` | Date-based directory + UUID + extension |
+| `OriginalNamingStrategy` | `photo.jpg` | Keeps the original filename unchanged |
+
+Apply a naming strategy at the disk level or per operation:
+
+```typescript
+import { UuidNamingStrategy } from '@fozooni/nestjs-storage';
+
+// In disk configuration
+StorageModule.forRoot({
+ default: 'local',
+ disks: {
+ local: {
+ driver: 'local',
+ root: './storage',
+ namingStrategy: new UuidNamingStrategy(),
+ },
+ },
+});
+
+// Or per operation
+await storage.putFile('uploads', file, {
+ namingStrategy: new DatePathNamingStrategy(),
+});
+```
+
+#### Signed & temporary URLs
+
+##### Temporary URLs
+
+Generate time-limited signed URLs for private files:
+
+```typescript
+const url = await storage.disk('s3').temporaryUrl(
+ 'private/report.pdf',
+ new Date(Date.now() + 3600_000), // expires in 1 hour
+);
+```
+
+For cloud drivers (S3, GCS, Azure), this creates a provider-native presigned URL. For the local driver, a HMAC-SHA256 signed URL is generated when `signSecret` is configured.
+
+##### Local signed URL middleware
+
+To protect local files served over HTTP, configure `signSecret` on the local disk and register the middleware:
+
+```typescript
+@@filename(app.module)
+import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
+import { StorageModule, LocalSignedUrlMiddleware } from '@fozooni/nestjs-storage';
+
+@Module({
+ imports: [
+ StorageModule.forRoot({
+ default: 'local',
+ disks: {
+ local: {
+ driver: 'local',
+ root: './storage',
+ signSecret: 'a-secret-of-at-least-32-characters',
+ },
+ },
+ }),
+ ],
+})
+export class AppModule implements NestModule {
+ configure(consumer: MiddlewareConsumer) {
+ consumer.apply(LocalSignedUrlMiddleware).forRoutes('/files/*');
+ }
+}
+```
+
+The middleware returns a `403` response for expired or invalid signatures, using `crypto.timingSafeEqual` to prevent timing attacks.
+
+##### Presigned POST
+
+For direct browser-to-storage uploads (bypassing your server), generate a presigned POST:
+
+```typescript
+const { url, fields } = await storage.disk('s3').presignedPost('uploads/user-photo.jpg', {
+ expires: 3600,
+ maxSize: 5 * 1024 * 1024,
+ allowedMimeTypes: ['image/jpeg', 'image/png'],
+});
+// Return `url` and `fields` to the client for a direct multipart/form-data upload
+```
+
+Presigned POST is supported by S3, R2, GCS, and Azure drivers.
+
+#### Decorator disks
+
+Decorator disks wrap an existing disk to add cross-cutting behaviour. They extend `DiskDecorator` and auto-delegate all `FilesystemContract` methods to the inner disk — override only the methods you need.
+
+##### Encrypted disk
+
+Transparent AES-256-GCM encryption. Each file gets a random 12-byte IV prepended to the ciphertext.
+
+```typescript
+const encrypted = storage.encrypted('local', {
+ key: process.env.ENCRYPTION_KEY, // 32-byte key as hex string or Buffer
+});
+
+await encrypted.put('secrets/data.json', sensitiveContent);
+const plaintext = await encrypted.get('secrets/data.json');
+```
+
+##### Cached disk
+
+Caches metadata operations (`exists`, `size`, `lastModified`, `mimeType`, `getMetadata`, `getVisibility`) with configurable TTLs. Cache is automatically invalidated on write operations.
+
+```typescript
+const cached = storage.cached('s3', {
+ ttl: 60_000,
+ ttlByMethod: {
+ exists: 30_000,
+ size: 60_000,
+ mimeType: 300_000,
+ },
+});
+```
+
+##### Retry disk
+
+Automatic retries with exponential backoff and full-jitter for transient failures:
+
+```typescript
+const retried = storage.withRetry('s3', {
+ maxRetries: 5,
+ baseDelay: 100,
+ maxDelay: 10_000,
+});
+```
+
+By default, only `StorageNetworkError` is retried. Errors like `StorageFileNotFoundError` and `StoragePermissionError` are never retried.
+
+##### Replicated disk
+
+Write to multiple disks for redundancy:
+
+```typescript
+const replicated = storage.replicated('primary', [
+ storage.disk('replica1'),
+ storage.disk('replica2'),
+], { strategy: 'all' });
+// Strategies: 'all' | 'quorum' | 'async'
+```
+
+Reads are always served from the primary disk.
+
+##### CDN disk
+
+Override `url()` to return CDN URLs and generate CloudFront signed URLs via `temporaryUrl()`:
+
+```typescript
+// Auto-configured via the `cdn` field in disk config:
+StorageModule.forRoot({
+ default: 's3',
+ disks: {
+ s3: {
+ driver: 's3',
+ bucket: 'my-bucket',
+ region: 'us-east-1',
+ key: '...',
+ secret: '...',
+ cdn: {
+ baseUrl: 'https://cdn.example.com',
+ provider: 'cloudfront',
+ signingKeyId: 'KXXXXXXXXXXXXX',
+ signingKey: process.env.CLOUDFRONT_PRIVATE_KEY,
+ },
+ },
+ },
+});
+```
+
+##### OpenTelemetry disk
+
+Wraps every async operation in an OpenTelemetry span. Zero-overhead when `@opentelemetry/api` is not installed.
+
+```typescript
+const traced = storage.withTracing('s3');
+```
+
+##### Quota disk
+
+Enforce storage quotas per disk or per user:
+
+```typescript
+import { MemoryQuotaStore } from '@fozooni/nestjs-storage';
+
+const quota = storage.withQuota('local', new MemoryQuotaStore(), {
+ maxBytes: 1_073_741_824, // 1 GB
+ prefix: 'users/123',
+});
+
+const { used, limit, percent } = await quota.getUsage();
+```
+
+Throws `StorageQuotaExceededError` when the limit is reached.
+
+##### Versioned disk
+
+Automatically snapshots previous content before every write:
+
+```typescript
+const versioned = storage.withVersioning('local');
+
+await versioned.put('config.json', newContent);
+
+const versions = await versioned.listVersions('config.json');
+await versioned.restoreVersion('config.json', versions[0].versionId);
+```
+
+Snapshots are stored under a `.versions` directory alongside the original file path. Versioning failures never block the actual write.
+
+##### Router disk
+
+Route files to different disks based on extension, prefix, MIME type, or size:
+
+```typescript
+import { byExtension, byPrefix, bySize } from '@fozooni/nestjs-storage';
+
+const router = storage.withRouting([
+ byExtension(['.jpg', '.png', '.webp'], storage.disk('images-s3')),
+ byPrefix('docs/', storage.disk('docs-gcs')),
+ bySize(5 * 1024 * 1024, storage.disk('small-local')),
+], storage.disk('default'));
+```
+
+First-match wins on write. Cross-disk `copy()` and `move()` are handled transparently.
+
+#### Streaming & range requests
+
+##### Streaming files
+
+Use `getStreamableFile()` to return files from NestJS controllers with proper headers:
+
+```typescript
+@@filename(download.controller)
+import { Controller, Get, Param, StreamableFile } from '@nestjs/common';
+import { StorageService } from '@fozooni/nestjs-storage';
+
+@Controller('download')
+export class DownloadController {
+ constructor(private readonly storage: StorageService) {}
+
+ @Get(':path')
+ async download(@Param('path') path: string): Promise {
+ return this.storage.getStreamableFile(path, {
+ filename: path,
+ disposition: 'attachment',
+ });
+ }
+}
+```
+
+##### Range requests
+
+For video streaming or large file downloads, use `serveRange()` which automatically handles the HTTP `Range` header:
+
+```typescript
+@@filename(stream.controller)
+import { Controller, Get, Param, Req, Res } from '@nestjs/common';
+import { Request, Response } from 'express';
+import { StorageService } from '@fozooni/nestjs-storage';
+
+@Controller('stream')
+export class StreamController {
+ constructor(private readonly storage: StorageService) {}
+
+ @Get(':path')
+ async stream(
+ @Param('path') path: string,
+ @Req() req: Request,
+ @Res() res: Response,
+ ) {
+ await this.storage.serveRange(path, req, res, 's3');
+ }
+}
+```
+
+This sets `Content-Range`, `Content-Length`, and `Accept-Ranges` headers automatically, returning HTTP `206` for partial content or `200` for the full file.
+
+#### Multipart uploads
+
+For large files, the module supports multipart uploads on cloud drivers:
+
+```typescript
+@@filename(large-upload.service)
+import { Injectable } from '@nestjs/common';
+import { StorageService } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class LargeUploadService {
+ constructor(private readonly storage: StorageService) {}
+
+ async uploadLargeFile(file: Express.Multer.File): Promise {
+ const disk = this.storage.disk('s3');
+ return disk.putFileMultipart('uploads/large', file, {
+ chunkSize: 10 * 1024 * 1024, // 10 MB chunks
+ onProgress: (loaded, total) => {
+ console.log(`Progress: ${((loaded / total) * 100).toFixed(1)}%`);
+ },
+ });
+ }
+}
+```
+
+For finer control, you can manage parts individually:
+
+```typescript
+const { uploadId } = await disk.initMultipartUpload('large-file.zip');
+const part1 = await disk.uploadPart(uploadId, 1, chunk1, 'large-file.zip');
+const part2 = await disk.uploadPart(uploadId, 2, chunk2, 'large-file.zip');
+await disk.completeMultipartUpload(uploadId, 'large-file.zip', [part1, part2]);
+```
+
+#### Conditional writes
+
+Conditional writes prevent race conditions when multiple processes write to the same file. They are supported on Local, S3, and FakeDisk drivers.
+
+```typescript
+// Write only if the current ETag matches (optimistic locking)
+const result = await disk.putIfMatch('config.json', newContent, 'known-etag');
+if (!result.success) {
+ // ETag mismatch — file was modified by another process
+}
+
+// Write only if the file does not exist (create-once pattern)
+const result = await disk.putIfNoneMatch('config.json', initialContent);
+if (!result.success) {
+ // File already exists
+}
+```
+
+#### Events & audit logging
+
+##### Storage events
+
+`StorageEventsService` emits events after each storage operation. You can subscribe to them for logging, analytics, or triggering side effects.
+
+```typescript
+@@filename(storage-listener.service)
+import { Injectable } from '@nestjs/common';
+import { StorageEventsService, StorageEvents } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class StorageListenerService {
+ constructor(private readonly events: StorageEventsService) {
+ this.events.on(StorageEvents.PUT, (event) => {
+ console.log('File written:', event.path);
+ });
+
+ this.events.on(StorageEvents.DELETE, (event) => {
+ console.log('File deleted:', event.path);
+ });
+ }
+}
+```
+
+Available events: `storage.put`, `storage.put_file`, `storage.delete`, `storage.delete_many`, `storage.copy`, `storage.move`, `storage.retry`.
+
+##### Audit logging
+
+Enable audit logging by setting `auditLog: true` in the module config:
+
+```typescript
+StorageModule.forRoot({
+ default: 'local',
+ disks: { ... },
+ auditLog: true,
+})
+```
+
+The default sink logs to the NestJS Logger. You can add custom sinks for your own logging pipeline:
+
+```typescript
+@@filename(audit-setup.service)
+import { Injectable } from '@nestjs/common';
+import { StorageAuditService } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class AuditSetupService {
+ constructor(private readonly audit: StorageAuditService) {
+ this.audit.addSink({
+ log(entry) {
+ // Send to your logging service
+ externalLogger.info({
+ operation: entry.operation,
+ disk: entry.disk,
+ path: entry.path,
+ timestamp: entry.timestamp,
+ success: entry.success,
+ });
+ },
+ });
+ }
+}
+```
+
+#### File archiving
+
+`StorageArchiver` creates streaming ZIP or TAR archives from files on any disk. Files are appended as streams, so the archive is never buffered in memory.
+
+> info **Hint** Install the `archiver` package and its type definitions: `npm install archiver` and `npm install -D @types/archiver`.
+
+```typescript
+@@filename(export.controller)
+import { Controller, Get, Res } from '@nestjs/common';
+import { Response } from 'express';
+import { StorageArchiver, StorageService } from '@fozooni/nestjs-storage';
+
+@Controller('export')
+export class ExportController {
+ constructor(
+ private readonly archiver: StorageArchiver,
+ private readonly storage: StorageService,
+ ) {}
+
+ @Get('reports')
+ async downloadReports(@Res() res: Response) {
+ const stream = await this.archiver.createZip([
+ { path: 'reports/q1.pdf', name: 'Q1 Report.pdf' },
+ { path: 'reports/q2.pdf', name: 'Q2 Report.pdf' },
+ { path: 'reports/q3.pdf' }, // name defaults to basename
+ ], this.storage.disk('s3'), { zlib: { level: 6 } });
+
+ res.setHeader('Content-Type', 'application/zip');
+ res.setHeader('Content-Disposition', 'attachment; filename="reports.zip"');
+ stream.pipe(res);
+ }
+}
+```
+
+Use `createTar()` for TAR archives with the same API.
+
+#### Data migration
+
+`StorageMigrator` copies files between disks using an async generator — files are never loaded all into memory at once.
+
+```typescript
+@@filename(migration.service)
+import { Injectable } from '@nestjs/common';
+import { StorageService, StorageMigrator } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class MigrationService {
+ constructor(
+ private readonly storage: StorageService,
+ private readonly migrator: StorageMigrator,
+ ) {}
+
+ async migrateToCloud() {
+ const source = this.storage.disk('local');
+ const target = this.storage.disk('s3');
+
+ for await (const progress of this.migrator.migrate(source, target, {
+ prefix: 'uploads/',
+ concurrency: 10,
+ verify: true,
+ onError: 'skip',
+ })) {
+ if (progress.status === 'failed') {
+ console.error('Failed:', progress.path, progress.error?.message);
+ }
+ }
+ }
+}
+```
+
+Options include `prefix` (filter files), `concurrency`, `verify` (checksum verification), `deleteSource`, `dryRun`, and `onError` (`'skip'` or `'abort'`).
+
+#### Health checks
+
+The module provides a `StorageHealthIndicator` for use with the `@nestjs/terminus` health check system. It performs a write/read/delete cycle on the target disk to verify connectivity.
+
+> info **Hint** Install the `@nestjs/terminus` package: `npm install @nestjs/terminus`.
+
+```typescript
+@@filename(health.controller)
+import { Controller, Get } from '@nestjs/common';
+import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
+import { StorageHealthIndicator } from '@fozooni/nestjs-storage';
+
+@Controller('health')
+export class HealthController {
+ constructor(
+ private readonly health: HealthCheckService,
+ private readonly storageHealth: StorageHealthIndicator,
+ ) {}
+
+ @Get()
+ @HealthCheck()
+ check() {
+ return this.health.check([
+ () => this.storageHealth.check('storage', 'local'),
+ () => this.storageHealth.checkDisks('all-storage', ['local', 's3'], {
+ timeout: 3000,
+ }),
+ ]);
+ }
+}
+```
+
+#### Temporary files
+
+Write files with a time-to-live (TTL). For the local driver, a `.ttl` sidecar file is created alongside the content. For S3, the `Expires` metadata header is set.
+
+```typescript
+await disk.putTemp('tmp/session-data.json', content, 3600); // expires in 1 hour
+```
+
+Use `StorageTempCleanupService` to remove expired files. You can run cleanup manually or schedule it with `@nestjs/schedule`:
+
+```typescript
+@@filename(cleanup.service)
+import { Injectable } from '@nestjs/common';
+import { Cron } from '@nestjs/schedule';
+import { StorageTempCleanupService } from '@fozooni/nestjs-storage';
+
+@Injectable()
+export class CleanupService {
+ constructor(private readonly cleanup: StorageTempCleanupService) {}
+
+ @Cron('0 * * * *')
+ async handleCleanup() {
+ const { deleted, errors } = await this.cleanup.runOnce('local');
+ }
+}
+```
+
+#### Error handling
+
+The module provides a typed error hierarchy. All storage errors extend the base `StorageError` class:
+
+| Error class | Description |
+|------------|-------------|
+| `StorageFileNotFoundError` | File or directory does not exist |
+| `StoragePermissionError` | Access denied or unsupported operation |
+| `StorageNetworkError` | Transient failure (safe to retry) |
+| `StorageConfigurationError` | Missing config or missing peer dependency |
+| `StorageQuotaExceededError` | Storage quota exceeded |
+
+```typescript
+import {
+ StorageError,
+ StorageFileNotFoundError,
+ StorageNetworkError,
+} from '@fozooni/nestjs-storage';
+
+try {
+ await storage.get('missing.txt');
+} catch (error) {
+ if (error instanceof StorageFileNotFoundError) {
+ // File not found — handle 404
+ } else if (error instanceof StorageNetworkError) {
+ // Transient failure — safe to retry
+ } else if (error instanceof StorageError) {
+ // Any other storage error
+ }
+}
+```
+
+#### Custom drivers
+
+You can register custom drivers by implementing `FilesystemContract` and using `storage.extend()`:
+
+```typescript
+@@filename(custom-disk)
+import { FilesystemContract, DiskConfig } from '@fozooni/nestjs-storage';
+
+export class CustomDisk implements FilesystemContract {
+ constructor(private readonly config: DiskConfig) {}
+
+ async exists(path: string): Promise {
+ // your implementation
+ }
+
+ async get(path: string): Promise {
+ // your implementation
+ }
+
+ async put(path: string, contents: string | Buffer): Promise {
+ // your implementation
+ }
+
+ // ... implement remaining required methods
+}
+```
+
+```typescript
+@@filename(app.module)
+import { Module, OnModuleInit } from '@nestjs/common';
+import { StorageService, StorageModule } from '@fozooni/nestjs-storage';
+import { CustomDisk } from './custom-disk';
+
+@Module({
+ imports: [
+ StorageModule.forRoot({
+ default: 'custom',
+ disks: {
+ custom: { driver: 'my-driver', /* your fields */ },
+ },
+ }),
+ ],
+})
+export class AppModule implements OnModuleInit {
+ constructor(private readonly storage: StorageService) {}
+
+ onModuleInit() {
+ this.storage.extend('my-driver', (config) => new CustomDisk(config));
+ }
+}
+```
+
+To add cross-cutting behaviour, extend `DiskDecorator` instead. All methods are auto-delegated to the wrapped disk — override only the ones you need:
+
+```typescript
+import { DiskDecorator, PutOptions } from '@fozooni/nestjs-storage';
+
+class LoggingDisk extends DiskDecorator {
+ async put(path: string, contents: any, options?: PutOptions): Promise {
+ console.log('Writing to', path);
+ return super.put(path, contents, options);
+ }
+}
+```
+
+#### Testing
+
+The module provides `FakeDisk`, a full in-memory `FilesystemContract` implementation, and `StorageTestUtils` to simplify testing.
+
+```typescript
+@@filename(files.service.spec)
+import { Test } from '@nestjs/testing';
+import { StorageService, StorageTestUtils, FakeDisk } from '@fozooni/nestjs-storage';
+import { FilesService } from './files.service';
+
+describe('FilesService', () => {
+ let service: FilesService;
+ let fakeDisk: FakeDisk;
+
+ beforeEach(async () => {
+ const module = await Test.createTestingModule({
+ providers: [FilesService, StorageService],
+ }).compile();
+
+ service = module.get(FilesService);
+ const storage = module.get(StorageService);
+ fakeDisk = StorageTestUtils.fake(storage);
+ });
+
+ afterEach(() => {
+ fakeDisk.reset();
+ });
+
+ it('should write and read a file', async () => {
+ await service.createFile('test.txt', 'Hello');
+
+ fakeDisk.assertExists('test.txt');
+ fakeDisk.assertContentEquals('test.txt', 'Hello');
+ });
+
+ it('should delete a file', async () => {
+ await fakeDisk.put('test.txt', 'Hello');
+ await service.deleteFile('test.txt');
+
+ fakeDisk.assertMissing('test.txt');
+ });
+});
+```
+
+`FakeDisk` includes assertion helpers: `assertExists()`, `assertMissing()`, `assertCount()`, `assertDirectoryEmpty()`, and `assertContentEquals()`. Use `StorageTestUtils.fakeFile()` to create mock `Multer.File` objects for upload testing.
+
+#### More information
+
+Visit the [`@fozooni/nestjs-storage` documentation](https://fozooni.github.io/nestjs-storage/) for detailed API reference and additional examples.
diff --git a/src/app/homepage/menu/menu.component.ts b/src/app/homepage/menu/menu.component.ts
index 28331c9b88..88b8ac1a59 100644
--- a/src/app/homepage/menu/menu.component.ts
+++ b/src/app/homepage/menu/menu.component.ts
@@ -258,6 +258,7 @@ export class MenuComponent implements OnInit {
{ title: 'Async local storage', path: '/recipes/async-local-storage' },
{ title: 'Necord', path: '/recipes/necord' },
{ title: 'Suites (Automock)', path: '/recipes/suites' },
+ { title: 'File storage', path: '/recipes/file-storage' },
],
},
{
diff --git a/src/app/homepage/pages/recipes/file-storage/file-storage.component.ts b/src/app/homepage/pages/recipes/file-storage/file-storage.component.ts
new file mode 100644
index 0000000000..58bb7e06d1
--- /dev/null
+++ b/src/app/homepage/pages/recipes/file-storage/file-storage.component.ts
@@ -0,0 +1,20 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { BasePageComponent } from '../../page/page.component';
+import { HeaderAnchorDirective } from '../../../../shared/directives/header-anchor.directive';
+import { CopyButtonComponent } from '../../../../shared/components/copy-button/copy-button.component';
+import { TabsComponent } from '../../../../shared/components/tabs/tabs.component';
+import { ExtensionPipe } from '../../../../shared/pipes/extension.pipe';
+
+@Component({
+ selector: 'app-file-storage',
+ templateUrl: './file-storage.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: true,
+ imports: [
+ HeaderAnchorDirective,
+ CopyButtonComponent,
+ TabsComponent,
+ ExtensionPipe,
+ ],
+})
+export class FileStorageComponent extends BasePageComponent {}
diff --git a/src/app/homepage/pages/recipes/recipes.routes.ts b/src/app/homepage/pages/recipes/recipes.routes.ts
index 357ca7053e..1dec6a9503 100644
--- a/src/app/homepage/pages/recipes/recipes.routes.ts
+++ b/src/app/homepage/pages/recipes/recipes.routes.ts
@@ -19,6 +19,7 @@ import { SuitesComponent } from './suites/suites.component';
import { SwcComponent } from './swc/swc.component';
import { NecordComponent } from './necord/necord.component';
import { PassportComponent } from './passport/passport.component';
+import { FileStorageComponent } from './file-storage/file-storage.component';
export const RECIPES_ROUTES: Routes = [
{
@@ -137,4 +138,9 @@ export const RECIPES_ROUTES: Routes = [
component: PassportComponent,
data: { title: 'passport' },
},
+ {
+ path: 'file-storage',
+ component: FileStorageComponent,
+ data: { title: 'File Storage' },
+ },
];