feat(storage): unified single-bucket architecture with Hetzner S3

- Refactor @manacore/shared-storage to use single `manacore-storage` bucket
- Add generateStorageKey() for path structure: {userId}/{appName}/...
- Update docker-compose.dev.yml for unified MinIO bucket
- Migrate CD workflow to use GitHub Environment Secrets
- Update picture and contacts backends to use unified storage
- Remove per-app bucket configuration (cleaner architecture)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-16 01:29:11 +01:00
parent d268e8e463
commit 78cd59a77a
10 changed files with 225 additions and 251 deletions

View file

@ -2,53 +2,71 @@
S3-compatible object storage client for the Manacore monorepo. Uses MinIO for local development and Hetzner Object Storage in production.
## Architecture
All apps use a **single unified bucket** with folder structure:
```
manacore-storage/
├── {userId}/
│ ├── picture/... # Picture app files
│ ├── chat/... # Chat attachments
│ ├── manadeck/... # Card assets
│ ├── contacts/... # Contact avatars
│ └── ...
```
## Setup
### Local Development
1. Start MinIO with Docker:
```bash
# Start MinIO with Docker
pnpm docker:up
# MinIO Console: http://localhost:9001
# Username: minioadmin
# Password: minioadmin
```
2. Access MinIO Console at http://localhost:9001
- Username: `minioadmin`
- Password: `minioadmin`
### Production (Hetzner Object Storage)
### Pre-created Buckets
1. Create Hetzner Object Storage in [Hetzner Cloud Console](https://console.hetzner.cloud/)
2. Generate S3 credentials (Access Key + Secret Key)
3. Run the setup script:
The following buckets are automatically created:
| Bucket | Project | Purpose |
|--------|---------|---------|
| `picture-storage` | Picture | Generated AI images |
| `chat-storage` | Chat | User file uploads |
| `manadeck-storage` | ManaDeck | Card/deck assets |
| `nutriphi-storage` | NutriPhi | Meal photos |
| `presi-storage` | Presi | Presentation slides |
| `calendar-storage` | Calendar | Calendar attachments |
| `contacts-storage` | Contacts | Contact avatars/files |
| `storage-storage` | Storage | Cloud drive files |
```bash
export S3_ENDPOINT="https://fsn1.your-objectstorage.com"
export S3_ACCESS_KEY="your-access-key"
export S3_SECRET_KEY="your-secret-key"
./scripts/setup-hetzner-storage.sh
```
## Usage
### Basic Usage
```typescript
import { createPictureStorage, generateUserFileKey, getContentType } from '@manacore/shared-storage';
import {
createUnifiedStorage,
generateStorageKey,
getContentType,
APPS,
} from '@manacore/shared-storage';
// Create client for Picture project
const storage = createPictureStorage();
// Create storage client
const storage = createUnifiedStorage();
// Generate a key for a user's file
const key = generateStorageKey('user-123', APPS.PICTURE, 'photo.jpg');
// => 'user-123/picture/a1b2c3d4-uuid.jpg'
// Upload a file
const key = generateUserFileKey('user-123', 'avatar.png');
const result = await storage.upload(key, imageBuffer, {
contentType: getContentType('avatar.png'),
contentType: getContentType('photo.jpg'),
public: true,
});
console.log(result.url); // http://localhost:9000/picture-storage/users/user-123/uuid.png
console.log(result.url);
// => 'http://localhost:9000/manacore-storage/user-123/picture/uuid.jpg'
// Download a file
const buffer = await storage.download(key);
@ -56,42 +74,63 @@ const buffer = await storage.download(key);
// Delete a file
await storage.delete(key);
// List files
const files = await storage.list('users/user-123/');
// List files for a user's app
const files = await storage.list('user-123/picture/');
// Generate presigned URLs
const uploadUrl = await storage.getUploadUrl('temp/upload.png', { expiresIn: 3600 });
const downloadUrl = await storage.getDownloadUrl(key, { expiresIn: 3600 });
```
### Custom Configuration
## Available Apps
```typescript
import { createStorageClient, BUCKETS } from '@manacore/shared-storage';
import { APPS } from '@manacore/shared-storage';
// Override default config
const storage = createStorageClient(BUCKETS.PICTURE, {
endpoint: 'https://fsn1.your-objectstorage.com',
region: 'fsn1',
accessKeyId: process.env.HETZNER_ACCESS_KEY,
secretAccessKey: process.env.HETZNER_SECRET_KEY,
forcePathStyle: false,
});
APPS.PICTURE // 'picture'
APPS.CHAT // 'chat'
APPS.MANADECK // 'manadeck'
APPS.NUTRIPHI // 'nutriphi'
APPS.PRESI // 'presi'
APPS.CALENDAR // 'calendar'
APPS.CONTACTS // 'contacts'
APPS.STORAGE // 'storage'
APPS.MAIL // 'mail'
APPS.INVENTORY // 'inventory'
APPS.MANACORE // 'manacore'
```
### Available Factory Functions
## Key Generation Utilities
```typescript
import {
createPictureStorage,
createChatStorage,
createManaDeckStorage,
createNutriPhiStorage,
createPresiStorage,
createCalendarStorage,
createContactsStorage,
createStorageStorage,
generateStorageKey,
generateFileKey,
generateUserFileKey,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
} from '@manacore/shared-storage';
// Recommended: App-scoped key
generateStorageKey('user-123', 'picture', 'photo.jpg');
// => 'user-123/picture/uuid.jpg'
// With subfolder
generateStorageKey('user-123', 'chat', 'doc.pdf', 'attachments');
// => 'user-123/chat/attachments/uuid.pdf'
// Generic file key
generateFileKey('photo.jpg', 'uploads', '2024');
// => 'uploads/2024/uuid.jpg'
// Get MIME type
getContentType('image.png'); // => 'image/png'
// Validate file
validateFileSize(fileSize, 10); // max 10MB
validateFileExtension('photo.jpg', IMAGE_EXTENSIONS);
```
## Environment Variables
@ -105,59 +144,25 @@ S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
MANACORE_STORAGE_PUBLIC_URL=http://localhost:9000/manacore-storage
```
### Production (Hetzner Object Storage)
### Production (Hetzner)
```env
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_REGION=fsn1
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
# Optional: public URLs for CDN access
PICTURE_STORAGE_PUBLIC_URL=https://picture-storage.fsn1.your-objectstorage.com
NUTRIPHI_S3_PUBLIC_URL=https://nutriphi-storage.fsn1.your-objectstorage.com
```
## Utilities
```typescript
import {
generateFileKey,
generateUserFileKey,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
DOCUMENT_EXTENSIONS,
} from '@manacore/shared-storage';
// Generate unique file key
const key = generateFileKey('photo.jpg', 'uploads', '2024');
// => 'uploads/2024/uuid.jpg'
// User-scoped key
const userKey = generateUserFileKey('user-123', 'avatar.png', 'avatars');
// => 'users/user-123/avatars/uuid.png'
// Get MIME type
const contentType = getContentType('image.png'); // => 'image/png'
// Validate file
const isValidSize = validateFileSize(fileSize, 10); // max 10MB
const isValidType = validateFileExtension('photo.jpg', IMAGE_EXTENSIONS);
MANACORE_STORAGE_PUBLIC_URL=https://manacore-storage.fsn1.your-objectstorage.com
```
## Docker Commands
```bash
# Start all infrastructure (Postgres, Redis, MinIO)
# Start infrastructure (Postgres, Redis, MinIO)
pnpm docker:up
# Start only database services (no MinIO)
pnpm docker:up:db
# View MinIO logs
docker logs manacore-minio

View file

@ -1,6 +1,6 @@
import { StorageClient } from './client';
import { BUCKETS } from './types';
import type { StorageConfig, BucketConfig, BucketName } from './types';
import { UNIFIED_BUCKET, APPS } from './types';
import type { StorageConfig, BucketConfig, AppName } from './types';
/**
* Environment variable names for storage configuration
@ -30,7 +30,6 @@ const MINIO_DEFAULTS: StorageConfig = {
export function getStorageConfig(): StorageConfig {
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
// Use environment variables if available, otherwise use MinIO defaults
return {
endpoint: process.env[ENV_KEYS.ENDPOINT] ?? (isDev ? MINIO_DEFAULTS.endpoint : ''),
region: process.env[ENV_KEYS.REGION] ?? MINIO_DEFAULTS.region,
@ -45,7 +44,7 @@ export function getStorageConfig(): StorageConfig {
* Create a storage client for a specific bucket
*/
export function createStorageClient(
bucket: BucketName | BucketConfig,
bucket: string | BucketConfig,
config?: Partial<StorageConfig>
): StorageClient {
const storageConfig = {
@ -55,7 +54,6 @@ export function createStorageClient(
const bucketConfig: BucketConfig = typeof bucket === 'string' ? { name: bucket } : bucket;
// Validate configuration
if (!storageConfig.endpoint) {
throw new Error('S3_ENDPOINT is required for storage configuration');
}
@ -67,83 +65,31 @@ export function createStorageClient(
}
/**
* Create a storage client for the Picture project
* Create the unified storage client for all Manacore apps
*
* Uses a single bucket with folder structure: {userId}/{appName}/...
*
* @example
* import { createUnifiedStorage, generateStorageKey, APPS } from '@manacore/shared-storage';
*
* const storage = createUnifiedStorage();
*
* // Upload for a specific user and app
* const key = generateStorageKey('user-123', APPS.PICTURE, 'photo.jpg');
* await storage.upload(key, imageBuffer, { contentType: 'image/jpeg', public: true });
*
* // List all files for a user in an app
* const files = await storage.list('user-123/picture/');
*/
export function createPictureStorage(publicUrl?: string): StorageClient {
export function createUnifiedStorage(publicUrl?: string): StorageClient {
return createStorageClient({
name: BUCKETS.PICTURE,
publicUrl: publicUrl ?? process.env.PICTURE_STORAGE_PUBLIC_URL,
name: UNIFIED_BUCKET,
publicUrl: publicUrl ?? process.env.MANACORE_STORAGE_PUBLIC_URL,
});
}
/**
* Create a storage client for the Chat project
* Re-export constants and types for convenience
*/
export function createChatStorage(): StorageClient {
return createStorageClient({ name: BUCKETS.CHAT });
}
/**
* Create a storage client for the ManaDeck project
*/
export function createManaDeckStorage(): StorageClient {
return createStorageClient({ name: BUCKETS.MANADECK });
}
/**
* Create a storage client for the NutriPhi project
*/
export function createNutriPhiStorage(publicUrl?: string): StorageClient {
return createStorageClient({
name: BUCKETS.NUTRIPHI,
publicUrl: publicUrl ?? process.env.NUTRIPHI_S3_PUBLIC_URL,
});
}
/**
* Create a storage client for the Presi project
*/
export function createPresiStorage(): StorageClient {
return createStorageClient({ name: BUCKETS.PRESI });
}
/**
* Create a storage client for the Calendar project
*/
export function createCalendarStorage(): StorageClient {
return createStorageClient({ name: BUCKETS.CALENDAR });
}
/**
* Create a storage client for the Contacts project
*/
export function createContactsStorage(): StorageClient {
return createStorageClient({ name: BUCKETS.CONTACTS });
}
/**
* Create a storage client for the Storage project (cloud drive)
*/
export function createStorageStorage(publicUrl?: string): StorageClient {
return createStorageClient({
name: BUCKETS.STORAGE,
publicUrl: publicUrl ?? process.env.STORAGE_S3_PUBLIC_URL,
});
}
/**
* Create a storage client for the Mail project
*/
export function createMailStorage(): StorageClient {
return createStorageClient({ name: BUCKETS.MAIL });
}
/**
* Create a storage client for the Inventory project
*/
export function createInventoryStorage(publicUrl?: string): StorageClient {
return createStorageClient({
name: BUCKETS.INVENTORY,
publicUrl: publicUrl ?? process.env.INVENTORY_S3_PUBLIC_URL,
});
}
export { UNIFIED_BUCKET, APPS };
export type { AppName };

View file

@ -4,23 +4,17 @@ export { StorageClient } from './client';
// Factory functions
export {
createStorageClient,
createUnifiedStorage,
getStorageConfig,
createPictureStorage,
createChatStorage,
createManaDeckStorage,
createNutriPhiStorage,
createPresiStorage,
createCalendarStorage,
createContactsStorage,
createStorageStorage,
createMailStorage,
createInventoryStorage,
UNIFIED_BUCKET,
APPS,
} from './factory';
// Utilities
export {
generateFileKey,
generateUserFileKey,
generateStorageKey,
getContentType,
validateFileSize,
validateFileExtension,
@ -31,13 +25,12 @@ export {
} from './utils';
// Types
export {
BUCKETS,
type StorageConfig,
type BucketConfig,
type BucketName,
type UploadOptions,
type PresignedUrlOptions,
type UploadResult,
type FileInfo,
export type {
StorageConfig,
BucketConfig,
AppName,
UploadOptions,
PresignedUrlOptions,
UploadResult,
FileInfo,
} from './types';

View file

@ -73,19 +73,26 @@ export interface FileInfo {
}
/**
* Predefined bucket names for each project
* Unified bucket name for all Manacore storage
* Structure: manacore-storage/{userId}/{appName}/...
*/
export const BUCKETS = {
PICTURE: 'picture-storage',
CHAT: 'chat-storage',
MANADECK: 'manadeck-storage',
NUTRIPHI: 'nutriphi-storage',
PRESI: 'presi-storage',
CALENDAR: 'calendar-storage',
CONTACTS: 'contacts-storage',
STORAGE: 'storage-storage',
MAIL: 'mail-storage',
INVENTORY: 'inventory-storage',
export const UNIFIED_BUCKET = 'manacore-storage';
/**
* App identifiers for folder structure within the unified bucket
*/
export const APPS = {
PICTURE: 'picture',
CHAT: 'chat',
MANADECK: 'manadeck',
NUTRIPHI: 'nutriphi',
PRESI: 'presi',
CALENDAR: 'calendar',
CONTACTS: 'contacts',
STORAGE: 'storage',
MAIL: 'mail',
INVENTORY: 'inventory',
MANACORE: 'manacore',
} as const;
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
export type AppName = (typeof APPS)[keyof typeof APPS];

View file

@ -1,5 +1,6 @@
import { randomUUID } from 'crypto';
import { extname } from 'path';
import type { AppName } from './types';
/**
* Generate a unique file key with optional folder structure
@ -23,6 +24,30 @@ export function generateFileKey(filename: string, ...folders: string[]): string
return key;
}
/**
* Generate a storage key for the unified bucket structure
*
* @example
* generateStorageKey('user-123', 'picture', 'photo.jpg')
* // => 'user-123/picture/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg'
*
* generateStorageKey('user-123', 'chat', 'document.pdf', 'attachments')
* // => 'user-123/chat/attachments/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf'
*/
export function generateStorageKey(
userId: string,
appName: AppName | string,
filename: string,
...subfolders: string[]
): string {
const ext = extname(filename);
const uuid = randomUUID();
const file = `${uuid}${ext}`;
const parts = [userId, appName, ...subfolders, file];
return parts.join('/');
}
/**
* Generate a user-scoped file key
*