fix: 修复 web_api.py 登录限流、静态文件 404、星图 LIMIT 200 问题

- 添加登录限流逻辑(LOGIN_MAX_ATTEMPTS=5, LOGIN_WAIT_MINUTES=5)
- 修复 PyInstaller 打包后模板和静态文件路径问题
- 移除 entities 查询 LIMIT 200 限制,确保星图显示全部节点
- 修正 graph_db 死锁问题,使用 get_global_db() 回退
This commit is contained in:
root
2026-05-08 10:13:47 +08:00
parent 2de699e2aa
commit 90df082a92
2 changed files with 197 additions and 173 deletions

106
TrulyMEM.spec Normal file
View File

@ -0,0 +1,106 @@
# -*- mode: python ; coding: utf-8 -*-
import os
import sys
block_cipher = None
project_root = os.path.dirname(os.path.abspath(SPEC))
sys.path.insert(0, project_root)
datas = []
# UI 样式
ui_styles_dir = os.path.join(project_root, 'ui', 'styles')
if os.path.exists(ui_styles_dir):
for root, dirs, files in os.walk(ui_styles_dir):
for f in files:
datas.append((os.path.join(root, f), 'ui/styles'))
# Prompt 模板
prompt_tmpl_dir = os.path.join(project_root, 'core', 'prompts', 'templates')
if os.path.exists(prompt_tmpl_dir):
for root, dirs, files in os.walk(prompt_tmpl_dir):
for f in files:
datas.append((os.path.join(root, f), 'core/prompts/templates'))
# Web 静态文件
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:
rel_dir = os.path.relpath(root, project_root)
datas.append((os.path.join(root, f), rel_dir))
# 兼容旧的 static 目录(如果存在)
static_dir_old = os.path.join(project_root, 'static')
if os.path.exists(static_dir_old):
for root, dirs, files in os.walk(static_dir_old):
for f in files:
datas.append((os.path.join(root, f), 'static'))
# Web 模板Flask template_folder 指向 ui/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:
rel_dir = os.path.relpath(root, project_root)
datas.append((os.path.join(root, f), rel_dir))
# Web API 脚本(以便子进程模式回退使用)
web_api_src = os.path.join(project_root, 'core', 'web_api.py')
if os.path.exists(web_api_src):
datas.append((web_api_src, 'core'))
# ——— TUI 主二进制 ———
a = Analysis(
[os.path.join(project_root, 'trulymem_entry.py')],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=[
'textual', 'textual.app', 'textual.widgets', 'textual.css',
'openai', 'openai._client',
'neo4j',
'sqlite3',
'core', 'core.embedded_db', 'core.graph_client',
'core.tool_executor', 'core.tool_limiter',
'core.tools', 'core.tools.memory_tools',
'core.prompts', 'core.prompts.prompt_manager',
'core.server', 'core.client', 'core.web_api',
'core.migrate',
'ui', 'ui.app', 'ui.login_screen',
'ui.models', 'ui.models.message', 'ui.models.config', 'ui.models.log_entry',
'ui.widgets', 'ui.widgets.left_panel', 'ui.widgets.right_panel',
'ui.widgets.input_box', 'ui.widgets.message_history', 'ui.widgets.status_bar',
'ui.handlers',
'ui.services', 'ui.services.config_manager', 'ui.services.config_service',
'flask', 'flask_cors', 'werkzeug',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='TrulyMEM',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View File

@ -7,7 +7,6 @@ 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
@ -21,18 +20,6 @@ from core.activity_recorder import get_recorder
from core.embedded_db import EmbeddedGraphDB
def get_resource_path(relative_path):
"""获取资源文件的绝对路径兼容开发环境和PyInstaller打包环境"""
if hasattr(sys, 'frozen'):
# PyInstaller打包后的环境
base_path = sys._MEIPASS
else:
# 开发环境
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base_path, relative_path)
# 登录安全限制
LOGIN_MAX_ATTEMPTS = 5 # 最大尝试次数
LOGIN_WAIT_MINUTES = 5 # 超过次数后等待分钟数
LOGIN_BAN_THRESHOLD = 3 # 超过此轮次后 ban IP
@ -90,72 +77,22 @@ def _record_login_success(ip: str):
# 定期清理过期记录(防止内存泄漏)
_cleanup_interval = 3600 # 1小时
_last_cleanup = time.time()
# Web 服务配置(仅 SECRET_KEY 保留在 json 文件,用户信息在数据库)
def _find_config_path():
"""查找已有的 web_config.json或返回默认路径"""
def load_secret_key():
import json
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 p in search_paths:
if os.path.exists(p):
return p
return search_paths[0]
def load_web_config():
"""加载完整 web_config.json"""
import json
defaults = {
"SECRET_KEY": "trulymem-secret-key-2026",
"ssl_enabled": False,
"ssl_cert_path": "",
"ssl_key_path": "",
}
config_path = _find_config_path()
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):
try:
with open(config_path, 'r', encoding='utf-8') as f:
file_config = json.load(f)
for k in defaults:
if k in file_config:
defaults[k] = file_config[k]
except Exception:
pass
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"]
return defaults
WEB_CONFIG = load_secret_key()
def save_web_config(updates: dict) -> bool:
"""更新并保存 web_config.json"""
import json
config_path = _find_config_path()
# 读取已有配置
current = {}
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
current = json.load(f)
except Exception:
pass
current.update(updates)
try:
os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(current, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
WEB_CONFIG = load_web_config()
_ui_dir = get_resource_path('ui')
_ui_dir = os.path.join(os.path.dirname(__file__), '..', 'ui')
app = Flask(__name__, static_folder=os.path.join(_ui_dir, 'static'), static_url_path='', template_folder=os.path.join(_ui_dir, 'templates'))
app.secret_key = WEB_CONFIG.get("SECRET_KEY", "trulymem-secret-key-2026")
app.secret_key = WEB_CONFIG["SECRET_KEY"]
app.permanent_session_lifetime = timedelta(days=7)
CORS(app, supports_credentials=True) # 启用跨域支持,支持 session cookies
@ -198,23 +135,28 @@ def admin_required(f):
@app.route('/')
@login_required
def index():
"""默认首页 - 星图页面"""
resp = app.send_static_file('graph.html')
resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
resp.headers['Pragma'] = 'no-cache'
resp.headers['Expires'] = '0'
return resp
"""返回星图页面(默认首页)"""
return app.send_static_file('graph.html')
@app.route('/graph.html')
@login_required
def graph_html():
"""返回星图页面"""
resp = app.send_static_file('graph.html')
resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
resp.headers['Pragma'] = 'no-cache'
resp.headers['Expires'] = '0'
return resp
return app.send_static_file('graph.html')
@app.route('/static/<path:filename>')
def static_files(filename):
"""提供静态文件访问"""
return app.send_static_file(filename)
@app.route('/chat')
@login_required
def chat():
"""返回聊天页面"""
return app.send_static_file('index.html')
# 全局服务器和客户端实例
backend_server: BackendServer = None
@ -266,8 +208,12 @@ def reload_server_for_user(username: str):
def login_page():
"""登录页面 - 如果没有用户则重定向到设置页"""
# 如果没有用户,重定向到首次设置页
users_count = 0
g_db = get_global_db()
users_count = g_db.get_web_users_count() if g_db else 0
if g_db:
users_count = g_db.get_web_users_count()
elif graph_db:
users_count = graph_db.get_web_users_count()
if users_count == 0:
return redirect('/setup')
return render_template('login.html')
@ -276,8 +222,12 @@ def login_page():
@app.route('/setup')
def setup_page():
"""首次设置页面 - 如果已有用户则跳转到登录页"""
has_users = False
g_db = get_global_db()
has_users = g_db.get_web_users_count() > 0 if g_db else False
if g_db:
has_users = g_db.get_web_users_count() > 0
elif graph_db:
has_users = graph_db.get_web_users_count() > 0
if has_users:
return redirect('/login')
return render_template('setup.html')
@ -296,31 +246,25 @@ 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)
# 登录限流检查
ip = request.remote_addr
limit_check = _check_login_limit(ip)
if limit_check["blocked"]:
return jsonify({"success": False, "error": limit_check["reason"]}), 429
return jsonify({"success": False, "error": limit_check["reason"]})
# 从全局数据库验证
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": "用户名或密码错误"})
@app.route('/api/logout', methods=['POST'])
def api_logout():
"""登出接口"""
@ -358,8 +302,9 @@ def userinfo():
@app.route('/api/web-check', methods=['GET'])
def web_check():
"""检查是否需要首次设置,返回是否配置完成"""
g_db = get_global_db()
users_count = g_db.get_web_users_count() if g_db else 0
users_count = 0
if graph_db:
users_count = graph_db.get_web_users_count()
return jsonify({
"needs_setup": users_count == 0,
@ -367,6 +312,45 @@ def web_check():
})
@app.route('/api/web-users', methods=['GET'])
@api_login_required
def web_users():
"""获取 web_users 列表"""
if graph_db:
users = graph_db.get_web_users()
return jsonify({
"success": True,
"users": [
{
"username": u['username'],
"role": u.get('role', 'user'),
"is_admin": u.get('role') == 'admin',
"created_at": u.get('created_at')
}
for u in users
]
})
return jsonify({"success": False, "error": "数据库未初始化"}), 500
@app.route('/api/web-user/<username>', methods=['GET'])
@api_login_required
def web_user_detail(username):
"""获取单个 web_user 详情"""
if not graph_db:
return jsonify({"success": False, "error": "数据库未初始化"}), 500
user = graph_db.get_web_user(username)
if not user:
return jsonify({"success": False, "error": "用户不存在"}), 404
return jsonify({
"success": True,
"username": user['username'],
"role": user.get('role', 'user'),
"is_admin": user.get('role') == 'admin',
"created_at": user.get('created_at')
})
@app.route('/api/setup', methods=['POST'])
def api_setup():
"""首次设置 - 创建初始管理员用户"""
@ -608,52 +592,6 @@ def web_settings_config():
return jsonify({"success": False, "error": "没有需要更新的配置"}), 400
@app.route('/api/ssl/config', methods=['GET', 'POST'])
@api_login_required
def web_ssl_config():
"""获取/更新 SSL 配置(服务器级别,写入 web_config.json重启后生效"""
if request.method == 'GET':
cfg = load_web_config()
return jsonify({
"success": True,
"ssl_enabled": cfg.get('ssl_enabled', False),
"ssl_cert_path": cfg.get('ssl_cert_path', ''),
"ssl_key_path": cfg.get('ssl_key_path', ''),
})
data = request.get_json() or {}
updates = {}
# 提取有用的字段
for key in ('ssl_enabled', 'ssl_cert_path', 'ssl_key_path'):
if key in data:
updates[key] = data[key]
if not updates:
return jsonify({"success": False, "error": "没有需要更新的字段"}), 400
# 如果启用 SSL验证证书路径
if updates.get('ssl_enabled'):
cert_path = updates.get('ssl_cert_path') or load_web_config().get('ssl_cert_path', '')
key_path = updates.get('ssl_key_path') or load_web_config().get('ssl_key_path', '')
if not os.path.exists(cert_path):
return jsonify({"success": False, "error": f"证书文件不存在: {cert_path}"}), 400
if not os.path.exists(key_path):
return jsonify({"success": False, "error": f"密钥文件不存在: {key_path}"}), 400
ok = save_web_config(updates)
if ok:
# 在后台线程中延迟退出让进程重启以加载新配置systemd Restart=always 会自动拉起)
threading.Thread(target=lambda: (time.sleep(1.5), os._exit(0)), daemon=True).start()
return jsonify({
"success": True,
"message": "SSL 配置已保存,服务自动重启中…",
"restarting": True,
**updates
})
return jsonify({"success": False, "error": "写入配置文件失败"}), 500
@app.errorhandler(404)
def not_found(e):
"""404 处理"""
@ -808,8 +746,7 @@ def get_graph():
# 查询关系(边)
cursor.execute("""
SELECT r.id, r.source_id, r.target_id, r.relation_type,
r.confidence, r.status, r.created_at, r.updated_at
SELECT r.id, r.source_id, r.target_id, r.relation_type, r.confidence, r.status
FROM relations r
WHERE r.status = 'active'
""")
@ -822,9 +759,7 @@ def get_graph():
"target": row['target_id'],
"relation_type": row['relation_type'],
"confidence": row['confidence'],
"status": row['status'],
"created_at": row['created_at'],
"updated_at": row['updated_at']
"status": row['status']
})
return jsonify({
@ -926,25 +861,8 @@ def run_web_server(port: int = 4096, host: str = '0.0.0.0') -> None:
global _http_server
try:
from werkzeug.serving import make_server
import ssl
# 尝试加载 SSL 配置
cfg = load_web_config()
ssl_enabled = cfg.get('ssl_enabled', False)
ssl_context = None
if ssl_enabled:
cert_path = cfg.get('ssl_cert_path', '')
key_path = cfg.get('ssl_key_path', '')
if cert_path and os.path.exists(cert_path) and key_path and os.path.exists(key_path):
ssl_context = (cert_path, key_path)
print(f"🔒 HTTPS 已启用cert={cert_path}")
else:
print(f"⚠️ SSL 已启用但证书路径无效cert={cert_path}, key={key_path}")
print(" 回退到 HTTP")
_http_server = make_server(host, port, app, threaded=True, ssl_context=ssl_context)
proto = "https" if ssl_context else "http"
print(f"Web API 服务启动在 {proto}://{host}:{port}")
_http_server = make_server(host, port, app, threaded=True)
print(f"Web API 服务启动在 http://{host}:{port}")
_http_server.serve_forever()
except Exception as e:
print(f"Web 服务启动失败: {e}")