refactor: God Mode 安全防護
This commit is contained in:
parent
bc933e1e39
commit
061ee3435a
289
all_code.txt
289
all_code.txt
|
|
@ -150,28 +150,36 @@ async def upsert_device_status(host: str, config_type: str, metadata: dict) -> b
|
||||||
if not pool:
|
if not pool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Extract known fields
|
cmts_version = metadata.get("cmts_version")
|
||||||
cmts_version = metadata.get("cmts_version", "unknown")
|
|
||||||
last_scanned = metadata.get("last_scanned", None)
|
last_scanned = metadata.get("last_scanned", None)
|
||||||
|
|
||||||
query = """
|
|
||||||
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (host, config_type)
|
|
||||||
DO UPDATE SET
|
|
||||||
cmts_version = EXCLUDED.cmts_version,
|
|
||||||
last_scanned = EXCLUDED.last_scanned,
|
|
||||||
updated_at = CURRENT_TIMESTAMP;
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(query, host, config_type, cmts_version, last_scanned)
|
# 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號!
|
||||||
|
if cmts_version and cmts_version != "unknown":
|
||||||
|
query = """
|
||||||
|
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (host, config_type)
|
||||||
|
DO UPDATE SET
|
||||||
|
cmts_version = EXCLUDED.cmts_version,
|
||||||
|
last_scanned = EXCLUDED.last_scanned,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
"""
|
||||||
|
await conn.execute(query, host, config_type, cmts_version, last_scanned)
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
INSERT INTO device_status (host, config_type, last_scanned)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (host, config_type)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_scanned = EXCLUDED.last_scanned,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
"""
|
||||||
|
await conn.execute(query, host, config_type, last_scanned)
|
||||||
return True
|
return True
|
||||||
except asyncpg.PostgresError as e:
|
|
||||||
logger.error(f"❌ DB Error (upsert_device_status): {e}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Unknown Error (upsert_device_status): {e}")
|
logger.error(f"❌ DB Error (upsert_device_status): {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
|
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|
@ -1247,6 +1255,31 @@ FILE: index.html
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:God Mode 授權視窗 (Modal) -->
|
||||||
|
<div id="godModeModal" class="modal-overlay">
|
||||||
|
<!-- 將對話框置中,限制最大寬度,改變一下配色風格 -->
|
||||||
|
<div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
|
||||||
|
<div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
|
||||||
|
<div class="modal-title" style="justify-content: center; width: 100%;">
|
||||||
|
<span>🔐 系統進階授權</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
|
||||||
|
<p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
|
||||||
|
請輸入維護者密碼以解鎖隱藏功能
|
||||||
|
</p>
|
||||||
|
<input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
|
||||||
|
|
||||||
|
<span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 15px; justify-content: center;">
|
||||||
|
<button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
|
||||||
|
<button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 引入獨立的 JavaScript 邏輯 -->
|
<!-- 引入獨立的 JavaScript 邏輯 -->
|
||||||
<script type="module" src="/static/app.js"></script>
|
<script type="module" src="/static/app.js"></script>
|
||||||
|
|
||||||
|
|
@ -1542,9 +1575,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
|
|
||||||
# 🌟 新增:在第一批次連線時,先抓取設備版本
|
# 🌟 新增:在第一批次連線時,先抓取設備版本
|
||||||
if cmts_version == "unknown":
|
if cmts_version == "unknown":
|
||||||
process.stdin.write("show version\n")
|
# 🌟 補上 | nomore,並稍微延長等待時間,確保不會被分頁卡住
|
||||||
|
process.stdin.write("show version | nomore\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
|
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
|
||||||
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
||||||
if match:
|
if match:
|
||||||
cmts_version = match.group(1)
|
cmts_version = match.group(1)
|
||||||
|
|
@ -1806,7 +1840,7 @@ from contextlib import asynccontextmanager
|
||||||
import database
|
import database
|
||||||
|
|
||||||
# 引入我們剛剛拆分出來的路由模組
|
# 引入我們剛剛拆分出來的路由模組
|
||||||
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics
|
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
|
@ -1828,6 +1862,7 @@ app.include_router(leaf_options.router, prefix="/api/v1")
|
||||||
app.include_router(lock.router, prefix="/api/v1")
|
app.include_router(lock.router, prefix="/api/v1")
|
||||||
app.include_router(backup.router, prefix="/api/v1")
|
app.include_router(backup.router, prefix="/api/v1")
|
||||||
app.include_router(diagnostics.router, prefix="/api/v1")
|
app.include_router(diagnostics.router, prefix="/api/v1")
|
||||||
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
|
||||||
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
||||||
app.include_router(terminal.router)
|
app.include_router(terminal.router)
|
||||||
|
|
@ -2223,6 +2258,23 @@ export async function apiSetLogLevel(module, level) {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 7. 授權與驗證 API (Auth)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiVerifyGodMode(password) {
|
||||||
|
const response = await fetch('/api/v1/auth/god-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || "驗證失敗");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
FILE: static/app.js
|
FILE: static/app.js
|
||||||
|
|
@ -2244,7 +2296,8 @@ import {
|
||||||
apiGetScanStatus, apiGetLockStatus,
|
apiGetScanStatus, apiGetLockStatus,
|
||||||
apiGetCmDiagnostics,
|
apiGetCmDiagnostics,
|
||||||
apiListCms, apiListRpds,
|
apiListCms, apiListRpds,
|
||||||
apiGetLogLevels, apiSetLogLevel
|
apiGetLogLevels, apiSetLogLevel,
|
||||||
|
apiVerifyGodMode
|
||||||
} from './api.js';
|
} from './api.js';
|
||||||
|
|
||||||
// 3. 共用工具模組 (Utils)
|
// 3. 共用工具模組 (Utils)
|
||||||
|
|
@ -2828,7 +2881,8 @@ async function fetchFullConfig(configType = 'running') {
|
||||||
const optData = await optRes.json();
|
const optData = await optRes.json();
|
||||||
const cachedVersion = optData.__metadata__?.cmts_version;
|
const cachedVersion = optData.__metadata__?.cmts_version;
|
||||||
|
|
||||||
if (cachedVersion && cachedVersion !== currentVersion) {
|
// 🌟 關鍵修正:當資料庫的版本為 unknown 時,不視為版本變更,直接放行,避免無意義的彈窗干擾
|
||||||
|
if (cachedVersion && cachedVersion !== 'unknown' && cachedVersion !== currentVersion) {
|
||||||
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
|
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
|
||||||
if (doClear) {
|
if (doClear) {
|
||||||
const allKeys = Object.keys(optData);
|
const allKeys = Object.keys(optData);
|
||||||
|
|
@ -3787,19 +3841,24 @@ window.applyBackupFilters = function() {
|
||||||
|
|
||||||
// 渲染過濾後的結果
|
// 渲染過濾後的結果
|
||||||
if (filteredData.length > 0) {
|
if (filteredData.length > 0) {
|
||||||
|
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
|
||||||
|
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||||||
|
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
||||||
|
|
||||||
tbody.innerHTML = filteredData.map(item => `
|
tbody.innerHTML = filteredData.map(item => `
|
||||||
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
||||||
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
||||||
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
||||||
<!-- 🟢 新增描述欄位,加上防撐破設計與 title 提示 -->
|
<!-- 🟢 描述欄位 -->
|
||||||
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
|
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
|
||||||
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
|
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
||||||
<td style="padding: 12px 10px; text-align: right;">
|
<td style="padding: 12px 10px; text-align: right;">
|
||||||
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
|
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
|
||||||
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
<!-- 🌟 加上 admin-only-action class 與動態 display 樣式,只有解鎖後才會出現 -->
|
||||||
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
||||||
|
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
@ -4124,44 +4183,127 @@ function closeModal() {
|
||||||
document.addEventListener("DOMContentLoaded", checkScanButtonState);
|
document.addEventListener("DOMContentLoaded", checkScanButtonState);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯
|
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
|
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
|
||||||
|
|
||||||
|
// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
|
||||||
|
function unlockAdminFeatures() {
|
||||||
|
// 1. 解鎖全域快取掃描按鈕
|
||||||
|
ADMIN_BTN_IDS.forEach(id => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
if (btn) btn.style.display = 'inline-block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕
|
||||||
|
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
|
||||||
|
btn.style.display = 'inline-block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕
|
||||||
|
document.querySelectorAll('.admin-only-action').forEach(btn => {
|
||||||
|
btn.style.display = 'inline-block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const appTitle = document.getElementById('app-title');
|
const appTitle = document.getElementById('app-title');
|
||||||
|
|
||||||
function unlockAdminFeatures() {
|
// 🌟 1. 頁面初始化檢查 (依賴 sessionStorage,關閉網頁即失效)
|
||||||
ADMIN_BTN_IDS.forEach(id => {
|
if (sessionStorage.getItem('godModeUnlocked') === 'true') {
|
||||||
const btn = document.getElementById(id);
|
unlockAdminFeatures();
|
||||||
if (btn) btn.style.display = 'inline-block';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
|
|
||||||
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
|
|
||||||
btn.style.display = 'inline-block';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
// 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門
|
||||||
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
|
|
||||||
|
|
||||||
|
// 🌟 3. 連點標題觸發機制
|
||||||
let clickCount = 0;
|
let clickCount = 0;
|
||||||
let clickTimer = null;
|
let clickTimer = null;
|
||||||
if (appTitle) {
|
if (appTitle) {
|
||||||
appTitle.addEventListener('click', () => {
|
appTitle.addEventListener('click', () => {
|
||||||
|
// 已解鎖則忽略
|
||||||
|
if (sessionStorage.getItem('godModeUnlocked') === 'true') return;
|
||||||
|
|
||||||
clickCount++;
|
clickCount++;
|
||||||
if (clickCount === 5) {
|
if (clickCount === 5) {
|
||||||
unlockAdminFeatures();
|
openGodModeModal(); // 喚出密碼對話框
|
||||||
alert('🔓 已解鎖系統維護者模式!');
|
|
||||||
clickCount = 0;
|
clickCount = 0;
|
||||||
}
|
}
|
||||||
clearTimeout(clickTimer);
|
clearTimeout(clickTimer);
|
||||||
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
|
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌟 4. 綁定密碼框的 Enter 快捷鍵
|
||||||
|
const pwInput = document.getElementById('godModePassword');
|
||||||
|
if (pwInput) {
|
||||||
|
pwInput.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') window.verifyGodMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🌟 Modal 操作與 API 驗證邏輯
|
||||||
|
window.openGodModeModal = function() {
|
||||||
|
const modal = document.getElementById('godModeModal');
|
||||||
|
const input = document.getElementById('godModePassword');
|
||||||
|
const errorSpan = document.getElementById('god-mode-error');
|
||||||
|
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
input.value = '';
|
||||||
|
input.classList.remove('shake-animation');
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeGodModeModal = function() {
|
||||||
|
document.getElementById('godModeModal').classList.remove('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.verifyGodMode = async function() {
|
||||||
|
const input = document.getElementById('godModePassword');
|
||||||
|
const errorSpan = document.getElementById('god-mode-error');
|
||||||
|
const btn = document.getElementById('btn-unlock-godmode');
|
||||||
|
const pwd = input.value.trim();
|
||||||
|
|
||||||
|
if (!pwd) {
|
||||||
|
errorSpan.textContent = '密碼不可為空!';
|
||||||
|
triggerShake(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '⏳ 驗證中...';
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
input.classList.remove('shake-animation');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiVerifyGodMode(pwd);
|
||||||
|
|
||||||
|
// 驗證成功
|
||||||
|
sessionStorage.setItem('godModeUnlocked', 'true');
|
||||||
|
closeGodModeModal();
|
||||||
|
alert('🔓 密碼正確!已解鎖系統維護者模式。');
|
||||||
|
unlockAdminFeatures();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
// 驗證失敗
|
||||||
|
errorSpan.textContent = '❌ 密碼錯誤,請重新輸入';
|
||||||
|
triggerShake(input);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '🚀 解鎖';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function triggerShake(element) {
|
||||||
|
element.classList.remove('shake-animation');
|
||||||
|
void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發
|
||||||
|
element.classList.add('shake-animation');
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
|
||||||
// 統一控制 4 顆按鈕的狀態
|
// 統一控制 4 顆按鈕的狀態
|
||||||
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
||||||
ADMIN_BTN_IDS.forEach(id => {
|
ADMIN_BTN_IDS.forEach(id => {
|
||||||
|
|
@ -4712,6 +4854,21 @@ button:hover { background-color: #3498db; }
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
🚨 密碼輸入錯誤震動動畫 (Shake Effect)
|
||||||
|
========================================= */
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake-animation {
|
||||||
|
animation: shake 0.4s ease-in-out;
|
||||||
|
border-color: #e74c3c !important; /* 震動時外框變紅 */
|
||||||
|
background-color: #fdedec !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
FILE: static/edit-mode.js
|
FILE: static/edit-mode.js
|
||||||
|
|
@ -4753,7 +4910,21 @@ export let currentEditDiffs = [];
|
||||||
|
|
||||||
export async function releaseAllLocks() {
|
export async function releaseAllLocks() {
|
||||||
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
||||||
if (keysToRelease.length === 0) return;
|
|
||||||
|
// 🌟 1. 檢查當前是否處於 God Mode 狀態
|
||||||
|
const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||||||
|
|
||||||
|
// 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限
|
||||||
|
sessionStorage.removeItem('godModeUnlocked');
|
||||||
|
|
||||||
|
if (keysToRelease.length === 0) {
|
||||||
|
// 如果沒有正在編輯的節點,但剛剛是 God Mode,依然要登出並重整
|
||||||
|
if (wasGodModeUnlocked) {
|
||||||
|
alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。");
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const releasePromises = keysToRelease.map(key => {
|
const releasePromises = keysToRelease.map(key => {
|
||||||
const [host, path] = key.split('@@');
|
const [host, path] = key.split('@@');
|
||||||
|
|
@ -4764,8 +4935,8 @@ export async function releaseAllLocks() {
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(releasePromises);
|
await Promise.all(releasePromises);
|
||||||
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
|
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。");
|
||||||
location.reload();
|
location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||||||
|
|
@ -5049,7 +5220,9 @@ export async function startEditLeaf(path, elementId) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
optData = await apiGetLeafOptions(host, currentMode);
|
optData = await apiGetLeafOptions(host, currentMode);
|
||||||
leafCache = optData[cacheKey];
|
leafCache = optData[cacheKey];
|
||||||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
|
||||||
|
// 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來
|
||||||
|
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
|
||||||
found = true; break;
|
found = true; break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7612,6 +7785,36 @@ async def list_rpds(host: str, username: str, password: str):
|
||||||
return {"rpds": []}
|
return {"rpds": []}
|
||||||
|
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
FILE: routers/auth.py
|
||||||
|
================================================================================
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("app.auth")
|
||||||
|
router = APIRouter(tags=["Auth"])
|
||||||
|
|
||||||
|
# 定義請求本體格式
|
||||||
|
class GodModeRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
|
||||||
|
async def verify_god_mode(req: GodModeRequest):
|
||||||
|
# 從環境變數讀取正確密碼,預設為 "admin999"
|
||||||
|
expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm")
|
||||||
|
|
||||||
|
# 使用 compare_digest 防禦計時攻擊
|
||||||
|
if secrets.compare_digest(req.password, expected_password):
|
||||||
|
logger.info("🔓 上帝模式解鎖成功!")
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid authorization code")
|
||||||
|
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
FILE: routers/config.py
|
FILE: routers/config.py
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|
|
||||||
|
|
@ -185,9 +185,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
|
|
||||||
# 🌟 新增:在第一批次連線時,先抓取設備版本
|
# 🌟 新增:在第一批次連線時,先抓取設備版本
|
||||||
if cmts_version == "unknown":
|
if cmts_version == "unknown":
|
||||||
process.stdin.write("show version\n")
|
# 🌟 補上 | nomore,並稍微延長等待時間,確保不會被分頁卡住
|
||||||
|
process.stdin.write("show version | nomore\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
|
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
|
||||||
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
||||||
if match:
|
if match:
|
||||||
cmts_version = match.group(1)
|
cmts_version = match.group(1)
|
||||||
|
|
|
||||||
40
database.py
40
database.py
|
|
@ -142,28 +142,36 @@ async def upsert_device_status(host: str, config_type: str, metadata: dict) -> b
|
||||||
if not pool:
|
if not pool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Extract known fields
|
cmts_version = metadata.get("cmts_version")
|
||||||
cmts_version = metadata.get("cmts_version", "unknown")
|
|
||||||
last_scanned = metadata.get("last_scanned", None)
|
last_scanned = metadata.get("last_scanned", None)
|
||||||
|
|
||||||
query = """
|
|
||||||
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (host, config_type)
|
|
||||||
DO UPDATE SET
|
|
||||||
cmts_version = EXCLUDED.cmts_version,
|
|
||||||
last_scanned = EXCLUDED.last_scanned,
|
|
||||||
updated_at = CURRENT_TIMESTAMP;
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(query, host, config_type, cmts_version, last_scanned)
|
# 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號!
|
||||||
|
if cmts_version and cmts_version != "unknown":
|
||||||
|
query = """
|
||||||
|
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (host, config_type)
|
||||||
|
DO UPDATE SET
|
||||||
|
cmts_version = EXCLUDED.cmts_version,
|
||||||
|
last_scanned = EXCLUDED.last_scanned,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
"""
|
||||||
|
await conn.execute(query, host, config_type, cmts_version, last_scanned)
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
INSERT INTO device_status (host, config_type, last_scanned)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (host, config_type)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_scanned = EXCLUDED.last_scanned,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
"""
|
||||||
|
await conn.execute(query, host, config_type, last_scanned)
|
||||||
return True
|
return True
|
||||||
except asyncpg.PostgresError as e:
|
|
||||||
logger.error(f"❌ DB Error (upsert_device_status): {e}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Unknown Error (upsert_device_status): {e}")
|
logger.error(f"❌ DB Error (upsert_device_status): {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
|
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|
|
||||||
25
index.html
25
index.html
|
|
@ -575,6 +575,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:God Mode 授權視窗 (Modal) -->
|
||||||
|
<div id="godModeModal" class="modal-overlay">
|
||||||
|
<!-- 將對話框置中,限制最大寬度,改變一下配色風格 -->
|
||||||
|
<div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
|
||||||
|
<div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
|
||||||
|
<div class="modal-title" style="justify-content: center; width: 100%;">
|
||||||
|
<span>🔐 系統進階授權</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
|
||||||
|
<p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
|
||||||
|
請輸入維護者密碼以解鎖隱藏功能
|
||||||
|
</p>
|
||||||
|
<input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
|
||||||
|
|
||||||
|
<span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 15px; justify-content: center;">
|
||||||
|
<button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
|
||||||
|
<button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 引入獨立的 JavaScript 邏輯 -->
|
<!-- 引入獨立的 JavaScript 邏輯 -->
|
||||||
<script type="module" src="/static/app.js"></script>
|
<script type="module" src="/static/app.js"></script>
|
||||||
|
|
||||||
|
|
|
||||||
3
main.py
3
main.py
|
|
@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
|
||||||
import database
|
import database
|
||||||
|
|
||||||
# 引入我們剛剛拆分出來的路由模組
|
# 引入我們剛剛拆分出來的路由模組
|
||||||
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics
|
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
|
@ -29,6 +29,7 @@ app.include_router(leaf_options.router, prefix="/api/v1")
|
||||||
app.include_router(lock.router, prefix="/api/v1")
|
app.include_router(lock.router, prefix="/api/v1")
|
||||||
app.include_router(backup.router, prefix="/api/v1")
|
app.include_router(backup.router, prefix="/api/v1")
|
||||||
app.include_router(diagnostics.router, prefix="/api/v1")
|
app.include_router(diagnostics.router, prefix="/api/v1")
|
||||||
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
|
||||||
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
||||||
app.include_router(terminal.router)
|
app.include_router(terminal.router)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("app.auth")
|
||||||
|
router = APIRouter(tags=["Auth"])
|
||||||
|
|
||||||
|
# 定義請求本體格式
|
||||||
|
class GodModeRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
|
||||||
|
async def verify_god_mode(req: GodModeRequest):
|
||||||
|
# 從環境變數讀取正確密碼,預設為 "admin999"
|
||||||
|
expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm")
|
||||||
|
|
||||||
|
# 使用 compare_digest 防禦計時攻擊
|
||||||
|
if secrets.compare_digest(req.password, expected_password):
|
||||||
|
logger.info("🔓 上帝模式解鎖成功!")
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid authorization code")
|
||||||
|
|
@ -225,3 +225,20 @@ export async function apiSetLogLevel(module, level) {
|
||||||
});
|
});
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 7. 授權與驗證 API (Auth)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiVerifyGodMode(password) {
|
||||||
|
const response = await fetch('/api/v1/auth/god-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || "驗證失敗");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
|
||||||
130
static/app.js
130
static/app.js
|
|
@ -15,7 +15,8 @@ import {
|
||||||
apiGetScanStatus, apiGetLockStatus,
|
apiGetScanStatus, apiGetLockStatus,
|
||||||
apiGetCmDiagnostics,
|
apiGetCmDiagnostics,
|
||||||
apiListCms, apiListRpds,
|
apiListCms, apiListRpds,
|
||||||
apiGetLogLevels, apiSetLogLevel
|
apiGetLogLevels, apiSetLogLevel,
|
||||||
|
apiVerifyGodMode
|
||||||
} from './api.js';
|
} from './api.js';
|
||||||
|
|
||||||
// 3. 共用工具模組 (Utils)
|
// 3. 共用工具模組 (Utils)
|
||||||
|
|
@ -599,7 +600,8 @@ async function fetchFullConfig(configType = 'running') {
|
||||||
const optData = await optRes.json();
|
const optData = await optRes.json();
|
||||||
const cachedVersion = optData.__metadata__?.cmts_version;
|
const cachedVersion = optData.__metadata__?.cmts_version;
|
||||||
|
|
||||||
if (cachedVersion && cachedVersion !== currentVersion) {
|
// 🌟 關鍵修正:當資料庫的版本為 unknown 時,不視為版本變更,直接放行,避免無意義的彈窗干擾
|
||||||
|
if (cachedVersion && cachedVersion !== 'unknown' && cachedVersion !== currentVersion) {
|
||||||
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
|
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
|
||||||
if (doClear) {
|
if (doClear) {
|
||||||
const allKeys = Object.keys(optData);
|
const allKeys = Object.keys(optData);
|
||||||
|
|
@ -1558,19 +1560,24 @@ window.applyBackupFilters = function() {
|
||||||
|
|
||||||
// 渲染過濾後的結果
|
// 渲染過濾後的結果
|
||||||
if (filteredData.length > 0) {
|
if (filteredData.length > 0) {
|
||||||
|
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
|
||||||
|
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||||||
|
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
||||||
|
|
||||||
tbody.innerHTML = filteredData.map(item => `
|
tbody.innerHTML = filteredData.map(item => `
|
||||||
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
||||||
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
||||||
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
||||||
<!-- 🟢 新增描述欄位,加上防撐破設計與 title 提示 -->
|
<!-- 🟢 描述欄位 -->
|
||||||
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
|
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
|
||||||
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
|
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
||||||
<td style="padding: 12px 10px; text-align: right;">
|
<td style="padding: 12px 10px; text-align: right;">
|
||||||
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
|
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
|
||||||
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
<!-- 🌟 加上 admin-only-action class 與動態 display 樣式,只有解鎖後才會出現 -->
|
||||||
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
||||||
|
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
@ -1895,44 +1902,127 @@ function closeModal() {
|
||||||
document.addEventListener("DOMContentLoaded", checkScanButtonState);
|
document.addEventListener("DOMContentLoaded", checkScanButtonState);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯
|
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
|
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
|
||||||
|
|
||||||
|
// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
|
||||||
|
function unlockAdminFeatures() {
|
||||||
|
// 1. 解鎖全域快取掃描按鈕
|
||||||
|
ADMIN_BTN_IDS.forEach(id => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
if (btn) btn.style.display = 'inline-block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕
|
||||||
|
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
|
||||||
|
btn.style.display = 'inline-block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕
|
||||||
|
document.querySelectorAll('.admin-only-action').forEach(btn => {
|
||||||
|
btn.style.display = 'inline-block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const appTitle = document.getElementById('app-title');
|
const appTitle = document.getElementById('app-title');
|
||||||
|
|
||||||
function unlockAdminFeatures() {
|
// 🌟 1. 頁面初始化檢查 (依賴 sessionStorage,關閉網頁即失效)
|
||||||
ADMIN_BTN_IDS.forEach(id => {
|
if (sessionStorage.getItem('godModeUnlocked') === 'true') {
|
||||||
const btn = document.getElementById(id);
|
unlockAdminFeatures();
|
||||||
if (btn) btn.style.display = 'inline-block';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
|
|
||||||
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
|
|
||||||
btn.style.display = 'inline-block';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
// 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門
|
||||||
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
|
|
||||||
|
|
||||||
|
// 🌟 3. 連點標題觸發機制
|
||||||
let clickCount = 0;
|
let clickCount = 0;
|
||||||
let clickTimer = null;
|
let clickTimer = null;
|
||||||
if (appTitle) {
|
if (appTitle) {
|
||||||
appTitle.addEventListener('click', () => {
|
appTitle.addEventListener('click', () => {
|
||||||
|
// 已解鎖則忽略
|
||||||
|
if (sessionStorage.getItem('godModeUnlocked') === 'true') return;
|
||||||
|
|
||||||
clickCount++;
|
clickCount++;
|
||||||
if (clickCount === 5) {
|
if (clickCount === 5) {
|
||||||
unlockAdminFeatures();
|
openGodModeModal(); // 喚出密碼對話框
|
||||||
alert('🔓 已解鎖系統維護者模式!');
|
|
||||||
clickCount = 0;
|
clickCount = 0;
|
||||||
}
|
}
|
||||||
clearTimeout(clickTimer);
|
clearTimeout(clickTimer);
|
||||||
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
|
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌟 4. 綁定密碼框的 Enter 快捷鍵
|
||||||
|
const pwInput = document.getElementById('godModePassword');
|
||||||
|
if (pwInput) {
|
||||||
|
pwInput.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') window.verifyGodMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🌟 Modal 操作與 API 驗證邏輯
|
||||||
|
window.openGodModeModal = function() {
|
||||||
|
const modal = document.getElementById('godModeModal');
|
||||||
|
const input = document.getElementById('godModePassword');
|
||||||
|
const errorSpan = document.getElementById('god-mode-error');
|
||||||
|
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
input.value = '';
|
||||||
|
input.classList.remove('shake-animation');
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeGodModeModal = function() {
|
||||||
|
document.getElementById('godModeModal').classList.remove('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.verifyGodMode = async function() {
|
||||||
|
const input = document.getElementById('godModePassword');
|
||||||
|
const errorSpan = document.getElementById('god-mode-error');
|
||||||
|
const btn = document.getElementById('btn-unlock-godmode');
|
||||||
|
const pwd = input.value.trim();
|
||||||
|
|
||||||
|
if (!pwd) {
|
||||||
|
errorSpan.textContent = '密碼不可為空!';
|
||||||
|
triggerShake(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '⏳ 驗證中...';
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
input.classList.remove('shake-animation');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiVerifyGodMode(pwd);
|
||||||
|
|
||||||
|
// 驗證成功
|
||||||
|
sessionStorage.setItem('godModeUnlocked', 'true');
|
||||||
|
closeGodModeModal();
|
||||||
|
alert('🔓 密碼正確!已解鎖系統維護者模式。');
|
||||||
|
unlockAdminFeatures();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
// 驗證失敗
|
||||||
|
errorSpan.textContent = '❌ 密碼錯誤,請重新輸入';
|
||||||
|
triggerShake(input);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '🚀 解鎖';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function triggerShake(element) {
|
||||||
|
element.classList.remove('shake-animation');
|
||||||
|
void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發
|
||||||
|
element.classList.add('shake-animation');
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
|
||||||
// 統一控制 4 顆按鈕的狀態
|
// 統一控制 4 顆按鈕的狀態
|
||||||
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
||||||
ADMIN_BTN_IDS.forEach(id => {
|
ADMIN_BTN_IDS.forEach(id => {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,21 @@ export let currentEditDiffs = [];
|
||||||
|
|
||||||
export async function releaseAllLocks() {
|
export async function releaseAllLocks() {
|
||||||
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
||||||
if (keysToRelease.length === 0) return;
|
|
||||||
|
// 🌟 1. 檢查當前是否處於 God Mode 狀態
|
||||||
|
const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||||||
|
|
||||||
|
// 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限
|
||||||
|
sessionStorage.removeItem('godModeUnlocked');
|
||||||
|
|
||||||
|
if (keysToRelease.length === 0) {
|
||||||
|
// 如果沒有正在編輯的節點,但剛剛是 God Mode,依然要登出並重整
|
||||||
|
if (wasGodModeUnlocked) {
|
||||||
|
alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。");
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const releasePromises = keysToRelease.map(key => {
|
const releasePromises = keysToRelease.map(key => {
|
||||||
const [host, path] = key.split('@@');
|
const [host, path] = key.split('@@');
|
||||||
|
|
@ -46,8 +60,8 @@ export async function releaseAllLocks() {
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(releasePromises);
|
await Promise.all(releasePromises);
|
||||||
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
|
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。");
|
||||||
location.reload();
|
location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||||||
|
|
@ -331,7 +345,9 @@ export async function startEditLeaf(path, elementId) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
optData = await apiGetLeafOptions(host, currentMode);
|
optData = await apiGetLeafOptions(host, currentMode);
|
||||||
leafCache = optData[cacheKey];
|
leafCache = optData[cacheKey];
|
||||||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
|
||||||
|
// 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來
|
||||||
|
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
|
||||||
found = true; break;
|
found = true; break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -272,3 +272,18 @@ button:hover { background-color: #3498db; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
🚨 密碼輸入錯誤震動動畫 (Shake Effect)
|
||||||
|
========================================= */
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake-animation {
|
||||||
|
animation: shake 0.4s ease-in-out;
|
||||||
|
border-color: #e74c3c !important; /* 震動時外框變紅 */
|
||||||
|
background-color: #fdedec !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue