const express = require('express'); const router = express.Router(); const { exec, execSync } = require('child_process'); const db = require('../db'); const path = require('path'); const fs = require('fs'); const { isAuthenticated, hasRole } = require('../middleware/authMiddleware'); const { generateLicense, verifyLicense } = require('../utils/licenseManager'); const { checkRemoteKey } = require('../utils/remoteLicense'); const cryptoUtil = require('../utils/cryptoUtil'); // Load Public Key for Verification const publicKeyPath = path.join(__dirname, '../config/public_key.pem'); let publicKey = null; try { if (fs.existsSync(publicKeyPath)) { publicKey = fs.readFileSync(publicKeyPath, 'utf8'); console.log('✅ Public Key loaded successfully for license verification'); } else { console.error('❌ WARNING: public_key.pem not found at:', publicKeyPath); } } catch (e) { console.error('❌ Error loading public key:', e); } // Helper to check if a setting key is allowed for general get/post // This prevents modification of sensitive keys if any const ALLOWED_SETTING_KEYS = [ 'subscriber_id', 'session_timeout', 'encryption_key', 'asset_id_rule', 'asset_categories', 'asset_locations', 'asset_statuses', 'asset_maintenance_types', 'gitea_url', 'gitea_user', 'gitea_password' ]; // --- .env File Utilities --- const envPath = path.join(__dirname, '../.env'); const readEnv = () => { if (!fs.existsSync(envPath)) return {}; const content = fs.readFileSync(envPath, 'utf8'); const lines = content.split('\n'); const env = {}; lines.forEach(line => { const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); if (match) { env[match[1]] = match[2] ? match[2].trim() : ''; } }); return env; }; const writeEnv = (updates) => { let content = fs.readFileSync(envPath, 'utf8'); Object.entries(updates).forEach(([key, value]) => { const regex = new RegExp(`^${key}=.*`, 'm'); if (regex.test(content)) { content = content.replace(regex, `${key}=${value}`); } else { content += `\n${key}=${value}`; } }); fs.writeFileSync(envPath, content, 'utf8'); }; const mysql = require('mysql2/promise'); // 0. Server Configuration (Subscriber ID & Session Timeout) router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { try { const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key', 'gitea_url', 'gitea_user', 'gitea_password')"); const settings = {}; rows.forEach(r => settings[r.setting_key] = r.setting_value); // Include .env DB settings const env = readEnv(); res.json({ subscriber_id: settings.subscriber_id || '', session_timeout: parseInt(settings.session_timeout) || 60, encryption_key: settings.encryption_key || '', gitea_url: settings.gitea_url || 'https://gitea.qideun.com/SOKUREE/smart_ims.git', gitea_user: settings.gitea_user || '', gitea_password: settings.gitea_password ? cryptoUtil.decryptMasterKey(settings.gitea_password) : '', db_config: { host: env.DB_HOST || '', user: env.DB_USER || '', password: env.DB_PASSWORD || '', database: env.DB_NAME || '', port: env.DB_PORT || '3306' } }); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); } }); router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { const { subscriber_id, session_timeout, encryption_key, db_config } = req.body; try { if (subscriber_id !== undefined) { await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('subscriber_id', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [subscriber_id]); } if (session_timeout !== undefined) { await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]); } if (encryption_key !== undefined) { const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key); await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]); } if (req.body.gitea_url !== undefined) { await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_url', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [req.body.gitea_url]); } if (req.body.gitea_user !== undefined) { await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_user', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [req.body.gitea_user]); } if (req.body.gitea_password !== undefined) { const encryptedPass = cryptoUtil.encryptMasterKey(req.body.gitea_password); await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_password', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedPass]); } // Handle .env DB settings if (db_config) { writeEnv({ DB_HOST: db_config.host, DB_USER: db_config.user, DB_PASSWORD: db_config.password, DB_NAME: db_config.database, DB_PORT: db_config.port }); } res.json({ message: 'Settings saved. Server may restart to apply DB changes.' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); } }); // --- Crypto & Key Rotation --- // 0.2 Test DB Connection router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => { const { host, user, password, database, port } = req.body; let conn; try { // 1. Try to connect without specifying database first to see if credentials/host are OK try { const basicConn = await mysql.createConnection({ host, user, password, port: parseInt(port) || 3306, connectTimeout: 3000 }); await basicConn.end(); } catch (basicErr) { return res.status(400).json({ success: false, error: `서버 접속 실패: 계정 정보나 호스트/포트를 확인하세요. (${basicErr.message})` }); } // 2. Try to connect with database conn = await mysql.createConnection({ host, user, password, database, port: parseInt(port) || 3306, connectTimeout: 5000 }); await conn.query('SELECT 1'); res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' }); } catch (err) { let msg = err.message; if (err.code === 'ER_BAD_DB_ERROR') { msg = `데이터베이스 '${database}'가 존재하지 않습니다. MariaDB에서 스키마를 먼저 생성해 주세요.`; } else if (err.code === 'ER_ACCESS_DENIED_ERROR') { msg = '사용자 계정 또는 비밀번호가 일치하지 않거나, 해당 DB에 대한 접근 권한이 없습니다.'; } res.status(400).json({ success: false, error: msg }); } finally { if (conn) await conn.end(); } }); // 0.3 Encryption Key Management & Rotation router.get('/encryption/status', isAuthenticated, hasRole('admin'), async (req, res) => { try { const [userRows] = await db.query('SELECT COUNT(*) as count FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"'); const currentKey = await cryptoUtil.getMasterKey(); res.json({ current_key: currentKey, affected_records: { users: userRows[0].count } }); } catch (err) { res.status(500).json({ error: 'Failed to fetch encryption status' }); } }); router.post('/encryption/rotate', isAuthenticated, hasRole('admin'), async (req, res) => { const { new_key } = req.body; if (!new_key) return res.status(400).json({ error: 'New key is required' }); const conn = await db.getConnection(); try { await conn.beginTransaction(); const oldKey = await cryptoUtil.getMasterKey(); // 1. Migrate Users Table (phone) const [users] = await conn.query('SELECT id, phone FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"'); for (const user of users) { const decrypted = await cryptoUtil.decrypt(user.phone, oldKey); const reEncrypted = await cryptoUtil.encrypt(decrypted, new_key); await conn.query('UPDATE users SET phone = ? WHERE id = ?', [reEncrypted, user.id]); } // 2. Update Master Key in settings (Encrypted for DB storage) const encryptedKeyForDb = cryptoUtil.encryptMasterKey(new_key); await conn.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]); await conn.commit(); cryptoUtil.clearCache(); // Force immediate reload of new key res.json({ success: true, message: 'Encryption key rotated and data migrated successfully.' }); } catch (err) { await conn.rollback(); console.error('Rotation failed:', err); res.status(500).json({ error: 'Key rotation failed: ' + err.message }); } finally { conn.release(); } }); // 0-1. Generic Setting Get/Set router.get('/settings/:key', isAuthenticated, async (req, res) => { const { key } = req.params; if (!ALLOWED_SETTING_KEYS.includes(key)) { return res.status(400).json({ error: 'Invalid setting key' }); } try { const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = ?", [key]); if (rows.length === 0) return res.json({ value: null }); res.json({ value: rows[0].setting_value }); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); } }); router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res) => { const { key } = req.params; const { value } = req.body; if (!ALLOWED_SETTING_KEYS.includes(key)) { return res.status(400).json({ error: 'Invalid setting key' }); } try { let stringValue = typeof value === 'string' ? value : JSON.stringify(value); // Special handling for sensitive keys to protect it in DB if (key === 'encryption_key' || key === 'gitea_password') { stringValue = cryptoUtil.encryptMasterKey(stringValue); } await db.query( `INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [key, stringValue] ); res.json({ success: true, message: 'Setting saved' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); } }); // 1. Get All Modules Status router.get('/modules', isAuthenticated, async (req, res) => { try { const [rows] = await db.query('SELECT * FROM system_modules'); const modules = {}; const defaults = ['asset', 'production', 'cctv']; // Get stored subscriber ID const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'"); // Ensure we return null or empty string if not found, DO NOT use any hardcoded fallback const serverSubscriberId = (subRows.length > 0 && subRows[0].setting_value) ? subRows[0].setting_value : ''; defaults.forEach(code => { const found = rows.find(r => r.code === code); if (found) { modules[code] = { active: !!found.is_active, type: found.license_type, expiry: found.expiry_date, subscriber_id: found.subscriber_id // Return who verified it }; } else { modules[code] = { active: false, type: null, expiry: null, subscriber_id: null }; } }); res.json({ modules, serverSubscriberId }); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); } }); const axios = require('axios'); // 2. Activate Module (Apply License) // Only admin can manage system modules router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async (req, res) => { const { code } = req.params; let { licenseKey } = req.body; if (!licenseKey) { return res.status(400).json({ error: 'License key is required' }); } licenseKey = licenseKey.trim(); // 1. Verify Key validity const result = verifyLicense(licenseKey, publicKey); if (!result.isValid) { return res.status(400).json({ error: `Invalid License: ${result.reason}` }); } // 2. Check Module match // Allow legacy 'monitoring' licenses to activate 'cctv' module const isMatch = result.module === code || (code === 'cctv' && result.module === 'monitoring'); if (!isMatch) { return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` }); } // 3. Check Subscriber match try { const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'"); const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null; if (!serverSubscriberId) { return res.status(400).json({ error: '서버 구독자 ID가 설정되지 않았습니다. [서버 환경 설정]에서 먼저 설정해주세요.' }); } // Allow 'dev' type to bypass strict subscriber check, but log it if (result.type === 'dev') { if (result.subscriberId !== serverSubscriberId) { console.warn(`⚠️ Dev License used: Subscriber ID mismatch allowed. Key: ${result.subscriberId}, Server: ${serverSubscriberId}`); } } else { if (result.subscriberId !== serverSubscriberId) { return res.status(400).json({ error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.` }); } } // 4. Archive Current License if exists const [current] = await db.query('SELECT * FROM system_modules WHERE code = ?', [code]); if (current.length > 0 && current[0].is_active && current[0].license_key) { const old = current[0]; const historySql = ` INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at) VALUES (?, ?, ?, ?, NOW()) `; await db.query(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]); } // Upsert into system_modules const sql = ` INSERT INTO system_modules (code, name, is_active, license_key, license_type, expiry_date, subscriber_id) VALUES (?, ?, true, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE is_active = true, license_key = VALUES(license_key), license_type = VALUES(license_type), expiry_date = VALUES(expiry_date), subscriber_id = VALUES(subscriber_id) `; // Map codes to names const names = { 'asset': '자산 관리', 'production': '생산 관리', 'cctv': 'CCTV' }; await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]); // 5. Sync status with License Manager const licenseManagerUrl = process.env.LICENSE_MANAGER_URL || 'http://localhost:3006/api'; try { await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey }); console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`); } catch (syncErr) { const errorDetail = syncErr.response ? `Status: ${syncErr.response.status}, Data: ${JSON.stringify(syncErr.response.data)}` : syncErr.message; console.error(`⚠️ Failed to sync status with License Manager: ${errorDetail}`); // We don't fail the whole activation if sync fails, but we log it } res.json({ success: true, message: 'Module activated', type: result.type, expiry: result.expiryDate }); } catch (err) { console.error('❌ License Activation Error:', err); res.status(500).json({ success: false, error: err.message || 'Database error', stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); } }); // 3. Get Module History router.get('/modules/:code/history', isAuthenticated, hasRole('admin'), async (req, res) => { try { const [rows] = await db.query('SELECT * FROM license_history WHERE module_code = ? ORDER BY id DESC LIMIT 10', [req.params.code]); res.json(rows); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); } }); // 4. Deactivate Module router.post('/modules/:code/deactivate', isAuthenticated, hasRole('admin'), async (req, res) => { const { code } = req.params; try { // Archive on deactivate too? const [current] = await db.query('SELECT * FROM system_modules WHERE code = ?', [code]); if (current.length > 0 && current[0].is_active) { const old = current[0]; const historySql = ` INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at) VALUES (?, ?, ?, ?, NOW()) `; await db.query(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]); } await db.query('UPDATE system_modules SET is_active = false WHERE code = ?', [code]); res.json({ success: true, message: 'Module deactivated' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); } }); // --- System Update Logic --- const getGiteaAuth = async () => { try { const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('gitea_url', 'gitea_user', 'gitea_password')"); const settings = {}; rows.forEach(r => settings[r.setting_key] = r.setting_value); const url = settings.gitea_url || 'https://gitea.qideun.com/SOKUREE/smart_ims.git'; if (settings.gitea_user && settings.gitea_password) { const pass = cryptoUtil.decryptMasterKey(settings.gitea_password); return { url, user: settings.gitea_user, pass: pass }; } return { url, user: null, pass: null }; } catch (e) { console.error('Failed to get Gitea auth:', e); } return { url: 'https://gitea.qideun.com/SOKUREE/smart_ims.git', user: null, pass: null }; }; // 5. Get Version Info (Current, Remote & History from Tags) router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => { try { // Local version detection (No caching) let currentVersion = '0.0.0.0'; try { const rootPkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')); currentVersion = rootPkg.version; } catch (e) { currentVersion = '0.4.2.1'; } // Prepare git fetch command const auth = await getGiteaAuth(); let fetchCmd = 'git fetch --tags'; if (auth.user && auth.pass && auth.url && auth.url.includes('https://')) { const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`); fetchCmd = `git fetch ${authenticatedUrl} --tags --force --prune`; } else if (auth.url) { fetchCmd = `git fetch ${auth.url} --tags --force --prune`; } const projectRoot = path.join(__dirname, '../..'); exec(fetchCmd, { cwd: projectRoot }, (err, stdout, stderr) => { if (err) { console.error('Git fetch failed:', err); const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@'); return res.json({ current: currentVersion, latest: null, history: [], error: `원격 저장소 동기화 실패: ${sanitizedError || err.message}` }); } // Also ensure we are looking at the remote tags directly if possible for the 'latest' check // but for history we still use the fetched local tags const format = '%(refname:short)|%(contents:subject)|%(contents:body)|%(creatordate:iso8601)'; const historyCmd = `git for-each-ref refs/tags --sort=-creatordate --format="${format}" --count=50`; exec(historyCmd, { cwd: projectRoot }, (err, stdout, stderr) => { const lines = stdout ? stdout.trim().split('\n') : []; const allTags = lines.map(line => { const [tag, subject, body, date] = line.split('|'); if (!tag) return null; let type = 'patch'; let title = subject || ''; const typeMatch = (subject || '').match(/^\[(URGENT|FEATURE|FIX|PATCH|HOTFIX|BUILD)\]\s*(.*)$/i); if (typeMatch) { const rawType = typeMatch[1].toUpperCase(); if (rawType === 'URGENT' || rawType === 'HOTFIX') type = 'urgent'; else if (rawType === 'FEATURE') type = 'feature'; else if (rawType === 'BUILD') type = 'patch'; else type = 'fix'; title = typeMatch[2]; } const changes = (body || '') .split('\n') .map(c => c.trim()) .filter(c => c.startsWith('-') || c.startsWith('*')) .map(c => c.replace(/^[-*]\s*/, '')); return { version: tag.replace(/^v/, ''), date: date ? date.split(' ')[0] : '', title: title || '업데이트', changes: changes.length > 0 ? changes : [subject || '세부 내역 없음'], type: type }; }).filter(Boolean); // Version Comparison Helper const parseV = (v) => (v || '').replace(/^v/, '').split('.').map(n => parseInt(n) || 0); const compare = (v1, v2) => { const p1 = parseV(v1); const p2 = parseV(v2); // Compare up to 4 parts (MAJOR.MINOR.PATCH.BUILD) for (let i = 0; i < 4; i++) { const n1 = p1[i] || 0; const n2 = p2[i] || 0; if (n1 > n2) return 1; if (n1 < n2) return -1; } return 0; }; // Filter 1: History is ONLY versions <= current const history = allTags.filter(t => compare(t.version, currentVersion) <= 0); const latestFromRemote = allTags[0] || null; const needsUpdate = latestFromRemote ? (compare(latestFromRemote.version, currentVersion) > 0) : false; console.log(`[VersionCheck] Current: "${currentVersion}", LatestTag: "${latestFromRemote?.version}", NeedsUpdate: ${needsUpdate}`); res.json({ current: currentVersion, latest: latestFromRemote ? `v${latestFromRemote.version}` : null, needsUpdate: needsUpdate, latestInfo: needsUpdate ? latestFromRemote : null, history: history }); }); }); } catch (err) { console.error(err); res.status(500).json({ error: '버전 정보를 가져오는 중 오류가 발생했습니다.' }); } }); // 6. Execute System Update // WARNING: This is a heavy operation and will reload the server router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, res) => { const { targetTag } = req.body; if (!targetTag) { return res.status(400).json({ error: '업데이트할 대상 태그가 지정되지 않았습니다.' }); } const auth = await getGiteaAuth(); const env = readEnv(); const isWindows = process.platform === 'win32'; const backupDir = isWindows ? './backup' : '/volume1/backup/smart_ims'; // Build auth URL for git commands let remoteUrl = auth.url; if (auth.user && auth.pass && auth.url.includes('https://')) { remoteUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`); } const timestamp = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0]; const dbName = env.DB_NAME || 'sokuree_platform_prod'; const dbUser = env.DB_USER || 'choibk'; const dbPass = env.DB_PASSWORD || ''; const dbPort = env.DB_PORT || '3307'; // 1. Generate Standalone Update Script Content let scriptContent = ''; const scriptName = isWindows ? 'update_system.bat' : 'update_system.sh'; const scriptPath = path.join(__dirname, '../..', scriptName); if (isWindows) { scriptContent = ` @echo off echo [Update] Starting update to ${targetTag}... REM Ensure backup directory set BACKUP_PATH=${backupDir} if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul if not exist "%BACKUP_PATH%" ( echo [Warning] Global backup failed, using local backup. set BACKUP_PATH=.\\server\\backups if not exist ".\\server\\backups" mkdir ".\\server\\backups" ) echo [Update] Backing up Config... if exist "server\\.env" ( copy /Y "server\\.env" "%BACKUP_PATH%\\.env.backup.${timestamp}" copy /Y "server\\.env" "server\\.env.tmp" ) echo [Update] Syncing Source Code... git fetch "${remoteUrl}" --tags --force --prune git checkout -f ${targetTag} echo [Update] Restoring Config... if exist "server\\.env.tmp" ( copy /Y "server\\.env.tmp" "server\\.env" del "server\\.env.tmp" ) else if exist "%BACKUP_PATH%\\.env.backup.${timestamp}" ( copy /Y "%BACKUP_PATH%\\.env.backup.${timestamp}" "server\\.env" ) echo [Update] Installing & Building... call npm install call npm run build cd server call npm install echo [Update] Done. `; } else { // Linux/Synology Script const dumpTool = '/usr/local/mariadb10/bin/mysqldump'; scriptContent = `#!/bin/bash exec > >(tee -a update.log) 2>&1 echo "[Update] Starting update to ${targetTag}..." # Ensure backup directory BACKUP_DIR="${backupDir}" mkdir -p "$BACKUP_DIR" || { echo "[Warning] Global backup failed, using local backup." BACKUP_DIR="./server/backups" mkdir -p "$BACKUP_DIR" } echo "[Update] Backing up Database..." if [ -f "${dumpTool}" ]; then echo "[Info] MySQL dump tool found. Attempting database backup..." ${dumpTool} -u ${dbUser} --password='${dbPass}' --port ${dbPort} ${dbName} > "$BACKUP_DIR/backup_db_${timestamp}.sql" 2>/dev/null if [ $? -eq 0 ]; then echo "[Info] Database backup successful." else echo "[Warning] Database backup failed, continuing..." fi else echo "[Warning] MySQL dump tool not found. Skipping DB backup." fi echo "[Update] Backing up Config..." if [ -f "server/.env" ]; then echo "[Info] Backing up 'server/.env' to '$BACKUP_DIR/.env.backup.${timestamp}' and 'server/.env.tmp'." cp "server/.env" "$BACKUP_DIR/.env.backup.${timestamp}" cp "server/.env" "server/.env.tmp" else echo "[Warning] 'server/.env' not found. Skipping config backup." fi echo "[Update] Syncing Source Code..." git remote set-url origin "${remoteUrl}" git fetch origin --tags --force --prune if [ $? -ne 0 ]; then echo "[Error] Git fetch failed. Exiting update." exit 1 fi echo "[Info] Git fetch successful." git checkout -f ${targetTag} if [ $? -ne 0 ]; then echo "[Error] Git checkout to ${targetTag} failed. Exiting update." exit 1 fi echo "[Info] Git checkout to ${targetTag} successful." echo "[Update] Restoring Config..." if [ -f "server/.env.tmp" ]; then cp "server/.env.tmp" "server/.env" rm "server/.env.tmp" elif [ -f "$BACKUP_DIR/.env.backup.${timestamp}" ]; then cp "$BACKUP_DIR/.env.backup.${timestamp}" "server/.env" fi echo "[Update] Installing & Building..." npm install npm run build cd server npm install echo "[Update] Reloading PM2..." pm2 reload smartims-api || echo "PM2 not found" echo "[Update] Done." `; } // 2. Write Script to File try { fs.writeFileSync(scriptPath, scriptContent, { encoding: 'utf8', mode: 0o755 }); } catch (err) { console.error('Failed to create update script:', err); return res.status(500).json({ error: '업데이트 스크립트 생성 실패' }); } // 3. Execute Script Detached console.log(`🚀 [Update] Spawning independent update process: ${scriptPath}`); const child = require('child_process').spawn( isWindows ? 'cmd.exe' : 'bash', isWindows ? ['/c', scriptName] : [scriptName], { cwd: path.join(__dirname, '../..'), detached: true, stdio: 'ignore' } ); child.unref(); // Allow node process to exit/reload without killing this child res.json({ success: true, message: '업데이트 프로세스가 독립적으로 실행되었습니다. 시스템이 곧 재시작됩니다.' }); }); module.exports = router;