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 @@