forked from wangziqi/gongxue-base
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) => {
|
const handleDelete = async (attachmentId: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/archive/${studentId}/attachments/${attachmentId}`);
|
await api.delete(`/archive/attachments/${attachmentId}`);
|
||||||
message.success('已删除');
|
message.success('已删除');
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -654,9 +654,18 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EyeOutlined />}
|
icon={<EyeOutlined />}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const token = localStorage.getItem('token');
|
try {
|
||||||
window.open(`/api/archive/${studentId}/attachments/${record.id}?token=${token}`, '_blank');
|
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,
|
Request,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
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 { ArchiveReportService } from './archive-report.service';
|
||||||
import { ArchiveService } from './archive.service';
|
import { ArchiveService } from './archive.service';
|
||||||
import {
|
import {
|
||||||
@@ -321,6 +323,23 @@ export class ArchiveController {
|
|||||||
return result;
|
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')
|
@Delete('attachments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -33,6 +33,23 @@ export class ArchiveService {
|
|||||||
private readonly notificationsService: NotificationsService,
|
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) {
|
async getProfile(studentId: number) {
|
||||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
@@ -160,7 +177,7 @@ export class ArchiveService {
|
|||||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
|
|
||||||
const uploadDir = path.join(process.cwd(), 'uploads', 'archive');
|
const uploadDir = this.uploadDir;
|
||||||
if (!fs.existsSync(uploadDir)) {
|
if (!fs.existsSync(uploadDir)) {
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -174,18 +191,32 @@ export class ArchiveService {
|
|||||||
studentId,
|
studentId,
|
||||||
category,
|
category,
|
||||||
fileName: file.originalname,
|
fileName: file.originalname,
|
||||||
filePath: `uploads/archive/${filename}`,
|
filePath: filename,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
});
|
});
|
||||||
return this.attachmentRepo.save(entity);
|
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) {
|
async deleteAttachment(id: number) {
|
||||||
const entity = await this.attachmentRepo.findOne({ where: { id } });
|
const entity = await this.attachmentRepo.findOne({ where: { id } });
|
||||||
if (!entity) throw new NotFoundException('附件不存在');
|
if (!entity) throw new NotFoundException('附件不存在');
|
||||||
|
|
||||||
const absPath = path.join(process.cwd(), entity.filePath);
|
const absPath = this.resolveAttachmentPath(entity.filePath);
|
||||||
if (fs.existsSync(absPath)) {
|
if (fs.existsSync(absPath)) {
|
||||||
fs.unlinkSync(absPath);
|
fs.unlinkSync(absPath);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user