fix: file preview URLs and milestone web link saving
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { prisma } from '../lib/prisma';
|
||||
@@ -8,6 +8,16 @@ 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 {
|
||||
@@ -70,6 +80,7 @@ router.get('/:id/view', async (req, res, next) => {
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { fileDownloadUrl, fileViewUrl } from '../../lib/apiClient';
|
||||
|
||||
interface ExcelPreviewProps {
|
||||
fileId: string;
|
||||
@@ -21,7 +22,7 @@ export function ExcelPreview({ fileId, fileName }: ExcelPreviewProps) {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/files/${fileId}/view`);
|
||||
const res = await fetch(fileViewUrl(fileId));
|
||||
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
|
||||
const buffer = await res.arrayBuffer();
|
||||
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">
|
||||
<p className="text-lg text-white/60">{error}</p>
|
||||
<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"
|
||||
>
|
||||
{fileName} 다운로드
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ExcelPreview } from './ExcelPreview';
|
||||
import { fileViewUrl } from '../../lib/apiClient';
|
||||
import { openLinkOnRightMonitor } from '../../lib/dualMonitor';
|
||||
import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay';
|
||||
import type { FileRecord, MilestoneLink } from '../../types';
|
||||
@@ -53,7 +54,7 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
|
||||
if (isExcel) {
|
||||
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
|
||||
}
|
||||
const src = `/api/files/${activeFile.id}/view`;
|
||||
const src = fileViewUrl(activeFile.id);
|
||||
if (isImage) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -291,6 +291,15 @@ export function StageModal({
|
||||
if (editTarget?.type === 'file') applyFileEdit();
|
||||
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 uploads: PendingFileUpload[] = [];
|
||||
const existingEdits: ExistingFileEdit[] = [];
|
||||
@@ -312,7 +321,7 @@ export function StageModal({
|
||||
}
|
||||
});
|
||||
|
||||
await onSave(form, {
|
||||
await onSave(saveForm, {
|
||||
uploads,
|
||||
existingEdits,
|
||||
deletedFileIds,
|
||||
|
||||
@@ -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) => {
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
|
||||
@@ -286,7 +286,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
startDate: data.startDate || undefined,
|
||||
dueDate: data.dueDate || undefined,
|
||||
progress: data.progress,
|
||||
links: data.links.length > 0 ? JSON.stringify(data.links) : undefined,
|
||||
links: data.links,
|
||||
};
|
||||
|
||||
let milestoneId: string;
|
||||
|
||||
Reference in New Issue
Block a user