Ctrl K
ComunidadGuías…

src/webui/host_credentials.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Host-level access password and Bot token lifecycle."""

from __future__ import annotations

from collections.abc import Callable, Mapping
import hmac
import json
import logging
from pathlib import Path
import secrets

from src.webui.access_password import (
    consume_reset_password,
    hash_access_password,
    is_hashed_access_password,
    is_valid_access_password,
    normalize_access_password,
    verify_access_password,
)


class HostCredentials:
    def __init__(
        self,
        *,
        state: dict,
        data_dir: Path,
        access_token_file: Path,
        environ: Mapping[str, str],
        save_config: Callable[[], None],
        logger: logging.Logger | None = None,
        plugin_data_dir: Path | None = None,
    ) -> None:
        self.state = state
        self.data_dir = data_dir
        self.plugin_data_dir = plugin_data_dir or data_dir
        self.access_token_file = access_token_file
        self.environ = environ
        self.save_config = save_config
        self.logger = logger or logging.getLogger("trpg")

    def legacy_plugin_bot_token(self) -> str:
        legacy_file = self.plugin_data_dir / "plugins" / "qq-napcat" / "secrets.json"
        if not legacy_file.exists():
            return ""
        try:
            data = json.loads(legacy_file.read_text(encoding="utf-8"))
        except (OSError, ValueError, json.JSONDecodeError):
            self.logger.warning("读取旧 QQ 插件 Bot Token 失败", exc_info=True)
            return ""
        return str(data.get("bot_token") or "").strip() if isinstance(data, dict) else ""

    def ensure_bot_token(self) -> str:
        current = str(self.state.get("bot_token") or "").strip()
        if current:
            return current
        current = self.legacy_plugin_bot_token() or secrets.token_urlsafe(32)
        self.state["bot_token"] = current
        self.save_config()
        self.logger.info("已生成全局 Bot API Token;可在设置 → Bot API 中复制")
        return current

    def initialize_access_password(self) -> None:
        reset_password = consume_reset_password(self.data_dir)
        configured_env_password = normalize_access_password(
            self.environ.get("TRPG_ACCESS_TOKEN")
        )
        stored_password = normalize_access_password(self.state.get("access_token"))
        self.state["access_token"] = stored_password
        if reset_password:
            self.state["access_token"] = hash_access_password(reset_password)
            self.save_config()
            self.delete_access_token_file()
            self.logger.warning(
                "访问密码已通过 data/reset_access_password.txt 重置,重置文件已删除。"
            )
        elif not is_valid_access_password(stored_password):
            if stored_password:
                self.logger.warning(
                    "保存的访问密码凭证无效,将重新生成首次启动密码。"
                )
            self.generate_initial_access_password()
        elif not is_hashed_access_password(stored_password) and not configured_env_password:
            self.state["access_token"] = hash_access_password(stored_password)
            self.save_config()
            password_file_value = self.read_access_token_file()
            if password_file_value and not hmac.compare_digest(
                password_file_value, stored_password
            ):
                self.delete_access_token_file()
                self.logger.warning(
                    "data/access_token.txt 与现有密码不一致,已删除过期文件。"
                )
            self.logger.info("已将旧版明文访问密码迁移为安全凭证。")
        elif configured_env_password:
            self.logger.info("使用环境变量 TRPG_ACCESS_TOKEN 配置的访问密码。")
        else:
            password_file_value = self.read_access_token_file()
            if password_file_value and not verify_access_password(
                password_file_value, stored_password
            ):
                self.delete_access_token_file()
                password_file_value = ""
                self.logger.warning(
                    "data/access_token.txt 与现有密码不一致,已删除过期文件。"
                )
            if password_file_value:
                self.logger.info(
                    "已加载访问密码;首次启动密码仍可在 data/access_token.txt 查看。"
                )
            else:
                self.logger.info(
                    "已加载访问密码安全凭证;忘记密码请使用 "
                    "data/reset_access_password.txt 重置。"
                )

    def write_access_token_file(self, password: str) -> None:
        temporary = self.access_token_file.with_suffix(
            self.access_token_file.suffix + ".tmp"
        )
        temporary.write_text(password + "\n", encoding="utf-8")
        temporary.replace(self.access_token_file)

    def delete_access_token_file(self) -> None:
        self.access_token_file.unlink(missing_ok=True)

    def read_access_token_file(self) -> str:
        try:
            return normalize_access_password(
                self.access_token_file.read_text(encoding="utf-8")
            )
        except (OSError, UnicodeDecodeError):
            return ""

    def generate_initial_access_password(self) -> None:
        generated_password = secrets.token_urlsafe(18)
        self.state["access_token"] = hash_access_password(generated_password)
        self.save_config()
        self.write_access_token_file(generated_password)
        print("\n" + "=" * 60, flush=True)
        print("  Initial access password: " + generated_password, flush=True)
        print("  Frontend will prompt for this on open.", flush=True)
        print("  It is also saved once to data/access_token.txt.", flush=True)
        print(
            "  If forgotten later: create data/reset_access_password.txt and restart.",
            flush=True,
        )
        print("=" * 60 + "\n", flush=True)