import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { ObjectStorageService } from './object-storage.service'; export interface CreateFileObjectDto { tenantId?: string; bucket: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number | string; checksum?: string; purpose: string; } export interface CreatePresignedUploadDto { objectKey: string; expiresInSeconds?: number; } export interface UploadFileDto { tenantId?: string; purpose: string; prefix?: string; } @Injectable() export class FilesService { constructor( private readonly prisma: PrismaService, private readonly objectStorage: ObjectStorageService, ) {} list(tenantId?: string) { return this.prisma.fileObject.findMany({ where: tenantId ? { tenantId } : undefined, orderBy: { createdAt: 'desc' }, take: 100, }).then((items) => items.map(serializeFileObject)); } create(data: CreateFileObjectDto) { const createData: Prisma.FileObjectUncheckedCreateInput = { tenantId: data.tenantId, bucket: data.bucket, objectKey: data.objectKey, fileName: data.fileName, contentType: data.contentType, sizeBytes: BigInt(data.sizeBytes), checksum: data.checksum, purpose: data.purpose, }; return this.prisma.fileObject.create({ data: createData }).then(serializeFileObject); } async createPresignedUpload(data: CreatePresignedUploadDto) { const uploadUrl = await this.objectStorage.presignedPutObject(data.objectKey, data.expiresInSeconds ?? 3600); return { bucket: this.objectStorage.getBucket(), objectKey: data.objectKey, uploadUrl, expiresInSeconds: data.expiresInSeconds ?? 3600, }; } async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) { const fileName = normalizeMultipartFileName(file.originalname); const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_'); const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`; await this.objectStorage.putObject(objectKey, file.buffer, file.size, file.mimetype || 'application/octet-stream'); return this.create({ tenantId: data.tenantId, bucket: this.objectStorage.getBucket(), objectKey, fileName, contentType: file.mimetype || 'application/octet-stream', sizeBytes: file.size, purpose: data.purpose, }); } async getDownload(id: string) { const fileObject = await this.prisma.fileObject.findUnique({ where: { id } }); if (!fileObject) { throw new NotFoundException('File object not found'); } const content = await this.objectStorage.getObject(fileObject.objectKey); return { fileObject: serializeFileObject(fileObject), content, }; } } function normalizeMultipartFileName(value: string) { if (![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) { return value; } const decoded = Buffer.from(value, 'latin1').toString('utf8'); return decoded.includes('\uFFFD') ? value : decoded; } function serializeFileObject(fileObject: T) { return { ...fileObject, sizeBytes: fileObject.sizeBytes.toString(), }; }