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:
@@ -630,7 +630,7 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
|
||||
|
||||
const handleDelete = async (attachmentId: number) => {
|
||||
try {
|
||||
await api.delete(`/archive/${studentId}/attachments/${attachmentId}`);
|
||||
await api.delete(`/archive/attachments/${attachmentId}`);
|
||||
message.success('已删除');
|
||||
onRefresh();
|
||||
} catch (e: unknown) {
|
||||
@@ -654,9 +654,18 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
const token = localStorage.getItem('token');
|
||||
window.open(`/api/archive/${studentId}/attachments/${record.id}?token=${token}`, '_blank');
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '查看失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
查看
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user