- Change all environments (local and NAS) to use SQLite database - Remove MariaDB dependency and complexity - Make database initialization optional in deployment script - Simplify deployment by using single database type across all environments 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
862 lines
27 KiB
JavaScript
862 lines
27 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const bcrypt = require('bcrypt');
|
|
const session = require('express-session');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
// 모든 환경에서 SQLite 사용
|
|
const DatabaseHelper = require('./database/db-helper');
|
|
console.log('🗄️ SQLite 데이터베이스 사용');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3005;
|
|
|
|
// 데이터베이스 헬퍼 인스턴스
|
|
const db = new DatabaseHelper();
|
|
|
|
// 미들웨어 설정
|
|
app.use(cors({
|
|
origin: true, // 모든 도메인 허용 (Vercel 배포용)
|
|
credentials: true
|
|
}));
|
|
app.use(express.json({ limit: '50mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
|
|
|
// 세션 설정
|
|
app.use(session({
|
|
secret: 'jaryo-file-manager-secret-key-2024',
|
|
resave: false,
|
|
saveUninitialized: true, // 세션 초기화 허용
|
|
cookie: {
|
|
secure: false, // 개발 환경에서도 HTTP로 작동하도록 수정
|
|
httpOnly: true,
|
|
maxAge: 24 * 60 * 60 * 1000 // 24시간
|
|
}
|
|
}));
|
|
|
|
// 모든 요청 로깅 미들웨어
|
|
app.use((req, res, next) => {
|
|
if (req.url.includes('/api/categories')) {
|
|
console.log(`📨 ${req.method} ${req.url} - Time: ${new Date().toISOString()}`);
|
|
console.log('Headers:', req.headers);
|
|
console.log('Body:', req.body);
|
|
}
|
|
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')));
|
|
|
|
// 루트 경로에서 메인 페이지로 리다이렉트
|
|
app.get('/', (req, res) => {
|
|
res.redirect('/index.html');
|
|
});
|
|
|
|
// 헬스 체크 엔드포인트
|
|
app.get('/health', (req, res) => {
|
|
res.json({
|
|
success: true,
|
|
message: 'Jaryo File Manager is running',
|
|
timestamp: new Date().toISOString(),
|
|
environment: process.env.NODE_ENV || 'development'
|
|
});
|
|
});
|
|
|
|
// Multer 설정 (파일 업로드)
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
const uploadDir = 'uploads';
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
cb(null, uploadDir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
// 한글 파일명 처리를 위해 Buffer로 디코딩
|
|
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
const extension = path.extname(originalName);
|
|
const baseName = path.basename(originalName, extension);
|
|
|
|
// 안전한 파일명 생성 (특수문자 제거)
|
|
const safeName = baseName.replace(/[<>:"/\\|?*]/g, '_');
|
|
cb(null, safeName + '-' + uniqueSuffix + extension);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
limits: {
|
|
fileSize: 2 * 1024 * 1024 * 1024, // 2GB 제한
|
|
files: 20, // 최대 파일 개수
|
|
fieldSize: 100 * 1024 * 1024 // 필드 크기 제한
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
// 모든 파일 타입 허용
|
|
cb(null, true);
|
|
}
|
|
});
|
|
|
|
// 인증 미들웨어
|
|
const requireAuth = async (req, res, next) => {
|
|
try {
|
|
if (!req.session.userId) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: '로그인이 필요합니다.'
|
|
});
|
|
}
|
|
|
|
const user = await db.getUserById(req.session.userId);
|
|
if (!user) {
|
|
req.session.destroy();
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: '유효하지 않은 세션입니다.'
|
|
});
|
|
}
|
|
|
|
req.user = user;
|
|
next();
|
|
} catch (error) {
|
|
console.error('인증 미들웨어 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: '인증 처리 중 오류가 발생했습니다.'
|
|
});
|
|
}
|
|
};
|
|
|
|
// 관리자 권한 확인 미들웨어
|
|
const requireAdmin = (req, res, next) => {
|
|
if (!req.user || req.user.role !== 'admin') {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: '관리자 권한이 필요합니다.'
|
|
});
|
|
}
|
|
next();
|
|
};
|
|
|
|
// API 라우트
|
|
|
|
// 회원가입
|
|
app.post('/api/auth/signup', async (req, res) => {
|
|
try {
|
|
const { email, password, name } = req.body;
|
|
|
|
if (!email || !password || !name) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: '이메일, 비밀번호, 이름은 필수입니다.'
|
|
});
|
|
}
|
|
|
|
// 이메일 중복 확인
|
|
const existingUser = await db.getUserByEmail(email);
|
|
if (existingUser) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: '이미 등록된 이메일입니다.'
|
|
});
|
|
}
|
|
|
|
// 비밀번호 해시화
|
|
const saltRounds = 10;
|
|
const password_hash = await bcrypt.hash(password, saltRounds);
|
|
|
|
// 사용자 생성
|
|
const result = await db.createUser({
|
|
email,
|
|
password_hash,
|
|
name,
|
|
role: 'user'
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: '회원가입이 완료되었습니다.',
|
|
data: { id: result.id }
|
|
});
|
|
} catch (error) {
|
|
console.error('회원가입 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: '회원가입 중 오류가 발생했습니다.'
|
|
});
|
|
}
|
|
});
|
|
|
|
// 로그인
|
|
app.post('/api/auth/login', async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
if (!email || !password) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: '이메일과 비밀번호를 입력해주세요.'
|
|
});
|
|
}
|
|
|
|
// 사용자 확인
|
|
const user = await db.getUserByEmail(email);
|
|
if (!user) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
|
});
|
|
}
|
|
|
|
// 비밀번호 확인
|
|
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
|
if (!isValidPassword) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
|
});
|
|
}
|
|
|
|
// 세션 설정
|
|
req.session.userId = user.id;
|
|
req.session.userEmail = user.email;
|
|
req.session.userName = user.name;
|
|
req.session.userRole = user.role;
|
|
|
|
// 마지막 로그인 시간 업데이트
|
|
await db.updateUserLastLogin(user.id);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: '로그인 성공',
|
|
data: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('로그인 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: '로그인 중 오류가 발생했습니다.'
|
|
});
|
|
}
|
|
});
|
|
|
|
// 로그아웃
|
|
app.post('/api/auth/logout', (req, res) => {
|
|
req.session.destroy((err) => {
|
|
if (err) {
|
|
console.error('로그아웃 오류:', err);
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: '로그아웃 중 오류가 발생했습니다.'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: '로그아웃되었습니다.'
|
|
});
|
|
});
|
|
});
|
|
|
|
// 현재 사용자 정보 조회
|
|
app.get('/api/auth/me', requireAuth, (req, res) => {
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
id: req.user.id,
|
|
email: req.user.email,
|
|
name: req.user.name,
|
|
role: req.user.role,
|
|
last_login: req.user.last_login
|
|
}
|
|
});
|
|
});
|
|
|
|
// 세션 상태 확인
|
|
app.get('/api/auth/session', async (req, res) => {
|
|
try {
|
|
if (!req.session.userId) {
|
|
return res.json({
|
|
success: true,
|
|
user: null
|
|
});
|
|
}
|
|
|
|
const user = await db.getUserById(req.session.userId);
|
|
if (!user) {
|
|
req.session.destroy();
|
|
return res.json({
|
|
success: true,
|
|
user: null
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
last_login: user.last_login
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('세션 확인 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: '세션 확인 중 오류가 발생했습니다.'
|
|
});
|
|
}
|
|
});
|
|
|
|
// 파일 목록 조회 (관리자용)
|
|
app.get('/api/files', async (req, res) => {
|
|
try {
|
|
const { search, category, limit = 100, offset = 0 } = req.query;
|
|
|
|
let files;
|
|
if (search) {
|
|
files = await db.searchFiles(search, category, parseInt(limit));
|
|
} else {
|
|
files = await db.getAllFiles(parseInt(limit), parseInt(offset));
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: files,
|
|
count: files.length
|
|
});
|
|
} catch (error) {
|
|
console.error('파일 목록 조회 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 공개 파일 목록 조회 (일반 사용자용)
|
|
app.get('/api/files/public', async (req, res) => {
|
|
try {
|
|
const { search, category, limit = 100, offset = 0 } = req.query;
|
|
|
|
let files;
|
|
if (search) {
|
|
files = await db.searchFiles(search, category, parseInt(limit));
|
|
} else {
|
|
files = await db.getAllFiles(parseInt(limit), parseInt(offset));
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: files,
|
|
count: files.length
|
|
});
|
|
} catch (error) {
|
|
console.error('공개 파일 목록 조회 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 파일 추가
|
|
app.post('/api/files', requireAuth, upload.array('files'), async (req, res) => {
|
|
try {
|
|
const { title, description, category, tags } = req.body;
|
|
|
|
if (!title || !category) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: '제목과 카테고리는 필수입니다.'
|
|
});
|
|
}
|
|
|
|
const fileId = db.generateId();
|
|
const fileData = {
|
|
id: fileId,
|
|
title,
|
|
description,
|
|
category,
|
|
tags: tags ? (typeof tags === 'string' ? JSON.parse(tags) : tags) : [],
|
|
user_id: req.user.id
|
|
};
|
|
|
|
// 파일 정보 저장
|
|
const result = await db.addFile(fileData);
|
|
|
|
// 첨부파일 처리
|
|
if (req.files && req.files.length > 0) {
|
|
for (const file of req.files) {
|
|
// 한글 파일명 처리
|
|
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
|
|
|
await db.addFileAttachment(fileId, {
|
|
original_name: originalName,
|
|
file_name: file.filename,
|
|
file_path: file.path,
|
|
file_size: file.size,
|
|
mime_type: file.mimetype
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: { id: fileId, ...result }
|
|
});
|
|
} catch (error) {
|
|
console.error('파일 추가 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 파일 수정
|
|
app.put('/api/files/:id', requireAuth, upload.array('files'), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { title, description, category, tags, filesToDelete } = req.body;
|
|
|
|
console.log('🔄 파일 업데이트 시작:', id);
|
|
console.log('📋 업데이트 데이터:', { title, description, category, tags });
|
|
console.log('🗑️ 삭제할 첨부파일:', filesToDelete);
|
|
console.log('📎 새 첨부파일 개수:', req.files ? req.files.length : 0);
|
|
|
|
// 기본 파일 정보 업데이트
|
|
const updates = {
|
|
title,
|
|
description,
|
|
category,
|
|
tags: tags ? (typeof tags === 'string' ? tags : JSON.stringify(tags)) : '[]'
|
|
};
|
|
|
|
const result = await db.updateFile(id, updates);
|
|
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '파일을 찾을 수 없습니다.'
|
|
});
|
|
}
|
|
|
|
// 첨부파일 삭제 처리
|
|
if (filesToDelete) {
|
|
const deleteIds = typeof filesToDelete === 'string' ? JSON.parse(filesToDelete) : filesToDelete;
|
|
console.log('삭제 처리할 첨부파일 ID들:', deleteIds);
|
|
|
|
if (Array.isArray(deleteIds) && deleteIds.length > 0) {
|
|
for (const attachmentId of deleteIds) {
|
|
try {
|
|
// 첨부파일 정보 조회
|
|
const attachments = await db.getFileAttachments(id);
|
|
const attachment = attachments.find(a => a.id == attachmentId);
|
|
|
|
if (attachment) {
|
|
// 실제 파일 삭제
|
|
const filePath = path.join(__dirname, attachment.file_path);
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
console.log('실제 파일 삭제됨:', filePath);
|
|
}
|
|
|
|
// 데이터베이스에서 첨부파일 삭제
|
|
await db.deleteFileAttachment(attachmentId);
|
|
console.log('DB에서 첨부파일 삭제됨:', attachmentId);
|
|
}
|
|
} catch (deleteError) {
|
|
console.error('첨부파일 삭제 오류:', deleteError);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 새 첨부파일 추가
|
|
if (req.files && req.files.length > 0) {
|
|
console.log('새 첨부파일 추가 시작');
|
|
for (const file of req.files) {
|
|
try {
|
|
// 한글 파일명 처리
|
|
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
|
|
|
await db.addFileAttachment(id, {
|
|
original_name: originalName,
|
|
file_name: file.filename,
|
|
file_path: file.path,
|
|
file_size: file.size,
|
|
mime_type: file.mimetype
|
|
});
|
|
|
|
console.log('새 첨부파일 추가됨:', originalName);
|
|
} catch (addError) {
|
|
console.error('새 첨부파일 추가 오류:', addError);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('✅ 파일 업데이트 완료');
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
} catch (error) {
|
|
console.error('파일 수정 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 파일 삭제
|
|
app.delete('/api/files/:id', requireAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await db.deleteFile(id);
|
|
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '파일을 찾을 수 없습니다.'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
} catch (error) {
|
|
console.error('파일 삭제 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 카테고리 목록 조회
|
|
app.get('/api/categories', async (req, res) => {
|
|
try {
|
|
const categories = await db.getCategories();
|
|
res.json({
|
|
success: true,
|
|
data: categories
|
|
});
|
|
} catch (error) {
|
|
console.error('카테고리 조회 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 테스트용 엔드포인트
|
|
app.get('/api/categories/test', (req, res) => {
|
|
console.log('📋 테스트 엔드포인트 호출됨');
|
|
res.json({
|
|
success: true,
|
|
message: '테스트 성공',
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
});
|
|
|
|
// 카테고리 추가
|
|
app.post('/api/categories', async (req, res) => {
|
|
try {
|
|
const { name } = req.body;
|
|
|
|
if (!name) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: '카테고리 이름은 필수입니다.'
|
|
});
|
|
}
|
|
|
|
const result = await db.addCategory(name);
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
} catch (error) {
|
|
console.error('카테고리 추가 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 카테고리 수정
|
|
app.put('/api/categories/:id', async (req, res) => {
|
|
try {
|
|
console.log('🔄 카테고리 수정 요청 받음');
|
|
console.log('URL:', req.url);
|
|
console.log('Method:', req.method);
|
|
console.log('Params:', req.params);
|
|
console.log('Body:', req.body);
|
|
|
|
const { id } = req.params;
|
|
const { name } = req.body;
|
|
|
|
console.log('추출된 ID:', id, 'Type:', typeof id);
|
|
console.log('추출된 name:', name);
|
|
|
|
if (!name) {
|
|
console.log('❌ 카테고리 이름이 없음');
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: '카테고리 이름은 필수입니다.'
|
|
});
|
|
}
|
|
|
|
console.log('📝 데이터베이스 업데이트 시작');
|
|
const result = await db.updateCategory(id, name);
|
|
console.log('📝 데이터베이스 업데이트 결과:', result);
|
|
|
|
if (result.changes === 0) {
|
|
console.log('❌ 변경된 행이 없음 - 카테고리를 찾을 수 없음');
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '카테고리를 찾을 수 없습니다.'
|
|
});
|
|
}
|
|
|
|
console.log('✅ 카테고리 수정 성공');
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
} catch (error) {
|
|
console.error('카테고리 수정 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 카테고리 삭제
|
|
app.delete('/api/categories/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await db.deleteCategory(id);
|
|
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '카테고리를 찾을 수 없습니다.'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
} catch (error) {
|
|
console.error('카테고리 삭제 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 통계 정보 조회
|
|
app.get('/api/stats', async (req, res) => {
|
|
try {
|
|
const stats = await db.getStats();
|
|
res.json({
|
|
success: true,
|
|
data: stats
|
|
});
|
|
} catch (error) {
|
|
console.error('통계 조회 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 파일 다운로드 (SQLite 호환)
|
|
app.get('/api/download/:id/:attachmentId', async (req, res) => {
|
|
try {
|
|
const { id, attachmentId } = req.params;
|
|
|
|
// SQLite에서 첨부파일 정보 조회
|
|
await db.connect();
|
|
const query = 'SELECT * FROM file_attachments WHERE id = ? AND file_id = ?';
|
|
|
|
db.db.get(query, [attachmentId, id], (err, row) => {
|
|
if (err) {
|
|
console.error('파일 조회 오류:', err);
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: err.message
|
|
});
|
|
}
|
|
|
|
if (!row) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '파일을 찾을 수 없습니다.'
|
|
});
|
|
}
|
|
|
|
const filePath = path.join(__dirname, row.file_path);
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
// 한글 파일명을 위한 개선된 헤더 설정
|
|
console.log('📁 다운로드 파일 정보:', {
|
|
original_name: row.original_name,
|
|
file_path: row.file_path,
|
|
storage_path: filePath
|
|
});
|
|
|
|
const originalName = row.original_name || 'download';
|
|
const encodedName = encodeURIComponent(originalName);
|
|
|
|
// 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('Accept-Ranges', 'bytes');
|
|
res.setHeader('Cache-Control', 'public, max-age=0');
|
|
|
|
// 클라이언트 연결 끊김 감지
|
|
res.on('close', () => {
|
|
if (!res.headersSent) {
|
|
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,
|
|
error: '파일이 존재하지 않습니다.'
|
|
});
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('파일 다운로드 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 에러 핸들러
|
|
app.use((error, req, res, next) => {
|
|
console.error('서버 오류:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: '서버 내부 오류가 발생했습니다.'
|
|
});
|
|
});
|
|
|
|
// 404 핸들러
|
|
app.use((req, res) => {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: '요청한 리소스를 찾을 수 없습니다.'
|
|
});
|
|
});
|
|
|
|
// Vercel 서버리스 환경을 위한 export
|
|
module.exports = app;
|
|
|
|
// 로컬 개발 환경에서만 서버 시작
|
|
if (process.env.NODE_ENV !== 'production' || process.env.VERCEL !== '1') {
|
|
const HOST = process.env.HOST || '0.0.0.0'; // NAS 호환성을 위해 모든 인터페이스에서 수신
|
|
const server = app.listen(PORT, HOST, () => {
|
|
const serverAddress = server.address();
|
|
const host = serverAddress.address === '::' ? 'localhost' :
|
|
serverAddress.address === '0.0.0.0' ? 'localhost' :
|
|
serverAddress.address;
|
|
|
|
console.log(`🚀 자료실 서버가 포트 ${PORT}에서 실행중입니다.`);
|
|
console.log(`📍 서버 주소: ${HOST}:${PORT}`);
|
|
console.log(`📱 Admin 페이지: http://${host}:${PORT}/admin/index.html`);
|
|
console.log(`🌐 Main 페이지: http://${host}:${PORT}/index.html`);
|
|
console.log(`📊 API: http://${host}:${PORT}/api/files`);
|
|
console.log(`🔧 NAS 접속: http://[NAS-IP]:${PORT}`);
|
|
});
|
|
|
|
// 대용량 파일 다운로드를 위해 서버 타임아웃을 30분으로 설정
|
|
server.timeout = 1800000; // 30분 (30 * 60 * 1000ms)
|
|
|
|
// 프로세스 종료 시 데이터베이스 연결 종료
|
|
process.on('SIGINT', async () => {
|
|
console.log('\n📝 서버를 종료합니다...');
|
|
await db.close();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGTERM', async () => {
|
|
console.log('\n📝 서버를 종료합니다...');
|
|
await db.close();
|
|
process.exit(0);
|
|
});
|
|
} |