112 lines
3.5 KiB
TypeScript
112 lines
3.5 KiB
TypeScript
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<T extends { sizeBytes: bigint | number | string }>(fileObject: T) {
|
|
return {
|
|
...fileObject,
|
|
sizeBytes: fileObject.sizeBytes.toString(),
|
|
};
|
|
}
|