feat(storage): unified single-bucket architecture with Hetzner S3

- Refactor @manacore/shared-storage to use single `manacore-storage` bucket
- Add generateStorageKey() for path structure: {userId}/{appName}/...
- Update docker-compose.dev.yml for unified MinIO bucket
- Migrate CD workflow to use GitHub Environment Secrets
- Update picture and contacts backends to use unified storage
- Remove per-app bucket configuration (cleaner architecture)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-16 01:29:11 +01:00
parent d268e8e463
commit 78cd59a77a
10 changed files with 225 additions and 251 deletions

View file

@ -4,19 +4,19 @@ import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { contacts } from '../db/schema';
import {
createContactsStorage,
generateUserFileKey,
createUnifiedStorage,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
APPS,
} from '@manacore/shared-storage';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable()
export class PhotoService {
private storage = createContactsStorage();
private storage = createUnifiedStorage();
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
@ -66,19 +66,22 @@ export class PhotoService {
}
}
// Generate unique key for the new photo
// Generate unique key for the new photo: {userId}/contacts/{contactId}.{ext}
const filename = `${contactId}.${extension}`;
const key = generateUserFileKey(userId, filename);
const key = `${userId}/${APPS.CONTACTS}/${filename}`;
// Upload to S3
const contentType = getContentType(filename);
await this.storage.upload(key, file.buffer, {
const result = await this.storage.upload(key, file.buffer, {
contentType,
public: true,
});
// Generate the URL (for MinIO, construct it manually)
const photoUrl = `http://localhost:9000/contacts-storage/${key}`;
// Get URL from storage client or construct manually
const photoUrl =
result.url ||
this.storage.getPublicUrl(key) ||
`${process.env.MANACORE_STORAGE_PUBLIC_URL || 'http://localhost:9000/manacore-storage'}/${key}`;
// Update contact with photo URL
await this.db
@ -125,8 +128,12 @@ export class PhotoService {
}
private extractKeyFromUrl(url: string): string | null {
// Extract key from URLs like http://localhost:9000/contacts-storage/users/xxx/file.jpg
const match = url.match(/contacts-storage\/(.+)$/);
return match ? match[1] : null;
// Extract key from URLs like http://localhost:9000/manacore-storage/userId/contacts/file.jpg
// Also support old format: http://localhost:9000/contacts-storage/users/xxx/file.jpg
const unifiedMatch = url.match(/manacore-storage\/(.+)$/);
if (unifiedMatch) return unifiedMatch[1];
const legacyMatch = url.match(/contacts-storage\/(.+)$/);
return legacyMatch ? legacyMatch[1] : null;
}
}

View file

@ -1,11 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
createPictureStorage,
StorageClient,
generateUserFileKey,
getContentType,
} from '@manacore/shared-storage';
import { createUnifiedStorage, StorageClient, APPS } from '@manacore/shared-storage';
export type StorageMode = 's3';
@ -18,15 +13,15 @@ export class StorageService implements OnModuleInit {
constructor(private configService: ConfigService) {
// Get public URL from config
this.publicUrl = this.configService.get<string>(
'STORAGE_PUBLIC_URL',
'http://localhost:9000/picture-storage'
'MANACORE_STORAGE_PUBLIC_URL',
'http://localhost:9000/manacore-storage'
);
}
onModuleInit() {
// Initialize storage client
this.storage = createPictureStorage(this.publicUrl);
this.logger.log(`Storage initialized with @manacore/shared-storage (bucket: picture-storage)`);
// Initialize unified storage client
this.storage = createUnifiedStorage(this.publicUrl);
this.logger.log(`Storage initialized with @manacore/shared-storage (bucket: manacore-storage)`);
}
async uploadFile(
@ -38,7 +33,8 @@ export class StorageService implements OnModuleInit {
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 10);
const ext = filename.split('.').pop() || 'jpg';
const storagePath = `${userId}/${timestamp}-${randomId}.${ext}`;
// Path: {userId}/picture/{timestamp}-{randomId}.{ext}
const storagePath = `${userId}/${APPS.PICTURE}/${timestamp}-${randomId}.${ext}`;
const result = await this.storage.upload(storagePath, buffer, {
contentType,
@ -82,7 +78,8 @@ export class StorageService implements OnModuleInit {
async uploadBoardThumbnail(boardId: string, dataUrl: string): Promise<string> {
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
const storagePath = `boards/${boardId}/thumbnail-${Date.now()}.png`;
// Path: boards/picture/{boardId}/thumbnail-{timestamp}.png
const storagePath = `boards/${APPS.PICTURE}/${boardId}/thumbnail-${Date.now()}.png`;
const result = await this.storage.upload(storagePath, buffer, {
contentType: 'image/png',