Skip to content

Storage

Storage

Manacore uses S3-compatible object storage for file uploads, generated images, and other media.

Architecture

EnvironmentServicePurpose
LocalMinIO (Docker)S3-compatible local storage
ProductionHetzner Object StorageCost-effective cloud storage

Local Development

Terminal window
# Start infrastructure (includes MinIO)
pnpm docker:up
# MinIO Web Console
open http://localhost:9001
# Credentials
# Username: minioadmin
# Password: minioadmin

Pre-configured Buckets

BucketProjectPurpose
picture-storagePictureAI-generated images
chat-storageChatUser file uploads
manadeck-storageManaDeckCard/deck assets
nutriphi-storageNutriPhiMeal photos
contacts-storageContactsContact avatars
calendar-storageCalendarEvent attachments

Usage

Backend Integration

import {
createPictureStorage,
generateUserFileKey,
getContentType,
} from '@manacore/shared-storage';
const storage = createPictureStorage();
// Upload a file
async 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 file
async function downloadImage(key: string) {
const data = await storage.download(key);
return data;
}
// Generate presigned URLs
async function getUploadUrl(key: string) {
return storage.getUploadUrl(key, { expiresIn: 3600 });
}
async function getDownloadUrl(key: string) {
return storage.getDownloadUrl(key, { expiresIn: 3600 });
}
// Delete a file
async function deleteImage(key: string) {
await storage.delete(key);
}
// List files
async 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 bucket
const 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 files
const key = generateUserFileKey(userId, 'photo.jpg');
// → users/{userId}/photo.jpg
// With subfolder
const key = generateUserFileKey(userId, 'avatars/profile.jpg');
// → users/{userId}/avatars/profile.jpg
// Public assets
const key = `public/assets/${filename}`;
// Temporary files
const key = `temp/${Date.now()}-${filename}`;

Environment Variables

S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin

Direct 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 backend
const { uploadUrl, key } = await api.getUploadUrl({
filename: file.name,
contentType: file.type,
});
// 2. Upload directly to S3
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
// 3. Notify backend of completed upload
await 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 URL
const url = `${S3_ENDPOINT}/${bucket}/${key}`;

Private Files

// Upload as private (default)
await storage.upload(key, buffer, {
contentType: 'image/png',
});
// Access via presigned URL
const 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:

Terminal window
docker ps | grep minio
pnpm docker:logs minio

Access Denied

Verify credentials in .env:

Terminal window
echo $S3_ACCESS_KEY
echo $S3_SECRET_KEY

Bucket Not Found

Create the bucket in MinIO console or via CLI:

Terminal window
docker exec minio mc mb /data/my-bucket