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;