fix(archive): serve student attachments securely

Download attachments through authenticated API requests, support configurable upload paths, validate resolved file locations, and retain compatibility with legacy stored paths.
This commit is contained in:
2026-07-10 14:11:47 +08:00
parent 55881863c1
commit 1ca4a4d185
3 changed files with 68 additions and 9 deletions

View File

@@ -10,9 +10,11 @@ import {
Request,
UseInterceptors,
UploadedFile,
Res,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request as ExpressRequest } from 'express';
import type { Request as ExpressRequest, Response } from 'express';
import * as fs from 'fs';
import { ArchiveReportService } from './archive-report.service';
import { ArchiveService } from './archive.service';
import {
@@ -321,6 +323,23 @@ export class ArchiveController {
return result;
}
@Get(':studentId/attachments/:id')
@RequirePermission('student:view')
async downloadAttachment(
@Param('studentId') studentId: string,
@Param('id') id: string,
@Res() res: Response,
) {
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(+studentId, +id);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`inline; filename="${encodeURIComponent(fileName)}"`,
);
const stream = fs.createReadStream(fullPath);
stream.pipe(res);
}
@Delete('attachments/:id')
@RequirePermission('student:edit')
async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs';
@@ -33,6 +33,23 @@ export class ArchiveService {
private readonly notificationsService: NotificationsService,
) {}
get uploadDir(): string {
const base = process.env.UPLOAD_DIR || './uploads';
return path.resolve(base, 'archive');
}
private resolveAttachmentPath(filePath: string): string {
const normalizedPath = filePath.replace(/\\/g, '/');
const fullPath = normalizedPath.startsWith('uploads/')
? path.resolve(process.cwd(), normalizedPath)
: path.resolve(this.uploadDir, normalizedPath);
const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')];
if (!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))) {
throw new BadRequestException('路径非法');
}
return fullPath;
}
async getProfile(studentId: number) {
const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');
@@ -160,7 +177,7 @@ export class ArchiveService {
const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');
const uploadDir = path.join(process.cwd(), 'uploads', 'archive');
const uploadDir = this.uploadDir;
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
@@ -174,18 +191,32 @@ export class ArchiveService {
studentId,
category,
fileName: file.originalname,
filePath: `uploads/archive/${filename}`,
filePath: filename,
fileSize: file.size,
mimeType: file.mimetype,
});
return this.attachmentRepo.save(entity);
}
async getAttachmentFile(studentId: number, id: number) {
const entity = await this.attachmentRepo.findOne({ where: { id, studentId } });
if (!entity) throw new NotFoundException('附件不存在');
const fullPath = this.resolveAttachmentPath(entity.filePath);
if (!fs.existsSync(fullPath)) throw new NotFoundException('附件文件丢失');
return {
fullPath,
fileName: entity.fileName || path.basename(fullPath),
mimeType: entity.mimeType || 'application/octet-stream',
};
}
async deleteAttachment(id: number) {
const entity = await this.attachmentRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('附件不存在');
const absPath = path.join(process.cwd(), entity.filePath);
const absPath = this.resolveAttachmentPath(entity.filePath);
if (fs.existsSync(absPath)) {
fs.unlinkSync(absPath);
}