From a88ca146394d2c595e2cf6b33f2e97a74f297845 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 40264 -> 40721 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 a847a599fcdc1de67faabd78da709c9a6e4710ba..3cfaf92e8919d43191e386a79c80032190ca7759 100644 GIT binary patch delta 9776 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 YNcP`bj!7ehdrgidPoqUNfc)eAKahgag8%>k delta 9254 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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"