feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
+41
View File
@@ -0,0 +1,41 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { FilesService } from './files.service';
type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
@ApiTags('client-files')
@Controller('client/files')
export class ClientFilesController {
constructor(private readonly files: FilesService) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
upload(
@CurrentSessionUserId() userId: string | undefined,
@UploadedFile() file: UploadedMultipartFile,
@Body('purpose') purpose: string,
@Body('prefix') prefix?: string,
) {
if (!file) throw new BadRequestException('Upload file is required');
return this.files.uploadForClient(userId, { purpose, prefix }, file);
}
@Get(':id/download')
async download(
@CurrentSessionUserId() userId: string | undefined,
@Param('id') id: string,
@Query('disposition') disposition: string | undefined,
@Res() response: DownloadResponse,
) {
const { fileObject, content } = await this.files.getClientDownload(userId, id);
const mode = disposition === 'inline' ? 'inline' : 'attachment';
response.setHeader('Content-Type', fileObject.contentType || 'application/octet-stream');
response.setHeader('Content-Length', content.length);
response.setHeader('Content-Disposition', `${mode}; filename*=UTF-8''${encodeURIComponent(fileObject.fileName)}`);
response.send(content);
}
}
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
import { ObjectStorageService } from './object-storage.service';
import { ClientFilesController } from './client-files.controller';
@Module({
controllers: [FilesController],
controllers: [FilesController, ClientFilesController],
providers: [FilesService, ObjectStorageService],
exports: [FilesService, ObjectStorageService],
})
+40
View File
@@ -136,4 +136,44 @@ describe('FilesService', () => {
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png');
});
it('derives client upload tenant from the authenticated user and ignores tenant headers', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-real' }) },
fileObject: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-client', ...data })) },
};
const objectStorage = {
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
putObject: jest.fn().mockResolvedValue({ etag: 'etag' }),
};
const service = new FilesService(prisma as never, objectStorage as never);
await expect(service.uploadForClient('user-1', {
purpose: 'enterprise_certification',
prefix: 'enterprise-certifications/license',
}, { originalname: 'license.pdf', mimetype: 'application/pdf', size: 4, buffer: Buffer.from('test') }))
.resolves.toEqual(expect.objectContaining({ tenantId: 'tenant-real', purpose: 'enterprise_certification' }));
expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'user-1' }) }));
});
it('rejects arbitrary client upload purposes and prefixes before object storage writes', async () => {
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }) } };
const objectStorage = { putObject: jest.fn() };
const service = new FilesService(prisma as never, objectStorage as never);
await expect(service.uploadForClient('user-1', { purpose: 'enterprise_certification', prefix: '../admin' }, {
originalname: 'file.pdf', mimetype: 'application/pdf', size: 4, buffer: Buffer.from('test'),
})).rejects.toBeInstanceOf(BadRequestException);
expect(objectStorage.putObject).not.toHaveBeenCalled();
});
it('prevents client users from downloading another tenant file', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }) },
fileObject: { findFirst: jest.fn().mockResolvedValue(null) },
};
const objectStorage = { getObject: jest.fn() };
const service = new FilesService(prisma as never, objectStorage as never);
await expect(service.getClientDownload('user-1', 'foreign-file')).rejects.toThrow('File object not found');
expect(prisma.fileObject.findFirst).toHaveBeenCalledWith({ where: { id: 'foreign-file', tenantId: 'tenant-1' } });
expect(objectStorage.getObject).not.toHaveBeenCalled();
});
});
+39
View File
@@ -26,6 +26,12 @@ export interface UploadFileDto {
prefix?: string;
}
const CLIENT_UPLOAD_RULES: Record<string, RegExp> = {
enterprise_certification: /^enterprise-certifications\/license$/,
signature_report_material: /^signature-materials$/,
drainage_report_material: /^drainage-materials\/[a-zA-Z0-9_-]+$/,
};
@Injectable()
export class FilesService {
constructor(
@@ -81,6 +87,15 @@ export class FilesService {
});
}
async uploadForClient(userId: string | undefined, data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
const tenantId = await this.resolveClientTenantId(userId);
const prefixRule = CLIENT_UPLOAD_RULES[data.purpose];
if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) {
throw new BadRequestException('不支持的客户端上传用途或目录');
}
return this.upload({ tenantId, purpose: data.purpose, prefix: data.prefix }, file);
}
async getDownload(id: string) {
const fileObject = await this.prisma.fileObject.findUnique({ where: { id } });
if (!fileObject) {
@@ -92,6 +107,30 @@ export class FilesService {
content,
};
}
async getClientDownload(userId: string | undefined, id: string) {
const tenantId = await this.resolveClientTenantId(userId);
const fileObject = await this.prisma.fileObject.findFirst({ where: { id, tenantId } });
if (!fileObject) {
throw new NotFoundException('File object not found');
}
const content = await this.objectStorage.getObject(fileObject.objectKey);
return { fileObject: serializeFileObject(fileObject), content };
}
private async resolveClientTenantId(userId?: string) {
if (!userId) {
throw new NotFoundException('Client user not found');
}
const user = await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } },
select: { tenantId: true },
});
if (!user?.tenantId) {
throw new NotFoundException('Client tenant not found');
}
return user.tenantId;
}
}
const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;