项目文件夹

文件
wehub-resource-sync 0af812490d
CI / python (push) Failing after 0s
CI / javascript (push) Failing after 1s
CI / dotnet (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:12:36 +08:00

292 行
9.7 KiB
Python

"""License validation and caching for CloakBrowser Pro.
Handles license key resolution, server validation with local caching,
and Pro version checks.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
import httpx
from .config import get_cache_dir, get_platform_tag
logger = logging.getLogger("cloakbrowser")
VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate"
PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version"
LICENSE_CACHE_TTL = 86400 # 24 hours
PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour
@dataclass
class LicenseInfo:
valid: bool
plan: str
expires: str | None
_LICENSE_KEY_SOURCE_PARAM = "param"
_LICENSE_KEY_SOURCE_ENV = "env"
_LICENSE_KEY_SOURCE_DEFAULT_FILE = "default_file"
_LICENSE_KEY_SOURCE_CUSTOM_FILE = "custom_file"
_LICENSE_KEY_SOURCE_NONE = "none"
def _resolve_license_key_with_source(
license_key: str | None = None,
) -> tuple[str | None, str]:
"""Resolve license key with source tracking for env-injection decisions.
Returns (key, source) where source is one of the _LICENSE_KEY_SOURCE_*
constants. The source tells the caller *how* the key was found so they
can decide whether env injection is needed (e.g. the binary reads the
default file path directly, so env injection is unnecessary).
"""
# 1. Explicit param
if license_key and license_key.strip():
return (license_key.strip(), _LICENSE_KEY_SOURCE_PARAM)
# 2. Environment variable
env_key = os.environ.get("CLOAKBROWSER_LICENSE_KEY", "").strip()
if env_key:
return (env_key, _LICENSE_KEY_SOURCE_ENV)
# 3. File in the wrapper cache dir
cache_dir = get_cache_dir()
key_file = cache_dir / "license.key"
try:
content = key_file.read_text().strip()
if content:
default_cache = Path.home() / ".cloakbrowser"
if cache_dir.resolve() == default_cache.resolve():
source = _LICENSE_KEY_SOURCE_DEFAULT_FILE
else:
source = _LICENSE_KEY_SOURCE_CUSTOM_FILE
return (content, source)
except OSError:
pass
return (None, _LICENSE_KEY_SOURCE_NONE)
def resolve_license_key(license_key: str | None = None) -> str | None:
"""Resolve the license key: explicit param > env var > file > None."""
key, _ = _resolve_license_key_with_source(license_key)
return key
def build_launch_env(
license_key: str | None = None,
user_env: dict[str, str] | None = None,
) -> dict[str, str] | None:
"""Build child process env dict with any needed license key injection.
The Pro binary reads ``CLOAKBROWSER_LICENSE_KEY`` from its own process
environment at startup. This helper merges the resolved key into the
child process env dict **only** when injection is necessary:
* **param** or **custom_file** source -> inject the key into the child env
(the binary cannot see the wrapper-only key or the custom file path).
* **env** source -> the key is already in ``os.environ``, so the child
inherits it naturally. No injection.
* **default_file** source -> the binary reads ``~/.cloakbrowser/license.key``
directly, so injection is unnecessary (and keeps the key out of process
env for security) — *unless* the caller passes a custom ``user_env``,
which Playwright uses to replace (not merge) the child env; a replaced
env can drop ``HOME`` and hide the file, so the key is injected then.
* **none** -> no key at all, no injection.
When *user_env* is provided (e.g. the caller passed ``env=`` via
Playwright kwargs), it is used as the base instead of ``os.environ``,
and the key is injected only when needed.
Returns ``None`` when no injection is needed and no custom user_env was
given — Playwright treats ``env=None`` as "inherit parent env", which
is correct in those cases.
"""
key, source = _resolve_license_key_with_source(license_key)
# Normalize the custom env once so every return path behaves identically:
# drop None values (Playwright's env is typed str->str).
base_env = (
{k: v for k, v in user_env.items() if v is not None}
if user_env is not None
else None
)
# Default file: binary reads it directly — no env injection needed,
# UNLESS the caller passes a custom env. Playwright replaces (not merges)
# the child env, which can drop HOME and hide the file from the binary,
# so inject the key too in that case (fall through to the merge below).
if source == _LICENSE_KEY_SOURCE_DEFAULT_FILE and base_env is None:
return None
# No key at all: pass through the custom env or None.
if source == _LICENSE_KEY_SOURCE_NONE or key is None:
return base_env
# Env source, no custom user env: child inherits parent env, which
# already has CLOAKBROWSER_LICENSE_KEY.
if source == _LICENSE_KEY_SOURCE_ENV and base_env is None:
return None
# Build the merged env dict.
merged = dict(base_env) if base_env is not None else dict(os.environ)
# For param/custom_file this is THE injection into the child env.
# For env source with a custom user_env this ensures the key persists
# through the user's env override (Playwright replaces, not merges).
merged["CLOAKBROWSER_LICENSE_KEY"] = key
return merged
def validate_license(license_key: str) -> LicenseInfo | None:
"""Validate a license key with the CloakBrowser server.
Checks a local file cache first (24h TTL). Falls back to stale
cache if the server is unreachable.
Returns LicenseInfo if validation succeeded, None on total failure.
"""
cache_path = get_cache_dir() / ".license_cache"
key_sha = hashlib.sha256(license_key.encode()).hexdigest()
cached = _read_cache(cache_path, key_sha)
if cached:
return cached
try:
resp = httpx.post(
VALIDATE_URL,
json={"license_key": license_key},
timeout=10.0,
)
resp.raise_for_status()
data = resp.json()
info = LicenseInfo(
valid=data.get("valid", False),
plan=data.get("plan", "solo"),
expires=data.get("expires"),
)
if info.valid:
_write_cache(cache_path, key_sha, info)
return info
except Exception as e:
logger.warning("License validation request failed: %s", e)
stale = _read_cache(cache_path, key_sha, ignore_ttl=True)
if stale:
logger.warning("Using cached license validation (server unreachable)")
return stale
return None
def get_pro_latest_version() -> str | None:
"""Get the latest Pro binary version from the server.
Rate-limited to 1 call per hour via a marker file.
"""
marker = get_cache_dir() / f".last_pro_version_check_{get_platform_tag()}"
if marker.exists():
try:
age = time.time() - marker.stat().st_mtime
if age < PRO_VERSION_CHECK_INTERVAL:
content = marker.read_text().strip()
return content if content else None
except OSError:
pass
try:
resp = httpx.get(
PRO_VERSION_URL,
headers={"X-Platform": get_platform_tag()},
timeout=10.0,
)
resp.raise_for_status()
version = resp.json().get("version")
if not version:
return None
marker.parent.mkdir(parents=True, exist_ok=True)
tmp = marker.with_suffix(".tmp")
tmp.write_text(version)
os.replace(str(tmp), str(marker))
return version
except Exception as e:
logger.debug("Pro version check failed: %s", e)
return None
def _read_cache(
cache_path: Path, key_sha: str, ignore_ttl: bool = False
) -> LicenseInfo | None:
"""Read cached license validation if it exists and is fresh."""
try:
if not cache_path.exists():
return None
data = json.loads(cache_path.read_text())
if data.get("key_sha256") != key_sha:
return None
if not ignore_ttl:
validated_at = data.get("validated_at", 0)
if time.time() - validated_at > LICENSE_CACHE_TTL:
return None
expires = data.get("expires")
if expires:
try:
from datetime import datetime, timezone
exp_dt = datetime.fromisoformat(expires)
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
if exp_dt < datetime.now(timezone.utc):
return LicenseInfo(valid=False, plan=data.get("plan", "solo"), expires=expires)
except (ValueError, TypeError):
pass
return LicenseInfo(
valid=data.get("valid", False),
plan=data.get("plan", "solo"),
expires=expires,
)
except (json.JSONDecodeError, OSError, KeyError, TypeError):
# TypeError: a corrupted cache with a non-numeric validated_at. Treat any
# unreadable cache as absent rather than crashing the caller.
return None
def _write_cache(cache_path: Path, key_sha: str, info: LicenseInfo) -> None:
"""Write license validation result to local cache (atomic via tmp+rename)."""
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = cache_path.with_suffix(".tmp")
tmp_path.write_text(json.dumps({
"key_sha256": key_sha,
"valid": info.valid,
"plan": info.plan,
"expires": info.expires,
"validated_at": time.time(),
}))
os.replace(str(tmp_path), str(cache_path))
except OSError as e:
logger.debug("Failed to write license cache: %s", e)