import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import path from 'path'; import checkDiskSpace from 'check-disk-space'; import { config } from './config'; import { initDatabase, ensureStorageDirs, cleanupOldTempFiles } from './services/storage'; import { checkFFmpegInstalled } from './services/ffmpeg'; import streamRouter from './routes/stream'; import hlsRouter from './routes/hls'; import frameRouter from './routes/frame'; import uploadRouter from './routes/upload'; import metaRouter from './routes/meta'; import annotationsRouter from './routes/annotations'; import geoRouter from './routes/geo'; import elevationRouter from './routes/elevation'; import tileRouter from './routes/tile'; import cameraRouter from './routes/camera'; const app = express(); // 내부 네트워크 접근 허용: 모든 origin 허용 (사내 단일 사용자 도구) app.use(cors({ origin: true })); // Raw body parser for chunk uploads (must come before express.json) app.use('/api/upload/chunk', express.raw({ type: 'application/octet-stream', limit: '110mb' })); app.use(express.json()); // Routes app.use('/api/stream', streamRouter); app.use('/api/hls', hlsRouter); app.use('/api/frame', frameRouter); app.use('/api/upload', uploadRouter); app.use('/api/videos', metaRouter); app.use('/api/meta', metaRouter); app.use('/api/annotations/:videoId', annotationsRouter); app.use('/api/geo', geoRouter); app.use('/api/elevation', elevationRouter); app.use('/api/tile', tileRouter); app.use('/api/camera', cameraRouter); app.get('/api/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // 프로덕션 빌드 정적 파일 서빙 (client/dist) const clientDistPath = path.resolve(__dirname, '../../../../client/dist'); // CSP 헤더: 브라우저 확장 프로그램의 스크립트 주입 차단 const cspHeader = "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "img-src 'self' blob: data:; " + "media-src 'self' blob:; " + "connect-src 'self'; " + "worker-src 'self' blob:;"; app.use((req, res, next) => { if (!req.path.startsWith('/api')) { res.setHeader('Content-Security-Policy', cspHeader); } next(); }); app.use(express.static(clientDistPath)); // SPA fallback: API가 아닌 모든 요청은 index.html로 app.get('*', (req, res, next) => { if (req.path.startsWith('/api')) return next(); res.sendFile(path.join(clientDistPath, 'index.html')); }); // Error handler app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error('[error]', err.message); res.status(500).json({ error: err.message }); }); async function start(): Promise { // Check FFmpeg const ffmpegOk = await checkFFmpegInstalled(); if (!ffmpegOk) { console.warn('[warn] FFmpeg not found in PATH. Frame extraction and HLS conversion will fail.'); } else { console.log('[ffmpeg] detected OK'); } // Init storage dirs + DB await ensureStorageDirs(); initDatabase(); // Startup cleanup await cleanupOldTempFiles(); // Disk space check every hour setInterval(async () => { try { const space = await checkDiskSpace(path.resolve(config.videosDir)); const freeGB = space.free / (1024 ** 3); if (freeGB < 10) { console.warn(`[disk] WARNING: only ${freeGB.toFixed(1)}GB free`); } } catch { /* ignore */ } }, 60 * 60 * 1000); // Periodic cleanup every hour setInterval(() => cleanupOldTempFiles(), 60 * 60 * 1000); app.listen(config.port, '0.0.0.0', () => { console.log(`[server] running on http://0.0.0.0:${config.port}`); }); } start().catch(console.error); export default app;