chore: snapshot before usage menu update

This commit is contained in:
JOJO 2025-11-24 00:47:17 +08:00
parent 7348eab83a
commit 8c8b2d3a20
14 changed files with 1145 additions and 54 deletions

View File

@ -73,10 +73,12 @@ class MainTerminal:
thinking_mode: bool = False,
data_dir: Optional[str] = None,
container_session: Optional["ContainerHandle"] = None,
usage_tracker: Optional[object] = None,
):
self.project_path = project_path
self.thinking_mode = thinking_mode # False=快速模式, True=思考模式
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
self.usage_tracker = usage_tracker
# 初始化组件
self.api_client = DeepSeekClient(thinking_mode=thinking_mode)
@ -163,6 +165,39 @@ class MainTerminal:
else:
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
def record_model_call(self, is_thinking: bool):
tracker = getattr(self, "usage_tracker", None)
if not tracker:
return True, {}
mode = "thinking" if is_thinking else "fast"
try:
allowed, info = tracker.check_and_increment(mode)
if allowed:
self._notify_quota_update(mode)
return allowed, info
except Exception:
return True, {}
def record_search_call(self):
tracker = getattr(self, "usage_tracker", None)
if not tracker:
return True, {}
try:
allowed, info = tracker.check_and_increment("search")
if allowed:
self._notify_quota_update("search")
return allowed, info
except Exception:
return True, {}
def _notify_quota_update(self, metric: str):
callback = getattr(self, "quota_update_callback", None)
if callable(callback):
try:
callback(metric)
except Exception:
pass
def update_container_session(self, session: Optional["ContainerHandle"]):
self._apply_container_session(session)
if getattr(self, "terminal_manager", None):
@ -1914,6 +1949,13 @@ class MainTerminal:
result = {"success": False, "error": f"文件未处于聚焦状态: {path}"}
elif tool_name == "web_search":
allowed, quota_info = self.record_search_call()
if not allowed:
return {
"success": False,
"error": f"搜索配额已用尽,将在 {quota_info.get('reset_at')} 重置。",
"quota": quota_info
}
search_response = await self.search_engine.search_with_summary(
query=arguments["query"],
max_results=arguments.get("max_results"),

View File

@ -51,9 +51,10 @@ class WebTerminal(MainTerminal):
message_callback: Optional[Callable] = None,
data_dir: Optional[str] = None,
container_session: Optional["ContainerHandle"] = None,
usage_tracker: Optional[object] = None,
):
# 调用父类初始化(包含对话持久化功能)
super().__init__(project_path, thinking_mode, data_dir=data_dir, container_session=container_session)
super().__init__(project_path, thinking_mode, data_dir=data_dir, container_session=container_session, usage_tracker=usage_tracker)
# Web特有属性
self.message_callback = message_callback

34
doc/phase3_summary.md Normal file
View File

@ -0,0 +1,34 @@
# Phase 3 Summary 鉴权与配额基线
## 交付成果
1. **HTTP / WebSocket 鉴权加固**
- 引入统一速率限制器与失败计数:登录/注册/文件写操作等接口按 IP/用户多维限流,同时重试上限触发临时封禁。
- 自定义 CSRF 体系:后端提供 `/api/csrf-token`,前端全局 `fetch` 包装自动附带 `X-CSRF-Token`,所有写操作在 `before_request` 校验;会话 Cookie 统一启用 `SameSite=Strict`、`Secure/HttpOnly` 并增加基础安全头。
- Socket.IO 握手新增一次性 token含 UA 指纹),连接、重连都会验证 token 并立即销毁,阻断单凭 session cookie 劫持的可能。
2. **模型 / 搜索用量配额**
- 新增 `modules/usage_tracker.py`,按用户与角色记录滑动窗口用量(普通用户:常规 50/5h、思考 20/5h、搜索 20/24h管理员 9999数据持久化在 `usage_stats.json`
- `core/main_terminal.py` 在每次模型/搜索调用前检查并递增配额,超限时直接拒绝并返回重置时间;`web_server.py` 同步向前端广播 `quota_notice`,防止静默失败。
- UI 用量面板展示实时配额、重置时间,并在输入框侧阻止超限请求;新增右上角 toast 通知提醒配额状态。
3. **界面与体验**
- Web 用量组件、新增安全 toast、自动刷新配额数据轮询 + Socket 事件),并隐藏 0 次额度的重置提示,避免噪音。
## 影响评估
- **攻击面收敛**CSRF、防爆破、Socket 凭证等基础鉴权补齐后未授权调用需要突破多重校验WebSocket 劫持和暴力破解风险显著下降。
- **可观测性/运维**:配额快照和 `/api/usage` 接口为后续差异化用户组与审计提供依据,可直观查看每类用量与重置时间。
- **用户体验**:前端能够即时获知配额状态,避免“点击无响应”,同时 toast 提醒让限额策略更透明。
## 仍待处理
- 尚未引入 CAPTCHA / MFA、异常告警、CLI API Token 等进一步的身份防护手段。
- Upload/MIME 检查、CSP/内容安全策略与更细粒度的 ACL 仍未落地。
- 配额目前只区分普通/管理员,可在下一阶段扩展“用户组 + 自定义窗口/额度”的配置中心,并联动告警系统。
## 验证
- `python3 -m py_compile web_server.py core/main_terminal.py core/web_terminal.py modules/usage_tracker.py`
- 手动验证:登录界面输错多次触发 429WebSocket 无 token 无法建立连接;浏览器抓包可见所有写请求均带 `X-CSRF-Token`
- 配额体验:普通用户 5 小时窗口内超限时前端弹出提示、API 返回重置时间5 小时后自动恢复。

View File

@ -53,10 +53,10 @@
| 1 | Critical | ✅ **执行环境隔离就绪**:实时终端、`run_command` 与 FileManager 统统绑定用户专属容器,`modules/container_file_proxy.py` 保证写入只发生在 `/workspace`,宿主机仅承载挂载目录。 | Web 用户仅能访问自己容器内的文件/进程;若容器崩溃可在宿主机安全回收。 | 持续关注容器逃逸与 runtime 补丁,下一步考虑 rootless Docker / gVisor 进一步降低宿主暴露面。 |
| 2 | Critical | **明文 API Key / Secret**`config/api.py:3-25` 存在硬编码模型 key`config/auth.py:1-7` 暴露管理员用户名 + 哈希。 | 仓库一旦共享即泄漏密钥;攻击者可伪造管理员账户或重放 API 请求。 | 将所有 secrets 挪到环境变量 / Secret Manager删除仓库中的明文在启动时校验缺省值并阻止运行。 |
| 3 | High | **Flask SECRET_KEY 固定且公开**`web_server.py:54-58` 将 `SECRET_KEY='your-secret-key-here'` 写死,且默认启用 `CORS(*)`。 | 攻击者可伪造 session cookie、冒充任意用户、解密/篡改会话。 | 将 SECRET_KEY 存储于环境变量,启用 `SESSION_COOKIE_SECURE/HTTPONLY/SAMESITE`,并限制 CORS 源。 |
| 4 | High | **鉴权与速率限制缺失**:登录接口没有 CAPTCHA/速率限制;`api_login_required` 仅检查 sessionSocket.IO 连接未绑定 IP/指纹。 | 暴力破解、会话劫持、重放攻击均无防护;一旦 cookie 泄漏即可接管终端。 | 引入 Flask-Limiter 等中间件记录失败次数必要时强制多因子或设备锁定WebSocket 握手附带一次性 token。 |
| 4 | High | **鉴权与速率限制补强**:登录/注册/文件等敏感接口统一接入速率限制与失败计数(按 IP/用户维度Socket.IO 握手改为一次性 token + Session。 | 暴力破解和凭证滥用的窗口被显著压缩WebSocket 不再能单靠 Cookie 劫持。 | 下一步考虑加入 CAPTCHA/MFA、失败告警以及对 CLI API 的 token 鉴权。 |
| 5 | High | ✅ **多租户容器化 + 读写代理**UserManager 登录即创建独立容器FileManager 通过容器内脚本执行 create/read/write/modify宿主机不再直接接触用户代码。 | 横向越权面大幅收窄:除非容器逃逸,否则无法读写他人目录。 | 下一步需在 API 层加上 workspace ACL 与审计日志,防止管理员 session 滥用。 |
| 6 | Medium | **用户与会话数据存储在本地 JSON**`modules/user_manager.py:167-195` 将账号、密码哈希、邀请码写入 `data/users.json`,没有备份策略、并发安全或加密。 | 易被本地用户读取/篡改;当并发写入时有数据损坏风险,也无法满足审计/恢复。 | 引入关系型数据库或托管身份服务;对敏感字段做透明加密,提供备份与迁移策略。 |
| 7 | Medium | **缺乏 CSRF、防重放与安全头部**:所有 POST/PUT 接口(如 `/api/gui/files/*`, `/api/upload`)均未校验 CSRF token。 | 登陆态用户可被恶意网站诱导发起操作(上传/删除/运行命令)。 | 在 Flask 层加入 CSRF 保护WTF-CSRF 或自定义 token并添加 Strict-Transport-Security、Content-Security-Policy 等响应头。 |
| 7 | Medium | **CSRF + 安全头**:所有写操作统一校验 `X-CSRF-Token`(匿名令牌 API + fetch 包装),并启用 `SameSite/HttpOnly/Secure` Cookie 与基础安全响应头。 | 浏览器无法再被第三方站点诱导执行写操作Cookie 也具备最小暴露。 | 仍需结合 CSP/Referer 限制和子域隔离,防止潜在的 XSS 复合攻击。 |
| 8 | Medium | **文件上传仅做文件名校验**`web_server.py:841-907, 985-1069` 只调用 `sanitize_filename_preserve_unicode`,但未检测 MIME、内容或执行权限。 | 可上传脚本并经终端执行;针对 Windows/Unix 的路径和符号链接处理也未覆盖。 | 引入内容扫描ClamAV/自建规则)、限制文件类型/数量,并将上传目录与执行目录隔离。 |
| 9 | Medium | **日志/终端信息缺乏审计与脱敏**`logs/`、`data/conversations/` 中保留所有指令和模型输出,没有访问控制。 | 可能泄漏用户代码、密钥;出现安全事件时也很难追踪。 | 将日志写入集中式系统ELK/ClickHouse对敏感字段脱敏建立查询与保留策略。 |
| 10 | Low | **配置默认值缺乏环境检测**:如 `DEFAULT_PROJECT_PATH="./project"`、`MAX_UPLOAD_SIZE=50MB` 固定写入;`main.py` 未检查当前用户权限。 | 误配置可能导致数据写入到未知磁盘或权限不足引发异常。 | 在启动阶段校验运行环境(磁盘权限、必需目录、环境变量),并提供友好报错与文档。 |

193
modules/usage_tracker.py Normal file
View File

@ -0,0 +1,193 @@
"""Per-user usage tracking utilities with rolling quota windows."""
import json
import threading
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Optional, Literal, Tuple
QuotaKey = Literal["fast", "thinking", "search"]
QUOTA_DEFAULTS = {
"default": {
"fast": {"limit": 50, "window_hours": 5},
"thinking": {"limit": 20, "window_hours": 5},
"search": {"limit": 20, "window_hours": 24},
},
"search_daily": {"limit": 20, "window_hours": 24},
"admin": {
"fast": {"limit": 9999, "window_hours": 5},
"thinking": {"limit": 9999, "window_hours": 5},
"search": {"limit": 9999, "window_hours": 24},
},
}
class UsageTracker:
"""Record per-user model/search usage statistics and enforce quotas."""
def __init__(self, data_dir: str, role: str = "user"):
self.data_dir = Path(data_dir).expanduser().resolve()
self.data_dir.mkdir(parents=True, exist_ok=True)
self.stats_file = self.data_dir / "usage_stats.json"
self.role = role or "user"
self._lock = threading.Lock()
self._state = {
"started_at": self._now_iso(),
"updated_at": self._now_iso(),
"windows": {
"fast": {"count": 0, "window_start": None},
"thinking": {"count": 0, "window_start": None},
"search": {"count": 0, "window_start": None},
},
}
self._load()
def _now_iso(self) -> str:
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
def _load(self):
if not self.stats_file.exists():
return
try:
data = json.loads(self.stats_file.read_text(encoding="utf-8"))
if isinstance(data, dict):
self._merge_state(data)
except Exception:
pass
def _merge_state(self, data: Dict):
windows = data.get("windows")
if windows:
for key in ("fast", "thinking", "search"):
win = windows.get(key) or {}
self._state["windows"][key] = {
"count": int(win.get("count", 0)),
"window_start": win.get("window_start"),
}
else:
legacy_model = data.get("model_calls") or {}
legacy_search = data.get("search_calls") or {}
self._state["windows"]["fast"]["count"] = int(legacy_model.get("fast", 0))
self._state["windows"]["thinking"]["count"] = int(legacy_model.get("thinking", 0))
self._state["windows"]["search"]["count"] = int(legacy_search.get("total", 0))
for key in ("fast", "thinking", "search"):
if not self._state["windows"][key].get("window_start"):
self._state["windows"][key]["window_start"] = None
self._state["started_at"] = data.get("started_at") or self._state["started_at"]
self._state["updated_at"] = data.get("updated_at") or self._state["updated_at"]
def _save(self):
with self.stats_file.open("w", encoding="utf-8") as f:
json.dump(self._state, f, ensure_ascii=False, indent=2)
def _get_quota_config(self, metric: QuotaKey) -> Dict[str, int]:
if self.role == "admin":
return QUOTA_DEFAULTS["admin"][metric]
if metric == "search":
return QUOTA_DEFAULTS["search_daily"]
return QUOTA_DEFAULTS["default"][metric]
def _ensure_window(self, metric: QuotaKey) -> Tuple[int, Optional[str], datetime, datetime]:
"""Returns tuple(count, window_start_iso, window_start_dt, reset_at_dt)."""
config = self._get_quota_config(metric)
window_hours = config["window_hours"]
window_delta = timedelta(hours=window_hours)
window_data = self._state["windows"].setdefault(metric, {"count": 0, "window_start": None})
window_start_iso = window_data.get("window_start")
now = datetime.utcnow()
if window_start_iso:
try:
parsed = datetime.fromisoformat(window_start_iso.replace("Z", ""))
window_start_dt = floor_to_hour(parsed)
except ValueError:
window_start_dt = floor_to_hour(now)
else:
window_start_dt = floor_to_hour(now)
window_data["window_start"] = frame_iso(window_start_dt)
reset_at_dt = window_start_dt + window_delta
if now >= reset_at_dt and window_data.get("window_start"):
window_data["count"] = 0
window_start_dt = floor_to_hour(now)
reset_at_dt = window_start_dt + window_delta
window_data["window_start"] = frame_iso(window_start_dt)
return (
int(window_data.get("count", 0)),
window_data["window_start"],
window_start_dt,
reset_at_dt,
)
def check_and_increment(self, metric: QuotaKey) -> Tuple[bool, Dict[str, str]]:
"""Check quota and increment if allowed. Returns (allowed, info)."""
with self._lock:
count, window_start_iso, window_start_dt, reset_at_dt = self._ensure_window(metric)
quota = self._get_quota_config(metric)["limit"]
if count >= quota:
reset_at_iso = frame_iso(reset_at_dt)
return False, {
"limit": quota,
"count": count,
"reset_at": reset_at_iso,
"window_start": window_start_iso or "",
}
new_count = count + 1
self._state["windows"][metric]["count"] = new_count
if not window_start_iso:
self._state["windows"][metric]["window_start"] = frame_iso(window_start_dt)
self._state["updated_at"] = self._now_iso()
self._save()
return True, {
"limit": quota,
"count": new_count,
"reset_at": frame_iso(reset_at_dt),
"window_start": self._state["windows"][metric]["window_start"],
}
def get_quota_snapshot(self) -> Dict[str, Dict[str, str]]:
snapshot = {}
for key in ("fast", "thinking", "search"):
count, window_start_iso, _, reset_dt = self._ensure_window(key)
limit = self._get_quota_config(key)["limit"]
if not count:
window_start_iso = None
reset_value = None
else:
reset_value = frame_iso(reset_dt)
snapshot[key] = {
"count": count,
"limit": limit,
"window_start": window_start_iso,
"reset_at": reset_value,
}
snapshot["role"] = self.role
return snapshot
def get_stats(self) -> Dict:
with self._lock:
data = json.loads(json.dumps(self._state))
quotas = self.get_quota_snapshot()
for key in ("fast", "thinking", "search"):
data["windows"][key]["reset_at"] = quotas[key]["reset_at"]
data["role"] = self.role
data["quotas"] = quotas
self._save()
return data
def frame_iso(value: datetime) -> str:
return value.replace(microsecond=0).isoformat() + "Z"
def floor_to_hour(value: datetime) -> datetime:
return value.replace(minute=0, second=0, microsecond=0)
__all__ = ["UsageTracker", "QUOTA_DEFAULTS"]

View File

@ -313,6 +313,15 @@ async function bootstrapApp() {
down_bps: null,
up_bps: null
},
usageQuota: {
fast: { count: 0, limit: 0, reset_at: null, window_start: null },
thinking: { count: 0, limit: 0, reset_at: null, window_start: null },
search: { count: 0, limit: 0, reset_at: null, window_start: null },
role: 'user'
},
usageQuotaTimer: null,
quotaToast: null,
quotaToastTimer: null,
// 对话压缩状态
compressing: false,
@ -363,8 +372,13 @@ async function bootstrapApp() {
async mounted() {
console.log('Vue应用已挂载');
if (window.ensureCsrfToken) {
window.ensureCsrfToken().catch((err) => {
console.warn('CSRF token 初始化失败:', err);
});
}
await this.bootstrapRoute();
this.initSocket();
await this.initSocket();
this.initScrollListener();
// 延迟加载初始数据
@ -414,6 +428,7 @@ async function bootstrapApp() {
});
this.startContainerStatsPolling();
this.startProjectStoragePolling();
this.startUsageQuotaPolling();
},
beforeUnmount() {
@ -438,6 +453,11 @@ async function bootstrapApp() {
}
this.stopContainerStatsPolling();
this.stopProjectStoragePolling();
this.stopUsageQuotaPolling();
if (this.quotaToastTimer) {
clearTimeout(this.quotaToastTimer);
this.quotaToastTimer = null;
}
const cleanup = this.destroyEasterEggEffect(true);
if (cleanup && typeof cleanup.catch === 'function') {
cleanup.catch(() => {});
@ -676,19 +696,44 @@ async function bootstrapApp() {
});
},
initSocket() {
async initSocket() {
try {
console.log('初始化WebSocket连接...');
const usePollingOnly = window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1';
this.socket = io('/', usePollingOnly ? {
const socketOptions = usePollingOnly ? {
transports: ['polling'],
upgrade: false
upgrade: false,
autoConnect: false
} : {
transports: ['websocket', 'polling']
transports: ['websocket', 'polling'],
autoConnect: false
};
this.socket = io('/', socketOptions);
const assignSocketToken = async () => {
if (typeof window.requestSocketToken !== 'function') {
console.warn('缺少 requestSocketToken(),无法获取实时连接凭证');
return false;
}
try {
const freshToken = await window.requestSocketToken();
this.socket.auth = { socket_token: freshToken };
return true;
} catch (error) {
console.error('获取 WebSocket token 失败:', error);
return false;
}
};
if (this.socket.io && typeof this.socket.io.on === 'function') {
this.socket.io.on('reconnect_attempt', async () => {
await assignSocketToken();
});
}
// 连接事件
this.socket.on('connect', () => {
@ -709,6 +754,35 @@ async function bootstrapApp() {
console.error('WebSocket连接错误:', error.message);
});
this.socket.on('quota_update', (data) => {
if (data && data.quotas) {
this.usageQuota = this.normalizeUsageQuota({ quotas: data.quotas });
} else {
this.fetchUsageQuota();
}
});
this.socket.on('quota_notice', (data) => {
this.showQuotaToast(data || {});
this.fetchUsageQuota();
});
this.socket.on('quota_exceeded', (data) => {
this.showQuotaToast(data || {});
this.fetchUsageQuota();
});
this.socket.on('reconnect_attempt', async () => {
await assignSocketToken();
});
const ready = await assignSocketToken();
if (!ready) {
console.error('无法获取实时连接凭证WebSocket 初始化中止。');
return;
}
this.socket.connect();
// ==========================================
// Token统计WebSocket事件处理修复版
// ==========================================
@ -1526,6 +1600,7 @@ async function bootstrapApp() {
this.agentVersion = statusData.version || this.agentVersion;
this.thinkingMode = !!statusData.thinking_mode;
this.applyStatusSnapshot(statusData);
await this.fetchUsageQuota();
// 获取当前对话信息
const statusConversationId = statusData.conversation && statusData.conversation.current_id;
@ -1766,6 +1841,50 @@ async function bootstrapApp() {
}
},
async fetchUsageQuota() {
try {
const response = await fetch('/api/usage');
if (!response.ok) {
throw new Error(response.statusText || '请求失败');
}
const data = await response.json();
if (data && data.success && data.data) {
this.usageQuota = this.normalizeUsageQuota(data.data);
}
} catch (error) {
console.warn('获取用量配额失败:', error);
}
},
normalizeUsageQuota(raw) {
const tpl = { count: 0, limit: 0, reset_at: null, window_start: null };
const quotas = (raw && raw.quotas) || {};
const role = quotas.role || (raw && raw.role) || 'user';
return {
fast: { ...tpl, ...(quotas.fast || {}) },
thinking: { ...tpl, ...(quotas.thinking || {}) },
search: { ...tpl, ...(quotas.search || {}) },
role
};
},
startUsageQuotaPolling() {
if (this.usageQuotaTimer) {
return;
}
this.fetchUsageQuota();
this.usageQuotaTimer = setInterval(() => {
this.fetchUsageQuota();
}, 60000);
},
stopUsageQuotaPolling() {
if (this.usageQuotaTimer) {
clearInterval(this.usageQuotaTimer);
this.usageQuotaTimer = null;
}
},
// ==========================================
// 对话管理核心功能
// ==========================================
@ -2832,6 +2951,12 @@ async function bootstrapApp() {
return;
}
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
if (this.isQuotaExceeded(quotaType)) {
this.showQuotaToast({ type: quotaType });
return;
}
const message = this.inputMessage;
if (message.startsWith('/')) {
@ -3897,6 +4022,84 @@ async function bootstrapApp() {
return !!(this.containerStatus && this.containerStatus.mode === 'docker' && this.containerStatus.stats);
},
quotaTypeLabel(type) {
switch (type) {
case 'thinking':
return '思考模型';
case 'search':
return '搜索';
default:
return '常规模型';
}
},
formatResetTime(iso) {
if (!iso) {
return '--:--';
}
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return '--:--';
}
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
},
formatQuotaValue(entry) {
if (!entry) {
return '--';
}
const count = Number(entry.count) || 0;
const limit = Number(entry.limit) || 0;
if (!limit) {
return `${count}`;
}
return `${count} / ${limit}`;
},
quotaResetSummary() {
const parts = [];
['fast', 'thinking', 'search'].forEach((type) => {
const entry = this.usageQuota && this.usageQuota[type];
if (entry && entry.reset_at && Number(entry.count || 0) > 0) {
parts.push(`${this.quotaTypeLabel(type)} ${this.formatResetTime(entry.reset_at)}`);
}
});
return parts.join(' · ');
},
isQuotaExceeded(type) {
const entry = this.usageQuota && this.usageQuota[type];
if (!entry) {
return false;
}
const limit = Number(entry.limit) || 0;
if (!limit) {
return false;
}
return Number(entry.count || 0) >= limit;
},
showQuotaToast(payload) {
if (!payload) {
return;
}
const type = payload.type || 'fast';
const resetAt = payload.reset_at || (this.usageQuota[type] && this.usageQuota[type].reset_at);
const label = this.quotaTypeLabel(type);
const timeText = this.formatResetTime(resetAt);
this.quotaToast = {
message: `${label} 配额已用完,将在 ${timeText} 重置`,
type
};
if (this.quotaToastTimer) {
clearTimeout(this.quotaToastTimer);
}
this.quotaToastTimer = setTimeout(() => {
this.quotaToast = null;
this.quotaToastTimer = null;
}, 5000);
},
containerStatusText() {
if (!this.containerStatus) {
return '未知';

View File

@ -25,6 +25,7 @@
<textarea id="editorArea" spellcheck="false"></textarea>
</main>
</div>
<script src="/static/security.js"></script>
<script src="/static/file_manager/editor.js"></script>
</body>
</html>

View File

@ -55,6 +55,7 @@
</div>
</div>
</div>
<script src="/static/security.js"></script>
<script src="/static/file_manager/app.js"></script>
</body>
</html>

View File

@ -32,6 +32,11 @@
</head>
<body>
<div id="app">
<transition name="quota-toast-fade">
<div class="quota-toast" v-if="quotaToast">
<span class="quota-toast-label">{{ quotaToast.message }}</span>
</div>
</transition>
<!-- Loading indicator -->
<div v-if="!isConnected && messages.length === 0" style="text-align: center; padding: 50px;">
<h2>正在连接服务器...</h2>
@ -328,9 +333,11 @@
<div class="token-drawer" v-if="currentConversationId" :class="{ collapsed: tokenPanelCollapsed }">
<div class="token-display-panel">
<div class="token-panel-content">
<div class="token-panel-layout">
<div class="token-card">
<div class="token-stats">
<div class="token-panel-grid">
<div class="panel-row">
<div class="token-card compact panel-card">
<div class="panel-row-title">Token 统计</div>
<div class="token-stats inline-row">
<div class="token-item">
<span class="token-label">当前上下文</span>
<span class="token-value current">{{ formatTokenCount(currentContextTokens || 0) }}</span>
@ -345,13 +352,12 @@
</div>
</div>
</div>
<div class="container-stats-card" v-if="containerStatus">
<div class="container-stats-header">
<span class="token-label">容器资源</span>
<div class="container-card compact panel-card" v-if="containerStatus">
<div class="panel-row-title">
<span class="token-label">CPU / 内存</span>
<span class="status-pill" :class="containerStatusClass()">{{ containerStatusText() }}</span>
</div>
<template v-if="hasContainerStats()">
<div class="container-metric-grid">
<div class="double-metric">
<div class="container-metric">
<span class="metric-label">CPU</span>
<span class="metric-value">{{ formatPercentage(containerStatus.stats?.cpu_percent) }}</span>
@ -368,6 +374,40 @@
{{ formatPercentage(containerStatus.stats.memory.percent) }}
</span>
</div>
</div>
</div>
<div class="container-card compact panel-card empty" v-else>
<div class="container-empty">
当前运行在宿主机模式,暂无容器指标。
</div>
</div>
</div>
<div class="panel-row">
<div class="token-card compact panel-card">
<div class="panel-row-title">模型 / 搜索 用量</div>
<div class="quota-inline">
<div class="quota-inline-item">
<div class="quota-label">常规模型</div>
<div class="quota-value">{{ formatQuotaValue(usageQuota.fast) }}</div>
<div class="quota-reset" v-if="(usageQuota.fast.count || 0) > 0">重置 {{ formatResetTime(usageQuota.fast.reset_at) }}</div>
</div>
<div class="quota-inline-item">
<div class="quota-label">思考模型</div>
<div class="quota-value">{{ formatQuotaValue(usageQuota.thinking) }}</div>
<div class="quota-reset" v-if="(usageQuota.thinking.count || 0) > 0">重置 {{ formatResetTime(usageQuota.thinking.reset_at) }}</div>
</div>
<div class="quota-inline-item">
<div class="quota-label">搜索</div>
<div class="quota-value">{{ formatQuotaValue(usageQuota.search) }}</div>
<div class="quota-reset" v-if="(usageQuota.search.count || 0) > 0">重置 {{ formatResetTime(usageQuota.search.reset_at) }}</div>
</div>
</div>
</div>
<div class="container-card compact panel-card">
<div class="panel-row-title">
<span class="token-label">网络 / 存储</span>
</div>
<div class="double-metric">
<div class="container-metric">
<span class="metric-label">网络</span>
<span class="metric-value">
@ -383,17 +423,12 @@
/ {{ formatBytes(projectStorage.limit_bytes) }}
</template>
</span>
<span class="metric-subtext" v-if="projectStorage.limit_bytes">
{{ formatPercentage(projectStorage.usage_percent) }}
<span class="metric-subtext" v-if="typeof projectStorage.usage_percent === 'number'">
{{ projectStorage.usage_percent.toFixed(1) }}%
</span>
</div>
</div>
</template>
<template v-else>
<div class="container-empty">
当前运行在宿主机模式,暂无容器指标。
</div>
</template>
</div>
</div>
</div>
@ -1089,6 +1124,7 @@
<script src="/static/easter-eggs/registry.js"></script>
<script src="/static/easter-eggs/flood.js"></script>
<script src="/static/easter-eggs/snake.js"></script>
<script src="/static/security.js"></script>
<!-- 加载应用脚本 -->
<script src="/static/app.js"></script>
</body>

View File

@ -105,10 +105,17 @@
还没有账号?<a href="/register">点击注册</a>
</div>
</div>
<script src="/static/security.js"></script>
<script>
const btn = document.getElementById('login-btn');
const errorEl = document.getElementById('error');
if (window.ensureCsrfToken) {
window.ensureCsrfToken().catch((err) => {
console.warn('获取 CSRF token 失败:', err);
});
}
btn.addEventListener('click', async () => {
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;

View File

@ -102,10 +102,17 @@
已有账号?<a href="/login">返回登录</a>
</div>
</div>
<script src="/static/security.js"></script>
<script>
const btn = document.getElementById('register-btn');
const errorEl = document.getElementById('error');
if (window.ensureCsrfToken) {
window.ensureCsrfToken().catch((err) => {
console.warn('获取 CSRF token 失败:', err);
});
}
async function register() {
const username = document.getElementById('username').value.trim();
const email = document.getElementById('email').value.trim();

109
static/security.js Normal file
View File

@ -0,0 +1,109 @@
(function () {
if (window.__secureFetchWrapped) {
return;
}
if (typeof window.fetch !== 'function') {
return;
}
window.__secureFetchWrapped = true;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
const originalFetch = window.fetch.bind(window);
const csrfState = {
token: null,
inflight: null
};
async function fetchCsrfToken(forceRefresh = false) {
if (!forceRefresh && csrfState.token) {
return csrfState.token;
}
if (csrfState.inflight && !forceRefresh) {
return csrfState.inflight;
}
csrfState.inflight = originalFetch('/api/csrf-token', { credentials: 'same-origin' })
.then((resp) => {
if (!resp.ok) {
throw new Error('无法获取 CSRF token');
}
return resp.json();
})
.then((data) => {
csrfState.token = data && data.token ? data.token : '';
csrfState.inflight = null;
return csrfState.token;
})
.catch((err) => {
csrfState.inflight = null;
csrfState.token = null;
throw err;
});
return csrfState.inflight;
}
function extractMethod(resource, options) {
if (options && options.method) {
return options.method;
}
const RequestCtor = typeof Request !== 'undefined' ? Request : null;
if (RequestCtor && resource instanceof RequestCtor && resource.method) {
return resource.method;
}
return 'GET';
}
function mergeHeaders(baseHeaders, extraHeaders) {
const merged = new Headers(baseHeaders || {});
if (extraHeaders) {
new Headers(extraHeaders).forEach((value, key) => merged.set(key, value));
}
return merged;
}
async function secureFetch(resource, init) {
const RequestCtor = typeof Request !== 'undefined' ? Request : null;
let requestInfo = resource;
let options = init ? { ...init } : {};
const method = (extractMethod(resource, options) || 'GET').toUpperCase();
const needsProtection = !SAFE_METHODS.has(method);
if (needsProtection) {
const token = await fetchCsrfToken();
if (RequestCtor && resource instanceof RequestCtor) {
const merged = mergeHeaders(resource.headers, options.headers);
merged.set('X-CSRF-Token', token);
requestInfo = new RequestCtor(resource, { headers: merged });
if (options.headers) {
options = { ...options };
delete options.headers;
}
} else {
const headers = mergeHeaders(null, options.headers);
headers.set('X-CSRF-Token', token);
options.headers = headers;
}
}
return originalFetch(requestInfo, options);
}
async function requestSocketToken() {
const resp = await originalFetch('/api/socket-token', { credentials: 'same-origin' });
if (!resp.ok) {
throw new Error('无法获取实时连接凭证');
}
const data = await resp.json();
if (!data || !data.success || !data.token) {
throw new Error((data && data.error) || '实时连接凭证无效');
}
return data.token;
}
window.fetch = function (resource, init) {
return secureFetch(resource, init);
};
window.ensureCsrfToken = fetchCsrfToken;
window.refreshCsrfToken = () => fetchCsrfToken(true);
window.requestSocketToken = requestSocketToken;
window.secureFetch = secureFetch;
})();

View File

@ -2425,10 +2425,15 @@ o-files {
padding: 16px 36px;
}
.token-panel-layout {
display: flex;
flex-wrap: wrap;
gap: 32px;
.token-panel-grid {
display: grid;
gap: 24px;
}
.panel-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 24px;
align-items: stretch;
}
@ -2436,10 +2441,12 @@ o-files {
flex: 1 1 320px;
}
.token-card.compact,
.container-stats-card {
flex: 1 1 320px;
padding-left: 24px;
border-left: 1px solid var(--claude-border);
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.token-stats {
@ -2449,6 +2456,57 @@ o-files {
flex-wrap: wrap;
}
.token-stats.inline-row {
flex-wrap: nowrap;
gap: 24px;
align-items: flex-start;
}
.token-stats.inline-row > .token-item {
flex: 1 1 0;
min-width: 0;
}
.quota-inline {
display: flex;
gap: 24px;
flex-wrap: nowrap;
justify-content: space-between;
align-items: flex-end;
}
.quota-inline-item {
flex: 1 1 0;
min-width: 0;
}
.usage-quota-card {
margin-top: 16px;
}
.quota-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.quota-label {
font-size: 12px;
color: var(--claude-text-secondary);
font-weight: 500;
}
.quota-value {
font-weight: 600;
font-size: 16px;
color: var(--claude-text);
}
.quota-reset {
font-size: 12px;
color: var(--claude-text-secondary);
}
.token-item {
display: flex;
flex-direction: column;
@ -2479,11 +2537,20 @@ o-files {
.token-value.input { color: var(--claude-success); }
.token-value.output { color: var(--claude-warning); }
.container-stats-header {
.panel-row-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 12px;
color: var(--claude-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.double-metric {
display: flex;
gap: 24px;
}
.status-pill {
@ -2495,6 +2562,36 @@ o-files {
color: var(--claude-text-secondary);
}
.quota-toast {
position: fixed;
top: 24px;
right: 32px;
padding: 12px 18px;
border-radius: 12px;
background: var(--claude-bg);
border: 1px solid var(--claude-border-strong);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
z-index: 2000;
max-width: 320px;
color: var(--claude-text);
font-weight: 500;
}
.quota-toast-label {
display: block;
}
.quota-toast-fade-enter-active,
.quota-toast-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.quota-toast-fade-enter-from,
.quota-toast-fade-leave-to {
opacity: 0;
transform: translateX(20px);
}
.status-pill--running {
background: rgba(118, 176, 134, 0.18);
color: var(--claude-success);
@ -2512,8 +2609,8 @@ o-files {
.container-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px 20px;
grid-template-columns: repeat(2, minmax(150px, 1fr));
gap: 16px 24px;
}
.container-metric {
@ -3113,3 +3210,7 @@ o-files {
.easter-egg-overlay.active {
opacity: 1;
}
.panel-card {
border-left: 1px solid var(--claude-border);
padding: 0 0 0 0;
}

View File

@ -22,6 +22,8 @@ from collections import defaultdict, deque
from werkzeug.utils import secure_filename
from werkzeug.routing import BaseConverter
import secrets
import logging
import hmac
# 添加项目根目录到Python路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -54,6 +56,7 @@ from modules.personalization_manager import (
save_personalization_config,
)
from modules.user_container_manager import UserContainerManager
from modules.usage_tracker import UsageTracker
app = Flask(__name__, static_folder='static')
app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE
@ -63,11 +66,30 @@ if not _secret_key:
print("[security] WEB_SECRET_KEY 未设置,已生成临时密钥(重启后失效)。")
app.config['SECRET_KEY'] = _secret_key
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
_cookie_secure_env = (os.environ.get("WEB_COOKIE_SECURE") or "").strip().lower()
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get("WEB_COOKIE_SAMESITE", "Strict")
app.config['SESSION_COOKIE_SECURE'] = _cookie_secure_env in {"1", "true", "yes"}
app.config['SESSION_COOKIE_HTTPONLY'] = True
CORS(app)
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
class EndpointFilter(logging.Filter):
"""过滤掉噪声请求日志。"""
BLOCK_PATTERNS = (
"GET /api/project-storage",
"GET /api/container-status",
)
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
return not any(pattern in message for pattern in self.BLOCK_PATTERNS)
logging.getLogger('werkzeug').addFilter(EndpointFilter())
class ConversationIdConverter(BaseConverter):
regex = r'(?:conv_)?\d{8}_\d{6}_\d{3}'
@ -80,9 +102,22 @@ user_terminals: Dict[str, WebTerminal] = {}
terminal_rooms: Dict[str, set] = {}
connection_users: Dict[str, str] = {}
stop_flags: Dict[str, Dict[str, Any]] = {}
RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque)
FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {}
pending_socket_tokens: Dict[str, Dict[str, Any]] = {}
usage_trackers: Dict[str, UsageTracker] = {}
DEFAULT_PORT = 8091
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_SESSION_KEY = "_csrf_token"
CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
CSRF_PROTECTED_PATHS = {"/login", "/register", "/logout"}
CSRF_PROTECTED_PREFIXES = ("/api/",)
CSRF_EXEMPT_PATHS = {"/api/csrf-token"}
FAILED_LOGIN_LIMIT = 5
FAILED_LOGIN_LOCK_SECONDS = 300
SOCKET_TOKEN_TTL_SECONDS = 45
def format_read_file_result(result_data: Dict) -> str:
@ -167,6 +202,149 @@ def sanitize_filename_preserve_unicode(filename: str) -> str:
return cleaned[:255]
def get_client_ip() -> str:
"""获取客户端IP支持 X-Forwarded-For."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.remote_addr or "unknown"
def resolve_identifier(scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> str:
if identifier:
return identifier
if scope == "user":
if kwargs:
username = kwargs.get('username')
if username:
return username
username = get_current_username()
if username:
return username
return get_client_ip()
def check_rate_limit(action: str, limit: int, window_seconds: int, identifier: Optional[str]) -> Tuple[bool, int]:
"""针对指定动作进行简单的滑动窗口限频。"""
bucket_key = f"{action}:{identifier or 'anonymous'}"
bucket = RATE_LIMIT_BUCKETS[bucket_key]
now = time.time()
while bucket and now - bucket[0] > window_seconds:
bucket.popleft()
if len(bucket) >= limit:
retry_after = window_seconds - int(now - bucket[0])
return True, max(retry_after, 1)
bucket.append(now)
return False, 0
def rate_limited(action: str, limit: int, window_seconds: int, scope: str = "ip", error_message: Optional[str] = None):
"""装饰器:为路由增加速率限制。"""
def decorator(func):
@wraps(func)
def wrapped(*args, **kwargs):
identifier = resolve_identifier(scope, kwargs=kwargs)
limited, retry_after = check_rate_limit(action, limit, window_seconds, identifier)
if limited:
message = error_message or "请求过于频繁,请稍后再试。"
return jsonify({
"success": False,
"error": message,
"retry_after": retry_after
}), 429
return func(*args, **kwargs)
return wrapped
return decorator
def register_failure(action: str, limit: int, lock_seconds: int, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> int:
"""记录失败次数,超过阈值后触发锁定。"""
ident = resolve_identifier(scope, identifier, kwargs)
key = f"{action}:{ident}"
now = time.time()
entry = FAILURE_TRACKERS.setdefault(key, {"count": 0, "blocked_until": 0})
blocked_until = entry.get("blocked_until", 0)
if blocked_until and blocked_until > now:
return int(blocked_until - now)
entry["count"] = entry.get("count", 0) + 1
if entry["count"] >= limit:
entry["count"] = 0
entry["blocked_until"] = now + lock_seconds
return lock_seconds
return 0
def is_action_blocked(action: str, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> Tuple[bool, int]:
ident = resolve_identifier(scope, identifier, kwargs)
key = f"{action}:{ident}"
entry = FAILURE_TRACKERS.get(key)
if not entry:
return False, 0
now = time.time()
blocked_until = entry.get("blocked_until", 0)
if blocked_until and blocked_until > now:
return True, int(blocked_until - now)
return False, 0
def clear_failures(action: str, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None):
ident = resolve_identifier(scope, identifier, kwargs)
key = f"{action}:{ident}"
FAILURE_TRACKERS.pop(key, None)
def get_csrf_token(force_new: bool = False) -> str:
token = session.get(CSRF_SESSION_KEY)
if force_new or not token:
token = secrets.token_urlsafe(32)
session[CSRF_SESSION_KEY] = token
return token
def requires_csrf_protection(path: str) -> bool:
if path in CSRF_EXEMPT_PATHS:
return False
if path in CSRF_PROTECTED_PATHS:
return True
return any(path.startswith(prefix) for prefix in CSRF_PROTECTED_PREFIXES)
def validate_csrf_request() -> bool:
expected = session.get(CSRF_SESSION_KEY)
provided = request.headers.get(CSRF_HEADER_NAME) or request.form.get("csrf_token")
if not expected or not provided:
return False
try:
return hmac.compare_digest(str(provided), str(expected))
except Exception:
return False
def prune_socket_tokens(now: Optional[float] = None):
current = now or time.time()
for token, meta in list(pending_socket_tokens.items()):
if meta.get("expires_at", 0) <= current:
pending_socket_tokens.pop(token, None)
def consume_socket_token(token_value: Optional[str], username: Optional[str]) -> bool:
if not token_value or not username:
return False
prune_socket_tokens()
token_meta = pending_socket_tokens.pop(token_value, None)
if not token_meta:
return False
if token_meta.get("username") != username:
return False
if token_meta.get("expires_at", 0) <= time.time():
return False
fingerprint = token_meta.get("fingerprint") or ""
request_fp = (request.headers.get("User-Agent") or "")[:128]
if fingerprint and request_fp and not hmac.compare_digest(fingerprint, request_fp):
return False
return True
def format_tool_result_notice(tool_name: str, tool_call_id: Optional[str], content: str) -> str:
"""将工具执行结果转为系统消息文本,方便在对话中回传。"""
header = f"[工具结果] {tool_name}"
@ -234,6 +412,7 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
return None, None
workspace = user_manager.ensure_user_workspace(username)
container_handle = container_manager.ensure_container(username, str(workspace.project_path))
usage_tracker = get_or_create_usage_tracker(username, workspace)
terminal = user_terminals.get(username)
if not terminal:
thinking_mode = session.get('thinking_mode', False)
@ -242,17 +421,52 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
thinking_mode=thinking_mode,
message_callback=make_terminal_callback(username),
data_dir=str(workspace.data_dir),
container_session=container_handle
container_session=container_handle,
usage_tracker=usage_tracker
)
if terminal.terminal_manager:
terminal.terminal_manager.broadcast = terminal.message_callback
user_terminals[username] = terminal
terminal.username = username
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
else:
terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, username)
terminal.username = username
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
return terminal, workspace
def get_or_create_usage_tracker(username: Optional[str], workspace: Optional[UserWorkspace] = None) -> Optional[UsageTracker]:
if not username:
return None
tracker = usage_trackers.get(username)
if tracker:
return tracker
if workspace is None:
workspace = user_manager.ensure_user_workspace(username)
record = user_manager.get_user(username)
role = getattr(record, "role", "user") if record else "user"
tracker = UsageTracker(str(workspace.data_dir), role=role or "user")
usage_trackers[username] = tracker
return tracker
def emit_user_quota_update(username: Optional[str]):
if not username:
return
tracker = get_or_create_usage_tracker(username)
if not tracker:
return
try:
snapshot = tracker.get_quota_snapshot()
socketio.emit('quota_update', {
'quotas': snapshot
}, room=f"user_{username}")
except Exception:
pass
def with_terminal(func):
"""注入用户专属终端和工作区"""
@wraps(func)
@ -481,6 +695,39 @@ def terminal_broadcast(event_type, data):
except Exception as e:
debug_log(f"终端广播错误: {e}")
@app.route('/api/csrf-token', methods=['GET'])
def issue_csrf_token():
"""提供 CSRF token供前端写操作附带。"""
token = get_csrf_token()
response = jsonify({"success": True, "token": token})
response.headers['Cache-Control'] = 'no-store'
return response
@app.before_request
def enforce_csrf_token():
method = (request.method or "GET").upper()
if method in CSRF_SAFE_METHODS:
return
if not requires_csrf_protection(request.path):
return
if validate_csrf_request():
return
return jsonify({"success": False, "error": "CSRF validation failed"}), 403
@app.after_request
def apply_security_headers(response):
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
if response.mimetype == "application/json":
response.headers.setdefault("Cache-Control", "no-store")
if app.config.get("SESSION_COOKIE_SECURE"):
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return response
@app.route('/login', methods=['GET', 'POST'])
def login():
"""登录页面与认证"""
@ -494,21 +741,51 @@ def login():
data = request.get_json() or {}
email = (data.get('email') or '').strip()
password = data.get('password') or ''
client_ip = get_client_ip()
limited, retry_after = check_rate_limit("login", 10, 60, client_ip)
if limited:
return jsonify({
"success": False,
"error": "登录请求过于频繁,请稍后再试。",
"retry_after": retry_after
}), 429
blocked, block_for = is_action_blocked("login", identifier=client_ip)
if blocked:
return jsonify({
"success": False,
"error": f"尝试次数过多,请 {block_for} 秒后重试。",
"retry_after": block_for
}), 429
record = user_manager.authenticate(email, password)
if not record:
return jsonify({"success": False, "error": "账号或密码错误"}), 401
wait_seconds = register_failure("login", FAILED_LOGIN_LIMIT, FAILED_LOGIN_LOCK_SECONDS, identifier=client_ip)
error_payload = {
"success": False,
"error": "账号或密码错误"
}
status_code = 401
if wait_seconds:
error_payload.update({
"error": f"尝试次数过多,请 {wait_seconds} 秒后重试。",
"retry_after": wait_seconds
})
status_code = 429
return jsonify(error_payload), status_code
session['logged_in'] = True
session['username'] = record.username
session['thinking_mode'] = app.config.get('DEFAULT_THINKING_MODE', False)
session.permanent = True
clear_failures("login", identifier=client_ip)
workspace = user_manager.ensure_user_workspace(record.username)
try:
container_manager.ensure_container(record.username, str(workspace.project_path))
except RuntimeError as exc:
session.clear()
return jsonify({"success": False, "error": str(exc), "code": "resource_busy"}), 503
get_csrf_token(force_new=True)
return jsonify({"success": True})
@ -526,6 +803,14 @@ def register():
password = data.get('password') or ''
invite_code = (data.get('invite_code') or '').strip()
limited, retry_after = check_rate_limit("register", 5, 300, get_client_ip())
if limited:
return jsonify({
"success": False,
"error": "注册请求过于频繁,请稍后再试。",
"retry_after": retry_after
}), 429
try:
user_manager.register_user(username, email, password, invite_code)
return jsonify({"success": True})
@ -544,6 +829,9 @@ def logout():
user_terminals.pop(username, None)
if username:
container_manager.release_container(username, reason="logout")
for token_value, meta in list(pending_socket_tokens.items()):
if meta.get("username") == username:
pending_socket_tokens.pop(token_value, None)
return jsonify({"success": True})
@ -676,9 +964,24 @@ def get_project_storage(terminal: WebTerminal, workspace: UserWorkspace, usernam
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@app.route('/api/usage', methods=['GET'])
@api_login_required
def get_usage_stats():
"""返回当前用户的模型/搜索调用统计。"""
username = get_current_username()
tracker = get_or_create_usage_tracker(username)
if not tracker:
return jsonify({"success": False, "error": "未找到用户"}), 404
return jsonify({
"success": True,
"data": tracker.get_stats()
})
@app.route('/api/thinking-mode', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("thinking_mode_toggle", 15, 60, scope="user")
def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""切换思考模式"""
try:
@ -733,6 +1036,7 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
@app.route('/api/personalization', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("personalization_update", 20, 300, scope="user")
def update_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""更新个性化配置"""
payload = request.get_json() or {}
@ -798,6 +1102,7 @@ def gui_list_entries(terminal: WebTerminal, workspace: UserWorkspace, username:
@app.route('/api/gui/files/create', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_create", 30, 60, scope="user")
def gui_create_entry(terminal: WebTerminal, workspace: UserWorkspace, username: str):
payload = request.get_json() or {}
parent = payload.get('path') or ""
@ -820,6 +1125,7 @@ def gui_create_entry(terminal: WebTerminal, workspace: UserWorkspace, username:
@app.route('/api/gui/files/delete', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_delete", 30, 60, scope="user")
def gui_delete_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
payload = request.get_json() or {}
paths = payload.get('paths') or []
@ -840,6 +1146,7 @@ def gui_delete_entries(terminal: WebTerminal, workspace: UserWorkspace, username
@app.route('/api/gui/files/rename', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_rename", 30, 60, scope="user")
def gui_rename_entry(terminal: WebTerminal, workspace: UserWorkspace, username: str):
payload = request.get_json() or {}
path = payload.get('path')
@ -863,6 +1170,7 @@ def gui_rename_entry(terminal: WebTerminal, workspace: UserWorkspace, username:
@app.route('/api/gui/files/copy', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_copy", 40, 120, scope="user")
def gui_copy_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
payload = request.get_json() or {}
paths = payload.get('paths') or []
@ -884,6 +1192,7 @@ def gui_copy_entries(terminal: WebTerminal, workspace: UserWorkspace, username:
@app.route('/api/gui/files/move', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_move", 40, 120, scope="user")
def gui_move_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
payload = request.get_json() or {}
paths = payload.get('paths') or []
@ -905,6 +1214,7 @@ def gui_move_entries(terminal: WebTerminal, workspace: UserWorkspace, username:
@app.route('/api/gui/files/upload', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_upload", 10, 300, scope="user")
def gui_upload_entry(terminal: WebTerminal, workspace: UserWorkspace, username: str):
if 'file' not in request.files:
return jsonify({"success": False, "error": "未找到文件"}), 400
@ -1049,6 +1359,7 @@ def get_todo_list(terminal: WebTerminal, workspace: UserWorkspace, username: str
@app.route('/api/upload', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("legacy_upload", 20, 300, scope="user")
def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""处理前端文件上传请求"""
if 'file' not in request.files:
@ -1273,13 +1584,37 @@ def get_terminals(terminal: WebTerminal, workspace: UserWorkspace, username: str
else:
return jsonify({"sessions": [], "active": None, "total": 0})
@app.route('/api/socket-token', methods=['GET'])
@api_login_required
def issue_socket_token():
"""生成一次性 WebSocket token供握手阶段使用。"""
username = get_current_username()
prune_socket_tokens()
now = time.time()
for token_value, meta in list(pending_socket_tokens.items()):
if meta.get("username") == username:
pending_socket_tokens.pop(token_value, None)
token_value = secrets.token_urlsafe(32)
pending_socket_tokens[token_value] = {
"username": username,
"expires_at": now + SOCKET_TOKEN_TTL_SECONDS,
"fingerprint": (request.headers.get('User-Agent') or '')[:128],
}
return jsonify({
"success": True,
"token": token_value,
"expires_in": SOCKET_TOKEN_TTL_SECONDS
})
@socketio.on('connect')
def handle_connect():
def handle_connect(auth):
"""客户端连接"""
print(f"[WebSocket] 客户端连接: {request.sid}")
username = get_current_username()
if not username:
emit('error', {'message': '未登录或会话已失效'})
token_value = (auth or {}).get('socket_token') if isinstance(auth, dict) else None
if not username or not consume_socket_token(token_value, username):
emit('error', {'message': '未登录或连接凭证无效'})
disconnect()
return
@ -2778,6 +3113,27 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client
thinking_expected = web_terminal.api_client.get_current_thinking_mode()
debug_log(f"思考模式: {thinking_expected}")
quota_allowed = True
quota_info = {}
if hasattr(web_terminal, "record_model_call"):
quota_allowed, quota_info = web_terminal.record_model_call(bool(thinking_expected))
if not quota_allowed:
quota_type = 'thinking' if thinking_expected else 'fast'
socketio.emit('quota_notice', {
'type': quota_type,
'reset_at': quota_info.get('reset_at'),
'limit': quota_info.get('limit'),
'count': quota_info.get('count')
}, room=f"user_{getattr(web_terminal, 'username', '')}")
sender('quota_exceeded', {
'type': quota_type,
'reset_at': quota_info.get('reset_at')
})
sender('error', {
'message': "配额已达到上限,暂时无法继续调用模型。",
'quota': quota_info
})
return
print(f"[API] 第{iteration + 1}次调用 (总工具调用: {total_tool_calls}/{MAX_TOTAL_TOOL_CALLS})")