// server.js // 1. 모듈 불러오기 const express = require('express'); const path = require('path'); const { spawn } = require('child_process'); const multer = require('multer'); const fs = require('fs'); // 2. Express 앱 생성 const app = express(); const port = 3000; // ======================================================== // [설정] 경로 및 폴더 초기화 // ======================================================== // AI 모델용 업로드 경로 const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva'; fs.mkdirSync(uploadPath, { recursive: true }); // 관심 인물(POI) 폴더 경로 (vip_tracks) const vipTracksPath = '/mnt/user_data/applications/misc/vip_tracks'; fs.mkdirSync(vipTracksPath, { recursive: true }); // ======================================================== // [설정] Multer (파일 업로드 핸들러) // ======================================================== // 1) AI 모델용 스토리지 const modelStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, uploadPath); }, filename: (req, file, cb) => { cb(null, file.originalname); } }); const modelFileFilter = (req, file, cb) => { if (!file.originalname.endsWith('.aiwbin')) { return cb(new Error('Invalid file type: Only .aiwbin files are allowed'), false); } cb(null, true); }; const uploadModel = multer({ storage: modelStorage, fileFilter: modelFileFilter }); // 2) 관심 인물(POI) 이미지용 스토리지 const poiStorage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, vipTracksPath); }, filename: (req, file, cb) => { // 임시 저장을 위해 충돌 없는 이름을 생성 const ext = path.extname(file.originalname); cb(null, `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${ext}`); } }); const poiFileFilter = (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (!['.jpg', '.jpeg', '.png'].includes(ext)) { return cb(new Error('이미지 파일만 업로드 가능합니다 (.jpg, .png)'), false); } cb(null, true); }; const uploadPoi = multer({ storage: poiStorage, fileFilter: poiFileFilter }); // ======================================================== // [미들웨어 및 라우트 설정] // ======================================================== app.use(express.static(path.join(__dirname, 'public'))); app.use(express.json()); // POI 이미지 서빙 app.get('/poi-images/:folder/:filename', (req, res) => { const { folder, filename } = req.params; if (folder.includes('..') || filename.includes('..')) { return res.status(400).send('Invalid path'); } const filePath = path.join(vipTracksPath, folder, filename); if (fs.existsSync(filePath)) { res.sendFile(filePath); } else { res.status(404).send('Image not found'); } }); // 페이지 라우팅 app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/dashboard', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'dashboard.html')); }); // ======================================================== // [API] AI 모델 관련 // ======================================================== app.post('/set-model', (req, res) => { const { model } = req.body; if (!model) return res.status(400).json({ status: 'error', message: '모델 값이 없습니다.' }); const scriptCommand = '/mnt/user_data/feat_control/feat_on.sh'; const args = [model]; const scriptProcess = spawn(scriptCommand, args); let stdoutData = ''; scriptProcess.stdout.on('data', (data) => { stdoutData += data.toString(); }); scriptProcess.on('close', (code) => { if (code === 0) { res.json({ status: 'success', message: `모델이 ${model}(으)로 변경됨`, output: stdoutData }); } else { res.status(500).json({ status: 'error', message: '쉘 스크립트 실행 실패' }); } }); }); app.post('/zoom-control', (req, res) => { const { zoom } = req.body; if (!zoom || (zoom !== 'IN' && zoom !== 'OUT')) { return res.status(400).json({ status: 'error', message: 'Invalid zoom state' }); } const scriptPath = '/mnt/user_data/feat_control/zoom_control.sh'; const scriptProcess = spawn(scriptPath, [zoom]); let output = ''; scriptProcess.stdout.on('data', (data) => { output += data.toString(); }); scriptProcess.stderr.on('data', (data) => { output += data.toString(); }); scriptProcess.on('close', (code) => { if (code === 0) { res.json({ status: 'success', message: `Zoom set to ${zoom}`, output }); } else { res.status(500).json({ status: 'error', message: `Script failed with code ${code}`, output }); } }); }); app.post('/upload-model', uploadModel.single('modelFile'), (req, res) => { if (!req.file) return res.status(400).json({ status: 'error', message: '파일 없음' }); res.json({ status: 'success', message: '업로드 성공', filePath: req.file.path }); }); function isVersionGreater(v1, v2) { if (!v2) return true; const parts1 = v1.replace('v', '').split('.').map(Number); const parts2 = v2.replace('v', '').split('.').map(Number); if (parts1[0] > parts2[0]) return true; if (parts1[0] < parts2[0]) return false; if (parts1[1] > parts2[1]) return true; return false; } app.get('/list-models', (req, res) => { const modelRegex = /^CUUVA_([A-Z]+)_v(\d+\.\d+)\.aiwbin$/; const latestModels = {}; fs.readdir(uploadPath, (err, files) => { if (err) return res.status(500).json({ status: 'error' }); files.forEach(file => { const match = file.match(modelRegex); if (match) { const role = match[1]; const version = `v${match[2]}`; const existing = latestModels[role]; if (!existing || isVersionGreater(version, existing.version)) { latestModels[role] = { file: file, version: version }; } } }); res.json(latestModels); }); }); app.post('/delete-model', (req, res) => { const { filename } = req.body; if (!filename) return res.status(400).json({ status: 'error', message: '파일명 누락' }); const safeFilename = path.basename(filename); if (!safeFilename.endsWith('.aiwbin')) return res.status(400).json({ status: 'error', message: '잘못된 파일' }); const filePath = path.join(uploadPath, safeFilename); fs.unlink(filePath, (err) => { if (err && err.code !== 'ENOENT') return res.status(500).json({ status: 'error' }); res.json({ status: 'success', message: '삭제 완료' }); }); }); // ======================================================== // [API] 관심 인물(POI) 관리 관련 // ======================================================== app.get('/poi/list', (req, res) => { fs.readdir(vipTracksPath, { withFileTypes: true }, (err, entries) => { if (err) return res.json([]); const result = entries .filter(dirent => dirent.isDirectory()) .map(dirent => { const name = dirent.name; const dirPath = path.join(vipTracksPath, name); let imageFiles = []; try { const files = fs.readdirSync(dirPath); imageFiles = files.filter(file => /\.(jpg|jpeg|png)$/i.test(file)); } catch (e) { imageFiles = []; } return { name, images: imageFiles }; }); res.json(result); }); }); app.post('/poi/register', (req, res) => { const { name } = req.body; const nameRegex = /^[a-zA-Z0-9]+$/; if (!name || !nameRegex.test(name)) { return res.status(400).json({ status: 'error', message: '이름은 영문과 숫자만 가능합니다.' }); } const targetDir = path.join(vipTracksPath, name); if (fs.existsSync(targetDir)) { return res.status(400).json({ status: 'error', message: '이미 등록된 이름입니다.' }); } try { fs.mkdirSync(targetDir); res.json({ status: 'success' }); } catch (err) { res.status(500).json({ status: 'error', message: '폴더 생성 실패' }); } }); app.post('/poi/delete', (req, res) => { const { name } = req.body; const safeName = path.basename(name); const targetDir = path.join(vipTracksPath, safeName); try { if (fs.existsSync(targetDir)) { fs.rmSync(targetDir, { recursive: true, force: true }); } res.json({ status: 'success' }); } catch (err) { res.status(500).json({ status: 'error', message: '삭제 실패' }); } }); app.post('/poi/delete-image', (req, res) => { const { name, imageName } = req.body; if (!name || !imageName) return res.status(400).json({ status: 'error', message: '정보 누락' }); const safeName = path.basename(name); const safeImageName = path.basename(imageName); const targetFile = path.join(vipTracksPath, safeName, safeImageName); if (fs.existsSync(targetFile)) { try { fs.unlinkSync(targetFile); res.json({ status: 'success', message: '이미지가 삭제되었습니다.' }); } catch (err) { res.status(500).json({ status: 'error', message: '파일 삭제 오류' }); } } else { res.status(404).json({ status: 'error', message: '이미지가 없습니다.' }); } }); // [수정됨] 다중 파일 업로드 처리 (uploadPoi.array 사용) app.post('/poi/upload-image', uploadPoi.array('poiFile'), (req, res) => { // 다중 파일인 경우 req.files에 배열로 들어옴 if (!req.files || req.files.length === 0) { return res.status(400).json({ status: 'error', message: '파일 업로드 실패 (jpg, png만 가능)' }); } const poiName = req.body.poiName; // poiName이 없으면 임시 파일 모두 삭제 if (!poiName) { req.files.forEach(file => { if(fs.existsSync(file.path)) fs.unlinkSync(file.path); }); return res.status(400).json({ status: 'error', message: '대상 인물 정보 누락' }); } const targetDir = path.join(vipTracksPath, poiName); try { if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir); } // 업로드된 모든 파일을 순회하며 이동 req.files.forEach(file => { const ext = path.extname(file.originalname).toLowerCase(); // 파일명 중복 방지를 위해 난수 추가 const newFileName = `img_${Date.now()}_${Math.floor(Math.random() * 100000)}${ext}`; const targetPath = path.join(targetDir, newFileName); fs.renameSync(file.path, targetPath); }); res.json({ status: 'success', message: '이미지 등록 완료' }); } catch (err) { console.error(err); // 에러 발생 시 남아있는 임시 파일 정리 req.files.forEach(file => { if(fs.existsSync(file.path)) try { fs.unlinkSync(file.path); } catch(e){} }); res.status(500).json({ status: 'error', message: '파일 이동 실패' }); } }); // 서버 실행 app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); });