Ctrl K
ComunidadGuías…

src/webui/session.py

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

Archivo original ↗Preguntar a KiBot
"""Session 中间件 —— UUID token + cookie,轻量身份系统。"""

from __future__ import annotations

import json
import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path

from aiohttp import web

from src.webui.cors import is_allowed_cors_origin

logger = logging.getLogger("trpg")


class SessionManager:
    """管理 player session:cookie → user_id 映射。"""

    def __init__(self, data_dir: Path):
        self._path = data_dir / "sessions.json"
        self._sessions: dict[str, dict] = {}
        self._load()

    def _load(self) -> None:
        if self._path.exists():
            try:
                self._sessions = json.loads(self._path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, ValueError):
                self._sessions = {}

    def _save(self) -> None:
        self._path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
        tmp_path.write_text(json.dumps(self._sessions, ensure_ascii=False, indent=2),
                            encoding="utf-8")
        tmp_path.replace(self._path)

    def get_or_create(self, token: str | None) -> tuple[str, str]:
        """返回 (session_token, user_id)。"""
        if token and token in self._sessions:
            return token, self._sessions[token]["user_id"]

        token = token or uuid.uuid4().hex
        existing_user_ids = {session["user_id"] for session in self._sessions.values()}
        user_id = f"web_{uuid.uuid4().hex[:8]}"
        while user_id in existing_user_ids:
            user_id = f"web_{uuid.uuid4().hex[:8]}"
        self._sessions[token] = {
            "user_id": user_id,
            "name": "",
            "created_at": datetime.now(timezone.utc).isoformat(),
        }
        self._save()
        return token, user_id

    def set_name(self, token: str, name: str) -> None:
        if token in self._sessions:
            self._sessions[token]["name"] = name
            self._save()

    def get_name(self, token: str) -> str:
        return self._sessions.get(token, {}).get("name", "")

    def revoke_game_binding(self, user_id: str, game_key: str) -> int:
        """Stop sessions bound to ``user_id`` from speaking for it in one game.

        Bindings are one uid per session, shared by every game. A seat's
        credential being rotated, or the seat being removed, must cut those
        sessions off in *that* game only: the session keeps its uid (and any
        seat it holds elsewhere) and is just an anonymous visitor here.
        """
        if not user_id or not game_key:
            return 0
        changed = 0
        for session in self._sessions.values():
            if session.get("user_id") != user_id:
                continue
            revoked = session.setdefault("revoked_games", [])
            if game_key not in revoked:
                revoked.append(game_key)
                changed += 1
        if changed:
            self._save()
        return changed

    def count_bound(self, user_id: str, game_key: str) -> int:
        """How many sessions still speak for ``user_id`` in ``game_key``.

        A session counts for a game it has entered with that uid. Sessions
        from before games were recorded have no ``games`` list and are
        counted conservatively (they may be bound here).
        """
        if not user_id:
            return 0
        count = 0
        for session in self._sessions.values():
            if session.get("user_id") != user_id:
                continue
            if game_key in (session.get("revoked_games") or []):
                continue
            games = session.get("games")
            if isinstance(games, list) and game_key not in games:
                continue
            count += 1
        return count

    def note_game(self, token: str, game_key: str) -> None:
        """Record that this session holds its uid's seat in ``game_key``."""
        session = self._sessions.get(token)
        if session is None or not game_key:
            return
        games = session.setdefault("games", [])
        if game_key not in games:
            games.append(game_key)
            self._save()

    def revoked_games(self, token: str) -> frozenset[str]:
        return frozenset(self._sessions.get(token, {}).get("revoked_games") or [])

    def rebind(self, token: str, user_id: str, game_key: str = "") -> None:
        """把当前 session token 绑定到指定 user_id(换设备恢复身份用)。"""
        session = self._sessions.get(token)
        if session is None:
            return
        if session.get("user_id") != user_id:
            # A new identity: revocations and game records were about the old one.
            session["user_id"] = user_id
            session.pop("revoked_games", None)
            session.pop("games", None)
        elif game_key:
            # Re-entering one game (with that seat's token) lifts only that
            # game's revocation; other games stay revoked.
            revoked = session.get("revoked_games") or []
            if game_key in revoked:
                revoked.remove(game_key)
        if game_key:
            games = session.setdefault("games", [])
            if game_key not in games:
                games.append(game_key)
        self._save()


def apply_session_cookie(request: web.Request, response: web.StreamResponse) -> None:
    """Apply a new identity before a streaming response prepares its headers."""
    new_token = request.get("session_token")
    if not new_token or new_token == request.cookies.get("trpg_session"):
        return
    cross_origin = is_allowed_cors_origin(request)
    response.set_cookie(
        "trpg_session", new_token, httponly=True, path="/", max_age=7 * 86400,
        samesite="None" if cross_origin else "Lax", secure=cross_origin,
    )


@web.middleware
async def session_middleware(request: web.Request, handler) -> web.StreamResponse:
    """aiohttp 中间件:解析 session token,注入 user_id。"""
    mgr: SessionManager = request.app.get("session_manager")
    if not mgr:
        return await handler(request)

    token = request.cookies.get("trpg_session")
    new_token, user_id = mgr.get_or_create(token)
    request["user_id"] = user_id
    request["session_token"] = new_token
    request["session_revoked_games"] = mgr.revoked_games(new_token)

    response = await handler(request)
    if not response.prepared:
        apply_session_cookie(request, response)
    return response