feat(storage): add MinIO local storage and @manacore/shared-storage package

- Add MinIO service to docker-compose.dev.yml with auto-bucket initialization
- Create @manacore/shared-storage package with S3-compatible client
- Add factory functions for each project (Picture, Chat, ManaDeck, etc.)
- Include file utilities (generateFileKey, getContentType, validators)
- Update environment variables for S3/MinIO configuration
- Document storage architecture in CLAUDE.md

Local dev uses MinIO, production will use Hetzner Object Storage.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-02 01:00:42 +01:00
parent eb173217c1
commit 2cfa09c84d
13 changed files with 1195 additions and 130 deletions

View file

@ -0,0 +1,194 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand,
type PutObjectCommandInput,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type {
StorageConfig,
BucketConfig,
UploadOptions,
PresignedUrlOptions,
UploadResult,
FileInfo,
} from './types.js';
/**
* S3-compatible storage client for MinIO (local) and Hetzner Object Storage (production)
*/
export class StorageClient {
private client: S3Client;
private bucket: BucketConfig;
constructor(config: StorageConfig, bucket: BucketConfig) {
this.client = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: config.forcePathStyle ?? true,
});
this.bucket = bucket;
}
/**
* Upload a file to the bucket
*/
async upload(
key: string,
body: Buffer | Uint8Array | string | ReadableStream,
options: UploadOptions = {}
): Promise<UploadResult> {
const input: PutObjectCommandInput = {
Bucket: this.bucket.name,
Key: key,
Body: body,
ContentType: options.contentType,
CacheControl: options.cacheControl,
Metadata: options.metadata,
};
if (options.public) {
input.ACL = 'public-read';
}
const command = new PutObjectCommand(input);
const result = await this.client.send(command);
return {
key,
url: this.getPublicUrl(key),
etag: result.ETag,
};
}
/**
* Download a file from the bucket
*/
async download(key: string): Promise<Buffer> {
const command = new GetObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
const response = await this.client.send(command);
if (!response.Body) {
throw new Error(`File not found: ${key}`);
}
// Convert stream to buffer
const chunks: Uint8Array[] = [];
const stream = response.Body as AsyncIterable<Uint8Array>;
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
/**
* Delete a file from the bucket
*/
async delete(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
await this.client.send(command);
}
/**
* Check if a file exists
*/
async exists(key: string): Promise<boolean> {
try {
const command = new HeadObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
await this.client.send(command);
return true;
} catch {
return false;
}
}
/**
* List files in the bucket with optional prefix
*/
async list(prefix?: string, maxKeys = 1000): Promise<FileInfo[]> {
const command = new ListObjectsV2Command({
Bucket: this.bucket.name,
Prefix: prefix,
MaxKeys: maxKeys,
});
const response = await this.client.send(command);
return (response.Contents ?? []).map((item) => ({
key: item.Key!,
size: item.Size ?? 0,
lastModified: item.LastModified ?? new Date(),
etag: item.ETag,
}));
}
/**
* Generate a presigned URL for uploading (PUT)
*/
async getUploadUrl(key: string, options: PresignedUrlOptions = {}): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
return getSignedUrl(this.client, command, {
expiresIn: options.expiresIn ?? 3600,
});
}
/**
* Generate a presigned URL for downloading (GET)
*/
async getDownloadUrl(key: string, options: PresignedUrlOptions = {}): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
return getSignedUrl(this.client, command, {
expiresIn: options.expiresIn ?? 3600,
});
}
/**
* Get the public URL for a file (if bucket is public)
*/
getPublicUrl(key: string): string | undefined {
if (!this.bucket.publicUrl) {
return undefined;
}
return `${this.bucket.publicUrl}/${key}`;
}
/**
* Get the underlying S3 client for advanced operations
*/
getS3Client(): S3Client {
return this.client;
}
/**
* Get the bucket name
*/
getBucketName(): string {
return this.bucket.name;
}
}

View file

@ -0,0 +1,107 @@
import { StorageClient } from './client.js';
import { BUCKETS, type StorageConfig, type BucketConfig, type BucketName } from './types.js';
/**
* Environment variable names for storage configuration
*/
const ENV_KEYS = {
ENDPOINT: 'S3_ENDPOINT',
REGION: 'S3_REGION',
ACCESS_KEY: 'S3_ACCESS_KEY',
SECRET_KEY: 'S3_SECRET_KEY',
} as const;
/**
* Default configuration for local MinIO development
*/
const MINIO_DEFAULTS: StorageConfig = {
endpoint: 'http://localhost:9000',
region: 'us-east-1',
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
forcePathStyle: true,
};
/**
* Get storage configuration from environment variables
* Falls back to MinIO defaults in development
*/
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,
accessKeyId: process.env[ENV_KEYS.ACCESS_KEY] ?? (isDev ? MINIO_DEFAULTS.accessKeyId : ''),
secretAccessKey:
process.env[ENV_KEYS.SECRET_KEY] ?? (isDev ? MINIO_DEFAULTS.secretAccessKey : ''),
forcePathStyle: isDev || process.env[ENV_KEYS.ENDPOINT]?.includes('localhost'),
};
}
/**
* Create a storage client for a specific bucket
*/
export function createStorageClient(
bucket: BucketName | BucketConfig,
config?: Partial<StorageConfig>
): StorageClient {
const storageConfig = {
...getStorageConfig(),
...config,
};
const bucketConfig: BucketConfig = typeof bucket === 'string' ? { name: bucket } : bucket;
// Validate configuration
if (!storageConfig.endpoint) {
throw new Error('S3_ENDPOINT is required for storage configuration');
}
if (!storageConfig.accessKeyId || !storageConfig.secretAccessKey) {
throw new Error('S3_ACCESS_KEY and S3_SECRET_KEY are required');
}
return new StorageClient(storageConfig, bucketConfig);
}
/**
* Create a storage client for the Picture project
*/
export function createPictureStorage(publicUrl?: string): StorageClient {
return createStorageClient({
name: BUCKETS.PICTURE,
publicUrl: publicUrl ?? process.env.PICTURE_STORAGE_PUBLIC_URL,
});
}
/**
* Create a storage client for the Chat project
*/
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 });
}

View file

@ -0,0 +1,38 @@
// Main client
export { StorageClient } from './client.js';
// Factory functions
export {
createStorageClient,
getStorageConfig,
createPictureStorage,
createChatStorage,
createManaDeckStorage,
createNutriPhiStorage,
createPresiStorage,
} from './factory.js';
// Utilities
export {
generateFileKey,
generateUserFileKey,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
DOCUMENT_EXTENSIONS,
AUDIO_EXTENSIONS,
VIDEO_EXTENSIONS,
} from './utils.js';
// Types
export {
BUCKETS,
type StorageConfig,
type BucketConfig,
type BucketName,
type UploadOptions,
type PresignedUrlOptions,
type UploadResult,
type FileInfo,
} from './types.js';

View file

@ -0,0 +1,86 @@
/**
* Storage configuration for S3-compatible services
*/
export interface StorageConfig {
/** S3 endpoint URL (e.g., http://localhost:9000 for MinIO) */
endpoint: string;
/** S3 region (e.g., 'us-east-1' or 'fsn1' for Hetzner) */
region: string;
/** Access key ID */
accessKeyId: string;
/** Secret access key */
secretAccessKey: string;
/** Force path-style URLs (required for MinIO) */
forcePathStyle?: boolean;
}
/**
* Bucket configuration for a specific project
*/
export interface BucketConfig {
/** Bucket name */
name: string;
/** Public URL for accessing files (optional, for CDN/public buckets) */
publicUrl?: string;
}
/**
* Options for uploading files
*/
export interface UploadOptions {
/** Content type (MIME type) */
contentType?: string;
/** Cache control header */
cacheControl?: string;
/** Custom metadata */
metadata?: Record<string, string>;
/** Make the object publicly readable */
public?: boolean;
}
/**
* Options for generating presigned URLs
*/
export interface PresignedUrlOptions {
/** URL expiration in seconds (default: 3600 = 1 hour) */
expiresIn?: number;
}
/**
* Result of a file upload
*/
export interface UploadResult {
/** The key/path of the uploaded file */
key: string;
/** Public URL if available */
url?: string;
/** ETag of the uploaded file */
etag?: string;
}
/**
* File info from listing
*/
export interface FileInfo {
/** File key/path */
key: string;
/** File size in bytes */
size: number;
/** Last modified date */
lastModified: Date;
/** ETag */
etag?: string;
}
/**
* Predefined bucket names for each project
*/
export const BUCKETS = {
PICTURE: 'picture-images',
CHAT: 'chat-files',
MANADECK: 'manadeck-assets',
NUTRIPHI: 'nutriphi-meals',
PRESI: 'presi-slides',
} as const;
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];

View file

@ -0,0 +1,135 @@
import { randomUUID } from 'crypto';
import { extname } from 'path';
/**
* Generate a unique file key with optional folder structure
*
* @example
* generateFileKey('image.png', 'user-123')
* // => 'user-123/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png'
*
* generateFileKey('photo.jpg', 'users', 'avatars')
* // => 'users/avatars/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg'
*/
export function generateFileKey(filename: string, ...folders: string[]): string {
const ext = extname(filename);
const uuid = randomUUID();
const key = `${uuid}${ext}`;
if (folders.length > 0) {
return [...folders, key].join('/');
}
return key;
}
/**
* Generate a user-scoped file key
*
* @example
* generateUserFileKey('user-123', 'avatar.png')
* // => 'users/user-123/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png'
*/
export function generateUserFileKey(userId: string, filename: string, subfolder?: string): string {
const folders = subfolder ? ['users', userId, subfolder] : ['users', userId];
return generateFileKey(filename, ...folders);
}
/**
* Get content type from filename extension
*/
export function getContentType(filename: string): string {
const ext = extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
// Images
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.avif': 'image/avif',
// Documents
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Text
'.txt': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
// Audio
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.m4a': 'audio/mp4',
// Video
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
// Archives
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.rar': 'application/vnd.rar',
// Other
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
};
return mimeTypes[ext] ?? 'application/octet-stream';
}
/**
* Validate file size
*/
export function validateFileSize(sizeInBytes: number, maxSizeMB: number): boolean {
const maxSizeBytes = maxSizeMB * 1024 * 1024;
return sizeInBytes <= maxSizeBytes;
}
/**
* Validate file extension
*/
export function validateFileExtension(filename: string, allowedExtensions: string[]): boolean {
const ext = extname(filename).toLowerCase();
return allowedExtensions.includes(ext);
}
/**
* Common allowed extensions for images
*/
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.avif'];
/**
* Common allowed extensions for documents
*/
export const DOCUMENT_EXTENSIONS = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'];
/**
* Common allowed extensions for audio
*/
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a'];
/**
* Common allowed extensions for video
*/
export const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi'];