mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
- Rename planta module to plants everywhere (routes, modules, API, branding, i18n, docker, docs, shared packages) - Fix package name collisions: @mana/credits-service, @mana/subscriptions-service (unblocks turbo) - Extract layout composables: use-ai-tier-items, use-sync-status-items, RouteTierGate (layout 1345→1015 lines) - Create shared DB pool for apps/api (lib/db.ts), migrate 5 modules - Add automations module queries.ts with useAllAutomations/useEnabledAutomations - Remove debug console.log statements from production code - Rename storage display name: Ablage → Speicher Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
260 lines
8.2 KiB
Markdown
260 lines
8.2 KiB
Markdown
# @mana/shared-storage
|
|
|
|
S3-compatible object storage client for the Mana monorepo. Uses MinIO for S3-compatible storage.
|
|
|
|
## Setup
|
|
|
|
### Local Development
|
|
|
|
```bash
|
|
pnpm docker:up # Start MinIO + Postgres + Redis
|
|
pnpm docker:up # MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
|
|
```
|
|
|
|
### Buckets
|
|
|
|
Each app gets its own isolated bucket, created automatically by `minio-init`:
|
|
|
|
| Bucket | Project | Purpose |
|
|
|--------|---------|---------|
|
|
| `mana-storage` | Mana | Avatars, auth assets |
|
|
| `picture-storage` | Picture | AI-generated images |
|
|
| `chat-storage` | Chat | User file uploads |
|
|
| `cards-storage` | Cards | Card/deck assets |
|
|
| `presi-storage` | Presi | Presentation slides |
|
|
| `calendar-storage` | Calendar | Calendar attachments |
|
|
| `contacts-storage` | Contacts | Contact avatars/files |
|
|
| `storage-storage` | Storage | Cloud drive files |
|
|
| `mail-storage` | Mail | Email attachments |
|
|
| `inventory-storage` | Inventory | Product photos |
|
|
| `music-storage` | Music | Music tracks, beats, covers |
|
|
| `plants-storage` | Planta | Plant photos |
|
|
| `projectdoc-storage` | ProjectDoc | Document files |
|
|
|
|
## Usage
|
|
|
|
### Basic Operations
|
|
|
|
```typescript
|
|
import { createPictureStorage, generateUserFileKey, getContentType } from '@mana/shared-storage';
|
|
|
|
const storage = createPictureStorage();
|
|
|
|
// Upload
|
|
const key = generateUserFileKey('user-123', 'avatar.png');
|
|
const result = await storage.upload(key, imageBuffer, {
|
|
contentType: getContentType('avatar.png'),
|
|
public: true,
|
|
maxSizeBytes: 10 * 1024 * 1024, // 10MB limit (works with buffers AND streams)
|
|
});
|
|
|
|
// Download
|
|
const buffer = await storage.download(key);
|
|
const stream = await storage.downloadStream(key); // Memory-efficient for large files
|
|
|
|
// Delete
|
|
await storage.delete(key);
|
|
await storage.deleteMany(['a.png', 'b.png', 'c.png']); // Bulk delete (auto-batches at 1000)
|
|
await storage.deleteByPrefix('users/user-123/'); // Delete all user files
|
|
|
|
// Copy & Move
|
|
const copied = await storage.copy('old/file.png', 'new/file.png');
|
|
const moved = await storage.move('src/file.png', 'dst/file.png'); // copy + delete
|
|
|
|
// Metadata (without downloading)
|
|
const meta = await storage.getMetadata(key);
|
|
// => { contentType: 'image/png', size: 4096, lastModified: Date, etag: '...', metadata: {} }
|
|
|
|
// Check existence
|
|
const exists = await storage.exists(key);
|
|
|
|
// List files (auto-paginates)
|
|
const files = await storage.list('users/user-123/');
|
|
|
|
// Presigned URLs
|
|
const uploadUrl = await storage.getUploadUrl('temp/upload.png', { expiresIn: 3600 });
|
|
const downloadUrl = await storage.getDownloadUrl(key);
|
|
|
|
// Public/CDN URLs
|
|
const publicUrl = storage.getPublicUrl(key);
|
|
const cdnUrl = storage.getCdnUrl(key); // Falls back to publicUrl if no CDN configured
|
|
```
|
|
|
|
### Generic Factory
|
|
|
|
```typescript
|
|
import { createStorage } from '@mana/shared-storage';
|
|
|
|
// Instead of app-specific factories:
|
|
const storage = createStorage('PICTURE');
|
|
const storage = createStorage('CHAT');
|
|
const storage = createStorage('MUSIC');
|
|
```
|
|
|
|
App-specific aliases still work: `createPictureStorage()`, `createChatStorage()`, etc.
|
|
|
|
### Multipart Upload (Server-Side)
|
|
|
|
For large files uploaded through the backend:
|
|
|
|
```typescript
|
|
const result = await storage.uploadMultipart('video.mp4', largeBuffer, {
|
|
contentType: 'video/mp4',
|
|
maxSizeBytes: 500 * 1024 * 1024, // 500MB limit
|
|
});
|
|
```
|
|
|
|
### Presigned Multipart Upload (Browser Direct-Upload)
|
|
|
|
Skip the backend — browser uploads directly to S3:
|
|
|
|
```typescript
|
|
// 1. Backend: initiate upload
|
|
const { uploadId, key } = await storage.createMultipartUpload(
|
|
'users/123/video.mp4',
|
|
'video/mp4'
|
|
);
|
|
|
|
// 2. Backend: generate presigned URLs for each part
|
|
const urls = await storage.getMultipartUploadUrls(key, uploadId, numberOfParts);
|
|
// => ['https://signed-url-part-1', 'https://signed-url-part-2', ...]
|
|
|
|
// 3. Browser: PUT each chunk to the corresponding URL
|
|
// (returns ETag in response headers)
|
|
|
|
// 4. Backend: complete upload with ETags from browser
|
|
const result = await storage.completeMultipartUpload(key, uploadId, [
|
|
{ partNumber: 1, etag: '"etag-from-part-1"' },
|
|
{ partNumber: 2, etag: '"etag-from-part-2"' },
|
|
]);
|
|
|
|
// If browser abandons upload:
|
|
await storage.abortMultipartUpload(key, uploadId);
|
|
```
|
|
|
|
## Hooks (Upload Events)
|
|
|
|
Fire-and-forget event system for post-upload processing:
|
|
|
|
```typescript
|
|
const storage = createPictureStorage();
|
|
|
|
// Thumbnail generation after upload
|
|
storage.hooks.on('upload', async ({ key, contentType, sizeBytes }) => {
|
|
if (contentType?.startsWith('image/')) {
|
|
await generateThumbnail(key);
|
|
}
|
|
});
|
|
|
|
// Error logging
|
|
storage.hooks.on('upload:error', ({ bucket, key, error }) => {
|
|
logger.error(`Upload failed: ${bucket}/${key}`, error);
|
|
});
|
|
|
|
// Track deletions
|
|
storage.hooks.on('delete', ({ bucket, keys }) => {
|
|
logger.info(`Deleted ${keys.length} files from ${bucket}`);
|
|
});
|
|
|
|
// Unsubscribe
|
|
const unsub = storage.hooks.on('download', handler);
|
|
unsub(); // Remove listener
|
|
|
|
// Available events: upload, upload:error, delete, delete:error, download
|
|
```
|
|
|
|
## Metrics
|
|
|
|
### In-Memory (Testing / Local Dev)
|
|
|
|
```typescript
|
|
import { InMemoryMetrics, attachMetrics } from '@mana/shared-storage';
|
|
|
|
const storage = createPictureStorage();
|
|
const metrics = new InMemoryMetrics();
|
|
attachMetrics(storage.hooks, metrics);
|
|
|
|
// After some operations:
|
|
console.log(metrics.counters.uploads); // 5
|
|
console.log(metrics.counters.deletes); // 2
|
|
console.log(metrics.sizes); // [1024, 2048, ...]
|
|
```
|
|
|
|
### Prometheus (NestJS Backends)
|
|
|
|
```typescript
|
|
import { MetricsService } from '@mana/shared-nestjs-metrics';
|
|
import { createPictureStorage, createPrometheusCollector, attachMetrics } from '@mana/shared-storage';
|
|
|
|
@Injectable()
|
|
export class StorageService {
|
|
private storage = createPictureStorage();
|
|
|
|
constructor(metricsService: MetricsService) {
|
|
const collector = createPrometheusCollector(metricsService);
|
|
attachMetrics(this.storage.hooks, collector);
|
|
}
|
|
}
|
|
```
|
|
|
|
This creates the following Prometheus metrics:
|
|
- `storage_uploads_total` (counter, labels: bucket, content_type)
|
|
- `storage_upload_errors_total` (counter, labels: bucket)
|
|
- `storage_deletes_total` (counter, labels: bucket)
|
|
- `storage_downloads_total` (counter, labels: bucket)
|
|
- `storage_upload_size_bytes` (histogram, labels: bucket, buckets: 1KB-100MB)
|
|
|
|
## Environment Variables
|
|
|
|
```env
|
|
# Required
|
|
S3_ENDPOINT=http://localhost:9000 # MinIO
|
|
S3_REGION=us-east-1
|
|
S3_ACCESS_KEY=minioadmin
|
|
S3_SECRET_KEY=minioadmin
|
|
|
|
# Optional
|
|
S3_PUBLIC_ENDPOINT=https://minio.mana.how # For presigned URLs (if internal != public)
|
|
PICTURE_STORAGE_PUBLIC_URL=https://... # Direct public URL per bucket
|
|
PICTURE_CDN_URL=https://cdn.example.com # CDN URL per bucket (getCdnUrl() uses this)
|
|
```
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
cd packages/shared-storage
|
|
pnpm test # Run 104 tests
|
|
pnpm test:watch # Watch mode
|
|
pnpm type-check # TypeScript check
|
|
pnpm build # Build to dist/
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### StorageClient
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `upload(key, body, options?)` | Upload a file (supports maxSizeBytes for buffers and streams) |
|
|
| `uploadMultipart(key, body, options?)` | Multipart upload for large files (10MB parts) |
|
|
| `download(key)` | Download file to Buffer |
|
|
| `downloadStream(key)` | Download as ReadableStream (memory-efficient) |
|
|
| `delete(key)` | Delete a file |
|
|
| `deleteMany(keys)` | Bulk delete (auto-batches at 1000) |
|
|
| `deleteByPrefix(prefix)` | Delete all files matching prefix |
|
|
| `copy(src, dest)` | Copy file within bucket |
|
|
| `move(src, dest)` | Move file (copy + delete) |
|
|
| `exists(key)` | Check if file exists |
|
|
| `getMetadata(key)` | Get content-type, size, metadata without download |
|
|
| `list(prefix?, maxKeys?)` | List files (auto-paginates) |
|
|
| `getUploadUrl(key, options?)` | Presigned PUT URL |
|
|
| `getDownloadUrl(key, options?)` | Presigned GET URL |
|
|
| `getPublicUrl(key)` | Direct public URL |
|
|
| `getCdnUrl(key)` | CDN URL (falls back to public) |
|
|
| `createMultipartUpload(key, contentType?)` | Initiate browser direct-upload |
|
|
| `getMultipartUploadUrls(key, uploadId, parts)` | Presigned URLs per part |
|
|
| `completeMultipartUpload(key, uploadId, parts)` | Finalize multipart upload |
|
|
| `abortMultipartUpload(key, uploadId)` | Cancel multipart upload |
|
|
| `hooks` | StorageHooks instance for event listeners |
|
|
| `getBucketName()` | Get bucket name |
|
|
| `getS3Client()` | Get underlying S3Client for advanced use |
|