chore: UI 图标替换 Font Awesome + 登录安全限制 + 配置加载路径优化

This commit is contained in:
root
2026-04-29 08:34:24 +08:00
parent 529672a93a
commit bb426cdb45
4 changed files with 134 additions and 49 deletions

View File

@ -23,18 +23,18 @@ if os.path.exists(prompt_tmpl_dir):
datas.append((os.path.join(root, f), 'core/prompts/templates')) datas.append((os.path.join(root, f), 'core/prompts/templates'))
# Web 静态文件 # Web 静态文件
static_dir = os.path.join(project_root, 'static') static_dir = os.path.join(project_root, 'ui', 'static')
if os.path.exists(static_dir): if os.path.exists(static_dir):
for root, dirs, files in os.walk(static_dir): for root, dirs, files in os.walk(static_dir):
for f in files: for f in files:
datas.append((os.path.join(root, f), 'static')) datas.append((os.path.join(root, f), 'ui/static'))
# Web 模板 # Web 模板
templates_dir = os.path.join(project_root, 'templates') templates_dir = os.path.join(project_root, 'ui', 'templates')
if os.path.exists(templates_dir): if os.path.exists(templates_dir):
for root, dirs, files in os.walk(templates_dir): for root, dirs, files in os.walk(templates_dir):
for f in files: for f in files:
datas.append((os.path.join(root, f), 'templates')) datas.append((os.path.join(root, f), 'ui/templates'))
# Web API 脚本(以便子进程模式回退使用) # Web API 脚本(以便子进程模式回退使用)
web_api_src = os.path.join(project_root, 'web_api.py') web_api_src = os.path.join(project_root, 'web_api.py')

View File

@ -7,6 +7,7 @@ import argparse
import threading import threading
import time import time
import hashlib import hashlib
from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from flask import Flask, request, jsonify, session, redirect, url_for, render_template from flask import Flask, request, jsonify, session, redirect, url_for, render_template
from flask_cors import CORS from flask_cors import CORS
@ -20,16 +21,85 @@ from core.activity_recorder import get_recorder
from core.embedded_db import EmbeddedGraphDB from core.embedded_db import EmbeddedGraphDB
# 登录安全限制
LOGIN_MAX_ATTEMPTS = 5 # 最大尝试次数
LOGIN_WAIT_MINUTES = 5 # 超过次数后等待分钟数
LOGIN_BAN_THRESHOLD = 3 # 超过此轮次后 ban IP
LOGIN_BAN_HOURS = 24 # IP ban 时长(小时)
# 内存记录:{ip: {"attempts": 0, "first_fail": 0, "ban_until": 0, "rounds": 0}}
_login_attempts: dict = {}
def _check_login_limit(ip: str) -> dict:
"""检查 IP 的登录限制。返回 {"blocked": bool, "reason": str, "wait_seconds": int}"""
now = time.time()
record = _login_attempts.get(ip)
if record:
# 检查是否在 ban 中
if record["ban_until"] > now:
remaining = int(record["ban_until"] - now)
return {"blocked": True, "reason": f"IP 已被临时封禁,剩余 {remaining//60} 分钟", "wait_seconds": remaining}
# 检查是否需要等待(连续失败超过阈值)
if record["attempts"] >= LOGIN_MAX_ATTEMPTS:
wait_end = record["first_fail"] + LOGIN_WAIT_MINUTES * 60
if wait_end > now:
remaining = int(wait_end - now)
return {"blocked": True, "reason": f"登录尝试过多,请等待 {remaining} 秒后再试", "wait_seconds": remaining}
else:
# 等待时间已过,重置计数但记录轮次
record["rounds"] += 1
record["attempts"] = 0
record["first_fail"] = 0
# 如果轮次超过阈值则 ban IP
if record["rounds"] >= LOGIN_BAN_THRESHOLD:
record["ban_until"] = now + LOGIN_BAN_HOURS * 3600
record["rounds"] = 0
return {"blocked": True, "reason": f"多次登录失败,IP 已被封禁 {LOGIN_BAN_HOURS} 小时", "wait_seconds": LOGIN_BAN_HOURS * 3600}
return {"blocked": False, "reason": "", "wait_seconds": 0}
def _record_login_fail(ip: str):
"""记录一次登录失败"""
now = time.time()
record = _login_attempts.get(ip)
if not record:
_login_attempts[ip] = {"attempts": 1, "first_fail": now, "ban_until": 0, "rounds": 0}
else:
if record["first_fail"] == 0:
record["first_fail"] = now
record["attempts"] += 1
def _record_login_success(ip: str):
"""登录成功后清除该 IP 的记录"""
_login_attempts.pop(ip, None)
# 定期清理过期记录(防止内存泄漏)
_cleanup_interval = 3600 # 1小时
_last_cleanup = time.time()
# Web 服务配置(仅 SECRET_KEY 保留在 json 文件,用户信息在数据库) # Web 服务配置(仅 SECRET_KEY 保留在 json 文件,用户信息在数据库)
def load_secret_key(): def load_secret_key():
import json import json
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json')
defaults = {"SECRET_KEY": "trulymem-secret-key-2026"} defaults = {"SECRET_KEY": "trulymem-secret-key-2026"}
if os.path.exists(config_path): search_paths = [
with open(config_path, 'r', encoding='utf-8') as f: os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json'),
file_config = json.load(f) os.path.join(os.getcwd(), 'web_config.json'),
if "SECRET_KEY" in file_config: os.path.join(os.path.expanduser("~"), ".trulymem", 'web_config.json'),
defaults["SECRET_KEY"] = file_config["SECRET_KEY"] ]
for config_path in search_paths:
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
file_config = json.load(f)
if "SECRET_KEY" in file_config:
defaults["SECRET_KEY"] = file_config["SECRET_KEY"]
break
except Exception:
continue
return defaults return defaults
WEB_CONFIG = load_secret_key() WEB_CONFIG = load_secret_key()
@ -177,6 +247,13 @@ def api_login():
username = data.get('username', '') username = data.get('username', '')
password = data.get('password', '') password = data.get('password', '')
client_ip = request.remote_addr or "unknown"
# 登录安全限制检查
limit_check = _check_login_limit(client_ip)
if limit_check["blocked"]:
return jsonify({"success": False, "error": limit_check["reason"]}), 429
# 从全局数据库验证 # 从全局数据库验证
g_db = get_global_db() g_db = get_global_db()
if g_db and g_db.verify_web_user(username, password): if g_db and g_db.verify_web_user(username, password):
@ -187,8 +264,10 @@ def api_login():
# 重新加载服务器使用该用户的数据库 # 重新加载服务器使用该用户的数据库
reload_server_for_user(username) reload_server_for_user(username)
_record_login_success(client_ip)
return jsonify({"success": True}) return jsonify({"success": True})
_record_login_fail(client_ip)
return jsonify({"success": False, "error": "用户名或密码错误"}) return jsonify({"success": False, "error": "用户名或密码错误"})

View File

@ -438,9 +438,9 @@
<input type="text" id="chatInput" placeholder="输入消息..." autocomplete="off"> <input type="text" id="chatInput" placeholder="输入消息..." autocomplete="off">
<button id="chatSendBtn">发送</button> <button id="chatSendBtn">发送</button>
</div> </div>
<div class="chat-status" id="chatStatus">🟢 已连接</div> <div class="chat-status" id="chatStatus"><span class="status-dot" style="color:#22c55e">●</span> 已连接</div>
</div> </div>
<button id="toggle-chat">📌</button> <button id="toggle-chat"><i class="fas fa-thumbtack"></i></button>
</div> <!-- /app-container --> </div> <!-- /app-container -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
@ -1780,13 +1780,13 @@ function smoothReposition() {
toggleChat.addEventListener('click', () => { toggleChat.addEventListener('click', () => {
chatCollapsed = !chatCollapsed; chatCollapsed = !chatCollapsed;
chatPanel.classList.toggle('collapsed', chatCollapsed); chatPanel.classList.toggle('collapsed', chatCollapsed);
toggleChat.textContent = chatCollapsed ? '📎' : '📌'; toggleChat.innerHTML = chatCollapsed ? "<i class=\"fas fa-paperclip\"></i>" : "<i class=\"fas fa-thumbtack\"></i>";
setTimeout(onWindowResize, 300); setTimeout(onWindowResize, 300);
}); });
chatCloseBtn.addEventListener('click', () => { chatCloseBtn.addEventListener('click', () => {
chatCollapsed = true; chatCollapsed = true;
chatPanel.classList.add('collapsed'); chatPanel.classList.add('collapsed');
toggleChat.textContent = '📎'; toggleChat.innerHTML = '<i class="fas fa-paperclip"></i>';
setTimeout(onWindowResize, 300); setTimeout(onWindowResize, 300);
}); });
@ -1856,16 +1856,16 @@ function smoothReposition() {
if (data.success) { if (data.success) {
const reply = data.content || data.data?.content || '(无回复)'; const reply = data.content || data.data?.content || '(无回复)';
addChatMessage('assistant', reply); addChatMessage('assistant', reply);
chatStatus.textContent = '🟢 已连接'; chatStatus.innerHTML = '<span class="status-dot" style="color:#22c55e">●</span> 已连接';
chatStatus.className = 'chat-status'; chatStatus.className = 'chat-status';
} else { } else {
addChatMessage('assistant', '❌ ' + (data.error || '请求失败')); addChatMessage('assistant', '❌ ' + (data.error || '请求失败'));
chatStatus.textContent = '🔴 错误: ' + (data.error || 'unknown'); chatStatus.innerHTML = '<span class="status-dot" style="color:#ef4444">●</span> 错误: ' + (data.error || 'unknown');
chatStatus.className = 'chat-status error'; chatStatus.className = 'chat-status error';
} }
} catch(e) { } catch(e) {
addChatMessage('assistant', '❌ 网络错误: ' + e.message); addChatMessage('assistant', '❌ 网络错误: ' + e.message);
chatStatus.textContent = '🔴 网络错误'; chatStatus.innerHTML = '<span class="status-dot" style="color:#ef4444">●</span> 网络错误';
chatStatus.className = 'chat-status error'; chatStatus.className = 'chat-status error';
} }
chatInput.disabled = false; chatInput.disabled = false;

View File

@ -101,10 +101,15 @@
} }
.btn:disabled { opacity: 0.6; cursor: not-allowed; } .btn:disabled { opacity: 0.6; cursor: not-allowed; }
.back-link { .back-link {
display: block; text-align: center; margin-top: 20px; color: #6666aa; display: block; text-align: center; margin-top: 20px; color: #4488ff;
text-decoration: none; font-size: 13px; transition: color 0.3s; text-decoration: none; cursor: pointer; font-size: 13px; transition: color 0.3s;
} }
.back-link:hover { color: #4488ff; } .back-link:hover { color: #66aaff; }
.logout-link {
display: block; text-align: center; margin-top: 8px; color: #ff4488;
text-decoration: none; cursor: pointer; font-size: 13px; transition: color 0.3s;
}
.logout-link:hover { color: #ff66aa; }
.success-msg, .error-msg { .success-msg, .error-msg {
display: none; margin-top: 12px; padding: 10px; display: none; margin-top: 12px; padding: 10px;
border-radius: 6px; font-size: 13px; text-align: center; border-radius: 6px; font-size: 13px; text-align: center;
@ -259,7 +264,7 @@
</form> </form>
<!-- TUI 服务控制 --> <!-- TUI 服务控制 -->
<div class="section-title">🖥 TUI 终端服务</div> <div class="section-title"><i class="fas fa-terminal"></i> TUI 终端服务</div>
<div class="toggle-row"> <div class="toggle-row">
<div> <div>
<div class="toggle-label">启用终端 TUI 服务</div> <div class="toggle-label">启用终端 TUI 服务</div>
@ -278,7 +283,7 @@
<!-- 用户管理(仅管理员可见) --> <!-- 用户管理(仅管理员可见) -->
<div id="adminSection" style="display:none;"> <div id="adminSection" style="display:none;">
<div class="section-title">👥 用户管理</div> <div class="section-title"><i class="fas fa-users"></i> 用户管理</div>
<div class="user-section"> <div class="user-section">
<table class="user-table" id="userTable"> <table class="user-table" id="userTable">
<thead> <thead>
@ -297,7 +302,8 @@
</div> </div>
</div> </div>
<a href="/graph.html" class="back-link">← 返回星图</a> <a href="/graph.html" class="back-link"><i class="fas fa-arrow-left"></i> 返回星图</a>
<a href="#" class="logout-link" onclick="event.preventDefault(); fetch('/api/logout', {method: 'POST'}).then(() => window.location.href='/login');"><i class="fas fa-sign-out-alt"></i> 退出登录</a>
</div> </div>
<!-- 添加用户弹窗 --> <!-- 添加用户弹窗 -->
@ -356,11 +362,11 @@
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
document.getElementById('enableTuiToggle').checked = data.enable_tui !== false; document.getElementById('enableTuiToggle').checked = data.enable_tui !== false;
document.getElementById('tuiStatus').textContent = document.getElementById('tuiStatus').innerHTML =
data.enable_tui ? '✅ TUI 服务已启用' : '⏹️ TUI 服务已禁用'; data.enable_tui ? '<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> TUI 服务已启用' : '<i class="fas fa-stop-circle" style="color:var(--warning-color,#f59e0b)"></i> TUI 服务已禁用';
} }
} catch (e) { } catch (e) {
document.getElementById('tuiStatus').textContent = '⚠️ 无法加载配置'; document.getElementById('tuiStatus').innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 无法加载配置';
} }
} }
@ -464,7 +470,7 @@
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
showAddUserSuccess('✅ 用户添加成功'); showAddUserSuccess('<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> 用户添加成功');
setTimeout(() => { setTimeout(() => {
document.getElementById('addUserModal').classList.remove('show'); document.getElementById('addUserModal').classList.remove('show');
document.getElementById('addUserForm').reset(); document.getElementById('addUserForm').reset();
@ -483,7 +489,7 @@
function showAddUserSuccess(msg) { function showAddUserSuccess(msg) {
const el = document.getElementById('addUserSuccess'); const el = document.getElementById('addUserSuccess');
el.textContent = msg; el.innerHTML = msg;
el.classList.add('show'); el.classList.add('show');
document.getElementById('addUserError').classList.remove('show'); document.getElementById('addUserError').classList.remove('show');
} }
@ -521,10 +527,10 @@
document.getElementById('settings_task_query').value = limits.task_query_max || 30; document.getElementById('settings_task_query').value = limits.task_query_max || 30;
document.getElementById('settings_memory_query').value = limits.memory_query_max || 30; document.getElementById('settings_memory_query').value = limits.memory_query_max || 30;
document.getElementById('settings_memory_update').value = limits.memory_update_max || 15; document.getElementById('settings_memory_update').value = limits.memory_update_max || 15;
document.getElementById('apiConfigStatus').textContent = '✅ 配置已加载'; document.getElementById('apiConfigStatus').innerHTML = '<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> 配置已加载';
} }
} catch (e) { } catch (e) {
document.getElementById('apiConfigStatus').textContent = '⚠️ 无法加载配置'; document.getElementById('apiConfigStatus').innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 无法加载配置';
} }
} }
@ -535,7 +541,7 @@
btn.disabled = true; btn.disabled = true;
btn.textContent = '保存中...'; btn.textContent = '保存中...';
const statusEl = document.getElementById('apiConfigStatus'); const statusEl = document.getElementById('apiConfigStatus');
statusEl.textContent = '🔄 保存中...'; statusEl.innerHTML = '<i class="fas fa-spinner fa-spin" style="color:var(--primary-color,#4488ff)"></i> 保存中...';
try { try {
const resp = await fetch('/api/settings', { const resp = await fetch('/api/settings', {
@ -552,13 +558,13 @@
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
statusEl.textContent = '✅ API 配置已保存并生效'; statusEl.innerHTML = '<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> API 配置已保存并生效';
setTimeout(() => { statusEl.textContent = ''; }, 3000); setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else { } else {
statusEl.textContent = '⚠️ 保存失败: ' + (data.error || '未知错误'); statusEl.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 保存失败: ' + (data.error || '未知错误');
} }
} catch (e) { } catch (e) {
statusEl.textContent = '⚠️ 网络错误'; statusEl.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 网络错误';
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = '保 存 配 置'; btn.textContent = '保 存 配 置';
@ -572,7 +578,7 @@
btn.disabled = true; btn.disabled = true;
btn.textContent = '保存中...'; btn.textContent = '保存中...';
const statusEl = document.getElementById('limitsStatus'); const statusEl = document.getElementById('limitsStatus');
statusEl.textContent = '🔄 保存中...'; statusEl.innerHTML = '<i class="fas fa-spinner fa-spin" style="color:var(--primary-color,#4488ff)"></i> 保存中...';
try { try {
const resp = await fetch('/api/settings', { const resp = await fetch('/api/settings', {
@ -590,13 +596,13 @@
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
statusEl.textContent = '✅ 工具限制已保存并生效'; statusEl.innerHTML = '<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> 工具限制已保存并生效';
setTimeout(() => { statusEl.textContent = ''; }, 3000); setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else { } else {
statusEl.textContent = '⚠️ 保存失败: ' + (data.error || '未知错误'); statusEl.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 保存失败: ' + (data.error || '未知错误');
} }
} catch (e) { } catch (e) {
statusEl.textContent = '⚠️ 网络错误'; statusEl.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 网络错误';
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = '保 存 限 制'; btn.textContent = '保 存 限 制';
@ -613,7 +619,7 @@
// TUI 开关 // TUI 开关
document.getElementById('enableTuiToggle').addEventListener('change', async function() { document.getElementById('enableTuiToggle').addEventListener('change', async function() {
const enable = this.checked; const enable = this.checked;
document.getElementById('tuiStatus').textContent = '🔄 更新中...'; document.getElementById('tuiStatus').innerHTML = '<i class="fas fa-spinner fa-spin" style="color:var(--primary-color,#4488ff)"></i> 更新中...';
try { try {
const resp = await fetch('/api/settings/config', { const resp = await fetch('/api/settings/config', {
method: 'PUT', method: 'PUT',
@ -622,15 +628,15 @@
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
document.getElementById('tuiStatus').textContent = document.getElementById('tuiStatus').innerHTML =
enable ? '✅ TUI 服务已启用' : '⏹️ TUI 服务已禁用'; enable ? '<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> TUI 服务已启用' : '<i class="fas fa-stop-circle" style="color:var(--warning-color,#f59e0b)"></i> TUI 服务已禁用';
showSuccess('设置已保存'); showSuccess('设置已保存');
} else { } else {
document.getElementById('tuiStatus').textContent = '⚠️ 更新失败'; document.getElementById('tuiStatus').innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 更新失败';
this.checked = !enable; this.checked = !enable;
} }
} catch (e) { } catch (e) {
document.getElementById('tuiStatus').textContent = '⚠️ 网络错误'; document.getElementById('tuiStatus').innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 网络错误';
this.checked = !enable; this.checked = !enable;
} }
}); });
@ -665,7 +671,7 @@
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
showSuccess('✅ 密码已更新'); showSuccess('<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> 密码已更新');
document.getElementById('current_password').value = ''; document.getElementById('current_password').value = '';
document.getElementById('new_password').value = ''; document.getElementById('new_password').value = '';
document.getElementById('confirm_password').value = ''; document.getElementById('confirm_password').value = '';
@ -682,7 +688,7 @@
function showSuccess(msg) { function showSuccess(msg) {
const el = document.getElementById('successMsg'); const el = document.getElementById('successMsg');
el.textContent = msg; el.innerHTML = msg;
el.classList.add('show'); el.classList.add('show');
document.getElementById('errorMsg').classList.remove('show'); document.getElementById('errorMsg').classList.remove('show');
setTimeout(() => el.classList.remove('show'), 3000); setTimeout(() => el.classList.remove('show'), 3000);