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 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);
|
||||||
|
|||||||
@@ -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} 다운로드
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user