라이선스 관리 DB 분리

This commit is contained in:
choibk 2026-01-23 16:52:14 +09:00
parent a771fc0561
commit 7ee6446f0f
19 changed files with 191 additions and 47 deletions

View File

@ -1,6 +1,6 @@
# CCTV 모바일 영상 및 카메라 설정 가이드 # CCTV 모바일 영상 및 카메라 설정 가이드
본 문서는 SmartAsset CCTV 모니터링 모듈의 고급 설정 기능을 설명합니다. 본 문서는 Smart IMS CCTV 모니터링 모듈의 고급 설정 기능을 설명합니다.
## 1. 전송 방식 (Transport Mode) ## 1. 전송 방식 (Transport Mode)
오래된 카메라나 네트워크 환경에 따라 RTSP 데이터 전송 방식을 선택할 수 있습니다. 오래된 카메라나 네트워크 환경에 따라 RTSP 데이터 전송 방식을 선택할 수 있습니다.

View File

@ -1,6 +1,6 @@
# 시스템 배포 가이드 (Deployment Guide) # 시스템 배포 가이드 (Deployment Guide)
본 문서는 빌드된 스마트어셋(SmartAsset) 솔루션을 실서버(Synology NAS 등)에 배포할 때 필요한 절차와 주의사항을 설명합니다. 본 문서는 빌드된 스마트 IMS(Smart IMS) 솔루션을 실서버(Synology NAS 등)에 배포할 때 필요한 절차와 주의사항을 설명합니다.
## 1. 배포 대상 폴더 및 파일 ## 1. 배포 대상 폴더 및 파일
서버에 업로드해야 하는 핵심 구성 요소는 다음과 같습니다. 서버에 업로드해야 하는 핵심 구성 요소는 다음과 같습니다.
@ -52,7 +52,7 @@
업로드가 완료된 후 서버 터미널(SSH)에서 다음 명령을 실행합니다. 업로드가 완료된 후 서버 터미널(SSH)에서 다음 명령을 실행합니다.
1. **의존성 설치**: `cd server` 이동 후 `npm install` 1. **의존성 설치**: `cd server` 이동 후 `npm install`
2. **서비스 시작**: `pm2 start index.js --name "smartasset"` 2. **서비스 시작**: `pm2 start index.js --name "smartims"`
3. **상태 확인**: `pm2 status`를 통해 서버가 `online` 상태인지 확인합니다. 3. **상태 확인**: `pm2 status`를 통해 서버가 `online` 상태인지 확인합니다.
--- ---

View File

@ -1,6 +1,6 @@
# 라이선스 관리자 매뉴얼 (최종본) # 라이선스 관리자 매뉴얼 (최종본)
본 문서는 스마트어셋(SmartAsset) 시스템의 라이선스 관리 기능을 사용하는 방법을 설명합니다. 모든 라이선스 관리는 **RSA 비대칭 암호화** 방식을 따르며, `tools/license_manager.cjs` CLI 도구를 통해 수행됩니다. 본 문서는 스마트 IMS(Smart IMS) 시스템의 라이선스 관리 기능을 사용하는 방법을 설명합니다. 모든 라이선스 관리는 **RSA 비대칭 암호화** 방식을 따르며, `tools/license_manager.cjs` CLI 도구를 통해 수행됩니다.
## 1. 사전 준비 및 구독자 설정 (중요) ## 1. 사전 준비 및 구독자 설정 (중요)
라이선스 키를 활성화하기 전에 서버가 어떤 고객사(구독자)에게 속해 있는지 식별하기 위해 **구독자 ID**를 설정해야 합니다. 라이선스 키를 활성화하기 전에 서버가 어떤 고객사(구독자)에게 속해 있는지 식별하기 위해 **구독자 ID**를 설정해야 합니다.

View File

@ -1,4 +1,4 @@
# SmartAsset Project - Task & Feature Roadmap # Smart IMS Project - Task & Feature Roadmap
## 1. Core Platform & Security ## 1. Core Platform & Security
- [x] **Project Initialization**: Created Vite + React + TypeScript base. - [x] **Project Initialization**: Created Vite + React + TypeScript base.

View File

@ -3,9 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="google" content="notranslate" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SmartAsset - 통합 자산관리 플랫폼</title> <title>Smart IMS - 통합 자산/공정 관리 플랫폼</title>
<script src="https://cdn.jsdelivr.net/gh/phoboslab/jsmpeg@master/jsmpeg.min.js"></script> <script src="https://cdn.jsdelivr.net/gh/phoboslab/jsmpeg@master/jsmpeg.min.js"></script>
</head> </head>

8
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "temp_app", "name": "smartims",
"version": "0.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "temp_app", "name": "smartims",
"version": "0.0.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",

View File

@ -1,5 +1,5 @@
{ {
"name": "temp_app", "name": "smartims",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -5,7 +5,7 @@ async function addColumn() {
process.env.DB_HOST = process.env.DB_HOST || 'localhost'; process.env.DB_HOST = process.env.DB_HOST || 'localhost';
process.env.DB_USER = process.env.DB_USER || 'root'; process.env.DB_USER = process.env.DB_USER || 'root';
process.env.DB_PASSWORD = process.env.DB_PASSWORD || 'password'; // Fallback or assume env is loaded process.env.DB_PASSWORD = process.env.DB_PASSWORD || 'password'; // Fallback or assume env is loaded
process.env.DB_NAME = process.env.DB_NAME || 'smartasset_db'; process.env.DB_NAME = process.env.DB_NAME || 'sokuree_platform_dev';
console.log(`Connecting to database: ${process.env.DB_NAME} at ${process.env.DB_HOST}`); console.log(`Connecting to database: ${process.env.DB_NAME} at ${process.env.DB_HOST}`);

View File

@ -0,0 +1,8 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAjwHtoUUGe+Ib7KA9a2KV00c3B+4a0UTeDu9+jCg+fzyo6qD1ii0p
hJoASYkEVpHm5tMGJNMp/fJGyi3glPvfI8OFzs3JDgXWB6f4ao38fgAPtGE/x4Q2
wsIHNvIY4scdFgokWYA1+k9//c4kszavOL8fRc85jOlkzUqzQLm3R1x34AVJEFdu
oo29vuHlFSzOqJ3c36cNUDJsya+h6Um96zPfM3UA2LLYsYVclr7Bem0jvSNv/sKt
hVcdG1QtlHTPAjQI4HZZf/51uIY/K2uXrVq3w4dplqMlLwIqDNb97Ire+Q+VaIe1
n+GJBR5Jfa5soYsVjTwRId8VFvjcG0EJCQIDAQAB
-----END RSA PUBLIC KEY-----

View File

@ -62,19 +62,33 @@ app.use('/uploads', express.static(uploadDir));
// Session Middleware // Session Middleware
app.use(session({ app.use(session({
key: 'smartasset_sid', key: 'smartims_sid',
secret: process.env.SESSION_SECRET || 'smartasset_session_secret_key', secret: process.env.SESSION_SECRET || 'smartims_session_secret_key',
store: sessionStore, store: sessionStore,
resave: false, resave: false,
saveUninitialized: true, // Save new sessions even if empty (helps with some client handshake issues) saveUninitialized: false,
cookie: { cookie: {
httpOnly: true, // Prevent JS access httpOnly: true,
secure: false, // Set true if using HTTPS secure: false, // Set true if using HTTPS
maxAge: 1000 * 60 * 60 * 24, // 1 day maxAge: null, // Browser session by default
sameSite: 'lax' // Recommended for better CSRF protection and reliability sameSite: 'lax'
} }
})); }));
// Dynamic Session Timeout Middleware
app.use(async (req, res, next) => {
if (req.session && req.session.user) {
try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
} catch (err) {
console.error('Session timeout fetch error:', err);
}
}
next();
});
// Apply CSRF Protection // Apply CSRF Protection
app.use(csrfProtection); app.use(csrfProtection);

View File

@ -127,7 +127,7 @@ class StreamRelay {
const transportMode = process.env.CCTV_TRANSPORT_OVERRIDE || camera.transport_mode || 'tcp'; const transportMode = process.env.CCTV_TRANSPORT_OVERRIDE || camera.transport_mode || 'tcp';
// 3. Unique User-Agent to prevent session conflicts // 3. Unique User-Agent to prevent session conflicts
const userAgent = `SmartAsset-Relay-v1.0.8-${transportMode}`; const userAgent = `SmartIMS-Relay-v1.0.8-${transportMode}`;
// Quality Scaling // Quality Scaling
let scaleFilter = []; let scaleFilter = [];

View File

@ -1,12 +1,12 @@
{ {
"name": "server", "name": "server",
"version": "1.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "server", "name": "server",
"version": "1.0.0", "version": "0.1.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@ -10,7 +10,7 @@ const { generateToken } = require('../middleware/csrfMiddleware');
// Key must be 32 bytes for aes-256-cbc // Key must be 32 bytes for aes-256-cbc
// 'my_super_secret_key_manage_asset' is 32 chars? // 'my_super_secret_key_manage_asset' is 32 chars?
// let's use a simpler approach to ensure length on startup or fallback // let's use a simpler approach to ensure length on startup or fallback
const SECRET_KEY = process.env.ENCRYPTION_KEY || 'smartasset_secret_key_0123456789'; // 32 chars needed const SECRET_KEY = process.env.ENCRYPTION_KEY || 'smartims_secret_key_0123456789'; // 32 chars needed
// Ideally use a buffer from hex, but string is okay if 32 chars. // Ideally use a buffer from hex, but string is okay if 32 chars.
// Let's pad it to ensure stability if env is missing. // Let's pad it to ensure stability if env is missing.
const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32); const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32);
@ -127,7 +127,7 @@ router.post('/logout', (req, res) => {
console.error('Logout error:', err); console.error('Logout error:', err);
return res.status(500).json({ success: false, message: 'Logout failed' }); return res.status(500).json({ success: false, message: 'Logout failed' });
} }
res.clearCookie('smartasset_sid'); // matching key in index.js res.clearCookie('smartims_sid'); // matching key in index.js
res.json({ success: true, message: 'Logged out' }); res.json({ success: true, message: 'Logged out' });
}); });
}); });

View File

@ -8,7 +8,7 @@ const { generateLicense, verifyLicense } = require('../utils/licenseManager');
const { checkRemoteKey } = require('../utils/remoteLicense'); const { checkRemoteKey } = require('../utils/remoteLicense');
// Load Public Key for Verification // Load Public Key for Verification
const publicKeyPath = path.join(__dirname, '../public_key.pem'); const publicKeyPath = path.join(__dirname, '../config/public_key.pem');
let publicKey = null; let publicKey = null;
try { try {
if (fs.existsSync(publicKeyPath)) { if (fs.existsSync(publicKeyPath)) {
@ -20,25 +20,34 @@ try {
console.error('Error loading public key:', e); console.error('Error loading public key:', e);
} }
// 0. Server Configuration (Subscriber ID) // 0. Server Configuration (Subscriber ID & Session Timeout)
router.get('/config', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
try { try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'"); const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')");
res.json({ subscriber_id: rows.length > 0 ? rows[0].setting_value : '' }); const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value);
res.json({
subscriber_id: settings.subscriber_id || '',
session_timeout: parseInt(settings.session_timeout) || 60 // Default 60 min
});
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });
} }
}); });
router.post('/config', isAuthenticated, hasRole('admin'), async (req, res) => { router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
const { subscriber_id } = req.body; const { subscriber_id, session_timeout } = req.body;
if (!subscriber_id) return res.status(400).json({ error: 'Subscriber ID is required' });
try { try {
const sql = `INSERT INTO system_settings (setting_key, setting_value) VALUES ('subscriber_id', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`; if (subscriber_id !== undefined) {
await db.query(sql, [subscriber_id]); 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]);
res.json({ message: 'Subscriber ID saved' }); }
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()]);
}
res.json({ message: 'Settings saved' });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });
@ -150,10 +159,6 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]); await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]);
// 5. Update Issued License Status (if exists in issued_licenses table)
// This ensures CLI 'list' command reflects activation even if done via UI
await db.query("UPDATE issued_licenses SET status = 'ACTIVE' WHERE license_key = ?", [licenseKey]);
res.json({ success: true, message: 'Module activated', type: result.type, expiry: result.expiryDate }); res.json({ success: true, message: 'Module activated', type: result.type, expiry: result.expiryDate });
} catch (err) { } catch (err) {

View File

@ -8,6 +8,7 @@ import { AssetRegisterPage } from '../modules/asset/pages/AssetRegisterPage';
import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage'; import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage';
import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage'; import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage';
import { UserManagementPage } from '../platform/pages/UserManagementPage'; import { UserManagementPage } from '../platform/pages/UserManagementPage';
import { BasicSettingsPage } from '../platform/pages/BasicSettingsPage';
import { ProductionPage } from '../production/pages/ProductionPage'; import { ProductionPage } from '../production/pages/ProductionPage';
import { MonitoringPage } from '../modules/cctv/pages/MonitoringPage'; import { MonitoringPage } from '../modules/cctv/pages/MonitoringPage';
import { AuthProvider } from '../shared/auth/AuthContext'; import { AuthProvider } from '../shared/auth/AuthContext';
@ -59,8 +60,9 @@ function App() {
{/* Admin Routes */} {/* Admin Routes */}
<Route path="/admin/users" element={<UserManagementPage />} /> <Route path="/admin/users" element={<UserManagementPage />} />
<Route path="/admin/settings" element={<BasicSettingsPage />} />
<Route path="/admin/license" element={<LicensePage />} /> <Route path="/admin/license" element={<LicensePage />} />
<Route path="/admin" element={<div>Admin (Coming Soon)</div>} /> <Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
</Route> </Route>
{/* Fallback */} {/* Fallback */}

View File

@ -37,11 +37,11 @@ export function LoginPage() {
<div className="brand-logo"> <div className="brand-logo">
<Box size={32} /> <Box size={32} />
</div> </div>
<h1>SmartAsset</h1> <h1>Smart IMS</h1>
<p> </p> <p> / </p>
</div> </div>
<form onSubmit={handleSubmit} className="login-form"> <form onSubmit={handleSubmit} className="login-form" autoComplete="off">
<div className="form-group"> <div className="form-group">
<label htmlFor="id"></label> <label htmlFor="id"></label>
<div className="input-wrapper"> <div className="input-wrapper">
@ -53,6 +53,7 @@ export function LoginPage() {
onChange={(e) => setId(e.target.value)} onChange={(e) => setId(e.target.value)}
placeholder="아이디를 입력하세요" placeholder="아이디를 입력하세요"
required required
autoComplete="one-time-code"
/> />
</div> </div>
</div> </div>
@ -68,6 +69,7 @@ export function LoginPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호를 입력하세요" placeholder="비밀번호를 입력하세요"
required required
autoComplete="new-password"
/> />
</div> </div>
</div> </div>
@ -80,7 +82,7 @@ export function LoginPage() {
</form> </form>
<div className="login-footer"> <div className="login-footer">
<p>© 2026 SmartAsset System. All rights reserved.</p> <p>© 2026 Smart IMS System. All rights reserved.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,108 @@
import { useState, useEffect } from 'react';
import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input';
import { apiClient } from '../../shared/api/client';
import { Save, Clock, Info } from 'lucide-react';
export function BasicSettingsPage() {
const [settings, setSettings] = useState({
subscriber_id: '',
session_timeout: 60
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const res = await apiClient.get('/system/settings');
setSettings(res.data);
} catch (error) {
console.error('Failed to fetch settings', error);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
await apiClient.post('/system/settings', settings);
alert('설정이 저장되었습니다.');
} catch (error) {
console.error('Save failed', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
if (loading) return <div className="p-6"> ...</div>;
return (
<div className="page-container p-6 max-w-4xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Info size={20} className="text-indigo-500" />
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> ID ( )</label>
<Input
value={settings.subscriber_id}
onChange={e => setSettings({ ...settings, subscriber_id: e.target.value })}
placeholder="예: SOKR-2024-001"
/>
<p className="text-xs text-slate-400 mt-1"> .</p>
</div>
</div>
</Card>
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Clock size={20} className="text-amber-500" />
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> ()</label>
<div className="flex items-center gap-3">
<Input
type="number"
min="5"
max="1440"
className="w-32"
value={settings.session_timeout}
onChange={e => setSettings({ ...settings, session_timeout: parseInt(e.target.value) })}
/>
<span className="text-slate-500 text-sm"> .</span>
</div>
<p className="text-xs text-slate-400 mt-1">( 5 ~ 24)</p>
</div>
</div>
</Card>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={saving}
icon={<Save size={18} />}
>
{saving ? '저장 중...' : '설정 저장'}
</Button>
</div>
</div>
</div>
);
}

View File

@ -28,7 +28,7 @@ export function MainLayout() {
<div className="sidebar-header"> <div className="sidebar-header">
<div className="brand"> <div className="brand">
<Box className="brand-icon" size={24} /> <Box className="brand-icon" size={24} />
<span className="brand-text">Platform</span> <span className="brand-text">Smart IMS</span>
</div> </div>
</div> </div>
@ -49,6 +49,10 @@ export function MainLayout() {
{expandedModules.includes('sys_mgmt') && ( {expandedModules.includes('sys_mgmt') && (
<div className="module-items"> <div className="module-items">
<Link to="/admin/settings" className={`nav-item ${location.pathname.includes('/admin/settings') ? 'active' : ''}`}>
<Settings size={18} />
<span> </span>
</Link>
<Link to="/admin/users" className={`nav-item ${location.pathname.includes('/admin/users') ? 'active' : ''}`}> <Link to="/admin/users" className={`nav-item ${location.pathname.includes('/admin/users') ? 'active' : ''}`}>
<UserIcon size={18} /> <UserIcon size={18} />
<span> </span> <span> </span>

View File

@ -1,5 +1,5 @@
/** /**
* SmartAsset License Management CLI * SmartIMS License Management CLI
* *
* Usage: node tools/license_manager.cjs <command> [args] * Usage: node tools/license_manager.cjs <command> [args]
* *
@ -33,7 +33,7 @@ const dbConfig = {
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root', user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'smartasset_db', database: process.env.DB_NAME || 'sokuree_platform_dev',
port: process.env.DB_PORT || 3306 port: process.env.DB_PORT || 3306
}; };
@ -105,7 +105,7 @@ async function main() {
function printUsage() { function printUsage() {
console.log(` console.log(`
SmartAsset License Manager SmartIMS License Manager
Usage: node tools/license_manager.cjs <command> [args] Usage: node tools/license_manager.cjs <command> [args]
Commands: Commands: