refactor(auth,planta): optimize storage usage

mana-core-auth:
- Replace manual key generation (Date.now) with generateUserFileKey()
- Replace manual validateFileSize with maxSizeBytes in upload()
- Remove OnModuleInit — init storage directly in constructor
- Add upload hooks for structured logging
- Remove redundant getPublicUrl() fallback chain (presigned URL for 1 year)
- Add deleteAllUserAvatars() for account deletion
- Simplify getAvatarUploadUrl() using storage.getPublicUrl()

planta:
- Replace createStorageClient() with manual config by createPlantaStorage()
- Replace manual uuid + path construction with generateUserFileKey()
- Remove uuid dependency for key generation
- Add maxSizeBytes validation (20MB)
- Add cacheControl header (immutable, 1 year)
- Add upload hooks for structured logging
- Add error handling in deletePhoto()
- Add deleteAllUserPhotos() for account deletion
- Make getPhotoUrl() synchronous (was async unnecessarily)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 20:59:14 +01:00
parent 6476521fd1
commit e64c298cec
2 changed files with 61 additions and 107 deletions

View file

@ -1,55 +1,64 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import {
import { createStorageClient, StorageClient } from '@manacore/shared-storage'; createPlantaStorage,
import { v4 as uuidv4 } from 'uuid'; generateUserFileKey,
type StorageClient,
} from '@manacore/shared-storage';
const MAX_PHOTO_SIZE = 20 * 1024 * 1024; // 20MB
@Injectable() @Injectable()
export class StorageService { export class StorageService {
private readonly logger = new Logger(StorageService.name);
private storage: StorageClient; private storage: StorageClient;
constructor(private configService: ConfigService) { constructor() {
const publicUrl = this.configService.get<string>('PLANTA_S3_PUBLIC_URL'); this.storage = createPlantaStorage();
this.storage = createStorageClient(
{ this.storage.hooks.on('upload', ({ key, sizeBytes }) => {
name: 'planta-storage', this.logger.debug(`Uploaded photo ${key} (${sizeBytes} bytes)`);
publicUrl, });
}, this.storage.hooks.on('upload:error', ({ key, error }) => {
{ this.logger.error(`Photo upload failed for ${key}: ${error.message}`);
endpoint: this.configService.get<string>('S3_ENDPOINT'), });
region: this.configService.get<string>('S3_REGION'),
accessKeyId: this.configService.get<string>('S3_ACCESS_KEY'),
secretAccessKey: this.configService.get<string>('S3_SECRET_KEY'),
}
);
} }
async uploadPhoto( async uploadPhoto(
userId: string, userId: string,
file: Express.Multer.File file: Express.Multer.File
): Promise<{ storagePath: string; publicUrl: string }> { ): Promise<{ storagePath: string; publicUrl: string }> {
const extension = file.originalname.split('.').pop() || 'jpg'; const storagePath = generateUserFileKey(userId, file.originalname, 'photos');
const filename = `${uuidv4()}.${extension}`;
const storagePath = `users/${userId}/photos/${filename}`;
await this.storage.upload(storagePath, file.buffer, { const result = await this.storage.upload(storagePath, file.buffer, {
contentType: file.mimetype, contentType: file.mimetype,
public: true, public: true,
maxSizeBytes: MAX_PHOTO_SIZE,
cacheControl: 'public, max-age=31536000, immutable',
}); });
const publicUrl = this.storage.getPublicUrl(storagePath) ?? ''; return { storagePath, publicUrl: result.url ?? this.storage.getPublicUrl(storagePath) ?? '' };
return { storagePath, publicUrl };
} }
async deletePhoto(storagePath: string): Promise<void> { async deletePhoto(storagePath: string): Promise<void> {
await this.storage.delete(storagePath); try {
await this.storage.delete(storagePath);
} catch (err) {
this.logger.warn(`Failed to delete photo ${storagePath}: ${err}`);
}
} }
async getPhotoUrl(storagePath: string): Promise<string> { getPhotoUrl(storagePath: string): string {
return this.storage.getPublicUrl(storagePath) ?? ''; return this.storage.getPublicUrl(storagePath) ?? '';
} }
async downloadPhoto(storagePath: string): Promise<Buffer> { async downloadPhoto(storagePath: string): Promise<Buffer> {
return this.storage.download(storagePath); return this.storage.download(storagePath);
} }
/**
* Delete all photos for a user (account deletion).
*/
async deleteAllUserPhotos(userId: string): Promise<number> {
return this.storage.deleteByPrefix(`users/${userId}/`);
}
} }

View file

@ -1,10 +1,9 @@
import { Injectable, Logger, BadRequestException, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { import {
createManaCoreStorage, createManaCoreStorage,
generateUserFileKey, generateUserFileKey,
getContentType, getContentType,
validateFileSize,
validateFileExtension, validateFileExtension,
IMAGE_EXTENSIONS, IMAGE_EXTENSIONS,
} from '@manacore/shared-storage'; } from '@manacore/shared-storage';
@ -13,18 +12,22 @@ import type { StorageClient } from '@manacore/shared-storage';
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable() @Injectable()
export class StorageService implements OnModuleInit { export class StorageService {
private readonly logger = new Logger(StorageService.name); private readonly logger = new Logger(StorageService.name);
private storage: StorageClient | null = null; private storage: StorageClient | null = null;
private readonly publicUrl: string | undefined;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.publicUrl = this.configService.get<string>('storage.publicUrl');
}
async onModuleInit() {
try { try {
this.storage = createManaCoreStorage(this.publicUrl); const publicUrl = this.configService.get<string>('storage.publicUrl');
this.storage = createManaCoreStorage(publicUrl);
this.storage.hooks.on('upload', ({ key, sizeBytes }) => {
this.logger.debug(`Uploaded avatar ${key} (${sizeBytes} bytes)`);
});
this.storage.hooks.on('upload:error', ({ key, error }) => {
this.logger.error(`Avatar upload failed for ${key}: ${error.message}`);
});
this.logger.log('Storage service initialized'); this.logger.log('Storage service initialized');
} catch (error) { } catch (error) {
this.logger.warn( this.logger.warn(
@ -43,10 +46,6 @@ export class StorageService implements OnModuleInit {
/** /**
* Generate a presigned URL for avatar upload * Generate a presigned URL for avatar upload
*
* @param userId - User ID
* @param filename - Original filename
* @returns Presigned upload URL and the final file URL
*/ */
async getAvatarUploadUrl( async getAvatarUploadUrl(
userId: string, userId: string,
@ -61,42 +60,20 @@ export class StorageService implements OnModuleInit {
throw new BadRequestException('Storage service is not configured'); throw new BadRequestException('Storage service is not configured');
} }
// Validate file extension if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
const ext = filename.split('.').pop()?.toLowerCase();
if (!ext || !validateFileExtension(filename, IMAGE_EXTENSIONS)) {
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`); throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
} }
// Generate unique key for avatar const key = generateUserFileKey(userId, filename, 'avatars');
const key = `avatars/${userId}/${Date.now()}.${ext}`;
const contentType = getContentType(filename);
// Get presigned upload URL (1 hour expiry)
const expiresIn = 3600; const expiresIn = 3600;
const uploadUrl = await this.storage.getUploadUrl(key, { const uploadUrl = await this.storage.getUploadUrl(key, { expiresIn });
expiresIn, const fileUrl = this.storage.getPublicUrl(key) ?? '';
});
// Construct the final public URL return { uploadUrl, fileUrl, key, expiresIn };
const fileUrl = await this.getPublicUrl(key);
this.logger.debug('Generated avatar upload URL', { userId, key });
return {
uploadUrl,
fileUrl,
key,
expiresIn,
};
} }
/** /**
* Upload avatar directly (for server-side uploads) * Upload avatar directly (for server-side uploads)
*
* @param userId - User ID
* @param buffer - File buffer
* @param filename - Original filename
* @returns Public URL of the uploaded avatar
*/ */
async uploadAvatar( async uploadAvatar(
userId: string, userId: string,
@ -107,40 +84,24 @@ export class StorageService implements OnModuleInit {
throw new BadRequestException('Storage service is not configured'); throw new BadRequestException('Storage service is not configured');
} }
// Validate file extension
if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) { if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`); throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
} }
// Validate file size const key = generateUserFileKey(userId, filename, 'avatars');
if (!validateFileSize(buffer.length, MAX_AVATAR_SIZE)) {
throw new BadRequestException(
`File too large. Maximum size: ${MAX_AVATAR_SIZE / 1024 / 1024}MB`
);
}
// Generate unique key for avatar
const ext = filename.split('.').pop()?.toLowerCase() || 'jpg';
const key = `avatars/${userId}/${Date.now()}.${ext}`;
// Upload file
const result = await this.storage.upload(key, buffer, { const result = await this.storage.upload(key, buffer, {
contentType: getContentType(filename), contentType: getContentType(filename),
public: true, public: true,
cacheControl: 'public, max-age=31536000', // 1 year cache maxSizeBytes: MAX_AVATAR_SIZE,
cacheControl: 'public, max-age=31536000, immutable',
}); });
const url = result.url || (await this.getPublicUrl(key)); return { url: result.url ?? this.storage.getPublicUrl(key) ?? '', key };
this.logger.log('Avatar uploaded', { userId, key });
return { url, key };
} }
/** /**
* Delete avatar * Delete avatar
*
* @param key - Storage key of the avatar
*/ */
async deleteAvatar(key: string): Promise<void> { async deleteAvatar(key: string): Promise<void> {
if (!this.storage) { if (!this.storage) {
@ -148,29 +109,13 @@ export class StorageService implements OnModuleInit {
} }
await this.storage.delete(key); await this.storage.delete(key);
this.logger.log('Avatar deleted', { key });
} }
/** /**
* Get public URL for a key * Delete all avatars for a user (account deletion).
*/ */
private async getPublicUrl(key: string): Promise<string> { async deleteAllUserAvatars(userId: string): Promise<number> {
if (!this.storage) { if (!this.storage) return 0;
throw new BadRequestException('Storage service is not configured'); return this.storage.deleteByPrefix(`users/${userId}/`);
}
// If we have a configured public URL, use it
if (this.publicUrl) {
return `${this.publicUrl}/${key}`;
}
// Check if the storage has a public URL configured
const publicUrl = this.storage.getPublicUrl(key);
if (publicUrl) {
return publicUrl;
}
// Otherwise, get a presigned URL for reading
return this.storage.getDownloadUrl(key, { expiresIn: 86400 * 365 }); // 1 year
} }
} }