diff --git a/build/trulymem.spec b/build/trulymem.spec
index 9912589..72bc37d 100644
--- a/build/trulymem.spec
+++ b/build/trulymem.spec
@@ -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')
diff --git a/core/web_api.py b/core/web_api.py
index 5a657f7..0732244 100644
--- a/core/web_api.py
+++ b/core/web_api.py
@@ -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()
@@ -176,19 +246,28 @@ def api_login():
data = request.get_json() or {}
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):
session['authenticated'] = True
session['username'] = username # 存储用户名
session.permanent = True
-
+
# 重新加载服务器使用该用户的数据库
reload_server_for_user(username)
-
+
+ _record_login_success(client_ip)
return jsonify({"success": True})
-
+
+ _record_login_fail(client_ip)
return jsonify({"success": False, "error": "用户名或密码错误"})
diff --git a/ui/static/graph.html b/ui/static/graph.html
index ee4b8fa..b7b9ae4 100644
--- a/ui/static/graph.html
+++ b/ui/static/graph.html
@@ -438,9 +438,9 @@
-
🟢 已连接
+ ● 已连接
-
+
@@ -1780,13 +1780,13 @@ function smoothReposition() {
toggleChat.addEventListener('click', () => {
chatCollapsed = !chatCollapsed;
chatPanel.classList.toggle('collapsed', chatCollapsed);
- toggleChat.textContent = chatCollapsed ? '📎' : '📌';
+ toggleChat.innerHTML = chatCollapsed ? "" : "";
setTimeout(onWindowResize, 300);
});
chatCloseBtn.addEventListener('click', () => {
chatCollapsed = true;
chatPanel.classList.add('collapsed');
- toggleChat.textContent = '📎';
+ toggleChat.innerHTML = '';
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 = '● 已连接';
chatStatus.className = 'chat-status';
} else {
addChatMessage('assistant', '❌ ' + (data.error || '请求失败'));
- chatStatus.textContent = '🔴 错误: ' + (data.error || 'unknown');
+ chatStatus.innerHTML = '● 错误: ' + (data.error || 'unknown');
chatStatus.className = 'chat-status error';
}
} catch(e) {
addChatMessage('assistant', '❌ 网络错误: ' + e.message);
- chatStatus.textContent = '🔴 网络错误';
+ chatStatus.innerHTML = '● 网络错误';
chatStatus.className = 'chat-status error';
}
chatInput.disabled = false;
diff --git a/ui/templates/settings.html b/ui/templates/settings.html
index 3dcdb5e..e2e27f4 100644
--- a/ui/templates/settings.html
+++ b/ui/templates/settings.html
@@ -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 @@
- 🖥 TUI 终端服务
+ TUI 终端服务
启用终端 TUI 服务
@@ -278,7 +283,7 @@
-
👥 用户管理
+
用户管理
@@ -297,7 +302,8 @@
- ← 返回星图
+ 返回星图
+ 退出登录
@@ -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 ? ' TUI 服务已启用' : ' TUI 服务已禁用';
}
} catch (e) {
- document.getElementById('tuiStatus').textContent = '⚠️ 无法加载配置';
+ document.getElementById('tuiStatus').innerHTML = ' 无法加载配置';
}
}
@@ -464,7 +470,7 @@
});
const data = await resp.json();
if (data.success) {
- showAddUserSuccess('✅ 用户添加成功');
+ showAddUserSuccess(' 用户添加成功');
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 = ' 配置已加载';
}
} catch (e) {
- document.getElementById('apiConfigStatus').textContent = '⚠️ 无法加载配置';
+ document.getElementById('apiConfigStatus').innerHTML = ' 无法加载配置';
}
}
@@ -535,7 +541,7 @@
btn.disabled = true;
btn.textContent = '保存中...';
const statusEl = document.getElementById('apiConfigStatus');
- statusEl.textContent = '🔄 保存中...';
+ statusEl.innerHTML = ' 保存中...';
try {
const resp = await fetch('/api/settings', {
@@ -552,13 +558,13 @@
});
const data = await resp.json();
if (data.success) {
- statusEl.textContent = '✅ API 配置已保存并生效';
+ statusEl.innerHTML = ' API 配置已保存并生效';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else {
- statusEl.textContent = '⚠️ 保存失败: ' + (data.error || '未知错误');
+ statusEl.innerHTML = ' 保存失败: ' + (data.error || '未知错误');
}
} catch (e) {
- statusEl.textContent = '⚠️ 网络错误';
+ statusEl.innerHTML = ' 网络错误';
} finally {
btn.disabled = false;
btn.textContent = '保 存 配 置';
@@ -572,7 +578,7 @@
btn.disabled = true;
btn.textContent = '保存中...';
const statusEl = document.getElementById('limitsStatus');
- statusEl.textContent = '🔄 保存中...';
+ statusEl.innerHTML = ' 保存中...';
try {
const resp = await fetch('/api/settings', {
@@ -590,13 +596,13 @@
});
const data = await resp.json();
if (data.success) {
- statusEl.textContent = '✅ 工具限制已保存并生效';
+ statusEl.innerHTML = ' 工具限制已保存并生效';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else {
- statusEl.textContent = '⚠️ 保存失败: ' + (data.error || '未知错误');
+ statusEl.innerHTML = ' 保存失败: ' + (data.error || '未知错误');
}
} catch (e) {
- statusEl.textContent = '⚠️ 网络错误';
+ statusEl.innerHTML = ' 网络错误';
} 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 = ' 更新中...';
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 ? ' TUI 服务已启用' : ' TUI 服务已禁用';
showSuccess('设置已保存');
} else {
- document.getElementById('tuiStatus').textContent = '⚠️ 更新失败';
+ document.getElementById('tuiStatus').innerHTML = ' 更新失败';
this.checked = !enable;
}
} catch (e) {
- document.getElementById('tuiStatus').textContent = '⚠️ 网络错误';
+ document.getElementById('tuiStatus').innerHTML = ' 网络错误';
this.checked = !enable;
}
});
@@ -665,7 +671,7 @@
});
const data = await resp.json();
if (data.success) {
- showSuccess('✅ 密码已更新');
+ showSuccess(' 密码已更新');
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);