fix: file preview URLs and milestone web link saving

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-05 22:44:52 +09:00
parent ccf892e479
commit fa8ed76e22
6 changed files with 36 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router, type Response } from 'express';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { prisma } from '../lib/prisma'; import { prisma } from '../lib/prisma';
@@ -8,6 +8,16 @@ import { AppError } from '../middleware/errorHandler';
const router = Router(); 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로 전달하는 한글 파일명 복원 */ /** multer가 latin1로 전달하는 한글 파일명 복원 */
function fixOriginalName(name: string): string { function fixOriginalName(name: string): string {
try { try {
@@ -70,6 +80,7 @@ router.get('/:id/view', async (req, res, next) => {
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.'); if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.'); if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
allowCrossOriginPreview(res);
res.setHeader('Content-Type', file.mimetype); res.setHeader('Content-Type', file.mimetype);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`); res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
fs.createReadStream(file.path).pipe(res); fs.createReadStream(file.path).pipe(res);

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { fileDownloadUrl, fileViewUrl } from '../../lib/apiClient';
interface ExcelPreviewProps { interface ExcelPreviewProps {
fileId: string; fileId: string;
@@ -21,7 +22,7 @@ export function ExcelPreview({ fileId, fileName }: ExcelPreviewProps) {
(async () => { (async () => {
try { try {
const res = await fetch(`/api/files/${fileId}/view`); const res = await fetch(fileViewUrl(fileId));
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.'); if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
const buffer = await res.arrayBuffer(); const buffer = await res.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array' }); const wb = XLSX.read(buffer, { type: 'array' });
@@ -48,7 +49,7 @@ export function ExcelPreview({ fileId, fileName }: ExcelPreviewProps) {
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center"> <div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-lg text-white/60">{error}</p> <p className="text-lg text-white/60">{error}</p>
<a <a
href={`/api/files/${fileId}/download`} href={fileDownloadUrl(fileId)}
className="rounded bg-white/10 px-4 py-2 text-sm font-bold text-white hover:bg-white/20" className="rounded bg-white/10 px-4 py-2 text-sm font-bold text-white hover:bg-white/20"
> >
{fileName} {fileName}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ExcelPreview } from './ExcelPreview'; import { ExcelPreview } from './ExcelPreview';
import { fileViewUrl } from '../../lib/apiClient';
import { openLinkOnRightMonitor } from '../../lib/dualMonitor'; import { openLinkOnRightMonitor } from '../../lib/dualMonitor';
import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay'; import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay';
import type { FileRecord, MilestoneLink } from '../../types'; import type { FileRecord, MilestoneLink } from '../../types';
@@ -53,7 +54,7 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
if (isExcel) { if (isExcel) {
return <ExcelPreview fileId={activeFile.id} fileName={label} />; return <ExcelPreview fileId={activeFile.id} fileName={label} />;
} }
const src = `/api/files/${activeFile.id}/view`; const src = fileViewUrl(activeFile.id);
if (isImage) { if (isImage) {
return ( return (
<img <img

View File

@@ -291,6 +291,15 @@ export function StageModal({
if (editTarget?.type === 'file') applyFileEdit(); if (editTarget?.type === 'file') applyFileEdit();
if (editTarget?.type === 'link') applyLinkEdit(); if (editTarget?.type === 'link') applyLinkEdit();
let saveForm = form;
const pendingUrl = linkUrl.trim();
if (editTarget?.type !== 'link' && pendingUrl) {
saveForm = {
...form,
links: [...form.links, { label: linkLabel.trim() || pendingUrl, url: pendingUrl }],
};
}
const sorted = [...fileRows].sort((a, b) => a.sortOrder - b.sortOrder); const sorted = [...fileRows].sort((a, b) => a.sortOrder - b.sortOrder);
const uploads: PendingFileUpload[] = []; const uploads: PendingFileUpload[] = [];
const existingEdits: ExistingFileEdit[] = []; const existingEdits: ExistingFileEdit[] = [];
@@ -312,7 +321,7 @@ export function StageModal({
} }
}); });
await onSave(form, { await onSave(saveForm, {
uploads, uploads,
existingEdits, existingEdits,
deletedFileIds, deletedFileIds,

View File

@@ -16,6 +16,14 @@ export const apiClient = axios.create({
}, },
}); });
export function fileViewUrl(fileId: string): string {
return `${baseURL}/files/${fileId}/view`;
}
export function fileDownloadUrl(fileId: string): string {
return `${baseURL}/files/${fileId}/download`;
}
apiClient.interceptors.request.use((config) => { apiClient.interceptors.request.use((config) => {
if (config.data instanceof FormData) { if (config.data instanceof FormData) {
delete config.headers['Content-Type']; delete config.headers['Content-Type'];

View File

@@ -286,7 +286,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
startDate: data.startDate || undefined, startDate: data.startDate || undefined,
dueDate: data.dueDate || undefined, dueDate: data.dueDate || undefined,
progress: data.progress, progress: data.progress,
links: data.links.length > 0 ? JSON.stringify(data.links) : undefined, links: data.links,
}; };
let milestoneId: string; let milestoneId: string;