import express from 'express'; import multer from 'multer'; import path from 'path'; import { listMedia, createMedia, getMediaBySn, fetchOriginalImage, listCctv, listSatRequests, createSatRequest, updateSatRequestStatus, isValidSatStatus, requestOilInference, checkInferenceHealth, stitchImages, listDroneStreams, startDroneStream, stopDroneStream, getHlsDirectory, } from './aerialService.js'; import { isValidNumber } from '../middleware/security.js'; import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; const router = express.Router(); const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } }); // ============================================================ // AERIAL_MEDIA 라우트 // ============================================================ // GET /api/aerial/media — 미디어 목록 router.get('/media', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { try { const { equipType, mediaType, acdntSn, search } = req.query; const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined; if (acdntSn && !isValidNumber(acdntSnNum, 1, 999999)) { res.status(400).json({ error: '유효하지 않은 사고 번호' }); return; } const items = await listMedia({ equipType: equipType as string | undefined, mediaType: mediaType as string | undefined, acdntSn: acdntSnNum, search: search as string | undefined, }); res.json(items); } catch (err) { console.error('[aerial] 미디어 목록 오류:', err); res.status(500).json({ error: '미디어 목록 조회 실패' }); } }); // POST /api/aerial/media — 미디어 메타 등록 router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async (req, res) => { try { const { acdntSn, fileNm, orgnlNm, filePath, lon, lat, locDc, equipTpCd, equipNm, mediaTpCd, takngDtm, fileSz, resolution, } = req.body; if (!fileNm) { res.status(400).json({ error: '파일명은 필수입니다.' }); return; } const result = await createMedia({ acdntSn, fileNm, orgnlNm, filePath, lon, lat, locDc, equipTpCd, equipNm, mediaTpCd, takngDtm, fileSz, resolution, }); res.status(201).json(result); } catch (err) { console.error('[aerial] 미디어 등록 오류:', err); res.status(500).json({ error: '미디어 등록 실패' }); } }); // GET /api/aerial/media/:sn/download — 원본 이미지 다운로드 router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { try { const sn = parseInt(req.params['sn'] as string, 10); if (!isValidNumber(sn, 1, 999999)) { res.status(400).json({ error: '유효하지 않은 미디어 번호' }); return; } const media = await getMediaBySn(sn); if (!media) { res.status(404).json({ error: '미디어를 찾을 수 없습니다.' }); return; } // fileId 추출: FILE_NM의 앞 36자가 UUID 형식인지 검증 (이미지 분석 생성 레코드만 다운로드 가능) const fileId = media.fileNm.substring(0, 36); const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!UUID_PATTERN.test(fileId) || !media.equipNm) { res.status(404).json({ error: '다운로드 가능한 이미지가 없습니다.' }); return; } const buffer = await fetchOriginalImage(media.equipNm, fileId); const downloadName = media.orgnlNm ?? media.fileNm; res.setHeader('Content-Type', 'image/jpeg'); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(downloadName)}`); res.send(buffer); } catch (err) { console.error('[aerial] 이미지 다운로드 오류:', err); res.status(502).json({ error: '이미지 다운로드 실패' }); } }); // ============================================================ // CCTV_CAMERA 라우트 // ============================================================ // GET /api/aerial/cctv — CCTV 목록 router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { try { const { region, status } = req.query; const items = await listCctv({ region: region as string | undefined, status: status as string | undefined, }); res.json(items); } catch (err) { console.error('[aerial] CCTV 목록 오류:', err); res.status(500).json({ error: 'CCTV 목록 조회 실패' }); } }); // ============================================================ // KBS 재난안전포탈 CCTV HLS 리졸버 // ============================================================ /** KBS cctvId → 실제 HLS m3u8 URL 캐시 (5분 TTL) */ const kbsHlsCache = new Map(); const KBS_CACHE_TTL = 5 * 60 * 1000; // GET /api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8 — KBS CCTV를 HLS로 리졸브 + 프록시 router.get('/cctv/kbs-hls/:cctvId/stream.m3u8', async (req, res) => { try { const cctvId = req.params.cctvId as string; if (!/^\d+$/.test(cctvId)) { res.status(400).json({ error: '유효하지 않은 cctvId' }); return; } let m3u8Url: string | null = null; // 캐시 확인 const cached = kbsHlsCache.get(cctvId); if (cached && Date.now() - cached.ts < KBS_CACHE_TTL) { m3u8Url = cached.url; } else { // 1단계: KBS 팝업 API에서 loomex API URL 추출 const popupRes = await fetch( `https://d.kbs.co.kr/special/cctv/cctvPopup?type=LIVE&cctvId=${cctvId}`, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' } }, ); if (!popupRes.ok) { res.status(502).json({ error: 'KBS 팝업 API 응답 실패' }); return; } const popupHtml = await popupRes.text(); const urlMatch = popupHtml.match(/id="url"\s+value="([^"]+)"/); if (!urlMatch) { res.status(502).json({ error: 'KBS 스트림 URL을 찾을 수 없습니다' }); return; } // 2단계: loomex API에서 실제 m3u8 URL 획득 const loomexRes = await fetch(urlMatch[1], { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, }); if (!loomexRes.ok) { res.status(502).json({ error: 'KBS 스트림 서버 응답 실패' }); return; } m3u8Url = (await loomexRes.text()).trim(); kbsHlsCache.set(cctvId, { url: m3u8Url, ts: Date.now() }); } // 3단계: m3u8 매니페스트를 프록시하여 세그먼트 URL 재작성 const upstream = await fetch(m3u8Url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, }); if (!upstream.ok) { // 캐시 무효화 후 재시도 유도 kbsHlsCache.delete(cctvId); res.status(502).json({ error: 'HLS 매니페스트 가져오기 실패' }); return; } const text = await upstream.text(); const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1); const proxyBase = '/api/aerial/cctv/stream-proxy?url='; const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => { if (line.startsWith('http://') || line.startsWith('https://')) { return `${proxyBase}${encodeURIComponent(line)}`; } return `${proxyBase}${encodeURIComponent(baseUrl + line)}`; }); res.set({ 'Content-Type': 'application/vnd.apple.mpegurl', 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*', }); res.send(rewritten); } catch (err) { console.error('[aerial] KBS HLS 리졸버 오류:', err); res.status(502).json({ error: 'KBS HLS 스트림 리졸브 실패' }); } }); // ============================================================ // CCTV HLS 스트림 프록시 (CORS 우회) // ============================================================ /** 허용 도메인 목록 */ const ALLOWED_STREAM_HOSTS = [ 'www.khoa.go.kr', 'kbsapi.loomex.net', 'kbscctv-cache.loomex.net', ]; // GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안) router.get('/cctv/stream-proxy', async (req, res) => { try { const targetUrl = req.query.url as string | undefined; if (!targetUrl) { res.status(400).json({ error: 'url 파라미터가 필요합니다.' }); return; } let parsed: URL; try { parsed = new URL(targetUrl); } catch { res.status(400).json({ error: '유효하지 않은 URL' }); return; } if (!ALLOWED_STREAM_HOSTS.includes(parsed.hostname)) { res.status(403).json({ error: '허용되지 않은 스트림 호스트' }); return; } const upstream = await fetch(targetUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, }); if (!upstream.ok) { res.status(upstream.status).json({ error: `스트림 서버 응답: ${upstream.status}` }); return; } const contentType = upstream.headers.get('content-type') || ''; // .m3u8 매니페스트: 상대 URL을 프록시 URL로 재작성 if (targetUrl.includes('.m3u8') || contentType.includes('mpegurl') || contentType.includes('m3u8')) { const text = await upstream.text(); const baseUrl = targetUrl.substring(0, targetUrl.lastIndexOf('/') + 1); const proxyBase = '/api/aerial/cctv/stream-proxy?url='; const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => { if (line.startsWith('http://') || line.startsWith('https://')) { return `${proxyBase}${encodeURIComponent(line)}`; } return `${proxyBase}${encodeURIComponent(baseUrl + line)}`; }); res.set({ 'Content-Type': 'application/vnd.apple.mpegurl', 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*', }); res.send(rewritten); return; } // .ts 세그먼트 등: 바이너리 스트리밍 res.set({ 'Content-Type': contentType || 'video/mp2t', 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*', }); const buffer = Buffer.from(await upstream.arrayBuffer()); res.send(buffer); } catch (err) { console.error('[aerial] CCTV 스트림 프록시 오류:', err); res.status(502).json({ error: '스트림 프록시 실패' }); } }); // ============================================================ // DRONE STREAM 라우트 (RTSP → HLS) // ============================================================ // GET /api/aerial/drone/streams — 드론 스트림 목록 + 상태 router.get('/drone/streams', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => { try { const streams = listDroneStreams(); res.json(streams); } catch (err) { console.error('[aerial] 드론 스트림 목록 오류:', err); res.status(500).json({ error: '드론 스트림 목록 조회 실패' }); } }); // POST /api/aerial/drone/streams/:id/start — 드론 스트림 시작 (RTSP→HLS 변환) router.post('/drone/streams/:id/start', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { try { const result = startDroneStream(req.params.id as string); if (!result.success) { res.status(400).json({ error: result.error }); return; } res.json(result); } catch (err) { console.error('[aerial] 드론 스트림 시작 오류:', err); res.status(500).json({ error: '드론 스트림 시작 실패' }); } }); // POST /api/aerial/drone/streams/:id/stop — 드론 스트림 중지 router.post('/drone/streams/:id/stop', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { try { const result = stopDroneStream(req.params.id as string); res.json(result); } catch (err) { console.error('[aerial] 드론 스트림 중지 오류:', err); res.status(500).json({ error: '드론 스트림 중지 실패' }); } }); // GET /api/aerial/drone/hls/:id/* — HLS 정적 파일 서빙 (.m3u8, .ts) router.get('/drone/hls/:id/*', async (req, res) => { try { const id = req.params.id as string; const hlsDir = getHlsDirectory(id); if (!hlsDir) { res.status(404).json({ error: '스트림을 찾을 수 없습니다' }); return; } // wildcard: req.params[0] contains the rest of the path // Cast through unknown because @types/express v5 types the wildcard key as string[] const rawParams = req.params as unknown as Record; const wildcardRaw = rawParams['0'] ?? ''; const wildcardParam = Array.isArray(wildcardRaw) ? wildcardRaw.join('/') : wildcardRaw; const filePath = path.join(hlsDir, wildcardParam); // Security: prevent path traversal if (!filePath.startsWith(hlsDir)) { res.status(403).json({ error: '접근 거부' }); return; } const ext = path.extname(filePath).toLowerCase(); const contentType = ext === '.m3u8' ? 'application/vnd.apple.mpegurl' : ext === '.ts' ? 'video/mp2t' : 'application/octet-stream'; res.set({ 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*', }); res.sendFile(filePath); } catch (err) { console.error('[aerial] HLS 파일 서빙 오류:', err); res.status(404).json({ error: 'HLS 파일을 찾을 수 없습니다' }); } }); // ============================================================ // SAT_REQUEST 라우트 // ============================================================ // GET /api/aerial/satellite — 위성 촬영 요청 목록 router.get('/satellite', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { try { const { status } = req.query; const items = await listSatRequests({ status: status as string | undefined, }); res.json(items); } catch (err) { console.error('[aerial] 위성 요청 목록 오류:', err); res.status(500).json({ error: '위성 요청 목록 조회 실패' }); } }); // POST /api/aerial/satellite — 위성 촬영 요청 생성 router.post('/satellite', requireAuth, requirePermission('aerial', 'CREATE'), async (req, res) => { try { const { reqCd, acdntSn, lon, lat, zoneDc, coordDc, zoneAreaKm2, satNm, providerNm, resolution, purposeDc, reqstrNm, reqDtm, expectedRcvDtm, } = req.body; if (!reqCd) { res.status(400).json({ error: '요청코드는 필수입니다.' }); return; } const result = await createSatRequest({ reqCd, acdntSn, lon, lat, zoneDc, coordDc, zoneAreaKm2, satNm, providerNm, resolution, purposeDc, reqstrNm, reqDtm, expectedRcvDtm, }); res.status(201).json(result); } catch (err) { console.error('[aerial] 위성 요청 생성 오류:', err); res.status(500).json({ error: '위성 요청 생성 실패' }); } }); // POST /api/aerial/satellite/:sn/status — 위성 요청 상태 변경 router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'CREATE'), async (req, res) => { try { const sn = parseInt(req.params.sn as string, 10); if (!isValidNumber(sn, 1, 999999)) { res.status(400).json({ error: '유효하지 않은 요청 번호' }); return; } const { sttsCd } = req.body; if (!sttsCd || !isValidSatStatus(sttsCd)) { res.status(400).json({ error: '유효하지 않은 상태값 (PENDING/SHOOTING/COMPLETED/CANCELLED)' }); return; } await updateSatRequestStatus(sn, sttsCd); res.json({ success: true }); } catch (err) { console.error('[aerial] 위성 요청 상태 변경 오류:', err); res.status(500).json({ error: '위성 요청 상태 변경 실패' }); } }); // ============================================================ // OIL INFERENCE 라우트 // ============================================================ // POST /api/aerial/oil-detect — 오일 유출 감지 (GPU 추론 서버 프록시) // base64 이미지 전송을 위해 3MB JSON 파서 적용 router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { try { const { image } = req.body; if (!image || typeof image !== 'string') { res.status(400).json({ error: 'image (base64) 필드가 필요합니다' }); return; } // base64 크기 제한 (약 2MB 이미지) if (image.length > 3_000_000) { res.status(400).json({ error: '이미지 크기가 너무 큽니다 (최대 2MB)' }); return; } const result = await requestOilInference(image); res.json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes('abort') || message.includes('timeout')) { console.error('[aerial] 추론 서버 타임아웃:', message); res.status(504).json({ error: '추론 서버 응답 시간 초과' }); return; } console.error('[aerial] 오일 감지 오류:', err); res.status(503).json({ error: '추론 서버 연결 불가' }); } }); // ============================================================ // STITCH (이미지 합성) 라우트 // ============================================================ // POST /api/aerial/stitch — 여러 이미지를 합성하여 JPEG 반환 router.post( '/stitch', requireAuth, requirePermission('aerial', 'READ'), stitchUpload.array('files', 6), async (req, res) => { try { const files = req.files as Express.Multer.File[]; if (!files || files.length < 2) { res.status(400).json({ error: '이미지를 최소 2장 이상 선택해주세요.' }); return; } const fileId = `stitch_${Date.now()}`; const buffer = await stitchImages(files, fileId); res.type('image/jpeg').send(buffer); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes('abort') || message.includes('timeout')) { console.error('[aerial] 스티칭 서버 타임아웃:', message); res.status(504).json({ error: '이미지 합성 서버 응답 시간 초과' }); return; } console.error('[aerial] 이미지 합성 오류:', err); res.status(503).json({ error: '이미지 합성 서버 연결 불가' }); } } ); // GET /api/aerial/oil-detect/health — 추론 서버 상태 확인 router.get('/oil-detect/health', requireAuth, async (_req, res) => { const health = await checkInferenceHealth(); res.json(health); }); export default router;