diff --git a/.gitignore b/.gitignore index 62ac6aa..68f51c2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,14 +22,6 @@ dist-ssr build out -# Environment Variables (CRITICAL) -.env -.env.local -.env.development.local -.env.test.local -.env.production.local -.env.production - # Editor directories and files .vscode/* !.vscode/extensions.json @@ -50,7 +42,6 @@ Desktop.ini server/uploads/* !server/uploads/.gitkeep server/server.zip -server/public_key.pem server/private_key.pem # Project Specific - Camera/Stream diff --git a/docs/PRODUCTION_DEPLOYMENT.md b/docs/PRODUCTION_DEPLOYMENT.md index 88a865a..34b14bb 100644 --- a/docs/PRODUCTION_DEPLOYMENT.md +++ b/docs/PRODUCTION_DEPLOYMENT.md @@ -22,19 +22,22 @@ SmartIMS 배포는 **Git Tag 기반 배포** 방식을 권장합니다. 이는 ### 2.2. 설치 단계 1. **소스 클론**: + `/volume1/web` 위치에서 아래 명령어를 실행하면 `smartims` 폴더가 생성되며 그 안에 소스가 들어갑니다. ```bash - git clone [저장소_URL] smartims + cd /volume1/web + # 저장소 URL 뒤에 'smartims'를 붙여 폴더명을 지정합니다. + git clone https://gitea.qideun.com/SOKUREE/smart_ims.git smartims cd smartims ``` 2. **안정 버전(Tag) 전환**: ```bash git fetch --all --tags - # 현재 배포 가능한 최신 태그로 전환 (예: v0.2.5) - git checkout v0.2.5 + # v0.2.6 버전으로 전환 + git checkout v0.2.6 ``` 3. **환경 설정**: * `server/.env` 파일을 환경에 맞게 생성(DB 정보 등 입력). - * `server/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용). + * `server/config/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용). 4. **패키지 설치 및 빌드**: ```bash # 전체 의존성 설치 및 프론트엔드 빌드 @@ -71,17 +74,19 @@ npm run build pm2 reload smartims-api ``` -### 3.2. 시스템 관리 메뉴를 통한 업데이트 (검토 중) -시스템의 "버전 정보" 화면에서 신규 버전을 감지하고 업데이트하는 기능을 검토 중입니다. +### 3.2. 시스템 관리 메뉴를 통한 업데이트 (지원됨) +시스템의 **"시스템 관리 > 버전 정보"** 화면에서 신규 버전을 감지하고 원 클릭으로 업데이트할 수 있습니다. + +* 참고: 이 기능을 사용하려면 **[기본 설정]** 메뉴에서 Gitea 원격 저장소 URL과 (필요 시) 계정 정보를 먼저 설정해야 합니다. * **동작 원리**: - 1. 서버가 원격 저장소의 최신 Tag 리스트를 정기적으로 또는 요청 시 확인합니다. - 2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교하여 업데이트 버튼을 활성화합니다. - 3. 업데이트 실행 시 서버 내부적으로 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동화된 스크립트로 수행합니다. + 1. 서버가 설정된 원격 저장소의 최신 Tag 리스트를 확인합니다. + 2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교합니다. + 3. 업데이트 실행 시 서버가 백그라운드에서 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동으로 수행합니다. * **기대 효과**: * 터미널(SSH) 접속 없이 관리자 화면에서 즉시 최신 기능 반영 가능. - * 버전 불일치 문제 예방 및 운영 편의성 증대. + * 운영 서버 환경에서도 편리한 버전 관리. --- diff --git a/server/.env b/server/.env new file mode 100644 index 0000000..ab7d08a --- /dev/null +++ b/server/.env @@ -0,0 +1,23 @@ +# ============================================== +# [Common Settings] +# ============================================== +DB_HOST=sokuree.com +DB_USER=choibk +DB_PASSWORD=^Ocean1472bk +PORT=3005 + +# ============================================== +# [Development Environment] - Local Windows +# ============================================== +# 로컬 개발용 DB (분리됨: sokuree_platform_dev) +DB_NAME=sokuree_platform_dev +DB_PORT=3307 +# Windows 환경 호환성 (tcp는 권한 오류 발생 가능) +CCTV_TRANSPORT_OVERRIDE=auto + +# ============================================== +# [Production Environment] - Synology NAS +# ============================================== +# DB_NAME=sokuree_platform_prod +# DB_PORT=3307 + diff --git a/server/routes/system.js b/server/routes/system.js index dfc5f43..0ed4098 100644 --- a/server/routes/system.js +++ b/server/routes/system.js @@ -7,6 +7,7 @@ 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'); @@ -32,7 +33,10 @@ const ALLOWED_SETTING_KEYS = [ 'asset_categories', 'asset_locations', 'asset_statuses', - 'asset_maintenance_types' + 'asset_maintenance_types', + 'gitea_url', + 'gitea_user', + 'gitea_password' ]; // --- .env File Utilities --- @@ -70,7 +74,7 @@ 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')"); + 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); @@ -81,6 +85,9 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { 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 || '', @@ -109,6 +116,16 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => 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) { @@ -129,7 +146,6 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => }); // --- Crypto & Key Rotation --- -const cryptoUtil = require('../utils/cryptoUtil'); // 0.2 Test DB Connection router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => { @@ -234,8 +250,8 @@ router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res try { let stringValue = typeof value === 'string' ? value : JSON.stringify(value); - // Special handling for encryption_key to protect it in DB - if (key === 'encryption_key') { + // Special handling for sensitive keys to protect it in DB + if (key === 'encryption_key' || key === 'gitea_password') { stringValue = cryptoUtil.encryptMasterKey(stringValue); } @@ -427,6 +443,25 @@ router.post('/modules/:code/deactivate', isAuthenticated, hasRole('admin'), asyn // --- 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) router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => { try { @@ -434,15 +469,27 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const currentVersion = packageJson.version; - // Run git fetch to update tags from remote - exec('git fetch --tags', (err, stdout, stderr) => { + // Prepare git fetch command with auth if available + const auth = await getGiteaAuth(); + let fetchCmd = 'git fetch --tags'; + + if (auth.user && auth.pass) { + // Inject auth into URL + const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`); + fetchCmd = `git fetch ${authenticatedUrl} --tags`; + } else { + fetchCmd = `git fetch ${auth.url} --tags`; + } + + exec(fetchCmd, (err, stdout, stderr) => { if (err) { console.error('Git fetch failed:', err); - console.error('Stderr:', stderr); + // Mask password in error message + const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@'); return res.json({ current: currentVersion, latest: null, - error: `원격 저장소 동기화 실패: ${stderr || err.message}` + error: `원격 저장소 동기화 실패: ${sanitizedError || err.message}` }); } @@ -485,7 +532,18 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re // This operation is asynchronous. We start it and return a message. // In a real production, we might want to log this to a terminal-like view. + const auth = await getGiteaAuth(); + let authPrefix = ''; + if (auth.user && auth.pass) { + const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`); + authPrefix = `git remote set-url origin ${authenticatedUrl} && `; + } else { + authPrefix = `git remote set-url origin ${auth.url} && `; + } + const updateScript = ` + ${authPrefix} + git fetch --tags && git checkout ${targetTag} && npm install && npm run build && diff --git a/src/app/App.tsx b/src/app/App.tsx index 3317b8c..50215de 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,23 +1,27 @@ -import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; -import { ModuleGuard } from '../shared/auth/ModuleGuard'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider } from '../shared/auth/AuthContext'; +import { SystemProvider } from '../shared/context/SystemContext'; +import { AuthGuard } from '../shared/auth/AuthGuard'; import { MainLayout } from '../widgets/layout/MainLayout'; import { LoginPage } from '../pages/auth/LoginPage'; -import { DashboardPage } from '../modules/asset/pages/DashboardPage'; -import { AssetListPage } from '../modules/asset/pages/AssetListPage'; -import { AssetRegisterPage } from '../modules/asset/pages/AssetRegisterPage'; -import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage'; -import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage'; + +// Modules +import { assetModule } from '../modules/asset/module'; +import { cctvModule } from '../modules/cctv/module'; +import { productionModule } from '../modules/production/module'; +import ModuleLoader from '../platform/ModuleLoader'; + +// Platform / System Pages import { UserManagementPage } from '../platform/pages/UserManagementPage'; import { BasicSettingsPage } from '../platform/pages/BasicSettingsPage'; -import { ProductionPage } from '../production/pages/ProductionPage'; -import { MonitoringPage } from '../modules/cctv/pages/MonitoringPage'; -import { AuthProvider } from '../shared/auth/AuthContext'; -import { AuthGuard } from '../shared/auth/AuthGuard'; - -import { SystemProvider } from '../shared/context/SystemContext'; +import { VersionPage } from '../platform/pages/VersionPage'; import { LicensePage } from '../system/pages/LicensePage'; -function App() { +import '../platform/styles/global.css'; + +const modules = [assetModule, cctvModule, productionModule]; + +export function App() { return ( @@ -29,39 +33,20 @@ function App() { {/* Protected Routes */} - + }> - {/* Asset Management Routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {/* Dynamic Module Routes */} + } /> - {/* Production Management Routes */} - }> - } /> - + {/* Navigation Fallback within Layout */} + } /> - {/* Monitoring Routes */} - }> - } /> - - - {/* Admin Routes */} + {/* Platform Admin Routes */} } /> } /> } /> + } /> } /> diff --git a/src/platform/ModuleLoader.tsx b/src/platform/ModuleLoader.tsx index 945435b..12743c3 100644 --- a/src/platform/ModuleLoader.tsx +++ b/src/platform/ModuleLoader.tsx @@ -33,9 +33,12 @@ export const ModuleLoader = ({ modules }: ModuleLoaderProps) => { if (!isActive) return null; - // 2. 沅뚰븳 泥댄겕 (Role 湲곕컲) - if (module.requiredRoles && user && !module.requiredRoles.includes(user.role)) { - return null; + // 2. 권한 체크 (Role 기반) + if (module.requiredRoles && user) { + // supervisor는 모든 권한을 가짐 + if (user.role !== 'supervisor' && !module.requiredRoles.includes(user.role)) { + return null; + } } return ( diff --git a/src/platform/pages/BasicSettingsPage.tsx b/src/platform/pages/BasicSettingsPage.tsx index f2a8e8c..75748f1 100644 --- a/src/platform/pages/BasicSettingsPage.tsx +++ b/src/platform/pages/BasicSettingsPage.tsx @@ -10,6 +10,9 @@ interface SystemSettings { session_timeout: number; encryption_key: string; subscriber_id: string; + gitea_url: string; + gitea_user: string; + gitea_password: string; db_config: { host: string; user: string; @@ -25,6 +28,9 @@ export function BasicSettingsPage() { session_timeout: 60, encryption_key: '', subscriber_id: '', + gitea_url: '', + gitea_user: '', + gitea_password: '', db_config: { host: '', user: '', @@ -39,6 +45,7 @@ export function BasicSettingsPage() { const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({ security: null, encryption: null, + repository: null, database: null }); @@ -176,7 +183,7 @@ export function BasicSettingsPage() { } }; - const handleSaveSection = async (section: 'security' | 'encryption' | 'database') => { + const handleSaveSection = async (section: 'security' | 'encryption' | 'database' | 'repository') => { if (section === 'database' && !isDbVerified) { alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.'); return; @@ -187,6 +194,11 @@ export function BasicSettingsPage() { if (section === 'security') payload = { session_timeout: settings.session_timeout }; else if (section === 'encryption') payload = { encryption_key: settings.encryption_key }; + else if (section === 'repository') payload = { + gitea_url: settings.gitea_url, + gitea_user: settings.gitea_user, + gitea_password: settings.gitea_password + }; else if (section === 'database') { if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return; payload = { db_config: settings.db_config }; @@ -351,6 +363,62 @@ export function BasicSettingsPage() { )} + {/* Section 2.5: Gitea Repository Update Settings */} + +
+

+ + 시스템 업데이트 저장소 설정 (Gitea) +

+
+
+

원격 저장소(Gitea)에서 최신 업데이트 태그를 가져오기 위한 주소 및 인증 정보를 설정합니다.

+
+
+ + setSettings({ ...settings, gitea_url: e.target.value })} + placeholder="https://gitea.example.com/org/repo.git" + /> +
+
+
+ + setSettings({ ...settings, gitea_user: e.target.value })} + placeholder="gitea_update_user" + /> +
+
+ + setSettings({ ...settings, gitea_password: e.target.value })} + placeholder="••••••••" + /> +
+
+
+
+
+
+ {saveResults.repository && ( +
+ {saveResults.repository.success ? : } + {saveResults.repository.message} +
+ )} +
+
+ + +
+
+
+ {/* Section 3: Database Infrastructure (Supervisor Protected) */}