diff --git a/admin/index.html b/admin/index.html
index 7ec02c7..eef1800 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -3,8 +3,11 @@
+
+
+
자료실 - 관리자
-
+
diff --git a/admin/styles.css b/admin/styles.css
index 08cf626..c97d5e4 100644
--- a/admin/styles.css
+++ b/admin/styles.css
@@ -29,7 +29,7 @@ header {
}
header h1 {
- color: #667eea;
+ color: #667eea !important;
font-size: 2.5rem;
margin-bottom: 10px;
}
diff --git a/index.html b/index.html
index 975000b..632d601 100644
--- a/index.html
+++ b/index.html
@@ -3,8 +3,11 @@
+
+
+
자료실 - 파일 보기
-
+
diff --git a/script.js b/script.js
index 054aab8..fb7c23a 100644
--- a/script.js
+++ b/script.js
@@ -343,9 +343,6 @@ class PublicFileViewer {
async downloadSingleFile(fileId, attachmentIndex) {
try {
- // 다운로드 시작 로딩 표시
- this.showLoading(true);
-
console.log('downloadSingleFile 호출됨:', fileId, attachmentIndex);
const file = this.files.find(f => f.id === fileId);
console.log('찾은 파일:', file);
@@ -359,70 +356,28 @@ class PublicFileViewer {
const downloadUrl = `/api/download/${fileId}/${attachmentId}`;
console.log('다운로드 URL:', downloadUrl);
- const response = await fetch(downloadUrl, {
- credentials: 'include'
- });
- console.log('응답 상태:', response.status, response.statusText);
-
- if (!response.ok) {
- const errorText = await response.text();
- console.log('응답 오류:', errorText);
- throw new Error(`HTTP error! status: ${response.status} - ${errorText}`);
- }
-
- console.log('다운로드 시작...');
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
-
- // 파일명을 서버에서 전송된 정보에서 추출 (개선된 방식)
- const contentDisposition = response.headers.get('Content-Disposition');
- let filename = file.files[attachmentIndex].original_name || `download_${Date.now()}`;
-
- console.log('📁 다운로드 파일명 처리:', {
- original_name: file.files[attachmentIndex].original_name,
- content_disposition: contentDisposition,
- default_filename: filename
- });
-
- if (contentDisposition) {
- // RFC 5987 filename* 파라미터를 우선 처리 (UTF-8 지원)
- const filenameStarMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/);
- if (filenameStarMatch) {
- filename = decodeURIComponent(filenameStarMatch[1]);
- console.log('📁 UTF-8 파일명 추출:', filename);
- } else {
- // 일반 filename 파라미터 처리
- const filenameMatch = contentDisposition.match(/filename="?([^";\r\n]+)"?/);
- if (filenameMatch) {
- filename = filenameMatch[1];
- console.log('📁 기본 파일명 추출:', filename);
- }
- }
- }
-
- // 파일명이 여전히 비어있다면 기본값 사용
- if (!filename || filename.trim() === '') {
- filename = file.files[attachmentIndex].original_name || `download_${Date.now()}`;
- console.log('📁 기본 파일명 사용:', filename);
- }
-
+ // 대용량 파일을 위해 직접 링크로 다운로드 (blob 사용하지 않음)
const link = document.createElement('a');
- link.href = url;
+ link.href = downloadUrl;
+ link.target = '_blank'; // 새 탭에서 열어 다운로드
+
+ // 파일명 설정 (서버에서 Content-Disposition 헤더로 처리됨)
+ const filename = file.files[attachmentIndex].original_name || `download_${Date.now()}`;
link.download = filename;
+
+ // 숨겨진 링크 생성 후 클릭
+ link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
- console.log('다운로드 완료');
- this.showLoading(false);
+ console.log('📁 대용량 파일 다운로드 시작:', filename);
if (arguments.length === 2) { // 단일 파일 다운로드인 경우만 알림 표시
- this.showNotification(`파일 다운로드 완료: ${filename}`, 'success');
+ this.showNotification(`파일 다운로드 시작: ${filename}`, 'success');
}
} catch (error) {
console.error('downloadSingleFile 오류:', error);
- this.showLoading(false);
this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
}
}
diff --git a/server.js b/server.js
index 6145e85..f82c3d5 100644
--- a/server.js
+++ b/server.js
@@ -44,6 +44,14 @@ app.use((req, res, next) => {
next();
});
+// CSS 파일에 대한 캐시 무효화 헤더 설정
+app.get('*.css', (req, res, next) => {
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+ res.setHeader('Pragma', 'no-cache');
+ res.setHeader('Expires', '0');
+ next();
+});
+
// 정적 파일 서빙
app.use(express.static(__dirname));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
@@ -731,10 +739,30 @@ app.get('/api/download/:id/:attachmentId', async (req, res) => {
// RFC 5987을 준수하는 헤더 설정 (한글 파일명 지원)
const stat = fs.statSync(filePath);
+ const fileSize = stat.size;
+
+ // Range 요청 처리
+ const range = req.headers.range;
+ let start = 0;
+ let end = fileSize - 1;
+ let statusCode = 200;
+
+ if (range) {
+ const parts = range.replace(/bytes=/, "").split("-");
+ start = parseInt(parts[0], 10);
+ end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
+ statusCode = 206; // Partial Content
+
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
+ res.setHeader('Content-Length', (end - start + 1));
+ } else {
+ res.setHeader('Content-Length', fileSize);
+ }
+
+ res.status(statusCode);
res.setHeader('Content-Disposition',
`attachment; filename*=UTF-8''${encodedName}`);
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
- res.setHeader('Content-Length', stat.size);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Cache-Control', 'public, max-age=0');
@@ -745,19 +773,22 @@ app.get('/api/download/:id/:attachmentId', async (req, res) => {
}
});
- // 원본 파일명으로 다운로드
- res.download(filePath, originalName, (err) => {
- if (err) {
- // ECONNABORTED는 클라이언트가 연결을 끊은 경우이므로 로그만 남김
- if (err.code === 'ECONNABORTED') {
- console.log('📁 다운로드 중단됨 (클라이언트 연결 해제):', originalName);
- } else {
- console.error('📁 다운로드 오류:', err);
- }
- } else {
- console.log('📁 다운로드 완료:', originalName);
+ // 스트림 기반 다운로드로 대용량 파일 지원 (Range 요청 지원)
+ const readStream = fs.createReadStream(filePath, { start, end });
+
+ readStream.on('error', (err) => {
+ console.error('📁 파일 읽기 오류:', err);
+ if (!res.headersSent) {
+ res.status(500).json({ error: '파일 읽기 실패' });
}
});
+
+ readStream.on('end', () => {
+ console.log('📁 다운로드 완료:', originalName);
+ });
+
+ // 스트림을 응답에 연결
+ readStream.pipe(res);
} else {
res.status(404).json({
success: false,
@@ -796,12 +827,15 @@ module.exports = app;
// 로컬 개발 환경에서만 서버 시작
if (process.env.NODE_ENV !== 'production' || process.env.VERCEL !== '1') {
- app.listen(PORT, () => {
+ const server = app.listen(PORT, () => {
console.log(`🚀 자료실 서버가 포트 ${PORT}에서 실행중입니다.`);
console.log(`📱 Admin 페이지: http://localhost:${PORT}/admin/index.html`);
console.log(`🌐 Main 페이지: http://localhost:${PORT}/index.html`);
console.log(`📊 API: http://localhost:${PORT}/api/files`);
});
+
+ // 대용량 파일 다운로드를 위해 서버 타임아웃을 30분으로 설정
+ server.timeout = 1800000; // 30분 (30 * 60 * 1000ms)
// 프로세스 종료 시 데이터베이스 연결 종료
process.on('SIGINT', async () => {
diff --git a/styles.css b/styles.css
index 54cefbe..e89fe78 100644
--- a/styles.css
+++ b/styles.css
@@ -29,7 +29,7 @@ header {
}
header h1 {
- color: #667eea;
+ color: #667eea !important;
font-size: 2.5rem;
margin-bottom: 10px;
}
@@ -618,7 +618,7 @@ header p {
/* 제목 스타일 */
.board-title {
- color: #374151;
+ color: #667eea;
font-weight: 500;
text-decoration: none;
cursor: pointer;