- 제주 DJI 시간기반 비행로그(CSV) 파싱 어댑터: 세그먼트 창 정렬, 가상 fps 환산 - 분할 영상(이름 오름차순) 연속재생 + 좌상단 영상목록 콤보(선택 재생), 세그먼트별 드론 로그 재정렬 - KML 다중 파일 병합 파싱: STA 체이니지 측점(71개) + 지장물 신포맷(타입/이름/텍스트박스_색상) - 시설물 라벨: 지정색 배경 박스 + 흰 글자, 팝업 중앙 정렬, 호버 구간 깜빡임 수정 - 카메라 자동감지(djmd: ZenmuseP1, focal 29.9mm) + <영상명>.camera.json PC 저장/폴더 자동 적용 - Yaw 자동추정(GPS 진행방위 기반) + 라벨 드래그 Yaw 역산 모드 - 스테이션바: GPS 이동거리 진행도(호버 시 정지), 노선밖 거리 표기, 원거리 구조물 마크 제외 - 서버: /api/camera 라우트, ecosystem 포트 54000·제주 데이터 경로 - docs/history: 작업 이력 21건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
116 lines
3.7 KiB
TypeScript
116 lines
3.7 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';
|
|
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<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;
|