mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-25 06:24:39 +02:00
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:
parent
6476521fd1
commit
e64c298cec
2 changed files with 61 additions and 107 deletions
|
|
@ -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}/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue