require('dotenv').config(); const express = require('express'); const cors = require('cors'); const path = require('path'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const app = express(); // 헬스체크와 개발용 엔드포인트는 CORS 이후에 등록 // ✅ 보안 헤더 설정 (Helmet) app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], imgSrc: ["'self'", "data:", "https:", "blob:"], fontSrc: ["'self'", "https://fonts.gstatic.com"], connectSrc: ["'self'", "https://api.technicalkorea.com"], }, }, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true } })); // ✅ 요청 바디 용량 제한 확장 app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use(express.json({ limit: '50mb' })); //개발용 CORS 설정 (수정됨) app.use(cors({ origin: function (origin, callback) { // 개발 환경에서는 모든 origin 허용 console.log('🌐 CORS Origin 요청:', origin); const allowedOrigins = [ 'http://localhost:20000', // 웹 UI 'http://localhost:3005', // API 서버 'http://localhost:3000', // 개발 포트 'http://127.0.0.1:20000', // 로컬호스트 대체 ]; // origin이 없는 경우 (직접 접근) 허용 if (!origin) { console.log('✅ Origin 없음 - 허용'); return callback(null, true); } // 허용된 origin인지 확인 if (allowedOrigins.includes(origin)) { console.log('✅ 허용된 Origin:', origin); return callback(null, true); } // 개발 환경에서는 모든 localhost 허용 if (origin.includes('localhost') || origin.includes('127.0.0.1')) { console.log('✅ 로컬호스트 허용:', origin); return callback(null, true); } console.log('❌ 차단된 Origin:', origin); callback(null, false); }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'] })); // ✅ Health check (CORS 이후에 등록) app.get('/api/health', (req, res) => { console.log('🟢 Health check 호출됨!'); res.status(200).json({ status: 'healthy', service: 'Hyungi API', timestamp: new Date().toISOString() }); }); // ✅ 개발용 Ping 엔드포인트 app.get('/api/ping', (req, res) => { console.log('🏓 Ping 요청 받음!'); res.status(200).json({ message: 'pong', timestamp: new Date().toISOString() }); }); // ✅ 서버 상태 엔드포인트 app.get('/api/status', (req, res) => { console.log('📊 Status 요청 받음!'); res.status(200).json({ status: 'running', service: 'Hyungi API', version: '2.1.0', environment: process.env.NODE_ENV || 'development', uptime: process.uptime(), timestamp: new Date().toISOString() }); }); // ✅ CORS 설정: 허용 origin 명시 (수정된 버전) //app.use(cors({ // origin: function (origin, callback) { // const allowedOrigins = process.env.ALLOWED_ORIGINS // ? process.env.ALLOWED_ORIGINS.split(',') // : [ // 'http://localhost:3000', // 'http://localhost:3005', // 'http://web-ui', // 'http://web-ui:80', // 'http://web-ui:3001', // 실제 내부 포트 // 'http://172.18.0.1', // 'http://172.18.0.1:3001', // 'http://172.18.0.2', // web-ui 컨테이너 IP // 'http://172.18.0.2:3001', // web-ui 컨테이너 IP:포트 // 'http://192.168.0.3', // 나스 외부 IP (포트 없음) // 'http://192.168.0.3:80', // 나스 외부 접근 // 'http://192.168.0.3:3001', // 나스 외부 접근 (실제 포트) // 'http://192.168.0.3:5000', // 시놀로지 기본 포트 // 'http://192.168.0.3:5001', // 시놀로지 HTTPS 포트 // // 추가: 더 유연한 허용 // 'http://192.168.0.3:3000', // 다른 포트들도 허용 // 'http://192.168.0.3:8080', // 'http://192.168.0.3:8000' // ]; // // // 개발 환경에서는 모든 로컬 IP 허용 // if (process.env.NODE_ENV === 'development' || !origin) { // return callback(null, true); // } // // // 192.168.x.x 대역 자동 허용 (시놀로지 환경) // if (origin && origin.match(/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/)) { // console.log('✅ 로컬 네트워크 IP 자동 허용:', origin); // return callback(null, true); // } // // if (allowedOrigins.includes(origin)) { // callback(null, true); // } else { // console.error('❌ CORS 차단됨:', origin); // console.log('허용된 Origins:', allowedOrigins); // callback(new Error('CORS 차단됨: ' + origin)); // } // }, // credentials: true // })); // ✅ 신뢰할 수 있는 프록시 설정 (IP 주소 정확히 가져오기) app.set('trust proxy', 1); // ✅ API 속도 제한 설정 // 일반 API 속도 제한 const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15분 max: process.env.RATE_LIMIT_MAX_REQUESTS || 100, message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도하세요.', standardHeaders: true, legacyHeaders: false, }); // 로그인 API 속도 제한 (개발 환경에서 완화됨) const loginLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5분으로 단축 max: process.env.LOGIN_RATE_LIMIT_MAX_REQUESTS || 20, // 5 -> 20으로 증가 message: '너무 많은 로그인 시도입니다. 5분 후에 다시 시도하세요.', standardHeaders: true, legacyHeaders: false, skipSuccessfulRequests: true, // 성공한 요청은 카운트하지 않음 }); // ✅ 라우터 등록 const authRoutes = require('./routes/authRoutes'); const projectRoutes = require('./routes/projectRoutes'); const workerRoutes = require('./routes/workerRoutes'); const taskRoutes = require('./routes/taskRoutes'); const workReportRoutes = require('./routes/workReportRoutes'); const toolsRoute = require('./routes/toolsRoute'); const uploadRoutes = require('./routes/uploadRoutes'); const uploadBgRoutes = require('./routes/uploadBgRoutes'); const dailyIssueReportRoutes = require('./routes/dailyIssueReportRoutes'); const issueTypeRoutes = require('./routes/issueTypeRoutes'); const healthRoutes = require('./routes/healthRoutes'); const dailyWorkReportRoutes = require('./routes/dailyWorkReportRoutes'); const workAnalysisRoutes = require('./routes/workAnalysisRoutes'); const analysisRoutes = require('./routes/analysisRoutes'); const systemRoutes = require('./routes/systemRoutes'); // 새로운 분석 라우트 // 🔒 인증 미들웨어 가져오기 const { verifyToken } = require('./middlewares/authMiddleware'); // ahn.hyungi.net 배포용 app.use(express.static(path.join(__dirname, 'public'))); // ✅ 업로드된 파일 정적 라우팅 추가 (웹에서 이미지 접근 가능하게) app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); // 🔒 활동 로깅 미들웨어 const activityLogger = (req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = Date.now() - start; const logData = { timestamp: new Date().toISOString(), method: req.method, url: req.originalUrl, status: res.statusCode, duration: duration + 'ms', ip: req.ip, user: req.user?.username || 'anonymous' }; // 성공/실패에 따른 로그 레벨 분기 if (res.statusCode >= 400) { console.error('[API Error]', logData); } else if (res.statusCode >= 300) { console.warn('[API Redirect]', logData); } else { console.log('[API Access]', logData); } }); next(); }; // ===== 📋 미들웨어 적용 순서 수정 (🔥 핵심 수정 부분) ===== // 모든 API 요청에 활동 로거 적용 app.use('/api/*', activityLogger); // 🔓 인증이 필요 없는 경로들을 먼저 등록 (순서 중요!) // Health check는 이미 맨 위에서 등록됨 // 🔓 로그인 관련 경로들 (인증 없이 접근 가능) // 로그인 엔드포인트에 특별한 속도 제한 적용 app.post('/api/auth/login', loginLimiter, (req, res, next) => { console.log('🔓 로그인 요청 받음:', req.body.username); authRoutes.handle(req, res, next); }); // 기타 공개 인증 엔드포인트들 app.use('/api/auth/refresh-token', loginLimiter); app.use('/api/auth/check-password-strength', loginLimiter); // 나머지 인증 라우트 app.use('/api/auth', authRoutes); // 🔒 일반 API 속도 제한 적용 app.use('/api/', apiLimiter); // 🔒 인증이 필요한 모든 API에 대해 토큰 검증 (수정된 버전) app.use('/api/*', (req, res, next) => { console.log(`🔍 API 요청: ${req.method} ${req.originalUrl}`); // 🔓 인증이 필요 없는 경로들은 통과 (정확한 매칭) const publicPaths = [ '/api/auth/login', '/api/auth/refresh-token', '/api/auth/check-password-strength', '/api/health', '/api/ping', // 개발용 핑 '/api/status' // 서버 상태 ]; // 정확한 경로 매칭 확인 const isPublicPath = publicPaths.some(path => { // 정확한 경로 또는 쿼리 파라미터가 있는 경우 const isMatch = req.originalUrl === path || req.originalUrl.startsWith(path + '?') || req.originalUrl.startsWith(path + '/'); if (isMatch) { console.log(`🔓 Public path 허용: ${req.originalUrl}`); } return isMatch; }); if (isPublicPath) { return next(); } // 나머지는 모두 인증 필요 console.log(`🔒 인증 필요한 경로: ${req.originalUrl}`); verifyToken(req, res, next); }); // ===== 📊 모든 라우트 등록 (인증된 사용자만) ===== // 📝 일반 기능들 app.use('/api/issue-reports', dailyIssueReportRoutes); app.use('/api/issue-types', issueTypeRoutes); // 👥 기본 데이터들 (모든 인증된 사용자) app.use('/api/workers', workerRoutes); app.use('/api/daily-work-reports', dailyWorkReportRoutes); app.use('/api/work-analysis', workAnalysisRoutes); app.use('/api/analysis', analysisRoutes); // 새로운 분석 라우트 등록 app.use('/api/daily-work-reports-analysis', require('./routes/workReportAnalysisRoutes')); // 데일리 워크 레포트 분석 라우트 // 📊 리포트 및 분석 app.use('/api/workreports', workReportRoutes); // 🔧 시스템 관리 (시스템 권한만) app.use('/api/system', systemRoutes); app.use('/api/uploads', uploadRoutes); // ⚙️ 시스템 데이터들 (모든 인증된 사용자) app.use('/api/projects', projectRoutes); app.use('/api/tasks', taskRoutes); app.use('/api/tools', toolsRoute); // 📤 파일 업로드 app.use('/api', uploadBgRoutes); // ===== 🔍 API 정보 엔드포인트 ===== app.get('/api', (req, res) => { res.json({ name: 'Technical Korea Work Management API', version: '2.1.0', description: '보안이 강화된 생산관리 시스템 API', timestamp: new Date().toISOString(), security: { authentication: 'JWT Bearer Token', rateLimit: { general: '100 requests per 15 minutes', login: '5 attempts per 15 minutes' }, cors: 'Configured for specific origins', headers: 'Security headers enabled (Helmet)' }, user: { username: req.user?.username || 'anonymous', access_level: req.user?.access_level || 'none', worker_id: req.user?.worker_id || null }, endpoints: { auth: { login: 'POST /api/auth/login', logout: 'POST /api/auth/logout', refreshToken: 'POST /api/auth/refresh-token', changePassword: 'POST /api/auth/change-password', adminChangePassword: 'POST /api/auth/admin/change-password', checkPasswordStrength: 'POST /api/auth/check-password-strength', me: 'GET /api/auth/me', users: 'GET /api/auth/users', register: 'POST /api/auth/register', updateUser: 'PUT /api/auth/users/:id', deleteUser: 'DELETE /api/auth/users/:id', loginHistory: 'GET /api/auth/login-history' }, dailyWorkReports: { workTypes: 'GET /api/daily-work-reports/work-types', workStatusTypes: 'GET /api/daily-work-reports/work-status-types', errorTypes: 'GET /api/daily-work-reports/error-types', create: 'POST /api/daily-work-reports', search: 'GET /api/daily-work-reports/search', summary: 'GET /api/daily-work-reports/summary', byDate: 'GET /api/daily-work-reports/date/:date', update: 'PUT /api/daily-work-reports/:id', delete: 'DELETE /api/daily-work-reports/:id' }, workAnalysis: { stats: 'GET /api/work-analysis/stats?start=YYYY-MM-DD&end=YYYY-MM-DD', dailyTrend: 'GET /api/work-analysis/daily-trend?start=YYYY-MM-DD&end=YYYY-MM-DD', workerStats: 'GET /api/work-analysis/worker-stats?start=YYYY-MM-DD&end=YYYY-MM-DD', projectStats: 'GET /api/work-analysis/project-stats?start=YYYY-MM-DD&end=YYYY-MM-DD', workTypeStats: 'GET /api/work-analysis/work-type-stats?start=YYYY-MM-DD&end=YYYY-MM-DD', recentWork: 'GET /api/work-analysis/recent-work?start=YYYY-MM-DD&end=YYYY-MM-DD&limit=10', weekdayPattern: 'GET /api/work-analysis/weekday-pattern?start=YYYY-MM-DD&end=YYYY-MM-DD', errorAnalysis: 'GET /api/work-analysis/error-analysis?start=YYYY-MM-DD&end=YYYY-MM-DD', monthlyComparison: 'GET /api/work-analysis/monthly-comparison?year=YYYY', workerSpecialization: 'GET /api/work-analysis/worker-specialization?start=YYYY-MM-DD&end=YYYY-MM-DD', dashboard: 'GET /api/work-analysis/dashboard?start=YYYY-MM-DD&end=YYYY-MM-DD', health: 'GET /api/work-analysis/health' }, workers: 'GET/POST/PUT/DELETE /api/workers', projects: 'GET/POST/PUT/DELETE /api/projects', issues: 'GET/POST/PUT/DELETE /api/issue-reports', reports: 'GET /api/workreports', uploads: 'POST /api/uploads' }, note: '모든 API는 로그인 후 접근 가능합니다. 자세한 API 문서는 관리자에게 문의하세요.' }); }); // ===== 🏠 메인 페이지 라우트 ===== app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // ✅ 서버 실행 const PORT = process.env.PORT || 3005; const server = app.listen(PORT, () => { console.log(` 🚀 Technical Korea Work Management System v2.1.0 📍 서버가 포트 ${PORT}에서 실행 중입니다. 🌐 접속 URL: http://localhost:${PORT} 📊 API 문서: http://localhost:${PORT}/api 🔒 보안 기능: ✅ JWT 토큰 인증 ✅ 로그인 실패 제한 (5회) ✅ API 속도 제한 ✅ 보안 헤더 (Helmet) ✅ CORS 설정 (192.168.0.3:3001 허용) ✅ 활동 로깅 📋 새로운 기능: 🔐 비밀번호 변경 (본인/관리자) 🔄 토큰 갱신 (Refresh Token) 📊 로그인 이력 조회 💪 비밀번호 강도 체크 `); }).on('error', (err) => { console.error('❌ 서버 실행 중 오류 발생:', err); if (err.code === 'EADDRINUSE') { console.error(`포트 ${PORT}이(가) 이미 사용 중입니다.`); } }); // ===== 🚨 에러 핸들링 ===== // 404 핸들러 app.use((req, res) => { console.log(`[404] ${req.method} ${req.originalUrl} - IP: ${req.ip}`); if (req.originalUrl.startsWith('/api/')) { res.status(404).json({ error: 'API 엔드포인트를 찾을 수 없습니다.', path: req.originalUrl, available: '/api', timestamp: new Date().toISOString() }); } else { res.status(404).json({ error: '요청하신 페이지를 찾을 수 없습니다.', timestamp: new Date().toISOString() }); } }); // 전역 에러 핸들러 app.use((err, req, res, next) => { const errorId = Date.now().toString(36); console.error(`[ERROR ${errorId}] ${new Date().toISOString()}:`, { message: err.message, stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, url: req.originalUrl, method: req.method, ip: req.ip, user: req.user?.username || 'anonymous' }); // CORS 에러 // if (err.message && err.message.includes('CORS 차단됨')) { // return res.status(403).json({ // error: 'CORS 정책에 의해 차단되었습니다.', // message: 'API 접근이 허용되지 않은 도메인입니다.', // errorId // }); // } // JWT 에러 if (err.name === 'JsonWebTokenError') { return res.status(401).json({ error: '유효하지 않은 토큰입니다.', errorId }); } if (err.name === 'TokenExpiredError') { return res.status(401).json({ error: '토큰이 만료되었습니다. 다시 로그인해주세요.', errorId }); } // 요청 크기 초과 if (err.type === 'entity.too.large') { return res.status(413).json({ error: '요청 크기가 너무 큽니다. 50MB 이하로 줄여주세요.', errorId }); } // 일반 서버 에러 res.status(err.status || 500).json({ error: '서버 오류가 발생했습니다.', message: process.env.NODE_ENV === 'development' ? err.message : '관리자에게 문의하세요.', errorId, timestamp: new Date().toISOString() }); }); // ===== 🔄 Graceful Shutdown ===== const gracefulShutdown = () => { console.log('\n🛑 서버 종료 신호를 받았습니다...'); server.close(() => { console.log('✅ HTTP 서버가 정상적으로 종료되었습니다.'); // DB 연결 종료 등 추가 정리 작업 // 예: db.end(), redis.quit() 등 process.exit(0); }); // 30초 후 강제 종료 setTimeout(() => { console.error('❌ 정상 종료 실패, 강제 종료합니다.'); process.exit(1); }, 30000); }; process.on('SIGTERM', gracefulShutdown); process.on('SIGINT', gracefulShutdown); // 처리되지 않은 Promise 거부 process.on('unhandledRejection', (reason, promise) => { console.error('처리되지 않은 Promise 거부:', reason); // 개발 환경에서는 크래시, 프로덕션에서는 로그만 if (process.env.NODE_ENV === 'development') { process.exit(1); } }); // 처리되지 않은 예외 process.on('uncaughtException', (error) => { console.error('처리되지 않은 예외:', error); gracefulShutdown(); }); module.exports = app;