From e7b6d5013b4eea5784d07c55bd8cb65d1450b3c0 Mon Sep 17 00:00:00 2001 From: migatu Date: Mon, 17 Aug 2026 18:39:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(astroklient):=20wersja=20demonstracyjna=20?= =?UTF-8?q?o=20dw=C3=B3ch=20funkcjach=20(PRE-28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Osobna warstwa prezentacji: dodanie pliku bazy i zapytanie o interpretację urodzeniową. Nic więcej. OSOBNA USŁUGA, NIE KONTO Z OGRANICZENIAMI. Mechanizm uprawnień z PRE-27 umiałby to ukryć w pełnej aplikacji, ale ukrycie a nieobecność to dwie różne rzeczy: tutaj pozostałych funkcji NIE MA W OBRAZIE — nie ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy okazji kodu reszty programu. Test porównuje zbiór tras aplikacji i zbiór metod klienta z listą dokładną, więc dopisanie czegokolwiek zapala się od razu. WGRANIE I WŁĄCZENIE TO JEDNA CZYNNOŚĆ. W pełnej aplikacji to dwie osobne decyzje (DAN-27), bo tam ktoś nad tym panuje. Tutaj „dodać plik do bazy" musi znaczyć, że plik od razu bierze udział w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i demo wyglądałoby na zepsute. Walidacja zostaje: plik o złym układzie nie wchodzi do użytku, ale też NIE JEST tracony, a komunikat nie zdradza reguł, bo te zna wyłącznie administrator. Osobny test szuka w komunikacie śladów mechanizmu. KONTO OSOBNE (DEMO_USER/DEMO_PASSWORD), nie współdzielone z główną aplikacją. Demo pracuje na TEJ SAMEJ warstwie danych co produkcja — świadoma decyzja właściciela — więc kto ma do niego dostęp, czyta oryginalne bazy, a jego wgrania trafiają do produkcyjnego zbioru. Własne poświadczenia pozwalają odciąć demo jedną zmienną, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek. Zapisane wprost w README usługi i w manifeście, nie tylko w tej wiadomości. Rozmowa z warstwą logiczną idzie tym samym szyfrowanym łączem (PRE-16) i pod tym samym tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę. Automatyczna dokumentacja wyłączona, jak w pełnej aplikacji: /docs wypisałoby komplet tras, a demo ma nie zdradzać nawet własnej powierzchni. Zależności celowo krótsze niż w prezentacji: bez Excela, bez stref czasowych z lokalizacji, bez niczego pod kosmogram. Każda zbędna zależność w obrazie demo to kolejna rzecz do pilnowania. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/build.yaml | 6 +- .gitea/workflows/tests.yml | 21 + docs/astrololo_wymagania.xlsx | Bin 40993 -> 41848 bytes services/astroklient/Dockerfile | 10 + services/astroklient/README.md | 78 +++ services/astroklient/app/__init__.py | 0 services/astroklient/app/clients/__init__.py | 0 .../astroklient/app/clients/logic_client.py | 70 +++ services/astroklient/app/config.py | 23 + services/astroklient/app/link_crypto.py | 525 ++++++++++++++++++ services/astroklient/app/main.py | 148 +++++ services/astroklient/app/security.py | 176 ++++++ services/astroklient/app/static/styles.css | 51 ++ services/astroklient/app/templates/base.html | 20 + services/astroklient/app/templates/index.html | 87 +++ services/astroklient/requirements-dev.txt | 2 + services/astroklient/requirements.txt | 11 + .../astroklient/tests/test_astroklient.py | 203 +++++++ 18 files changed, 1429 insertions(+), 2 deletions(-) create mode 100644 services/astroklient/Dockerfile create mode 100644 services/astroklient/README.md create mode 100644 services/astroklient/app/__init__.py create mode 100644 services/astroklient/app/clients/__init__.py create mode 100644 services/astroklient/app/clients/logic_client.py create mode 100644 services/astroklient/app/config.py create mode 100644 services/astroklient/app/link_crypto.py create mode 100644 services/astroklient/app/main.py create mode 100644 services/astroklient/app/security.py create mode 100644 services/astroklient/app/static/styles.css create mode 100644 services/astroklient/app/templates/base.html create mode 100644 services/astroklient/app/templates/index.html create mode 100644 services/astroklient/requirements-dev.txt create mode 100644 services/astroklient/requirements.txt create mode 100644 services/astroklient/tests/test_astroklient.py diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 64479d7..caeb9aa 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -9,10 +9,12 @@ jobs: - uses: actions/checkout@v4 - name: Login run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin - - name: Build & push (data, logic, presentation) + # astroklient dołącza do tej samej pętli: dzieli warstwę logiczną i łącze, + # więc jego obraz ma powstawać z tego samego commita co reszta produktu. + - name: Build & push (data, logic, presentation, astroklient) run: | TAG=${GITHUB_SHA::8} - for SVC in data logic presentation; do + for SVC in data logic presentation astroklient; do docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG done diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index e8bf142..ec025a4 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -91,6 +91,27 @@ jobs: PYTHONPATH: . run: pytest tests -q -rs + astroklient-tests: + name: Testy astroklienta (wersja demo) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: services/astroklient/requirements-dev.txt + - name: Instalacja zależności + run: pip install -r services/astroklient/requirements-dev.txt + # Demo rozmawia z warstwą danych PRODUKCJI, więc jego powierzchnia musi być + # pilnowana tak samo jak reszty: testy sprawdzają m.in., że nie przybyła + # żadna trasa poza dwiema funkcjami. + - name: Testy (pytest) + working-directory: services/astroklient + env: + PYTHONPATH: . + run: pytest tests -q -rs + swisseph-image: name: Build obrazu silnika B (swisseph) runs-on: ubuntu-latest diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 656a3cf773d9f80047b34666cb78ec73f608c63a..63e679c4b585e300b43447732b9801d2d81b9e64 100644 GIT binary patch delta 11774 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 delta 10944 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 dict[str, str]: + token = os.getenv("INTERNAL_TOKEN", "") + return {"X-Astrololo-Token": token} if token else {} + + +def _link() -> link_crypto.Link | None: + """Klucz łącza czytany przy KAŻDYM wywołaniu — pod przeżywa rotację sekretu + bez restartu, a brak klucza przy wymaganym szyfrowaniu ma zatrzymać żądanie + ZANIM cokolwiek wyjdzie jawnym tekstem.""" + key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC) + return link_crypto.Link(key) if key else None + + +class LogicClient: + def __init__(self, base_url: str | None = None) -> None: + self.base_url = (base_url or settings.logic_url).rstrip("/") + + def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]: + """Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach. + + Jedyne pytanie, jakie astroklient zadaje o treść — bez grupowania, + bez promptów, bez modelu językowego.""" + payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, + "limit": settings.report_limit, "group": False} + with httpx.Client(timeout=settings.http_timeout) as client: + return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report", + payload=payload, headers=_auth_headers(), link=_link()) + + def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]: + with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: + return link_crypto.call_json( + client, "POST", f"{self.base_url}/files/upload", + payload={"filename": filename, "content_b64": content_b64, "by": by}, + headers=_auth_headers(), link=_link()) + + def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]: + """Włączenie wgranego pliku do użytku. W demo wgranie i włączenie to JEDNA + czynność: „dodać do bazy" ma znaczyć, że plik od razu bierze udział + w wyszukiwaniu — inaczej demo wyglądałoby na zepsute.""" + with httpx.Client(timeout=settings.http_timeout) as client: + return link_crypto.call_json( + client, "POST", f"{self.base_url}/files/status", + payload={"path": path, "status": status, "by": by}, + headers=_auth_headers(), link=_link()) + + def health(self) -> dict[str, Any]: + with httpx.Client(timeout=settings.http_timeout) as client: + r = client.get(f"{self.base_url}/health", headers=_auth_headers()) + r.raise_for_status() + return r.json() diff --git a/services/astroklient/app/config.py b/services/astroklient/app/config.py new file mode 100644 index 0000000..0e3769b --- /dev/null +++ b/services/astroklient/app/config.py @@ -0,0 +1,23 @@ +"""Konfiguracja astroklienta — wersji demonstracyjnej (PRE-28). + +Zna WYŁĄCZNIE adres warstwy logicznej, tak samo jak pełna prezentacja. Nie wie +nic o bazach, Excelu ani SQL-u. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +@dataclass +class Settings: + logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001")) + # Szukanie interpretacji potrafi zajrzeć do wszystkich baz naraz, więc limit + # czasu jest wyższy niż domyślny dla zwykłego żądania. + http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "30"))) + # Górny limit rekordów w jednej odpowiedzi. Nie jest to optymalizacja, tylko + # ograniczenie, ile treści baz może wyjść jednym zapytaniem. + report_limit: int = field(default_factory=lambda: int(os.getenv("REPORT_LIMIT", "2000"))) + + +settings = Settings() diff --git a/services/astroklient/app/link_crypto.py b/services/astroklient/app/link_crypto.py new file mode 100644 index 0000000..89ed3d8 --- /dev/null +++ b/services/astroklient/app/link_crypto.py @@ -0,0 +1,525 @@ +"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33). + +Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token +międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź +— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń +produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu, +zrzut z węzła), miał je w całości. + +Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi. +GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt +nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale +podatne na modyfikację". + +**Dwa niezależne klucze**, po jednym na parę rozmówców: + * ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika, + * ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane. +Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych, +gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony. + +Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF). +Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie +losowej jednorazówki w jedną stronę nie osłabia drugiej. + +Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu): + + [4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik] + +Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu +i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć +po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu. + +Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność +wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa +działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa +regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu. +""" +from __future__ import annotations + +import base64 +import binascii +import logging +import os +import struct +import time +from typing import Iterable, Iterator + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +log = logging.getLogger("astrololo.link") + +MAGIC = b"AL1" +VERSION = "v1" +NONCE_BYTES = 12 +KEY_BYTES = 32 # AES-256 +LENGTH_PREFIX = 4 +MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości +MAX_SKEW_SECONDS = 300.0 + +HEADER_ENC = "X-Astrololo-Enc" +HEADER_TS = "X-Astrololo-Enc-Ts" +CONTENT_TYPE = "application/vnd.astrololo.enc" + +ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" +ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" +# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych — +# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie +# jej klucza nie może otwierać łącza do logiki ani do danych. +ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER" +ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" + +REQUEST, RESPONSE = b"req", b"res" + +# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka +# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji. +PUBLIC_PATHS = frozenset({"/health"}) + + +class LinkError(Exception): + """Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz.""" + + +# --------------------------------------------------------------------- klucze + +def parse_key(raw: str) -> bytes: + """Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty.""" + text = raw.strip() + if not text: + raise LinkError("pusty klucz łącza") + try: + key = bytes.fromhex(text) + except ValueError: + try: + key = base64.b64decode(text, validate=True) + except (binascii.Error, ValueError) as exc: + raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc + if len(key) != KEY_BYTES: + raise LinkError( + f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez " + f"`openssl rand -hex 32`" + ) + return key + + +def key_from_env(env_name: str) -> bytes | None: + """Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu.""" + raw = os.getenv(env_name, "") + return parse_key(raw) if raw.strip() else None + + +def encryption_required() -> bool: + """Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu. + + Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza + wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść + zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim + cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu. + """ + return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _subkey(link_key: bytes, direction: bytes) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, + info=b"astrololo/link/" + direction, + ).derive(link_key) + + +class Link: + """Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze.""" + + def __init__(self, link_key: bytes) -> None: + self._by_direction = { + REQUEST: AESGCM(_subkey(link_key, REQUEST)), + RESPONSE: AESGCM(_subkey(link_key, RESPONSE)), + } + + # ---------------------------------------------------------- pojedyncza ramka + + def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes: + return b"|".join([MAGIC, direction, path.encode("utf-8"), + stamp.encode("ascii"), str(seq).encode("ascii")]) + + def seal(self, direction: bytes, path: str, stamp: str, seq: int, + plaintext: bytes) -> bytes: + nonce = os.urandom(NONCE_BYTES) + sealed = self._by_direction[direction].encrypt( + nonce, plaintext, self._aad(direction, path, stamp, seq)) + return MAGIC + nonce + sealed + + def open(self, direction: bytes, path: str, stamp: str, seq: int, + frame: bytes) -> bytes: + if not frame.startswith(MAGIC): + raise LinkError("ramka bez znacznika astrololo") + body = frame[len(MAGIC):] + if len(body) <= NONCE_BYTES: + raise LinkError("ramka za krótka") + nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:] + try: + return self._by_direction[direction].decrypt( + nonce, sealed, self._aad(direction, path, stamp, seq)) + except InvalidTag as exc: + # Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt, + # przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich + # na zewnątrz podpowiadałoby atakującemu, w co trafił. + raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc + + # ------------------------------------------------------------ strumień ramek + + def seal_stream(self, direction: bytes, path: str, stamp: str, + chunks: Iterable[bytes]) -> Iterator[bytes]: + for seq, chunk in enumerate(chunks): + yield frame_out(self.seal(direction, path, stamp, seq, chunk)) + + def open_stream(self, direction: bytes, path: str, stamp: str, + raw: bytes) -> Iterator[bytes]: + for seq, frame in enumerate(frames_in(raw)): + yield self.open(direction, path, stamp, seq, frame) + + def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes: + return b"".join(self.open_stream(direction, path, stamp, raw)) + + +# ---------------------------------------------------------------- ramkowanie + +def frame_out(payload: bytes) -> bytes: + return struct.pack(">I", len(payload)) + payload + + +def frames_in(raw: bytes) -> Iterator[bytes]: + """Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych.""" + offset = 0 + while offset < len(raw): + if offset + LENGTH_PREFIX > len(raw): + raise LinkError("urwana ramka (brak nagłówka długości)") + (size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + offset += LENGTH_PREFIX + if offset + size > len(raw): + raise LinkError("urwana ramka (za mało danych)") + yield raw[offset:offset + size] + offset += size + + +def unframe_incremental(buffer: bytearray) -> Iterator[bytes]: + """Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później. + + Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi + rozjechać się między dwa odczyty. + """ + while True: + if len(buffer) < LENGTH_PREFIX: + return + (size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + if len(buffer) < LENGTH_PREFIX + size: + return + frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size]) + del buffer[:LENGTH_PREFIX + size] + yield frame + + +# ------------------------------------------------------------- świeżość ruchu + +def stamp_now() -> str: + return f"{time.time():.3f}" + + +def check_stamp(stamp: str) -> None: + """Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się + odtworzyć w dowolnym momencie w przyszłości.""" + try: + sent = float(stamp) + except (TypeError, ValueError) as exc: + raise LinkError("brak albo błędny znacznik czasu") from exc + if abs(time.time() - sent) > MAX_SKEW_SECONDS: + raise LinkError("znacznik czasu poza dopuszczalnym oknem") + + +# =========================================================== strona serwerowa + +class LinkCryptoMiddleware: + """Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi. + + Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba + podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić + odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci. + """ + + def __init__(self, app, link: Link | None, layer: str) -> None: + self.app = app + self.link = link + self.layer = layer + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS: + return await self.app(scope, receive, send) + + path = scope["path"] + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} + + if headers.get(HEADER_ENC.lower()) != VERSION: + # Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo + # pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie + # chcemy po cichu wrócić do jawnego ruchu. + log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)", + self.layer, path) + return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.") + + stamp = headers.get(HEADER_TS.lower(), "") + try: + check_stamp(stamp) + plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive)) + except LinkError as exc: + log.warning("warstwa %s: %s (%s)", self.layer, exc, path) + return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.") + + scope = dict(scope) + scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext)) + await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path)) + + def _sealing_send(self, send, path: str): + state: dict = {"stamp": "", "seq": 0} + + async def sealing(message): + if message["type"] == "http.response.start": + state["stamp"] = stamp_now() + keep = [(k, v) for k, v in message.get("headers", []) + if k.lower() not in (b"content-length", b"content-type")] + message = dict(message) + message["headers"] = keep + [ + (b"content-type", CONTENT_TYPE.encode()), + (HEADER_ENC.lower().encode(), VERSION.encode()), + (HEADER_TS.lower().encode(), state["stamp"].encode()), + ] + return await send(message) + + if message["type"] == "http.response.body": + chunk = message.get("body", b"") + sealed = b"" + if chunk: + sealed = frame_out(self.link.seal( + RESPONSE, path, state["stamp"], state["seq"], chunk)) + state["seq"] += 1 + return await send({"type": "http.response.body", "body": sealed, + "more_body": message.get("more_body", False)}) + + return await send(message) + + return sealing + + +def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int): + """Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI + próbowałby sparsować JSON o cudzej deklarowanej wielkości.""" + kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")] + kept.append((b"content-length", str(length).encode())) + if length: + kept.append((b"content-type", b"application/json")) + return kept + + +async def _read_body(receive) -> bytes: + body = bytearray() + while True: + message = await receive() + if message["type"] == "http.disconnect": + raise LinkError("rozłączenie w trakcie odbioru żądania") + body += message.get("body", b"") + if not message.get("more_body", False): + return bytes(body) + + +def _replay(body: bytes, original): + """Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji. + + Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu + zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia + równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji + okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały. + """ + delivered = False + + async def receive(): + nonlocal delivered + if delivered: + return await original() + delivered = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +async def _refuse(send, detail: str) -> None: + """Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować, + więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny.""" + payload = f'{{"detail":"{detail}"}}'.encode("utf-8") + await send({"type": "http.response.start", "status": 400, "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(payload)).encode()), + ]}) + await send({"type": "http.response.body", "body": payload}) + + +def install(app, env_name: str, layer: str): + """Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa + tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał.""" + link_key = key_from_env(env_name) + if link_key is None and encryption_required(): + # Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania: + # wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni + # niczego. Pod w CrashLoop widać od razu, jawny ruch — nie. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — " + f"warstwa {layer} nie wystartuje bez klucza łącza" + ) + if link_key is None: + log.warning( + "UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, " + "więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha " + "ruch wewnątrz sieci.", env_name, layer, + ) + return None + link = Link(link_key) + app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer) + log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name) + return link + + +# ============================================================ strona kliencka + +def call(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None) -> bytes: + """Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi. + + Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego + argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby + się niejasnym błędem odszyfrowania. + """ + import json as _json + + import httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero + # zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie + # sygnifikatory, o które pytamy bazę. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie " + f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem" + ) + response = client.request(method, url, json=payload, headers=request_headers) + response.raise_for_status() + return response.content + + path = httpx.URL(url).path + stamp = stamp_now() + plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8") + body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext)) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, + "Content-Type": CONTENT_TYPE}) + + response = client.request(method, url, content=body, headers=request_headers) + if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION: + log.error("łącze %s odmówiło: %s", path, response.text[:200]) + response.raise_for_status() + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony") + reply_stamp = response.headers.get(HEADER_TS, "") + check_stamp(reply_stamp) + return link.open_all(RESPONSE, path, reply_stamp, response.content) + + +def call_json(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None): + import json as _json + + return _json.loads(call(client, method, url, payload=payload, + headers=headers, link=link)) + + +def open_response_stream(response, link: Link | None) -> Iterator[bytes]: + """Odbiór odpowiedzi płynącej kawałkami (okno postępu). + + Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją + w buforze zamiast zakładać, że każdy kawałek to komplet. + """ + if link is None: + yield from response.iter_bytes() + return + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony") + stamp = response.headers.get(HEADER_TS, "") + check_stamp(stamp) + path = response.request.url.path + buffer = bytearray() + seq = 0 + for chunk in response.iter_bytes(): + buffer += chunk + for frame in unframe_incremental(buffer): + yield link.open(RESPONSE, path, stamp, seq, frame) + seq += 1 + if buffer: + raise LinkError("strumień urwał się w połowie ramki") + + +def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None, + link: Link | None) -> Iterator[str]: + """Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo. + + Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc + czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest + pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE + pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je + dopiero na znakach nowej linii. + + Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów + działał bez zmian. + """ + import json as _json + + import httpx as _httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy + # szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale + # ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień " + f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem" + ) + with client.stream("POST", url, json=payload, headers=request_headers) as response: + response.raise_for_status() + for text_line in response.iter_lines(): + if text_line: + yield text_line + return + + path = _httpx.URL(url).path + stamp = stamp_now() + body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8"))) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE}) + with client.stream("POST", url, content=body, headers=request_headers) as response: + response.raise_for_status() + buffer = bytearray() + for plain in open_response_stream(response, link): + buffer += plain + while True: + nl = buffer.find(b"\n") + if nl < 0: + break + text_line = bytes(buffer[:nl]) + del buffer[:nl + 1] + if text_line: + yield text_line.decode("utf-8") + if buffer: + yield bytes(buffer).decode("utf-8") diff --git a/services/astroklient/app/main.py b/services/astroklient/app/main.py new file mode 100644 index 0000000..fb15aad --- /dev/null +++ b/services/astroklient/app/main.py @@ -0,0 +1,148 @@ +"""astroklient — wersja DEMONSTRACYJNA (PRE-28). + +DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ: + 1. dodanie pliku bazy (wgranie + włączenie do użytku), + 2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych. + +DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Mechanizm uprawnień z PRE-27 +umiałby to ukryć w pełnej aplikacji, ale ukrycie a nieobecność to dwie różne rzeczy. +Tutaj pozostałych funkcji NIE MA W OBRAZIE: nie ma tras, nie ma szablonów, nie ma +nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając +przy okazji kodu reszty programu. + +UWAGA, ŚWIADOMA DECYZJA WŁAŚCICIELA: astroklient pracuje na TEJ SAMEJ warstwie +danych co produkcja. Kto ma do niego dostęp, czyta oryginalne bazy interpretacyjne, +a pliki przez niego wgrane trafiają do produkcyjnego zbioru. Dlatego konto jest +osobne (DEMO_USER/DEMO_PASSWORD) — żeby dało się odciąć demo jedną zmienną, bez +ruszania kont głównej aplikacji. +""" +from __future__ import annotations + +import base64 +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import FastAPI, File, Form, Request, UploadFile +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from app import link_crypto, security +from app.clients.logic_client import LogicClient + +# Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs +# wypisałoby komplet tras, a demo ma nie zdradzać nawet własnej powierzchni. +app = FastAPI(title="astroklient · demo", docs_url=None, redoc_url=None, openapi_url=None) +security.install(app) + +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + +logic = LogicClient() + +# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana +# tutaj wprost, bo astroklient nie importuje tamtej warstwy; literówka objawiłaby +# się dopiero na żywo, więc pilnuje jej test. +ACTIVE = "active" + + +def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: + """Data + godzina lokalna + offset → chwila w UTC. + + Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma + dwie funkcje, a strefy to trzecia.""" + try: + local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M") + except ValueError as e: + raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e + moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset))) + return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M") + + +def _blad(e: Exception) -> str: + if isinstance(e, httpx.HTTPStatusError): + try: + return str(e.response.json().get("detail") or e) + except Exception: # noqa: BLE001 + return str(e) + if isinstance(e, link_crypto.LinkError): + return f"Łącze do warstwy logicznej nie działa: {e}" + return f"Warstwa logiczna niedostępna: {e}" + + +def _ile_rekordow(report: dict) -> int: + return sum(len(s.get("samples") or []) for p in (report.get("positions") or []) + for s in (p.get("significators") or [])) + + +@app.get("/", response_class=HTMLResponse) +def formularz(request: Request, error: str = "", done: str = ""): + return templates.TemplateResponse(request, "index.html", + {"result": None, "form": {}, "error": error, "done": done}) + + +@app.post("/", response_class=HTMLResponse) +def interpretacja(request: Request, date: str = Form(...), time: str = Form(...), + tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)): + """Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść.""" + form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon} + ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None} + try: + iso_utc, label = _build_utc(date, time, tz_offset) + ctx["moment"] = label + ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon) + # Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17). + security.audit_records(request, _ile_rekordow(ctx["result"])) + except ValueError as e: + ctx["error"] = str(e) + except (httpx.HTTPError, link_crypto.LinkError) as e: + ctx["error"] = _blad(e) + return templates.TemplateResponse(request, "index.html", ctx) + + +@app.post("/plik") +async def dodaj_plik(request: Request, upload: UploadFile = File(...)): + """Dodanie pliku bazy: wgranie ORAZ włączenie do użytku. + + W pełnej aplikacji to dwie osobne decyzje (DAN-27), bo tam ktoś nad tym panuje. + Tutaj „dodać do bazy" ma znaczyć, że plik od razu bierze udział w wyszukiwaniu — + inaczej po wgraniu nic by się nie zmieniło i demo wyglądałoby na zepsute. + + Walidacja zostaje: plik o złym układzie NIE wchodzi do użytku, ale też NIE JEST + tracony — leży dalej na udziale i decyzję o nim podejmuje administrator + w pełnej aplikacji.""" + raw = await upload.read() + if not raw: + return RedirectResponse("/?error=Pusty+plik.", status_code=303) + + who = getattr(request.state, "user", "") or "demo" + try: + out = logic.files_upload(upload.filename or "baza.xlsx", + base64.b64encode(raw).decode("ascii"), by=who) + except (httpx.HTTPError, link_crypto.LinkError) as e: + return _wroc(error=_blad(e)) + + nazwa = out.get("name") or "plik" + if not out.get("accepted"): + # Bez słowa o regułach — te zna wyłącznie administrator (DAN-27). + return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się " + f"do użycia w tej postaci.") + try: + logic.files_status(out["path"], ACTIVE, by=who) + except (httpx.HTTPError, link_crypto.LinkError) as e: + return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}") + return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.") + + +def _wroc(error: str = "", done: str = "") -> RedirectResponse: + """Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może + wgrać pliku drugi raz.""" + from urllib.parse import urlencode + + q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v}) + return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok", "layer": "astroklient"} diff --git a/services/astroklient/app/security.py b/services/astroklient/app/security.py new file mode 100644 index 0000000..6058179 --- /dev/null +++ b/services/astroklient/app/security.py @@ -0,0 +1,176 @@ +"""Logowanie do astroklienta (PRE-28). + +OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej +warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy +interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną +środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek. + +Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont +i bez uprawnień. Astroklient ma dwie funkcje i obie są dla tego samego człowieka — +model uprawnień z PRE-27 nie miałby tu czego dzielić. + +Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem +co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba. +""" +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import logging +import os +import time +from collections import deque + +from fastapi import Request +from fastapi.responses import HTMLResponse, JSONResponse + +log = logging.getLogger("astroklient.security") +audit_log = logging.getLogger("astroklient.audit") + +_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 +_HASH_PREFIX = "scrypt$" + +PUBLIC_PATHS = frozenset({"/health"}) +PUBLIC_PREFIXES = ("/static/",) + +_hits: dict[str, deque[float]] = {} +MAX_TRACKED_CLIENTS = 4096 + + +def demo_user() -> str: + return os.getenv("DEMO_USER", "demo") + + +def demo_password() -> str: + return os.getenv("DEMO_PASSWORD", "") + + +def rate_limit_per_min() -> int: + return int(os.getenv("RATE_LIMIT_PER_MIN", "60")) + + +def trust_proxy() -> bool: + return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} + + +def auth_enabled() -> bool: + return bool(demo_password()) + + +def _secret_matches(spec: str, password: str) -> bool: + if spec.startswith(_HASH_PREFIX): + try: + _, salt_hex, hash_hex = spec.split("$", 2) + salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex) + except ValueError: + return False + actual = hashlib.scrypt(password.encode("utf-8"), salt=salt, + n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN) + return hmac.compare_digest(actual, expected) + # BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach + # spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy. + return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8")) + + +def authenticate(header: str | None) -> str | None: + if not header or not header.lower().startswith("basic "): + return None + try: + raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") + user, _, password = raw.partition(":") + except (binascii.Error, UnicodeDecodeError, IndexError): + return None + if user != demo_user(): + return None + return user if _secret_matches(demo_password(), password) else None + + +def client_ip(request: Request) -> str: + """Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy — + inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit.""" + peer = request.client.host if request.client else "?" + if not trust_proxy(): + return peer + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + last = forwarded.rsplit(",", 1)[-1].strip() + if last: + return last + return request.headers.get("x-real-ip", "").strip() or peer + + +def _rate_limited(client: str) -> bool: + cap = rate_limit_per_min() + if cap <= 0: + return False + now = time.monotonic() + window = _hits.get(client) + if window is None: + if len(_hits) >= MAX_TRACKED_CLIENTS: + _hits.clear() + window = _hits[client] = deque() + while window and now - window[0] > 60.0: + window.popleft() + if len(window) >= cap: + return True + window.append(now) + return False + + +def _setup_audit_logging() -> None: + """Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje + naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był + pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17).""" + audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper()) + if not audit_log.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + audit_log.addHandler(handler) + audit_log.propagate = False + + +def audit_records(request: Request, count: int) -> None: + try: + request.state.audit_records = int(count) + except (TypeError, ValueError): + pass + + +def install(app) -> None: + _setup_audit_logging() + if not auth_enabled(): + log.warning( + "UWAGA: DEMO_PASSWORD nie ustawione — astroklient stoi OTWARTY, a pracuje " + "na tej samej warstwie danych co produkcja, więc wystawia treść " + "oryginalnych baz interpretacyjnych każdemu, kto zna adres." + ) + + @app.middleware("http") + async def _guard(request: Request, call_next): + if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES): + return await call_next(request) + + started = time.monotonic() + if _rate_limited(client_ip(request)): + return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."}, + status_code=429, headers={"Retry-After": "60"}) + + user = "" + if auth_enabled(): + user = authenticate(request.headers.get("authorization")) or "" + if not user: + return HTMLResponse( + "

401 — wymagane logowanie

", status_code=401, + headers={"WWW-Authenticate": 'Basic realm="astroklient"'}) + request.state.user = user + + response = await call_next(request) + records = getattr(request.state, "audit_records", None) + audit_log.info( + "AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f", + user or "-", client_ip(request), request.method, request.url.path, + response.status_code, "-" if records is None else records, + (time.monotonic() - started) * 1000) + return response diff --git a/services/astroklient/app/static/styles.css b/services/astroklient/app/static/styles.css new file mode 100644 index 0000000..08e8986 --- /dev/null +++ b/services/astroklient/app/static/styles.css @@ -0,0 +1,51 @@ +/* astroklient — arkusz CELOWO minimalny. Nie kopiujemy stylów pełnej aplikacji: + demo ma dwa formularze i tabelę wyników, a każda przeniesiona klasa niosłaby + nazwy funkcji, których tu nie ma. */ +:root { + --bg: #11121b; --panel: #191b26; --line: #2b2e3d; + --fg: #e8e9f0; --muted: #9aa0b4; --accent: #8ab4ff; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--bg); color: var(--fg); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; } +.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line); } +h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; } +h2 { margin: 0 0 .3rem; font-size: 1.05rem; } +h3 { margin: 0 0 .5rem; font-size: 1rem; } + +.card { + margin-top: 1.25rem; padding: 1rem 1.25rem; + background: var(--panel); border: 1px solid var(--line); border-radius: 12px; +} +.row { display: flex; gap: .75rem; flex-wrap: wrap; align-items: flex-end; margin-top: .6rem; } +label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 10rem; } +input { + width: 100%; padding: .45rem .6rem; background: #0e0f18; color: var(--fg); + border: 1px solid var(--line); border-radius: 8px; font: inherit; +} +button { + padding: .5rem 1.25rem; background: var(--accent); color: #10121a; + border: none; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer; +} + +table { width: 100%; border-collapse: collapse; margin-top: .3rem; } +td { padding: .35rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; font-size: .92rem; } +td.key { color: var(--accent); white-space: nowrap; padding-right: 1rem; } +tr:last-child td { border-bottom: none; } + +.sig { margin-top: .75rem; } +.sig-head { color: var(--muted); font-size: .85rem; } +.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; } +.muted { color: var(--muted); } +.small { font-size: .85rem; } +.foot { margin-top: 2.5rem; color: var(--muted); font-size: .8rem; } + +.alert, .ok { + margin: 1rem 0; padding: .6rem .9rem; border-radius: 8px; font-size: .92rem; + border: 1px solid var(--line); border-left-width: 4px; +} +.alert { border-left-color: #b8860b; background: rgba(184, 134, 11, .1); } +.ok { border-left-color: var(--accent); background: rgba(138, 180, 255, .08); } diff --git a/services/astroklient/app/templates/base.html b/services/astroklient/app/templates/base.html new file mode 100644 index 0000000..52a9d13 --- /dev/null +++ b/services/astroklient/app/templates/base.html @@ -0,0 +1,20 @@ + + + + + + astroklient · {% block title %}{% endblock %} + + + +
+
+

astroklient

+ {# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie, + a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #} +
+ {% block content %}{% endblock %} +
wersja demonstracyjna
+
+ + diff --git a/services/astroklient/app/templates/index.html b/services/astroklient/app/templates/index.html new file mode 100644 index 0000000..014530c --- /dev/null +++ b/services/astroklient/app/templates/index.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Interpretacja{% endblock %} + +{% block content %} +{% if error %}

{{ error }}

{% endif %} +{% if done %}

{{ done }}

{% endif %} + +
+

Dodaj bazę

+

+ Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji. +

+
+
+ + +
+
+
+ +
+

Zapytaj o interpretację urodzeniową

+
+
+ + + +
+
+ + + +
+
+
+ +{% if result %} +

+ Moment: {{ moment }} + {% if result.engine %}· silnik: {{ result.engine }}{% endif %} + {% if result.objects %}· obiektów: {{ result.objects | length }}{% endif %} +

+ + {# Kształt odpowiedzi: objects[] → facets[] → samples[]. Pytamy bez grupowania, + więc `groups` tu nie wystąpi i nie ma czego obsługiwać. #} + {% set znaleziono = namespace(ile=0) %} + {% for o in result.objects or [] %} + {% set trafienia = o.facets | selectattr('samples') | list %} + {% if trafienia %} + {% set znaleziono.ile = znaleziono.ile + trafienia | length %} +
+

{{ o.object }} + w {{ o.sign }}{% if o.house %}, {{ o.house }}. dom{% endif %} + {%- if o.direction == 'Rx' %} · Rx{% endif %} +

+ {% for f in trafienia %} +
+
{{ f.label }} — {{ f.count }} dopasowań
+ + + {% for s in f.samples %} + + + + + {% endfor %} + +
{{ s.expanded }}{{ s.effect }}
+
+ {% endfor %} +
+ {% endif %} + {% endfor %} + + {% if znaleziono.ile == 0 %} +

+ Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę + zawierającą pasujące opisy. +

+ {% endif %} +{% endif %} +{% endblock %} diff --git a/services/astroklient/requirements-dev.txt b/services/astroklient/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/services/astroklient/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/services/astroklient/requirements.txt b/services/astroklient/requirements.txt new file mode 100644 index 0000000..1297467 --- /dev/null +++ b/services/astroklient/requirements.txt @@ -0,0 +1,11 @@ +# CELOWO KRÓTSZA niż w warstwie prezentacji. Astroklient ma dwie funkcje, więc nie +# potrzebuje ani Excela (brak eksportu), ani stref czasowych z lokalizacji (offset +# podaje się wprost), ani niczego pod kosmogram — a każda zbędna zależność w obrazie +# demo to kolejna rzecz do pilnowania i kolejna droga do środka. +fastapi>=0.115 +uvicorn[standard]>=0.34 +httpx>=0.28 +jinja2>=3.1 +python-multipart>=0.0.20 +# Szyfrowanie łącza do warstwy logicznej (PRE-16): AES-256-GCM + HKDF +cryptography>=44.0 diff --git a/services/astroklient/tests/test_astroklient.py b/services/astroklient/tests/test_astroklient.py new file mode 100644 index 0000000..d4d41c0 --- /dev/null +++ b/services/astroklient/tests/test_astroklient.py @@ -0,0 +1,203 @@ +"""astroklient — wersja demonstracyjna (PRE-28). + +DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK + +1. Demo ma DWIE funkcje i ani jednej więcej. Sprawdzamy to na trasach aplikacji, + nie w szablonie: trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem, + bo ta usługa rozmawia z warstwą danych produkcji. + +2. „Dodać plik do bazy" znaczy, że plik ZACZYNA BYĆ UŻYWANY. W pełnej aplikacji + wgranie i włączenie to dwie decyzje (DAN-27); tutaj muszą być jedną, inaczej + po wgraniu nic się nie zmienia i demo wygląda na zepsute. +""" +import base64 + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + monkeypatch.setenv("DEMO_USER", "demo") + monkeypatch.setenv("DEMO_PASSWORD", "tajne-demo") + monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + + +def _auth(user="demo", password="tajne-demo"): + return {"Authorization": "Basic " + base64.b64encode( + f"{user}:{password}".encode()).decode()} + + +REPORT = { + "engine": "test", + "objects": [{ + "object": "Sun", "sign": "Taurus", "house": 11, "direction": "D", + "planet_token": "Su", "planet_total": 3, + "facets": [{"label": "w znaku Taurus", "token": "Su Tau", "count": 1, "score": 5.0, + "samples": [{"significator": "Su Tau", "expanded": "Sun in Taurus", + "effect": "wytrwały, praktyczny"}]}], + }], +} + + +def _client(monkeypatch, calls=None): + from starlette.testclient import TestClient + + from app.main import app, logic + + calls = calls if calls is not None else [] + monkeypatch.setattr(logic, "report", + lambda **kw: (calls.append(("report", kw)), REPORT)[1]) + monkeypatch.setattr(logic, "files_upload", + lambda *a, **kw: (calls.append(("upload", a, kw)), + {"path": "nowa.xlsx", "name": "nowa.xlsx", + "accepted": True})[1]) + monkeypatch.setattr(logic, "files_status", + lambda *a, **kw: (calls.append(("status", a, kw)), {})[1]) + return TestClient(app), calls + + +FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2", + "lat": "50.06", "lon": "19.94"} + + +# ── powierzchnia: dokładnie dwie funkcje ──────────────────────────────── + +def test_the_service_exposes_nothing_beyond_its_two_functions(): + """Trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem — ta usługa + rozmawia z warstwą danych PRODUKCJI.""" + from app.main import app + + trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set()) + if not r.path.startswith("/static") and m in {"GET", "POST"}} + assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health")} + + +def test_the_client_can_only_ask_for_what_the_demo_needs(): + """Klient warstwy logicznej jest okrojony CELOWO: metody, której nie ma, nikt + przypadkiem nie zawoła i nie podpowie ona, że po drugiej stronie jest więcej.""" + from app.clients.logic_client import LogicClient + + publiczne = {n for n in dir(LogicClient) if not n.startswith("_")} + assert publiczne == {"report", "files_upload", "files_status", "health"} + + +def test_api_docs_are_off(): + """/docs wypisałoby komplet tras — demo ma nie zdradzać własnej powierzchni.""" + from app.main import app + + assert app.docs_url is None and app.openapi_url is None and app.redoc_url is None + + +# ── logowanie ──────────────────────────────────────────────────────────── + +def test_without_credentials_nothing_is_served(env, monkeypatch): + c, _ = _client(monkeypatch) + for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")): + assert getattr(c, method)(path).status_code == 401, path + assert c.get("/health").status_code == 200, "sonda musi działać bez hasła" + + +def test_demo_account_is_separate_from_the_main_application(env, monkeypatch): + """Konto astroklienta MUSI być własne: demo pracuje na tej samej warstwie + danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł + w głównej aplikacji.""" + monkeypatch.setenv("APP_USER", "astrololo") + monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji") + c, _ = _client(monkeypatch) + assert c.get("/", headers=_auth("astrololo", "haslo-glownej-aplikacji")).status_code == 401 + assert c.get("/", headers=_auth()).status_code == 200 + + +def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch): + monkeypatch.setenv("DEMO_PASSWORD", "zażółć-gęślą") + c, _ = _client(monkeypatch) + assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401 + assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200 + + +# ── funkcja 1: dodanie pliku ───────────────────────────────────────────── + +def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch): + """Sedno wymagania: jedna czynność, nie dwie.""" + c, calls = _client(monkeypatch) + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("baza.xlsx", b"zawartosc", "application/vnd.ms-excel")}) + assert r.status_code == 303 + rodzaje = [k[0] for k in calls] + assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}" + assert calls[1][1][1] == "active", "plik nie został włączony do użytku" + + +def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch): + """Walidacja zostaje, ale jej REGUŁY zna wyłącznie administrator (DAN-27) — + demo nie ma prawa ich zdradzić, nawet mimochodem.""" + from urllib.parse import unquote_plus + + from app.main import logic + + c, calls = _client(monkeypatch) + monkeypatch.setattr(logic, "files_upload", lambda *a, **kw: ( + calls.append(("upload", a, kw)), + {"path": "zla.xlsx", "name": "zla.xlsx", "accepted": False})[1]) + + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("zla.xlsx", b"x", "text/plain")}) + assert r.status_code == 303 + assert [k[0] for k in calls] == ["upload"], "odrzucony plik został włączony do użytku" + komunikat = unquote_plus(r.headers["location"]) + for slowo in ("walidacj", "reguł", "kolumn", "rozszerzeni", "rozmiar"): + assert slowo not in komunikat, f"komunikat zdradza mechanizm: „{slowo}”" + + +def test_an_empty_file_is_refused_before_anything_is_sent(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("pusty.xlsx", b"", "application/vnd.ms-excel")}) + assert r.status_code == 303 and calls == [] + + +# ── funkcja 2: interpretacja urodzeniowa ──────────────────────────────── + +def test_asking_for_an_interpretation_returns_the_matches(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/", headers=_auth(), data=FORM) + assert r.status_code == 200 + assert "Sun in Taurus" in r.text and "wytrwały, praktyczny" in r.text + assert [k[0] for k in calls] == ["report"] + + +def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch): + """11:20 przy offsecie +2 to 09:20 UTC. Pomyłka tutaj przesuwa CAŁY horoskop, + a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost.""" + c, calls = _client(monkeypatch) + c.post("/", headers=_auth(), data=FORM) + assert calls[0][1]["when_utc_iso"].startswith("1984-04-30T09:20") + + +def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/", headers=_auth(), data={**FORM, "date": "30 kwietnia"}) + assert r.status_code == 200 and "RRRR-MM-DD" in r.text + assert calls == [], "poszło zapytanie mimo błędnych danych" + + +def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch): + import httpx + + from app.main import logic + + c, _ = _client(monkeypatch) + monkeypatch.setattr(logic, "report", lambda **kw: (_ for _ in ()).throw( + httpx.ConnectError("brak połączenia"))) + r = c.post("/", headers=_auth(), data=FORM) + assert r.status_code == 200 and "Warstwa logiczna niedostępna" in r.text + + +def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch): + """Demo ma wyglądać na KOMPLETNE w swojej postaci — żadnych śladów funkcji, + których nie ma (ta sama zasada, co przy uprawnieniach w PRE-27).""" + c, _ = _client(monkeypatch) + html = c.get("/", headers=_auth()).text + for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia", + "Konta", "PDF", "Excel", "prompt", "model"): + assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie"