"""Request routing and the privilege gate for the central panel (stdlib only). ``route`` is a pure function: it takes the panel context, an HTTP method and a request target, and returns a ``Response``. Nothing in this module starts a process, writes a file or opens an outbound connection -- the panel's read-only claim is structural, not a promise in a comment: * only ``GET`` and ``HEAD`` are served; every other method is answered with 405 and a Turkish explanation, never by an action; * static assets are served from an explicit allow-list, so a request can never walk the filesystem (``/static/../server.py`` is a 404); * an unknown path is a Turkish 404, not a redirect anywhere. Because ``route`` is pure, tests exercise the whole HTTP surface without binding a port. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from urllib.parse import parse_qs, unquote from . import render from .fleet_data import FleetSnapshot from .render import Privilege, ServerInfo #: Local assets only. Adding a name here is the only way to serve a new file. STATIC_FILES: dict[str, str] = { "tokens.css": "text/css; charset=utf-8", "app.css": "text/css; charset=utf-8", } READ_METHODS: tuple[str, ...] = ("GET", "HEAD") #: Response headers that keep an air-gapped panel inert: no script runs, no #: remote origin is reachable, and the browser is told not to sniff types. SECURITY_HEADERS: tuple[tuple[str, str], ...] = ( ("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "no-referrer"), ( "Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; " "script-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", ), ) @dataclass(frozen=True) class Response: """One HTTP answer, fully decided before the socket sees it.""" status: int content_type: str body: bytes content_length: int headers: tuple[tuple[str, str], ...] = () @property def is_html(self) -> bool: return self.content_type.startswith("text/html") @dataclass(frozen=True) class PanelContext: """Everything a request needs: the snapshot, the privilege level, the facts.""" snapshot: FleetSnapshot privilege: Privilege server_info: ServerInfo static_dir: Path = render.STATIC_DIR def route(context: PanelContext, method: str, target: str) -> Response: """Decide one request. Pure: same inputs, same bytes, no side effects.""" verb = (method or "").upper() raw_path, _, query_string = target.partition("?") if verb not in READ_METHODS: return _html( 405, render.render_refused_page( verb or "BİLİNMİYOR", snapshot=context.snapshot, privilege=context.privilege, server_info=context.server_info, ), verb, extra_headers=(("Allow", ", ".join(READ_METHODS)),), ) decoded = unquote(raw_path) if ".." in decoded or "\x00" in decoded: return _not_found(context, decoded, verb) params = parse_qs(query_string, keep_blank_values=True) if decoded in ("/", "/filo", "/fleet"): page = render.render_fleet_page( context.snapshot, params, privilege=context.privilege, server_info=context.server_info, ) return _html(200, page, verb) if decoded in ("/hakkinda", "/hakkinda/", "/about"): page = render.render_about_page( context.snapshot, privilege=context.privilege, server_info=context.server_info, ) return _html(200, page, verb) if decoded.startswith("/static/"): return _static(context, decoded[len("/static/"):], verb) return _not_found(context, decoded, verb) def _html( status: int, page: str, verb: str, *, extra_headers: tuple[tuple[str, str], ...] = (), ) -> Response: body = page.encode("utf-8") headers = SECURITY_HEADERS + (("Cache-Control", "no-store"),) + extra_headers if verb == "HEAD": return Response(status, "text/html; charset=utf-8", b"", len(body), headers) return Response(status, "text/html; charset=utf-8", body, len(body), headers) def _not_found(context: PanelContext, request_path: str, verb: str) -> Response: page = render.render_not_found_page( request_path, snapshot=context.snapshot, privilege=context.privilege, server_info=context.server_info, ) return _html(404, page, verb) def _static(context: PanelContext, name: str, verb: str) -> Response: # Allow-list first: anything not named here is a 404, so no path trick can # reach a file this panel did not decide to publish. content_type = STATIC_FILES.get(name) if content_type is None: return _not_found(context, f"/static/{name}", verb) path = context.static_dir / name if not path.is_file(): return _not_found(context, f"/static/{name}", verb) body = path.read_bytes() headers = SECURITY_HEADERS + ( ("Cache-Control", "no-cache"), ) if verb == "HEAD": return Response(200, content_type, b"", len(body), headers) return Response(200, content_type, body, len(body), headers)