Storage
Storage
Manacore uses S3-compatible object storage for file uploads, generated images, and other media.
Architecture
| Environment | Service | Purpose |
|---|---|---|
| Local | MinIO (Docker) | S3-compatible local storage |
| Production | Hetzner Object Storage | Cost-effective cloud storage |
Local Development
# Start infrastructure (includes MinIO)pnpm docker:up
# MinIO Web Consoleopen http://localhost:9001
# Credentials# Username: minioadmin# Password: minioadminPre-configured Buckets
| Bucket | Project | Purpose |
|---|---|---|
picture-storage | Picture | AI-generated images |
chat-storage | Chat | User file uploads |
manadeck-storage | ManaDeck | Card/deck assets |
nutriphi-storage | NutriPhi | Meal photos |
contacts-storage | Contacts | Contact avatars |
calendar-storage | Calendar | Event attachments |
Usage
Backend Integration
import { createPictureStorage, generateUserFileKey, getContentType,} from '@manacore/shared-storage';
const storage = createPictureStorage();
// Upload a fileasync function uploadImage(userId: string, file: Buffer, filename: string) { const key = generateUserFileKey(userId, filename);
const result = await storage.upload(key, file, { contentType: getContentType(filename), public: true, });
return result.url;}
// Download a fileasync function downloadImage(key: string) { const data = await storage.download(key); return data;}
// Generate presigned URLsasync function getUploadUrl(key: string) { return storage.getUploadUrl(key, { expiresIn: 3600 });}
async function getDownloadUrl(key: string) { return storage.getDownloadUrl(key, { expiresIn: 3600 });}
// Delete a fileasync function deleteImage(key: string) { await storage.delete(key);}
// List filesasync function listUserFiles(userId: string) { const prefix = `users/${userId}/`; return storage.list(prefix);}Factory Functions
import { createPictureStorage, createChatStorage, createManaDeckStorage, createContactsStorage,} from '@manacore/shared-storage';
// Each creates a client configured for that bucketconst pictureStorage = createPictureStorage();const chatStorage = createChatStorage();Custom Storage Client
import { createStorageClient } from '@manacore/shared-storage';
const customStorage = createStorageClient({ bucket: 'my-custom-bucket', region: process.env.S3_REGION, endpoint: process.env.S3_ENDPOINT, accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY,});File Key Patterns
// User-specific filesconst key = generateUserFileKey(userId, 'photo.jpg');// → users/{userId}/photo.jpg
// With subfolderconst key = generateUserFileKey(userId, 'avatars/profile.jpg');// → users/{userId}/avatars/profile.jpg
// Public assetsconst key = `public/assets/${filename}`;
// Temporary filesconst key = `temp/${Date.now()}-${filename}`;Environment Variables
S3_ENDPOINT=http://localhost:9000S3_REGION=us-east-1S3_ACCESS_KEY=minioadminS3_SECRET_KEY=minioadminS3_ENDPOINT=https://fsn1.your-objectstorage.comS3_REGION=fsn1S3_ACCESS_KEY=your-access-keyS3_SECRET_KEY=your-secret-keyDirect Upload (Presigned URLs)
For large files, use presigned URLs to upload directly from the client:
Backend: Generate URL
@Post('upload-url')async getUploadUrl( @CurrentUser() user: CurrentUserData, @Body() dto: { filename: string; contentType: string }) { const key = generateUserFileKey(user.userId, dto.filename);
const uploadUrl = await storage.getUploadUrl(key, { expiresIn: 300, // 5 minutes contentType: dto.contentType, });
return { uploadUrl, key };}Client: Upload File
// 1. Get presigned URL from backendconst { uploadUrl, key } = await api.getUploadUrl({ filename: file.name, contentType: file.type,});
// 2. Upload directly to S3await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type, },});
// 3. Notify backend of completed uploadawait api.confirmUpload({ key });Public vs Private Files
Public Files
// Upload as public (anyone can access)await storage.upload(key, buffer, { contentType: 'image/png', public: true,});
// Access via direct URLconst url = `${S3_ENDPOINT}/${bucket}/${key}`;Private Files
// Upload as private (default)await storage.upload(key, buffer, { contentType: 'image/png',});
// Access via presigned URLconst url = await storage.getDownloadUrl(key, { expiresIn: 3600, // 1 hour});Image Processing
For image uploads, consider processing:
import sharp from 'sharp';
async function processAndUpload(userId: string, buffer: Buffer, filename: string) { // Create thumbnail const thumbnail = await sharp(buffer) .resize(200, 200, { fit: 'cover' }) .jpeg({ quality: 80 }) .toBuffer();
// Create optimized version const optimized = await sharp(buffer) .resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 85 }) .toBuffer();
// Upload both const [thumbResult, fullResult] = await Promise.all([ storage.upload( generateUserFileKey(userId, `thumbs/${filename}`), thumbnail, { contentType: 'image/jpeg', public: true } ), storage.upload( generateUserFileKey(userId, filename), optimized, { contentType: 'image/jpeg', public: true } ), ]);
return { thumbnailUrl: thumbResult.url, fullUrl: fullResult.url, };}Best Practices
Troubleshooting
Connection Refused
Check MinIO is running:
docker ps | grep miniopnpm docker:logs minioAccess Denied
Verify credentials in .env:
echo $S3_ACCESS_KEYecho $S3_SECRET_KEYBucket Not Found
Create the bucket in MinIO console or via CLI:
docker exec minio mc mb /data/my-bucket