feat: harden platform workflows and UI governance
This commit is contained in:
@@ -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,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],
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user