refactor(picture): optimize storage service

- Replace manual key generation (Date.now + Math.random) with
  generateUserFileKey() for UUID-based collision-free keys
- Add maxSizeBytes validation (50MB images, 5MB thumbnails)
- Add cacheControl headers (immutable for images, 7d for thumbnails)
- Add upload hooks for structured logging
- Simplify constructor (remove OnModuleInit, init storage directly)
- Remove unused StorageMode type and getStorageMode()
- Add deleteAllUserFiles() for account deletion
- Consistent error handling in deleteFile() (warn + swallow)
- Board thumbnails use stable path (no timestamp) for overwrite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 20:56:44 +01:00
parent 887b60c8c1
commit 69eac206fa

View file

@ -1,32 +1,28 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
createPictureStorage,
StorageClient,
generateUserFileKey,
getContentType,
} from '@manacore/shared-storage';
import { createPictureStorage, StorageClient, generateUserFileKey } from '@manacore/shared-storage';
export type StorageMode = 's3';
const MAX_IMAGE_SIZE = 50 * 1024 * 1024; // 50MB
const MAX_THUMBNAIL_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable()
export class StorageService implements OnModuleInit {
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private storage!: StorageClient;
private publicUrl: string;
private storage: StorageClient;
constructor(private configService: ConfigService) {
// Get public URL from config
this.publicUrl = this.configService.get<string>(
const publicUrl = this.configService.get<string>(
'STORAGE_PUBLIC_URL',
'http://localhost:9000/picture-storage'
);
}
this.storage = createPictureStorage(publicUrl);
onModuleInit() {
// Initialize storage client
this.storage = createPictureStorage(this.publicUrl);
this.logger.log(`Storage initialized with @manacore/shared-storage (bucket: picture-storage)`);
this.storage.hooks.on('upload', ({ key, sizeBytes, contentType }) => {
this.logger.debug(`Uploaded ${key} (${sizeBytes} bytes, ${contentType})`);
});
this.storage.hooks.on('upload:error', ({ key, error }) => {
this.logger.error(`Upload failed for ${key}: ${error.message}`);
});
}
async uploadFile(
@ -35,21 +31,16 @@ export class StorageService implements OnModuleInit {
filename: string,
contentType: string
): Promise<{ storagePath: string; publicUrl: string }> {
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}`;
const storagePath = generateUserFileKey(userId, filename, 'images');
const result = await this.storage.upload(storagePath, buffer, {
contentType,
public: true,
maxSizeBytes: MAX_IMAGE_SIZE,
cacheControl: 'public, max-age=31536000, immutable',
});
const publicUrl = result.url || this.getPublicUrl(storagePath);
this.logger.debug(`Uploaded file to ${storagePath}`);
return { storagePath, publicUrl };
return { storagePath, publicUrl: result.url ?? this.getPublicUrl(storagePath) };
}
async uploadFromUrl(
@ -57,7 +48,6 @@ export class StorageService implements OnModuleInit {
userId: string,
filename: string
): Promise<{ storagePath: string; publicUrl: string }> {
// Download the file
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download file from ${url}`);
@ -72,24 +62,24 @@ export class StorageService implements OnModuleInit {
async deleteFile(storagePath: string): Promise<void> {
try {
await this.storage.delete(storagePath);
this.logger.debug(`Deleted file: ${storagePath}`);
} catch (error) {
this.logger.error(`Error deleting file ${storagePath}`, error);
throw error;
this.logger.warn(`Failed to delete file ${storagePath}: ${error}`);
}
}
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`;
const storagePath = `boards/${boardId}/thumbnail.png`;
const result = await this.storage.upload(storagePath, buffer, {
contentType: 'image/png',
public: true,
maxSizeBytes: MAX_THUMBNAIL_SIZE,
cacheControl: 'public, max-age=604800', // 7 days (thumbnails can be regenerated)
});
return result.url || this.getPublicUrl(storagePath);
return result.url ?? this.getPublicUrl(storagePath);
}
async getFile(storagePath: string): Promise<Buffer | null> {
@ -101,11 +91,14 @@ export class StorageService implements OnModuleInit {
}
}
getStorageMode(): StorageMode {
return 's3';
getPublicUrl(storagePath: string): string {
return this.storage.getPublicUrl(storagePath) ?? '';
}
getPublicUrl(storagePath: string): string {
return this.storage.getPublicUrl(storagePath) || `${this.publicUrl}/${storagePath}`;
/**
* Delete all files for a user (account deletion).
*/
async deleteAllUserFiles(userId: string): Promise<number> {
return this.storage.deleteByPrefix(`users/${userId}/`);
}
}