Files
b23042andClaude Opus 4.8 e0ff5dd6d0 feat: 지도 나침반(OSM/위성) 추가 + 측점 검색·마커 정확도 개선 + POI/영상 수정
- 지도 기반 나침반(노스업, 현재위치, 시야 역삼각형): 호버 확대·휠 줌·클릭 위성전환, OSM/Esri 타일 서버 프록시(/api/tile)
- 스테이션 검색: 실제 측점(chain) 기준 이동, 없으면 '측점 없음' 안내
- 역 마커: 직교 투영 측점 일치 시에만 표시, 미도착 종점은 추가 방식
- POI 팝업 겹침/재등장·라벨 정합 수정
- 영상 fps 데이터 기반 자동 산출
- 기술/발표/쉬운설명 문서 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:03:30 +09:00

114 lines
3.6 KiB
TypeScript

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';
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.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<void> {
// 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;