mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 12:03:38 +02:00
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:
parent
eb173217c1
commit
2cfa09c84d
13 changed files with 1195 additions and 130 deletions
194
packages/shared-storage/src/client.ts
Normal file
194
packages/shared-storage/src/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
107
packages/shared-storage/src/factory.ts
Normal file
107
packages/shared-storage/src/factory.ts
Normal 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 });
|
||||
}
|
||||
38
packages/shared-storage/src/index.ts
Normal file
38
packages/shared-storage/src/index.ts
Normal 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';
|
||||
86
packages/shared-storage/src/types.ts
Normal file
86
packages/shared-storage/src/types.ts
Normal 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];
|
||||
135
packages/shared-storage/src/utils.ts
Normal file
135
packages/shared-storage/src/utils.ts
Normal 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'];
|
||||
Loading…
Add table
Add a link
Reference in a new issue