"""HTTP-слой: маршруты /api/*, JSON, cookie сессии, защита от CSRF. Только стандартная библиотека.""" from __future__ import annotations import ipaddress import json import logging from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlsplit from . import security from .config import Config from .mailer import MailQueueFull from .service import MESSAGES, ApiError, Service log = logging.getLogger("splitapi") access = logging.getLogger("splitapi.access") MAX_BODY = 300_000 CSRF_HEADER = "X-Requested-With" CSRF_VALUE = "split-trainer" BUSY_RETRY_SECONDS = 5 # путь -> {метод: имя обработчика} ROUTES: dict[str, dict[str, str]] = { "/api/health": {"GET": "health"}, "/api/register": {"POST": "register"}, "/api/verify": {"POST": "verify"}, "/api/login": {"POST": "login"}, "/api/logout": {"POST": "logout"}, "/api/me": {"GET": "me"}, "/api/forgot": {"POST": "forgot"}, "/api/reset": {"POST": "reset"}, "/api/delete-account": {"POST": "delete_account"}, "/api/state": {"GET": "get_state", "PUT": "put_state"}, } # Откуда принимаем X-Real-IP: тот же список, что в set_real_ip_from у nginx (частные сети и localhost) _TRUSTED_PEERS = tuple( ipaddress.ip_network(net) for net in ("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "fc00::/7", "fe80::/10") ) def _parse_ip(raw: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: try: ip = ipaddress.ip_address(raw) except ValueError: return None return ip.ipv4_mapped if ip.version == 6 and ip.ipv4_mapped else ip def is_trusted_proxy_peer(address: str) -> bool: ip = _parse_ip(address) return ip is not None and any(ip in net for net in _TRUSTED_PEERS) def normalize_ip(raw: str) -> str | None: """IPv4 как есть; IPv6 сводим к сети /64: у одного абонента их миллиарды, поэтому счётчик общий.""" ip = _parse_ip(raw) if ip is None: return None if ip.version == 6: return str(ipaddress.ip_network(f"{ip}/64", strict=False)) return str(ip) def _forbid_constant(name: str) -> None: raise ValueError(f"недопустимая константа {name}") def make_handler(cfg: Config, service: Service) -> type[BaseHTTPRequestHandler]: class Handler(BaseHTTPRequestHandler): server_version = "splitapi" sys_version = "" timeout = 10 # защита от медленных соединений (основную работу делает nginx) # --- вспомогательное --- def log_message(self, format: str, *args: object) -> None: # noqa: A002 - сигнатура базового класса pass def client_ip(self) -> str: peer = self.client_address[0] chosen = peer if cfg.trust_proxy and is_trusted_proxy_peer(peer): # заголовку верим только от своего прокси candidate = (self.headers.get("X-Real-IP") or "").strip() if normalize_ip(candidate): chosen = candidate return normalize_ip(chosen) or peer def session_token(self) -> str | None: """Токен из cookie. Если cookie с таким именем несколько, не верим ни одной (подброс cookie с поддомена).""" values = [] for part in (self.headers.get("Cookie") or "").split(";"): name, _, value = part.strip().partition("=") if name == cfg.cookie_name: values.append(value.strip()) return values[0] if len(values) == 1 else None def cookie_header(self, token: str, max_age: int) -> str: parts = [f"{cfg.cookie_name}={token}", f"Max-Age={max_age}", "Path=/", "HttpOnly", "SameSite=Lax"] if cfg.secure_cookie: parts.append("Secure") return "; ".join(parts) def send_json(self, status: int, body: dict, headers: dict[str, str] | None = None, cookies: list[str] | None = None) -> None: payload = json.dumps(body, ensure_ascii=False).encode("utf-8") try: self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Referrer-Policy", "no-referrer") for name, value in (headers or {}).items(): self.send_header(name, value) for cookie in cookies or []: self.send_header("Set-Cookie", cookie) self.end_headers() self.wfile.write(payload) except OSError: self.close_connection = True # клиент ушёл, не дождавшись ответа: это не ошибка сервера return access.info("%s %s %r -> %s", self.client_ip(), self.command, urlsplit(self.path).path, status) def error(self, err: ApiError) -> None: headers = {"Retry-After": str(err.retry_after)} if err.retry_after else None self.send_json(err.status, {"error": err.code, "message": err.message, **err.extra}, headers) # --- проверки запроса --- def check_csrf(self) -> None: origin = self.headers.get("Origin") if origin is not None and origin != cfg.origin: log.warning("запрос отклонён: Origin %r, ожидался %r (проверьте PUBLIC_URL)", origin, cfg.origin) raise ApiError(403, "csrf") if self.headers.get(CSRF_HEADER) != CSRF_VALUE: raise ApiError(403, "csrf") def read_json(self) -> dict: content_type = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower() if content_type != "application/json": raise ApiError(415, "unsupported_media_type") try: length = int(self.headers.get("Content-Length") or 0) except ValueError: raise ApiError(400, "invalid_json") from None if length < 0: raise ApiError(400, "invalid_json") if length > MAX_BODY: raise ApiError(413, "payload_too_large") raw = self.rfile.read(length) if length else b"" try: data = json.loads(raw.decode("utf-8"), parse_constant=_forbid_constant) except (ValueError, RecursionError): raise ApiError(400, "invalid_json") from None if not isinstance(data, dict): raise ApiError(400, "invalid_json") return data def require_user(self) -> dict: user = service.me(self.session_token()) if user is None: raise ApiError(401, "unauthorized") return user # --- обработчики маршрутов --- def h_health(self, body: dict | None) -> None: self.send_json(200, {"ok": True}) def h_register(self, body: dict) -> None: self.send_json(200, service.register(body.get("email"), self.client_ip())) def h_verify(self, body: dict) -> None: self.send_json(200, service.verify(body.get("token"), body.get("password"), self.client_ip())) def h_login(self, body: dict) -> None: token, email = service.login(body.get("email"), body.get("password"), self.client_ip()) self.send_json(200, {"ok": True, "email": email}, cookies=[self.cookie_header(token, cfg.session_seconds)]) def h_logout(self, body: dict) -> None: service.logout(self.session_token()) self.send_json(200, {"ok": True}, cookies=[self.cookie_header("", 0)]) def h_me(self, body: dict | None) -> None: # Гостю тоже 200 (email: null): иначе каждый заход анонима оставляет «ошибку» в консоли браузера user = service.me(self.session_token()) self.send_json(200, {"email": user["email"] if user else None}) def h_forgot(self, body: dict) -> None: self.send_json(200, service.forgot(body.get("email"), self.client_ip())) def h_reset(self, body: dict) -> None: self.send_json(200, service.reset(body.get("token"), body.get("password"), self.client_ip())) def h_delete_account(self, body: dict) -> None: result = service.delete_account(self.session_token(), body.get("password")) self.send_json(200, result, cookies=[self.cookie_header("", 0)]) def h_get_state(self, body: dict | None) -> None: user = self.require_user() self.send_json(200, {**service.get_state(user["id"]), "email": user["email"]}) def h_put_state(self, body: dict) -> None: user = self.require_user() self.send_json(200, service.put_state(user["id"], body.get("version"), body.get("data"), user["email"], body.get("email"))) # --- маршрутизация --- def dispatch(self) -> None: self.close_connection = True path = urlsplit(self.path).path try: methods = ROUTES.get(path) if methods is None: raise ApiError(404, "not_found") if self.command not in methods: self.send_json(405, {"error": "method_not_allowed", "message": MESSAGES["method_not_allowed"]}, {"Allow": ", ".join(methods)}) return body = None if self.command != "GET": self.check_csrf() body = self.read_json() getattr(self, "h_" + methods[self.command])(body) except ApiError as err: self.error(err) except (security.HashBusy, MailQueueFull): self.error(ApiError(503, "busy", retry_after=BUSY_RETRY_SECONDS)) except (TimeoutError, ConnectionError): log.debug("соединение оборвано клиентом: %s %r", self.command, path) except Exception: log.exception("необработанная ошибка в %s %r", self.command, path) self.send_json(500, {"error": "server_error", "message": MESSAGES["server_error"]}) do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = dispatch return Handler def make_server(cfg: Config, service: Service, host: str | None = None, port: int | None = None) -> ThreadingHTTPServer: server = ThreadingHTTPServer((host or cfg.bind, cfg.port if port is None else port), make_handler(cfg, service)) server.daemon_threads = True # не держим процесс из-за зависших соединений при остановке return server