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; 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;