mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 09:26:42 +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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue