事故根因:HEL-214/221/226/233 均直接基于 main(8a5e78f) 构建部署,
绕过了 .11 正式线(HEL-183/188/190/191/192/193/199/207/208 + HEL-164
共 14 个已验收提交),导致问天工具行右对齐、侧栏等高、管理员刷新
结果、快照补档、收盘日线修复等整体丢失。
本合并以 deploy 前最后完整基线 89b8d33(镜像 official-limit-guard-d9ee725)
为底,合入 cefc869(HEL-221 情绪周期等高、HEL-226 登录门户与免密切换、
HEL-233 移动端系统管理五页),三处 CSS 缓存版本统一刷新为 20260829-hel237,
architecture-inventory 按合并后源码重新生成。
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -18,6 +18,8 @@ TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$")
|
||||
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$")
|
||||
SESSION_COOKIE = "xiaobai_session"
|
||||
SESSION_MAX_AGE = 30 * 24 * 60 * 60
|
||||
DEVICE_COOKIE = "xiaobai_device"
|
||||
DEVICE_MAX_AGE = 180 * 24 * 60 * 60
|
||||
|
||||
|
||||
def load_local_env() -> None:
|
||||
|
||||
@@ -2,6 +2,7 @@ from .m0001_adopt_legacy import MIGRATION as M0001_ADOPT_LEGACY
|
||||
from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS
|
||||
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
|
||||
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES
|
||||
from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS
|
||||
from .runner import Migration, MigrationError, MigrationRunner
|
||||
|
||||
MIGRATIONS = (
|
||||
@@ -9,6 +10,7 @@ MIGRATIONS = (
|
||||
M0002_JOB_RUNS,
|
||||
M0003_LLM_AUDIT,
|
||||
M0004_MENTOR_NOTES,
|
||||
M0005_ACCOUNT_SWITCH_GRANTS,
|
||||
)
|
||||
|
||||
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from backend.database.migrations.runner import Migration
|
||||
|
||||
|
||||
def create_account_switch_grants(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS account_switch_grants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_hash TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
UNIQUE (device_hash, user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_account_switch_grants_device
|
||||
ON account_switch_grants(device_hash, last_used_at DESC)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_account_switch_grants_expiry
|
||||
ON account_switch_grants(expires_at)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
MIGRATION = Migration(
|
||||
version="0005",
|
||||
name="create_account_switch_grants",
|
||||
action=create_account_switch_grants,
|
||||
signature="account-switch-grants:v1:device,user,granted,used,expires,unique",
|
||||
)
|
||||
@@ -28,11 +28,11 @@ class AccountApplicationMixin:
|
||||
def update_membership(self, payload: dict[str, Any]) -> None:
|
||||
self.accounts.update_membership(payload)
|
||||
|
||||
def register_account(self, username: str, password: str) -> dict[str, Any]:
|
||||
return self.accounts.register(username, password)
|
||||
def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
return self.accounts.register(username, password, device_hash)
|
||||
|
||||
def login_account(self, username: str, password: str) -> dict[str, Any]:
|
||||
return self.accounts.login(username, password)
|
||||
def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
return self.accounts.login(username, password, device_hash)
|
||||
|
||||
def change_password(self, current_password: str, new_password: str) -> None:
|
||||
self.accounts.change_password(current_password, new_password)
|
||||
|
||||
@@ -1,49 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
|
||||
from backend.features.accounts.security import token_hash
|
||||
|
||||
|
||||
class AccountHttpMixin:
|
||||
def _device_hash(self) -> str:
|
||||
raw = self.device_token()
|
||||
return token_hash(raw) if raw else ""
|
||||
|
||||
def _ensure_device_token(self) -> str:
|
||||
return self.device_token() or secrets.token_urlsafe(32)
|
||||
|
||||
def _auth_success_headers(self, session_token: str, device_raw: str) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("Set-Cookie", self.session_cookie(session_token)),
|
||||
("Set-Cookie", self.device_cookie(device_raw)),
|
||||
]
|
||||
|
||||
def _send_authenticated_session(self, result: dict, status: HTTPStatus, device_raw: str) -> None:
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
status,
|
||||
self._auth_success_headers(result["session_token"], device_raw),
|
||||
)
|
||||
|
||||
def auth_register(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
device_raw = self._ensure_device_token()
|
||||
result = self.application_service.register_account(
|
||||
str(body.get("username") or ""),
|
||||
str(body.get("password") or ""),
|
||||
token_hash(device_raw),
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
HTTPStatus.CREATED,
|
||||
{"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||
)
|
||||
self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def auth_login(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
device_raw = self._ensure_device_token()
|
||||
result = self.application_service.login_account(
|
||||
str(body.get("username") or ""),
|
||||
str(body.get("password") or ""),
|
||||
token_hash(device_raw),
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
headers={"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||
)
|
||||
self._send_authenticated_session(result, HTTPStatus.OK, device_raw)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)
|
||||
|
||||
def auth_accounts(self) -> None:
|
||||
current_user_id = None
|
||||
if self.require_auth(send_error=False):
|
||||
current_user_id = int(self.auth_user["id"])
|
||||
payload = self.application_service.accounts.list_device_accounts(
|
||||
self._device_hash(),
|
||||
current_user_id,
|
||||
)
|
||||
self.send_json({"ok": True, **payload})
|
||||
|
||||
def auth_switch(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
try:
|
||||
user_id = int(body.get("user_id"))
|
||||
except (TypeError, ValueError):
|
||||
user_id = 0
|
||||
device_raw = self.device_token()
|
||||
result = self.application_service.accounts.switch_account(
|
||||
token_hash(device_raw) if device_raw else "",
|
||||
user_id,
|
||||
)
|
||||
self._send_authenticated_session(
|
||||
result,
|
||||
HTTPStatus.OK,
|
||||
device_raw or self._ensure_device_token(),
|
||||
)
|
||||
except PermissionError as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def auth_forget(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
try:
|
||||
user_id = int(body.get("user_id"))
|
||||
except (TypeError, ValueError):
|
||||
user_id = 0
|
||||
self.application_service.accounts.forget_account(self._device_hash(), user_id)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def auth_me(self) -> None:
|
||||
service = self.application_service
|
||||
if not self.require_auth(send_error=False):
|
||||
@@ -55,6 +114,15 @@ class AccountHttpMixin:
|
||||
}
|
||||
)
|
||||
return
|
||||
headers = None
|
||||
if not self.device_token():
|
||||
device_raw = secrets.token_urlsafe(32)
|
||||
service.accounts.remember_account(
|
||||
token_hash(device_raw),
|
||||
int(self.auth_user["id"]),
|
||||
fresh=True,
|
||||
)
|
||||
headers = [("Set-Cookie", self.device_cookie(device_raw))]
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -66,15 +134,19 @@ class AccountHttpMixin:
|
||||
"membership": service.membership(),
|
||||
},
|
||||
"csrf_token": str(self.auth_user["csrf_token"]),
|
||||
}
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def auth_logout(self) -> None:
|
||||
raw_token = self.session_token()
|
||||
user_id = int(getattr(self, "auth_user", {}).get("id") or 0)
|
||||
if raw_token:
|
||||
from backend.features.accounts.security import token_hash
|
||||
|
||||
self.application_service.database.delete_session(token_hash(raw_token))
|
||||
self.application_service.accounts.revoke_current_device_grant(
|
||||
self._device_hash(),
|
||||
user_id,
|
||||
)
|
||||
self.send_json(
|
||||
{"ok": True},
|
||||
headers={"Set-Cookie": self.session_cookie("", clear=True)},
|
||||
|
||||
@@ -234,3 +234,96 @@ class AccountRepositoryMixin:
|
||||
(user_id,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def cleanup_expired_switch_grants(self, now: str) -> int:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM account_switch_grants WHERE expires_at <= ?",
|
||||
(now,),
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
def list_switch_grants(self, device_hash: str, now: str) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status,
|
||||
u.membership_plan, u.membership_starts_at, u.membership_expires_at,
|
||||
u.created_at, g.last_used_at, g.granted_at, g.expires_at
|
||||
FROM account_switch_grants AS g
|
||||
JOIN users AS u ON u.id = g.user_id
|
||||
WHERE g.device_hash = ? AND g.expires_at > ?
|
||||
ORDER BY g.last_used_at DESC, g.id DESC
|
||||
""",
|
||||
(device_hash, now),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_switch_grant(self, device_hash: str, user_id: int) -> dict[str, Any] | None:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT device_hash, user_id, granted_at, last_used_at, expires_at
|
||||
FROM account_switch_grants
|
||||
WHERE device_hash = ? AND user_id = ?
|
||||
""",
|
||||
(device_hash, user_id),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def upsert_switch_grant(
|
||||
self,
|
||||
device_hash: str,
|
||||
user_id: int,
|
||||
granted_at: str,
|
||||
last_used_at: str,
|
||||
expires_at: str,
|
||||
) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO account_switch_grants
|
||||
(device_hash, user_id, granted_at, last_used_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_hash, user_id) DO UPDATE SET
|
||||
granted_at = excluded.granted_at,
|
||||
last_used_at = excluded.last_used_at,
|
||||
expires_at = excluded.expires_at
|
||||
""",
|
||||
(device_hash, user_id, granted_at, last_used_at, expires_at),
|
||||
)
|
||||
|
||||
def prune_switch_grants(self, device_hash: str, keep: int) -> int:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id FROM account_switch_grants
|
||||
WHERE device_hash = ?
|
||||
ORDER BY last_used_at DESC, id DESC
|
||||
""",
|
||||
(device_hash,),
|
||||
).fetchall()
|
||||
extra = [int(row["id"]) for row in rows[keep:]]
|
||||
if not extra:
|
||||
return 0
|
||||
connection.execute(
|
||||
f"DELETE FROM account_switch_grants WHERE id IN ({','.join('?' * len(extra))})",
|
||||
extra,
|
||||
)
|
||||
return len(extra)
|
||||
|
||||
def delete_switch_grant(self, device_hash: str, user_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM account_switch_grants WHERE device_hash = ? AND user_id = ?",
|
||||
(device_hash, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_switch_grants_for_user(self, user_id: int) -> int:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM account_switch_grants WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
@@ -6,6 +6,9 @@ class AccountRoutesMixin:
|
||||
if parsed.path == "/api/auth/me":
|
||||
self.auth_me()
|
||||
return True
|
||||
if parsed.path == "/api/auth/accounts":
|
||||
self.auth_accounts()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_accounts_get(self, parsed) -> bool:
|
||||
|
||||
@@ -77,15 +77,22 @@ class AccountService:
|
||||
access = self.access_supplier() or self.database.user_access(self.current_user_id) or {}
|
||||
return self.membership_for_access(access)
|
||||
|
||||
def register(self, username: str, password: str) -> dict[str, Any]:
|
||||
GRANT_SLIDE_DAYS = 30
|
||||
GRANT_HARD_DAYS = 180
|
||||
MAX_GRANTS_PER_DEVICE = 5
|
||||
SWITCH_REAUTH_MESSAGE = "该账号需重新验证"
|
||||
|
||||
def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
username = username.strip()
|
||||
self.validate_input(username, password)
|
||||
with self.auth_lock:
|
||||
salt, password_digest = hash_password(password)
|
||||
user = self.database.create_user(username, salt, password_digest)
|
||||
return self.create_session(user)
|
||||
result = self.create_session(user)
|
||||
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
||||
return result
|
||||
|
||||
def login(self, username: str, password: str) -> dict[str, Any]:
|
||||
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
username = username.strip()
|
||||
if not username or not password:
|
||||
raise ValueError("账号名和密码不能为空。")
|
||||
@@ -96,7 +103,9 @@ class AccountService:
|
||||
str(user.get("password_hash") or ""),
|
||||
):
|
||||
raise ValueError("账号名或密码不正确。")
|
||||
return self.create_session(user)
|
||||
result = self.create_session(user)
|
||||
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
||||
return result
|
||||
|
||||
def change_password(self, current_password: str, new_password: str) -> None:
|
||||
current_password = str(current_password or "")
|
||||
@@ -112,6 +121,86 @@ class AccountService:
|
||||
salt, digest = hash_password(new_password)
|
||||
if not self.database.update_user_password(self.current_user_id, salt, digest):
|
||||
raise ValueError("账号不存在。")
|
||||
self.database.delete_switch_grants_for_user(self.current_user_id)
|
||||
|
||||
@staticmethod
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@classmethod
|
||||
def _iso(cls, value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
def remember_account(self, device_hash: str, user_id: int, *, fresh: bool = False) -> None:
|
||||
if not device_hash or user_id <= 0:
|
||||
return
|
||||
now = self._utc_now()
|
||||
now_text = self._iso(now)
|
||||
self.database.cleanup_expired_switch_grants(now_text)
|
||||
existing = None if fresh else self.database.get_switch_grant(device_hash, user_id)
|
||||
granted_at = parse_iso_datetime(existing["granted_at"]) if existing else now
|
||||
if granted_at is None:
|
||||
granted_at = now
|
||||
expires = min(
|
||||
now + timedelta(days=self.GRANT_SLIDE_DAYS),
|
||||
granted_at + timedelta(days=self.GRANT_HARD_DAYS),
|
||||
)
|
||||
if not existing:
|
||||
self.database.prune_switch_grants(device_hash, self.MAX_GRANTS_PER_DEVICE - 1)
|
||||
self.database.upsert_switch_grant(
|
||||
device_hash,
|
||||
user_id,
|
||||
self._iso(granted_at),
|
||||
now_text,
|
||||
self._iso(expires),
|
||||
)
|
||||
|
||||
def list_device_accounts(
|
||||
self, device_hash: str, current_user_id: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not device_hash:
|
||||
return {"accounts": [], "current_user_id": current_user_id}
|
||||
now_text = self._iso(self._utc_now())
|
||||
self.database.cleanup_expired_switch_grants(now_text)
|
||||
accounts = []
|
||||
for row in self.database.list_switch_grants(device_hash, now_text):
|
||||
accounts.append(
|
||||
{
|
||||
"user_id": int(row["id"]),
|
||||
"username": str(row["username"]),
|
||||
"role": str(row.get("role") or "user"),
|
||||
"membership": self.membership_for_access(row),
|
||||
"last_used_at": str(row.get("last_used_at") or ""),
|
||||
}
|
||||
)
|
||||
return {"accounts": accounts, "current_user_id": current_user_id}
|
||||
|
||||
def switch_account(self, device_hash: str, user_id: int) -> dict[str, Any]:
|
||||
if not device_hash or user_id <= 0:
|
||||
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
|
||||
now = self._utc_now()
|
||||
now_text = self._iso(now)
|
||||
self.database.cleanup_expired_switch_grants(now_text)
|
||||
grant = self.database.get_switch_grant(device_hash, user_id)
|
||||
expires = parse_iso_datetime(grant.get("expires_at")) if grant else None
|
||||
if not grant or not expires or expires <= now:
|
||||
if grant:
|
||||
self.database.delete_switch_grant(device_hash, user_id)
|
||||
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
|
||||
user = self.database.user_access(user_id)
|
||||
if not user:
|
||||
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
|
||||
result = self.create_session(user)
|
||||
self.remember_account(device_hash, user_id)
|
||||
return result
|
||||
|
||||
def forget_account(self, device_hash: str, user_id: int) -> None:
|
||||
if device_hash and user_id > 0:
|
||||
self.database.delete_switch_grant(device_hash, user_id)
|
||||
|
||||
def revoke_current_device_grant(self, device_hash: str, user_id: int) -> None:
|
||||
if device_hash and user_id > 0:
|
||||
self.database.delete_switch_grant(device_hash, user_id)
|
||||
|
||||
def create_session(self, user: dict[str, Any]) -> dict[str, Any]:
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
|
||||
@@ -7,6 +7,8 @@ from urllib.parse import urlparse
|
||||
PUBLIC_POST_HANDLERS = {
|
||||
"/api/auth/register": "auth_register",
|
||||
"/api/auth/login": "auth_login",
|
||||
"/api/auth/switch": "auth_switch",
|
||||
"/api/auth/forget": "auth_forget",
|
||||
}
|
||||
|
||||
AUTHENTICATED_POST_HANDLERS = {
|
||||
|
||||
+26
-10
@@ -9,7 +9,13 @@ from http.cookies import SimpleCookie
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from backend.bootstrap.config import SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR
|
||||
from backend.bootstrap.config import (
|
||||
DEVICE_COOKIE,
|
||||
DEVICE_MAX_AGE,
|
||||
SESSION_COOKIE,
|
||||
SESSION_MAX_AGE,
|
||||
STATIC_DIR,
|
||||
)
|
||||
from backend.features.accounts.security import token_hash
|
||||
from backend.http.context import correlation_id
|
||||
from backend.http.errors import normalize_error_payload
|
||||
@@ -21,15 +27,21 @@ class HttpTransportMixin:
|
||||
application_service: Any
|
||||
route_registry: Any
|
||||
|
||||
def session_token(self) -> str:
|
||||
def cookie_value(self, name: str) -> str:
|
||||
cookie = SimpleCookie()
|
||||
try:
|
||||
cookie.load(self.headers.get("Cookie", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
morsel = cookie.get(SESSION_COOKIE)
|
||||
morsel = cookie.get(name)
|
||||
return morsel.value if morsel else ""
|
||||
|
||||
def session_token(self) -> str:
|
||||
return self.cookie_value(SESSION_COOKIE)
|
||||
|
||||
def device_token(self) -> str:
|
||||
return self.cookie_value(DEVICE_COOKIE)
|
||||
|
||||
def require_auth(self, send_error: bool = True) -> bool:
|
||||
raw_token = self.session_token()
|
||||
service = self.application_service
|
||||
@@ -79,15 +91,18 @@ class HttpTransportMixin:
|
||||
return self.require_member()
|
||||
return True
|
||||
|
||||
def session_cookie(self, value: str, clear: bool = False) -> str:
|
||||
max_age = 0 if clear else SESSION_MAX_AGE
|
||||
cookie = (
|
||||
f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
|
||||
)
|
||||
def _cookie_header(self, name: str, value: str, max_age: int) -> str:
|
||||
cookie = f"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
|
||||
if self.headers.get("X-Forwarded-Proto", "").lower() == "https":
|
||||
cookie += "; Secure"
|
||||
return cookie
|
||||
|
||||
def session_cookie(self, value: str, clear: bool = False) -> str:
|
||||
return self._cookie_header(SESSION_COOKIE, value, 0 if clear else SESSION_MAX_AGE)
|
||||
|
||||
def device_cookie(self, value: str, clear: bool = False) -> str:
|
||||
return self._cookie_header(DEVICE_COOKIE, value, 0 if clear else DEVICE_MAX_AGE)
|
||||
|
||||
def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length == 0 and allow_empty:
|
||||
@@ -132,7 +147,7 @@ class HttpTransportMixin:
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
status: HTTPStatus = HTTPStatus.OK,
|
||||
headers: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | list[tuple[str, str]] | tuple[tuple[str, str], ...] | None = None,
|
||||
) -> None:
|
||||
request_id = getattr(self, "_correlation_id", "")
|
||||
if not request_id:
|
||||
@@ -145,7 +160,8 @@ class HttpTransportMixin:
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Request-ID", request_id)
|
||||
for name, value in (headers or {}).items():
|
||||
header_items = headers.items() if isinstance(headers, dict) else (headers or ())
|
||||
for name, value in header_items:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
Reference in New Issue
Block a user