Implement host-mode multi-workspace concurrency while preserving one active task per workspace. Host terminals are scoped by workspace id, host workspace switching no longer globally refreshes or blocks on tasks, and task APIs now expose workspace/conversation metadata for frontend coordination. Update conversation navigation so creating/loading conversations during an active workspace task is view-only and does not mutate backend terminal context. This prevents running task output from being pushed into the currently viewed conversation after switching or creating a new conversation. Add running task awareness to the frontend sidebar, composer, and polling flow. Running tasks can be shown across workspaces, current-workspace active conversations render inline loaders, completed unviewed tasks persist as check indicators across refresh, and completed tasks clear once viewed. Restore running conversations correctly after switching workspace/conversation or refreshing during an active task. REST polling is rebound only for the matching conversation/task, stale poll responses are ignored, empty assistant placeholders are restored before first content, and completion clears loader/stop states without requiring page refresh. Adjust host workspace UX so switching workspaces loads the active conversation if one is running, otherwise opens the new-conversation state. Other conversations in a busy workspace are view-only with disabled input instead of blocked navigation. Validation: python3 -m py_compile server/auth.py server/context.py server/conversation.py server/status.py server/tasks.py; python3 -m unittest test.test_server_refactor_smoke; npm run build --silent 2>&1 | tail -n 12.
505 lines
19 KiB
Python
505 lines
19 KiB
Python
from __future__ import annotations
|
||
import mimetypes
|
||
import secrets
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app, make_response
|
||
|
||
from modules.personalization_manager import load_personalization_config
|
||
from modules.host_workspace_manager import resolve_host_workspace
|
||
from modules.user_manager import UserWorkspace
|
||
from config import (
|
||
TERMINAL_SANDBOX_MODE,
|
||
DATA_DIR,
|
||
LOGS_DIR,
|
||
UPLOAD_QUARANTINE_SUBDIR,
|
||
LINUX_SAFETY,
|
||
LOGS_DIR,
|
||
)
|
||
|
||
from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username, is_logged_in
|
||
from .security import (
|
||
get_csrf_token,
|
||
check_rate_limit,
|
||
register_failure,
|
||
is_action_blocked,
|
||
clear_failures,
|
||
)
|
||
from .context import with_terminal, get_gui_manager
|
||
from . import state
|
||
from .utils_common import debug_log
|
||
|
||
auth_bp = Blueprint("auth", __name__)
|
||
AUTH_DEBUG_FILE = Path(LOGS_DIR).expanduser().resolve() / "auth_debug.log"
|
||
|
||
|
||
def auth_debug_log(message: str):
|
||
AUTH_DEBUG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
line = f"[{ts}] {message}"
|
||
try:
|
||
with AUTH_DEBUG_FILE.open("a", encoding="utf-8") as f:
|
||
f.write(line + "\n")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
debug_log(line)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _session_debug_snapshot():
|
||
return {
|
||
"logged_in": bool(session.get("logged_in")),
|
||
"username": session.get("username"),
|
||
"role": session.get("role"),
|
||
"run_mode": session.get("run_mode"),
|
||
"thinking_mode": session.get("thinking_mode"),
|
||
"host_mode": bool(session.get("host_mode")),
|
||
}
|
||
|
||
|
||
def _issue_login_nonce(username: str) -> str:
|
||
nonce = secrets.token_urlsafe(16)
|
||
user = (username or "").strip().lower()
|
||
if user:
|
||
state.active_login_nonces[user].add(nonce)
|
||
session["login_nonce"] = nonce
|
||
return nonce
|
||
|
||
|
||
def _revoke_login_nonce(username: str, nonce: str | None):
|
||
user = (username or "").strip().lower()
|
||
if not user or not nonce:
|
||
return
|
||
pool = state.active_login_nonces.get(user)
|
||
if not pool:
|
||
return
|
||
pool.discard(nonce)
|
||
if not pool:
|
||
state.active_login_nonces.pop(user, None)
|
||
|
||
|
||
def _cookie_debug_snapshot():
|
||
raw = request.headers.get("Cookie", "") or ""
|
||
return raw[:300]
|
||
|
||
|
||
def _expire_session_cookie(response):
|
||
"""尽可能清理不同 domain/path 组合下的 session cookie(线上反向代理场景兜底)。"""
|
||
cookie_name = current_app.config.get("SESSION_COOKIE_NAME", "session")
|
||
configured_domain = current_app.config.get("SESSION_COOKIE_DOMAIN")
|
||
host = (request.host or "").split(":")[0]
|
||
candidate_domains = {None, configured_domain, host}
|
||
if host.count(".") >= 2:
|
||
# 兼容 .example.com / example.com 两种历史写法
|
||
parent = ".".join(host.split(".")[1:])
|
||
candidate_domains.add(parent)
|
||
candidate_domains.add(f".{parent}")
|
||
for domain in candidate_domains:
|
||
try:
|
||
response.delete_cookie(cookie_name, path="/", domain=domain)
|
||
except Exception:
|
||
continue
|
||
response.headers["Cache-Control"] = "no-store"
|
||
return response
|
||
|
||
|
||
@auth_bp.route('/api/csrf-token', methods=['GET'])
|
||
def issue_csrf_token():
|
||
token = get_csrf_token()
|
||
response = jsonify({"success": True, "token": token})
|
||
response.headers['Cache-Control'] = 'no-store'
|
||
return response
|
||
|
||
|
||
@auth_bp.route('/api/host-mode-enabled', methods=['GET'])
|
||
def host_mode_enabled():
|
||
enabled = (TERMINAL_SANDBOX_MODE or "").lower() == "host" and not LINUX_SAFETY
|
||
return jsonify({"success": True, "enabled": enabled})
|
||
|
||
|
||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||
def login():
|
||
if request.method == 'GET':
|
||
auth_debug_log(f"[auth_debug] GET /login session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}")
|
||
if is_logged_in():
|
||
return redirect('/new')
|
||
# 避免“session 内残留 username 但已失效”导致 /login <-> /new 重定向循环
|
||
if session.get('username'):
|
||
stale_username = session.get('username')
|
||
stale_nonce = session.get('login_nonce')
|
||
_revoke_login_nonce(stale_username, stale_nonce)
|
||
session.clear()
|
||
resp = make_response(current_app.send_static_file('login.html'))
|
||
return _expire_session_cookie(resp)
|
||
if not state.container_manager.has_capacity():
|
||
return current_app.send_static_file('resource_busy.html'), 503
|
||
return current_app.send_static_file('login.html')
|
||
|
||
data = request.get_json() or {}
|
||
email = (data.get('email') or '').strip()
|
||
password = data.get('password') or ''
|
||
client_ip = request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or request.remote_addr or 'unknown'
|
||
|
||
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 = state.user_manager.authenticate(email, password)
|
||
if not record:
|
||
wait_seconds = register_failure("login", state.FAILED_LOGIN_LIMIT, state.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
|
||
|
||
workspace = state.user_manager.ensure_user_workspace(record.username)
|
||
preferred_run_mode = None
|
||
try:
|
||
personal_config = load_personalization_config(workspace.data_dir)
|
||
candidate_mode = (personal_config or {}).get('default_run_mode')
|
||
if isinstance(candidate_mode, str):
|
||
normalized_mode = candidate_mode.lower()
|
||
if normalized_mode in {"fast", "thinking", "deep"}:
|
||
preferred_run_mode = normalized_mode
|
||
except Exception as exc:
|
||
debug_log(f"加载个性化偏好失败: {exc}")
|
||
|
||
# 清理旧会话(尤其是从 host-login 切换到普通登录时遗留的 host_mode)
|
||
prev_username = session.get('username')
|
||
prev_nonce = session.get('login_nonce')
|
||
_revoke_login_nonce(prev_username, prev_nonce)
|
||
session.clear()
|
||
|
||
session['logged_in'] = True
|
||
session['username'] = record.username
|
||
session['role'] = record.role or 'user'
|
||
session['host_mode'] = False
|
||
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
|
||
session['thinking_mode'] = default_thinking
|
||
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
|
||
if preferred_run_mode:
|
||
session['run_mode'] = preferred_run_mode
|
||
session['thinking_mode'] = preferred_run_mode != 'fast'
|
||
session.permanent = True
|
||
_issue_login_nonce(record.username)
|
||
clear_failures("login", identifier=client_ip)
|
||
try:
|
||
state.container_manager.ensure_container(record.username, str(workspace.project_path), preferred_mode="docker")
|
||
except RuntimeError as exc:
|
||
session.clear()
|
||
return jsonify({"success": False, "error": str(exc), "code": "resource_busy"}), 503
|
||
from .usage import record_user_activity
|
||
record_user_activity(record.username)
|
||
get_csrf_token(force_new=True)
|
||
return jsonify({"success": True})
|
||
|
||
|
||
@auth_bp.route('/host-login', methods=['POST'])
|
||
def host_login():
|
||
"""宿主机模式一键进入(仅当 TERMINAL_SANDBOX_MODE=host 时可用)。"""
|
||
if (TERMINAL_SANDBOX_MODE or "").lower() != "host":
|
||
return jsonify({"success": False, "error": "宿主机模式未启用"}), 403
|
||
if not state.container_manager.has_capacity("host"):
|
||
return jsonify({"success": False, "error": "资源繁忙,请稍后再试"}), 503
|
||
|
||
_, host_workspace = resolve_host_workspace()
|
||
host_workspace_id = host_workspace.get("workspace_id") or "default"
|
||
host_path = Path(host_workspace.get("path") or "").expanduser().resolve()
|
||
host_path.mkdir(parents=True, exist_ok=True)
|
||
data_dir = Path(DATA_DIR).expanduser().resolve()
|
||
data_dir.mkdir(parents=True, exist_ok=True)
|
||
logs_dir = Path(LOGS_DIR).expanduser().resolve()
|
||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||
uploads_dir = host_path / ".agents" / "user_upload"
|
||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
|
||
if not quarantine_root.is_absolute():
|
||
quarantine_root = (host_path.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
|
||
quarantine_root.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 初始化 session,跳过账号体系
|
||
session.clear()
|
||
session['logged_in'] = True
|
||
session['username'] = 'host'
|
||
session['role'] = 'admin'
|
||
session['host_mode'] = True
|
||
session['host_workspace_id'] = host_workspace_id
|
||
session['workspace_id'] = host_workspace_id
|
||
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
|
||
session['thinking_mode'] = default_thinking
|
||
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
|
||
session.permanent = True
|
||
_issue_login_nonce('host')
|
||
|
||
# 预先创建宿主机模式的终端/容器句柄(host 模式不会启动 Docker)
|
||
try:
|
||
state.container_manager.ensure_container(
|
||
"host",
|
||
str(host_path),
|
||
container_key=f"host::{host_workspace_id}",
|
||
preferred_mode="host",
|
||
)
|
||
except RuntimeError as exc:
|
||
session.clear()
|
||
return jsonify({"success": False, "error": str(exc)}), 503
|
||
|
||
get_csrf_token(force_new=True)
|
||
return jsonify({"success": True})
|
||
|
||
|
||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||
def register():
|
||
if request.method == 'GET':
|
||
auth_debug_log(f"[auth_debug] GET /register session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}")
|
||
if is_logged_in():
|
||
auth_debug_log("[auth_debug] GET /register redirected to /new because session.username exists")
|
||
return redirect('/new')
|
||
if session.get('username'):
|
||
stale_username = session.get('username')
|
||
stale_nonce = session.get('login_nonce')
|
||
_revoke_login_nonce(stale_username, stale_nonce)
|
||
session.clear()
|
||
resp = make_response(current_app.send_static_file('register.html'))
|
||
return _expire_session_cookie(resp)
|
||
return current_app.send_static_file('register.html')
|
||
|
||
data = request.get_json() or {}
|
||
username = (data.get('username') or '').strip()
|
||
email = (data.get('email') or '').strip()
|
||
password = data.get('password') or ''
|
||
invite_code = (data.get('invite_code') or '').strip()
|
||
|
||
from .security import get_client_ip
|
||
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:
|
||
state.user_manager.register_user(username, email, password, invite_code)
|
||
auth_debug_log(f"[auth_debug] POST /register success username={username}")
|
||
return jsonify({"success": True})
|
||
except ValueError as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 400
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@auth_bp.route('/logout', methods=['GET', 'POST'])
|
||
def logout():
|
||
auth_debug_log(
|
||
f"[auth_debug] {request.method} /logout before_clear "
|
||
f"session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}"
|
||
)
|
||
username = session.get('username')
|
||
login_nonce = session.get('login_nonce')
|
||
session.clear()
|
||
_revoke_login_nonce(username, login_nonce)
|
||
if username:
|
||
# 清理该用户相关的所有终端/容器(包含 API 多工作区)
|
||
term_keys = [k for k in list(state.user_terminals.keys()) if k == username or k.startswith(f"{username}::")]
|
||
for key in term_keys:
|
||
state.user_terminals.pop(key, None)
|
||
try:
|
||
state.container_manager.release_container(key, reason="logout")
|
||
except Exception:
|
||
pass
|
||
for token_value, meta in list(state.pending_socket_tokens.items()):
|
||
if meta.get("username") == username:
|
||
state.pending_socket_tokens.pop(token_value, None)
|
||
auth_debug_log(f"[auth_debug] {request.method} /logout after_clear session={_session_debug_snapshot()}")
|
||
if request.method == 'GET':
|
||
resp = make_response(redirect('/login?logged_out=1'))
|
||
resp = _expire_session_cookie(resp)
|
||
auth_debug_log(f"[auth_debug] GET /logout set-cookie={resp.headers.get('Set-Cookie', '')[:300]}")
|
||
return resp
|
||
resp = make_response(jsonify({"success": True}))
|
||
resp = _expire_session_cookie(resp)
|
||
auth_debug_log(f"[auth_debug] POST /logout set-cookie={resp.headers.get('Set-Cookie', '')[:300]}")
|
||
return resp
|
||
|
||
|
||
@auth_bp.route('/api/session-status', methods=['GET'])
|
||
def session_status():
|
||
"""前端调试用:查看当前会话是否已清理。"""
|
||
snapshot = _session_debug_snapshot()
|
||
auth_debug_log(f"[auth_debug] GET /api/session-status session={snapshot} cookie={_cookie_debug_snapshot()}")
|
||
return jsonify({"success": True, "session": snapshot})
|
||
|
||
|
||
@auth_bp.route('/api/tutorial-status', methods=['GET'])
|
||
@api_login_required
|
||
def get_tutorial_status():
|
||
username = (get_current_username() or "").strip().lower()
|
||
if not username:
|
||
return jsonify({"success": False, "error": "未登录"}), 401
|
||
|
||
if bool(session.get("host_mode")) or username == "host":
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"username": username,
|
||
"applicable": False,
|
||
"tutorial_completed": True,
|
||
"should_prompt": False,
|
||
}
|
||
})
|
||
|
||
record = state.user_manager.get_user(username)
|
||
if not record:
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"username": username,
|
||
"applicable": False,
|
||
"tutorial_completed": True,
|
||
"should_prompt": False,
|
||
}
|
||
})
|
||
|
||
completed = bool(getattr(record, "tutorial_completed", False))
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"username": record.username,
|
||
"applicable": True,
|
||
"tutorial_completed": completed,
|
||
"should_prompt": not completed,
|
||
}
|
||
})
|
||
|
||
|
||
@auth_bp.route('/api/tutorial-status', methods=['POST'])
|
||
@api_login_required
|
||
def set_tutorial_status():
|
||
username = (get_current_username() or "").strip().lower()
|
||
if not username:
|
||
return jsonify({"success": False, "error": "未登录"}), 401
|
||
if bool(session.get("host_mode")) or username == "host":
|
||
return jsonify({"success": False, "error": "宿主机模式无需设置新手教程状态"}), 400
|
||
|
||
payload = request.get_json(silent=True) or {}
|
||
completed = payload.get("tutorial_completed", True)
|
||
record = state.user_manager.set_tutorial_completed(username, bool(completed))
|
||
if not record:
|
||
return jsonify({"success": False, "error": "用户不存在"}), 404
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"username": record.username,
|
||
"tutorial_completed": bool(record.tutorial_completed),
|
||
}
|
||
})
|
||
|
||
|
||
@auth_bp.route('/')
|
||
@login_required
|
||
def index():
|
||
return redirect('/new')
|
||
|
||
|
||
@auth_bp.route('/new')
|
||
@login_required
|
||
def new_page():
|
||
return current_app.send_static_file('index.html')
|
||
|
||
|
||
@auth_bp.route('/<conv:conversation_id>')
|
||
@login_required
|
||
def conversation_page(conversation_id):
|
||
return current_app.send_static_file('index.html')
|
||
|
||
|
||
@auth_bp.route('/terminal')
|
||
@login_required
|
||
def terminal_page():
|
||
from .auth_helpers import resolve_admin_policy
|
||
policy = resolve_admin_policy(get_current_user_record())
|
||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||
return "实时终端已被管理员禁用", 403
|
||
return current_app.send_static_file('terminal.html')
|
||
|
||
|
||
@auth_bp.route('/file-manager')
|
||
@login_required
|
||
def gui_file_manager_page():
|
||
from .auth_helpers import resolve_admin_policy
|
||
policy = resolve_admin_policy(get_current_user_record())
|
||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||
return "文件管理器已被管理员禁用", 403
|
||
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'index.html')
|
||
|
||
|
||
@auth_bp.route('/file-manager/editor')
|
||
@login_required
|
||
def gui_file_editor_page():
|
||
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'editor.html')
|
||
|
||
|
||
@auth_bp.route('/file-preview/<path:relative_path>')
|
||
@login_required
|
||
@with_terminal
|
||
def gui_file_preview(relative_path: str, terminal, workspace: UserWorkspace, username: str):
|
||
manager = get_gui_manager(workspace)
|
||
try:
|
||
target = manager.prepare_download(relative_path)
|
||
if not target.is_file():
|
||
return "预览仅支持文件", 400
|
||
return send_from_directory(directory=target.parent, path=target.name, mimetype='text/html')
|
||
except Exception as exc:
|
||
return f"无法预览文件: {exc}", 400
|
||
|
||
|
||
@auth_bp.route('/user_upload/<path:filename>')
|
||
@login_required
|
||
def serve_user_upload(filename: str):
|
||
user = get_current_user_record()
|
||
if not user:
|
||
return redirect('/login')
|
||
workspace = state.user_manager.ensure_user_workspace(user.username)
|
||
uploads_dir = workspace.uploads_dir.resolve()
|
||
target = (uploads_dir / filename).resolve()
|
||
try:
|
||
target.relative_to(uploads_dir)
|
||
except ValueError:
|
||
abort(403)
|
||
if not target.exists() or not target.is_file():
|
||
abort(404)
|
||
return send_from_directory(str(uploads_dir), str(target.relative_to(uploads_dir)))
|
||
|
||
|
||
@auth_bp.route('/workspace/<path:filename>')
|
||
@login_required
|
||
def serve_workspace_file(filename: str):
|
||
user = get_current_user_record()
|
||
if not user:
|
||
return redirect('/login')
|
||
workspace = state.user_manager.ensure_user_workspace(user.username)
|
||
project_root = workspace.project_path.resolve()
|
||
target = (project_root / filename).resolve()
|
||
try:
|
||
target.relative_to(project_root)
|
||
except ValueError:
|
||
abort(403)
|
||
if not target.exists() or not target.is_file():
|
||
abort(404)
|
||
mime_type, _ = mimetypes.guess_type(str(target))
|
||
if not mime_type or not mime_type.startswith("image/"):
|
||
abort(415)
|
||
return send_from_directory(str(target.parent), target.name)
|
||
|
||
|
||
@auth_bp.route('/static/<path:filename>')
|
||
def static_files(filename):
|
||
if filename.startswith('admin_dashboard'):
|
||
abort(404)
|
||
return send_from_directory('static', filename)
|
||
|
||
__all__ = ["auth_bp"]
|