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'))
# Web 静态文件
static_dir = os.path.join(project_root, 'static')
static_dir = os.path.join(project_root, 'ui', 'static')
if os.path.exists(static_dir):
for root, dirs, files in os.walk(static_dir):
for f in files:
datas.append((os.path.join(root, f), 'static'))
datas.append((os.path.join(root, f), 'ui/static'))
# Web 模板
templates_dir = os.path.join(project_root, 'templates')
templates_dir = os.path.join(project_root, 'ui', 'templates')
if os.path.exists(templates_dir):
for root, dirs, files in os.walk(templates_dir):
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_src = os.path.join(project_root, 'web_api.py')

View File

@ -7,6 +7,7 @@ import argparse
import threading
import time
import hashlib
from collections import defaultdict
from datetime import timedelta
from flask import Flask, request, jsonify, session, redirect, url_for, render_template
from flask_cors import CORS
@ -20,16 +21,85 @@ from core.activity_recorder import get_recorder
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 文件,用户信息在数据库)
def load_secret_key():
import json
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json')
defaults = {"SECRET_KEY": "trulymem-secret-key-2026"}
if os.path.exists(config_path):
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"]
search_paths = [
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json'),
os.path.join(os.getcwd(), 'web_config.json'),
os.path.join(os.path.expanduser("~"), ".trulymem", 'web_config.json'),
]
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
WEB_CONFIG = load_secret_key()
@ -177,6 +247,13 @@ def api_login():
username = data.get('username', '')
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()
if g_db and g_db.verify_web_user(username, password):
@ -187,8 +264,10 @@ def api_login():
# 重新加载服务器使用该用户的数据库
reload_server_for_user(username)
_record_login_success(client_ip)
return jsonify({"success": True})
_record_login_fail(client_ip)
return jsonify({"success": False, "error": "用户名或密码错误"})

View File

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

View File

@ -101,10 +101,15 @@
}
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.back-link {
display: block; text-align: center; margin-top: 20px; color: #6666aa;
text-decoration: none; font-size: 13px; transition: color 0.3s;
display: block; text-align: center; margin-top: 20px; color: #4488ff;
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 {
display: none; margin-top: 12px; padding: 10px;
border-radius: 6px; font-size: 13px; text-align: center;
@ -259,7 +264,7 @@
</form>
<!-- TUI 服务控制 -->
<div class="section-title">🖥 TUI 终端服务</div>
<div class="section-title"><i class="fas fa-terminal"></i> TUI 终端服务</div>
<div class="toggle-row">
<div>
<div class="toggle-label">启用终端 TUI 服务</div>
@ -278,7 +283,7 @@
<!-- 用户管理(仅管理员可见) -->
<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">
<table class="user-table" id="userTable">
<thead>
@ -297,7 +302,8 @@
</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>
<!-- 添加用户弹窗 -->
@ -356,11 +362,11 @@
const data = await resp.json();
if (data.success) {
document.getElementById('enableTuiToggle').checked = data.enable_tui !== false;
document.getElementById('tuiStatus').textContent =
data.enable_tui ? '✅ TUI 服务已启用' : '⏹️ TUI 服务已禁用';
document.getElementById('tuiStatus').innerHTML =
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) {
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();
if (data.success) {
showAddUserSuccess(' 用户添加成功');
showAddUserSuccess('<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> 用户添加成功');
setTimeout(() => {
document.getElementById('addUserModal').classList.remove('show');
document.getElementById('addUserForm').reset();
@ -483,7 +489,7 @@
function showAddUserSuccess(msg) {
const el = document.getElementById('addUserSuccess');
el.textContent = msg;
el.innerHTML = msg;
el.classList.add('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_memory_query').value = limits.memory_query_max || 30;
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) {
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.textContent = '保存中...';
const statusEl = document.getElementById('apiConfigStatus');
statusEl.textContent = '🔄 保存中...';
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin" style="color:var(--primary-color,#4488ff)"></i> 保存中...';
try {
const resp = await fetch('/api/settings', {
@ -552,13 +558,13 @@
});
const data = await resp.json();
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);
} else {
statusEl.textContent = '⚠️ 保存失败: ' + (data.error || '未知错误');
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 保存失败: ' + (data.error || '未知错误');
}
} catch (e) {
statusEl.textContent = '⚠️ 网络错误';
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 网络错误';
} finally {
btn.disabled = false;
btn.textContent = '保 存 配 置';
@ -572,7 +578,7 @@
btn.disabled = true;
btn.textContent = '保存中...';
const statusEl = document.getElementById('limitsStatus');
statusEl.textContent = '🔄 保存中...';
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin" style="color:var(--primary-color,#4488ff)"></i> 保存中...';
try {
const resp = await fetch('/api/settings', {
@ -590,13 +596,13 @@
});
const data = await resp.json();
if (data.success) {
statusEl.textContent = '✅ 工具限制已保存并生效';
statusEl.innerHTML = '<i class="fas fa-check-circle" style="color:var(--success-color,#22c55e)"></i> 工具限制已保存并生效';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else {
statusEl.textContent = '⚠️ 保存失败: ' + (data.error || '未知错误');
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 保存失败: ' + (data.error || '未知错误');
}
} catch (e) {
statusEl.textContent = '⚠️ 网络错误';
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:var(--warning-color,#f59e0b)"></i> 网络错误';
} finally {
btn.disabled = false;
btn.textContent = '保 存 限 制';
@ -613,7 +619,7 @@
// TUI 开关
document.getElementById('enableTuiToggle').addEventListener('change', async function() {
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 {
const resp = await fetch('/api/settings/config', {
method: 'PUT',
@ -622,15 +628,15 @@
});
const data = await resp.json();
if (data.success) {
document.getElementById('tuiStatus').textContent =
enable ? '✅ TUI 服务已启用' : '⏹️ TUI 服务已禁用';
document.getElementById('tuiStatus').innerHTML =
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('设置已保存');
} 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;
}
} 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;
}
});
@ -665,7 +671,7 @@
});
const data = await resp.json();
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('new_password').value = '';
document.getElementById('confirm_password').value = '';
@ -682,7 +688,7 @@
function showSuccess(msg) {
const el = document.getElementById('successMsg');
el.textContent = msg;
el.innerHTML = msg;
el.classList.add('show');
document.getElementById('errorMsg').classList.remove('show');
setTimeout(() => el.classList.remove('show'), 3000);