라이선스 관리 DB 분리
This commit is contained in:
parent
a771fc0561
commit
7ee6446f0f
@ -1,6 +1,6 @@
|
||||
# CCTV 모바일 영상 및 카메라 설정 가이드
|
||||
|
||||
본 문서는 SmartAsset CCTV 모니터링 모듈의 고급 설정 기능을 설명합니다.
|
||||
본 문서는 Smart IMS CCTV 모니터링 모듈의 고급 설정 기능을 설명합니다.
|
||||
|
||||
## 1. 전송 방식 (Transport Mode)
|
||||
오래된 카메라나 네트워크 환경에 따라 RTSP 데이터 전송 방식을 선택할 수 있습니다.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 시스템 배포 가이드 (Deployment Guide)
|
||||
|
||||
본 문서는 빌드된 스마트어셋(SmartAsset) 솔루션을 실서버(Synology NAS 등)에 배포할 때 필요한 절차와 주의사항을 설명합니다.
|
||||
본 문서는 빌드된 스마트 IMS(Smart IMS) 솔루션을 실서버(Synology NAS 등)에 배포할 때 필요한 절차와 주의사항을 설명합니다.
|
||||
|
||||
## 1. 배포 대상 폴더 및 파일
|
||||
서버에 업로드해야 하는 핵심 구성 요소는 다음과 같습니다.
|
||||
@ -52,7 +52,7 @@
|
||||
업로드가 완료된 후 서버 터미널(SSH)에서 다음 명령을 실행합니다.
|
||||
|
||||
1. **의존성 설치**: `cd server` 이동 후 `npm install`
|
||||
2. **서비스 시작**: `pm2 start index.js --name "smartasset"`
|
||||
2. **서비스 시작**: `pm2 start index.js --name "smartims"`
|
||||
3. **상태 확인**: `pm2 status`를 통해 서버가 `online` 상태인지 확인합니다.
|
||||
|
||||
---
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 라이선스 관리자 매뉴얼 (최종본)
|
||||
|
||||
본 문서는 스마트어셋(SmartAsset) 시스템의 라이선스 관리 기능을 사용하는 방법을 설명합니다. 모든 라이선스 관리는 **RSA 비대칭 암호화** 방식을 따르며, `tools/license_manager.cjs` CLI 도구를 통해 수행됩니다.
|
||||
본 문서는 스마트 IMS(Smart IMS) 시스템의 라이선스 관리 기능을 사용하는 방법을 설명합니다. 모든 라이선스 관리는 **RSA 비대칭 암호화** 방식을 따르며, `tools/license_manager.cjs` CLI 도구를 통해 수행됩니다.
|
||||
|
||||
## 1. 사전 준비 및 구독자 설정 (중요)
|
||||
라이선스 키를 활성화하기 전에 서버가 어떤 고객사(구독자)에게 속해 있는지 식별하기 위해 **구독자 ID**를 설정해야 합니다.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# SmartAsset Project - Task & Feature Roadmap
|
||||
# Smart IMS Project - Task & Feature Roadmap
|
||||
|
||||
## 1. Core Platform & Security
|
||||
- [x] **Project Initialization**: Created Vite + React + TypeScript base.
|
||||
|
||||
@ -3,9 +3,10 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<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>
|
||||
</head>
|
||||
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp_app",
|
||||
"version": "0.0.0",
|
||||
"name": "smartims",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temp_app",
|
||||
"version": "0.0.0",
|
||||
"name": "smartims",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "temp_app",
|
||||
"name": "smartims",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
||||
@ -5,7 +5,7 @@ async function addColumn() {
|
||||
process.env.DB_HOST = process.env.DB_HOST || 'localhost';
|
||||
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_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}`);
|
||||
|
||||
|
||||
8
server/config/public_key.pem
Normal file
8
server/config/public_key.pem
Normal 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-----
|
||||
@ -62,19 +62,33 @@ app.use('/uploads', express.static(uploadDir));
|
||||
|
||||
// Session Middleware
|
||||
app.use(session({
|
||||
key: 'smartasset_sid',
|
||||
secret: process.env.SESSION_SECRET || 'smartasset_session_secret_key',
|
||||
key: 'smartims_sid',
|
||||
secret: process.env.SESSION_SECRET || 'smartims_session_secret_key',
|
||||
store: sessionStore,
|
||||
resave: false,
|
||||
saveUninitialized: true, // Save new sessions even if empty (helps with some client handshake issues)
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true, // Prevent JS access
|
||||
httpOnly: true,
|
||||
secure: false, // Set true if using HTTPS
|
||||
maxAge: 1000 * 60 * 60 * 24, // 1 day
|
||||
sameSite: 'lax' // Recommended for better CSRF protection and reliability
|
||||
maxAge: null, // Browser session by default
|
||||
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
|
||||
app.use(csrfProtection);
|
||||
|
||||
|
||||
@ -127,7 +127,7 @@ class StreamRelay {
|
||||
const transportMode = process.env.CCTV_TRANSPORT_OVERRIDE || camera.transport_mode || 'tcp';
|
||||
|
||||
// 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
|
||||
let scaleFilter = [];
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@ -10,7 +10,7 @@ const { generateToken } = require('../middleware/csrfMiddleware');
|
||||
// Key must be 32 bytes for aes-256-cbc
|
||||
// 'my_super_secret_key_manage_asset' is 32 chars?
|
||||
// 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.
|
||||
// Let's pad it to ensure stability if env is missing.
|
||||
const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32);
|
||||
@ -127,7 +127,7 @@ router.post('/logout', (req, res) => {
|
||||
console.error('Logout error:', err);
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ const { generateLicense, verifyLicense } = require('../utils/licenseManager');
|
||||
const { checkRemoteKey } = require('../utils/remoteLicense');
|
||||
|
||||
// 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;
|
||||
try {
|
||||
if (fs.existsSync(publicKeyPath)) {
|
||||
@ -20,25 +20,34 @@ try {
|
||||
console.error('Error loading public key:', e);
|
||||
}
|
||||
|
||||
// 0. Server Configuration (Subscriber ID)
|
||||
router.get('/config', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
// 0. Server Configuration (Subscriber ID & Session Timeout)
|
||||
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||
res.json({ subscriber_id: rows.length > 0 ? rows[0].setting_value : '' });
|
||||
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')");
|
||||
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) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/config', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { subscriber_id } = req.body;
|
||||
if (!subscriber_id) return res.status(400).json({ error: 'Subscriber ID is required' });
|
||||
router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { subscriber_id, session_timeout } = req.body;
|
||||
|
||||
try {
|
||||
const sql = `INSERT INTO system_settings (setting_key, setting_value) VALUES ('subscriber_id', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`;
|
||||
await db.query(sql, [subscriber_id]);
|
||||
res.json({ message: 'Subscriber ID saved' });
|
||||
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()]);
|
||||
}
|
||||
res.json({ message: 'Settings saved' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
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]);
|
||||
|
||||
// 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 });
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@ -8,6 +8,7 @@ import { AssetRegisterPage } from '../modules/asset/pages/AssetRegisterPage';
|
||||
import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage';
|
||||
import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage';
|
||||
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';
|
||||
@ -59,8 +60,9 @@ function App() {
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin/users" element={<UserManagementPage />} />
|
||||
<Route path="/admin/settings" element={<BasicSettingsPage />} />
|
||||
<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>
|
||||
|
||||
{/* Fallback */}
|
||||
|
||||
@ -37,11 +37,11 @@ export function LoginPage() {
|
||||
<div className="brand-logo">
|
||||
<Box size={32} />
|
||||
</div>
|
||||
<h1>SmartAsset</h1>
|
||||
<p>통합 자산관리 시스템</p>
|
||||
<h1>Smart IMS</h1>
|
||||
<p>통합 자산/공정 관리 플랫폼</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<form onSubmit={handleSubmit} className="login-form" autoComplete="off">
|
||||
<div className="form-group">
|
||||
<label htmlFor="id">아이디</label>
|
||||
<div className="input-wrapper">
|
||||
@ -53,6 +53,7 @@ export function LoginPage() {
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="아이디를 입력하세요"
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -68,6 +69,7 @@ export function LoginPage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -80,7 +82,7 @@ export function LoginPage() {
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>© 2026 SmartAsset System. All rights reserved.</p>
|
||||
<p>© 2026 Smart IMS System. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
108
src/platform/pages/BasicSettingsPage.tsx
Normal file
108
src/platform/pages/BasicSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -28,7 +28,7 @@ export function MainLayout() {
|
||||
<div className="sidebar-header">
|
||||
<div className="brand">
|
||||
<Box className="brand-icon" size={24} />
|
||||
<span className="brand-text">Platform</span>
|
||||
<span className="brand-text">Smart IMS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -49,6 +49,10 @@ export function MainLayout() {
|
||||
|
||||
{expandedModules.includes('sys_mgmt') && (
|
||||
<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' : ''}`}>
|
||||
<UserIcon size={18} />
|
||||
<span>사용자 관리</span>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* SmartAsset License Management CLI
|
||||
* SmartIMS License Management CLI
|
||||
*
|
||||
* Usage: node tools/license_manager.cjs <command> [args]
|
||||
*
|
||||
@ -33,7 +33,7 @@ const dbConfig = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
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
|
||||
};
|
||||
|
||||
@ -105,7 +105,7 @@ async function main() {
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
SmartAsset License Manager
|
||||
SmartIMS License Manager
|
||||
Usage: node tools/license_manager.cjs <command> [args]
|
||||
|
||||
Commands:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user