"""站点预览签名 URL:HMAC 短时效令牌,密钥首次使用时生成并持久化。 令牌只授予「该站点 dist/ 目录静态文件读」这一项能力,不携带任何其他 API 权限。 """ from __future__ import annotations import base64 import hashlib import hmac import secrets import time from pathlib import Path from deerflow.config.runtime_paths import runtime_home SECRET_FILE_NAME = ".web-app-secret" _DEFAULT_TTL_SECONDS = 7200 _secret_cache: bytes | None = None def _secret_path() -> Path: return runtime_home() / SECRET_FILE_NAME def get_preview_secret() -> bytes: """加载或生成实例级 HMAC 密钥(0600 权限持久化在 .deer-flow 下)。""" global _secret_cache if _secret_cache is not None: return _secret_cache path = _secret_path() try: if path.is_file(): data = path.read_bytes().strip() if len(data) >= 32: _secret_cache = data return _secret_cache path.parent.mkdir(parents=True, exist_ok=True) data = secrets.token_hex(32).encode("ascii") path.write_bytes(data) try: path.chmod(0o600) except OSError: # Windows 无 POSIX 权限,忽略 pass _secret_cache = data return _secret_cache except OSError: # 持久化失败时退化为进程内随机密钥(重启后旧令牌失效,可接受) _secret_cache = secrets.token_hex(32).encode("ascii") return _secret_cache def mint_preview_token(thread_id: str, site_name: str, ttl_seconds: int = _DEFAULT_TTL_SECONDS) -> tuple[str, int]: """签发预览令牌,返回 (token, expires_at_epoch)。""" expires_at = int(time.time()) + max(60, ttl_seconds) payload = f"{thread_id}|{site_name}|{expires_at}" encoded = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii").rstrip("=") signature = hmac.new(get_preview_secret(), payload.encode("utf-8"), hashlib.sha256).hexdigest() return f"{encoded}.{signature}", expires_at def verify_preview_token(token: str) -> tuple[str, str]: """校验令牌,返回 (thread_id, site_name)。 Raises: ValueError: 令牌格式非法、签名不符或已过期。 """ if not token or token.count(".") != 1: raise ValueError("malformed token") encoded, signature = token.split(".", 1) if not encoded or not signature: raise ValueError("malformed token") padding = "=" * (-len(encoded) % 4) try: payload = base64.urlsafe_b64decode(encoded + padding).decode("utf-8") except (ValueError, UnicodeDecodeError) as exc: raise ValueError("malformed token") from exc parts = payload.split("|") if len(parts) != 3: raise ValueError("malformed token") thread_id, site_name, exp_raw = parts expected = hmac.new(get_preview_secret(), payload.encode("utf-8"), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): raise ValueError("bad signature") try: expires_at = int(exp_raw) except ValueError as exc: raise ValueError("malformed token") from exc if time.time() > expires_at: raise ValueError("token expired") if not thread_id or not site_name: raise ValueError("malformed token") return thread_id, site_name