179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
import { Router, type Response } from 'express';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { prisma } from '../lib/prisma';
|
|
import { resolveTaskActorId } from '../lib/resolveUser';
|
|
import { upload } from '../middleware/upload';
|
|
import { AppError } from '../middleware/errorHandler';
|
|
|
|
const router = Router();
|
|
|
|
/** Vercel 상세 창에서 PDF 등 iframe 미리보기 허용 */
|
|
function allowCrossOriginPreview(res: Response) {
|
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
|
res.setHeader(
|
|
'Content-Security-Policy',
|
|
"frame-ancestors 'self' https://eene-dashboard.vercel.app https://*.vercel.app http://localhost:3000",
|
|
);
|
|
res.removeHeader('X-Frame-Options');
|
|
}
|
|
|
|
/** multer가 latin1로 전달하는 한글 파일명 복원 */
|
|
function fixOriginalName(name: string): string {
|
|
try {
|
|
return Buffer.from(name, 'latin1').toString('utf8') || name;
|
|
} catch {
|
|
return name;
|
|
}
|
|
}
|
|
|
|
// POST /api/files/upload/:taskId — 파일 업로드
|
|
router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => {
|
|
try {
|
|
if (!req.file) throw new AppError(400, '파일이 없습니다.');
|
|
const taskId = String(req.params.taskId);
|
|
|
|
const task = await prisma.task.findUnique({ where: { id: taskId } });
|
|
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
|
|
|
const body = req.body as Record<string, string>;
|
|
const milestoneId = body.milestoneId?.trim() || null;
|
|
|
|
if (milestoneId) {
|
|
const milestone = await prisma.milestone.findFirst({
|
|
where: { id: milestoneId, taskId },
|
|
});
|
|
if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
|
}
|
|
|
|
const uploadedBy = await resolveTaskActorId(taskId);
|
|
|
|
const displayName = body.displayName?.trim() || null;
|
|
const sortOrder = body.sortOrder !== undefined ? Number(body.sortOrder) : 0;
|
|
|
|
const fileRecord = await prisma.file.create({
|
|
data: {
|
|
taskId,
|
|
milestoneId,
|
|
filename: req.file.filename,
|
|
originalName: fixOriginalName(req.file.originalname),
|
|
displayName,
|
|
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
|
|
mimetype: req.file.mimetype,
|
|
size: req.file.size,
|
|
path: req.file.path,
|
|
uploadedBy,
|
|
},
|
|
});
|
|
|
|
res.status(201).json(fileRecord);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/files/:id/view — 파일 미리보기 (브라우저에서 바로 열기)
|
|
router.get('/:id/view', async (req, res, next) => {
|
|
try {
|
|
const fileId = String(req.params.id);
|
|
const file = await prisma.file.findUnique({ where: { id: fileId } });
|
|
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
|
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
|
|
|
allowCrossOriginPreview(res);
|
|
res.setHeader('Content-Type', file.mimetype);
|
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
|
|
fs.createReadStream(file.path).pipe(res);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/files/:id/download — 파일 다운로드
|
|
router.get('/:id/download', async (req, res, next) => {
|
|
try {
|
|
const fileId = String(req.params.id);
|
|
const file = await prisma.file.findUnique({ where: { id: fileId } });
|
|
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
|
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
|
|
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.originalName)}"`);
|
|
res.download(file.path, file.originalName);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/files/:id/replace — 파일 교체
|
|
router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
|
|
try {
|
|
if (!req.file) throw new AppError(400, '파일이 없습니다.');
|
|
|
|
const fileId = String(req.params.id);
|
|
const existing = await prisma.file.findUnique({ where: { id: fileId } });
|
|
if (!existing) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
|
|
|
if (fs.existsSync(existing.path)) {
|
|
fs.unlinkSync(existing.path);
|
|
}
|
|
|
|
const file = await prisma.file.update({
|
|
where: { id: fileId },
|
|
data: {
|
|
filename: req.file.filename,
|
|
originalName: fixOriginalName(req.file.originalname),
|
|
mimetype: req.file.mimetype,
|
|
size: req.file.size,
|
|
path: req.file.path,
|
|
},
|
|
});
|
|
|
|
res.json(file);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// PATCH /api/files/:id — 표시명·순서 수정
|
|
router.patch('/:id', async (req, res, next) => {
|
|
try {
|
|
const fileId = String(req.params.id);
|
|
const { displayName, sortOrder } = req.body as { displayName?: string; sortOrder?: number };
|
|
const existing = await prisma.file.findUnique({ where: { id: fileId } });
|
|
if (!existing) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
|
|
|
const file = await prisma.file.update({
|
|
where: { id: fileId },
|
|
data: {
|
|
...(displayName !== undefined && { displayName: displayName?.trim() || null }),
|
|
...(sortOrder !== undefined && { sortOrder: Number(sortOrder) }),
|
|
},
|
|
});
|
|
|
|
res.json(file);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// DELETE /api/files/:id — 파일 삭제
|
|
router.delete('/:id', async (req, res, next) => {
|
|
try {
|
|
const fileId = String(req.params.id);
|
|
const file = await prisma.file.findUnique({ where: { id: fileId } });
|
|
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
|
|
|
// 실제 파일 삭제
|
|
if (fs.existsSync(file.path)) {
|
|
fs.unlinkSync(file.path);
|
|
}
|
|
|
|
await prisma.file.delete({ where: { id: fileId } });
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export default router;
|