feat(astroklient): wersja demonstracyjna o dwóch funkcjach (PRE-28)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -9,10 +9,12 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Login
|
- name: Login
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
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: |
|
run: |
|
||||||
TAG=${GITHUB_SHA::8}
|
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 build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||||
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -91,6 +91,27 @@ jobs:
|
|||||||
PYTHONPATH: .
|
PYTHONPATH: .
|
||||||
run: pytest tests -q -rs
|
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:
|
swisseph-image:
|
||||||
name: Build obrazu silnika B (swisseph)
|
name: Build obrazu silnika B (swisseph)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8005
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# astroklient — wersja demonstracyjna (PRE-28)
|
||||||
|
|
||||||
|
Osobna warstwa prezentacji o **dwóch funkcjach**:
|
||||||
|
|
||||||
|
1. **dodanie pliku bazy** — wgranie i włączenie do użytku jedną czynnością,
|
||||||
|
2. **zapytanie o interpretację urodzeniową** dla podanych danych wejściowych.
|
||||||
|
|
||||||
|
I na tym koniec. Nie ma tu kosmogramu, synastrii, kalendarza, promptów do modelu,
|
||||||
|
eksportu ani kont — nie są ukryte, tylko **nie istnieją w tym obrazie**.
|
||||||
|
|
||||||
|
## 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 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.
|
||||||
|
|
||||||
|
## ⚠️ Demo pracuje na produkcyjnej warstwie danych
|
||||||
|
|
||||||
|
Świadoma decyzja właściciela. Konsekwencje, żeby nie było niespodzianek:
|
||||||
|
|
||||||
|
* **kto ma dostęp do demo, czyta oryginalne bazy interpretacyjne** — czyli rdzeń
|
||||||
|
produktu, którego pilnują LOG-32, DAN-25 i PRE-27,
|
||||||
|
* **pliki wgrane przez demo trafiają do produkcyjnego zbioru baz** i od razu biorą
|
||||||
|
udział w wyszukiwaniu, także w pełnej aplikacji.
|
||||||
|
|
||||||
|
Dlatego konto astroklienta jest **osobne** (`DEMO_USER` / `DEMO_PASSWORD`): demo
|
||||||
|
odcina się jedną zmienną środowiskową, bez ruszania kont głównej aplikacji i bez
|
||||||
|
zmiany hasła komukolwiek.
|
||||||
|
|
||||||
|
Jeśli demo ma trafić do kogoś spoza kręgu zaufania, właściwą odpowiedzią jest
|
||||||
|
osobna warstwa danych z pustym udziałem — nie jest to dziś zrobione.
|
||||||
|
|
||||||
|
## Konfiguracja
|
||||||
|
|
||||||
|
| zmienna | znaczenie |
|
||||||
|
|---|---|
|
||||||
|
| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) |
|
||||||
|
| `DEMO_USER` | login (domyślnie `demo`) |
|
||||||
|
| `DEMO_PASSWORD` | hasło — jawne albo hash `scrypt$sól$hash`. **Puste = aplikacja stoi otwarta** i mówi o tym głośno przy starcie |
|
||||||
|
| `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług |
|
||||||
|
| `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja |
|
||||||
|
| `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza |
|
||||||
|
| `REPORT_LIMIT` | górny limit rekordów w jednej odpowiedzi, domyślnie 2000 |
|
||||||
|
| `TRUST_PROXY` | `true` za Ingressem — inaczej limit liczy wszystkich do jednego wiadra |
|
||||||
|
|
||||||
|
Hash hasła zrobisz tym samym skryptem, co dla głównej aplikacji:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/presentation && python scripts/make_user.py demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uruchomienie lokalne
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/astroklient
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
DEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \
|
||||||
|
uvicorn app.main:app --port 8005
|
||||||
|
```
|
||||||
|
|
||||||
|
Testy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/astroklient && PYTHONPATH=. pytest tests -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Co pilnują testy
|
||||||
|
|
||||||
|
Poza zwykłą poprawnością — dwie własności, które łatwo zepsuć bez zauważenia:
|
||||||
|
|
||||||
|
* **powierzchnia**: zbiór tras aplikacji musi być dokładnie taki, jak dwie funkcje
|
||||||
|
wymagają; klient warstwy logicznej ma tylko te metody, których używa. Trasa,
|
||||||
|
o której nikt nie pamiętał, jest tu realnym ryzykiem, bo ta usługa rozmawia
|
||||||
|
z produkcyjną warstwą danych.
|
||||||
|
* **milczenie o regułach walidacji**: plik odrzucony daje komunikat bez powodu.
|
||||||
|
Reguły zna wyłącznie administrator w pełnej aplikacji (DAN-27), a demo nie ma
|
||||||
|
prawa ich zdradzić nawet mimochodem.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28).
|
||||||
|
|
||||||
|
Astroklient nie dziedziczy klienta z pełnej prezentacji i nie importuje go: ma tu
|
||||||
|
być wyłącznie to, czego naprawdę używa. Metoda, której nie ma, nie zostanie
|
||||||
|
przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest coś więcej.
|
||||||
|
|
||||||
|
Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym
|
||||||
|
tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> 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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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"}
|
||||||
@@ -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(
|
||||||
|
"<h1>401 — wymagane logowanie</h1>", 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
|
||||||
@@ -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); }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>astroklient · {% block title %}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>astroklient</h1>
|
||||||
|
{# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie,
|
||||||
|
a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
|
||||||
|
</header>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
<footer class="foot">wersja demonstracyjna</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Interpretacja{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
|
||||||
|
{% if done %}<p class="ok">{{ done }}</p>{% endif %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Dodaj bazę</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/plik" enctype="multipart/form-data">
|
||||||
|
<div class="row">
|
||||||
|
<label>Plik <input type="file" name="upload" required></label>
|
||||||
|
<button type="submit">Dodaj</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Zapytaj o interpretację urodzeniową</h2>
|
||||||
|
<form method="post" action="/">
|
||||||
|
<div class="row">
|
||||||
|
<label>Data urodzenia
|
||||||
|
<input type="date" name="date" value="{{ form.date or '' }}" required></label>
|
||||||
|
<label>Godzina (lokalna)
|
||||||
|
<input type="time" name="time" value="{{ form.time or '' }}" required></label>
|
||||||
|
<label>Offset względem GMT (h)
|
||||||
|
<input type="number" name="tz_offset" step="0.5" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}"></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Szerokość (lat, + N)
|
||||||
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}" required></label>
|
||||||
|
<label>Długość (lon, + E)
|
||||||
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}" required></label>
|
||||||
|
<button type="submit">Szukaj interpretacji</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
<p class="meta">
|
||||||
|
Moment: <strong>{{ moment }}</strong>
|
||||||
|
{% if result.engine %}· silnik: {{ result.engine }}{% endif %}
|
||||||
|
{% if result.objects %}· obiektów: {{ result.objects | length }}{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# 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 %}
|
||||||
|
<section class="card">
|
||||||
|
<h3>{{ o.object }}
|
||||||
|
<span class="muted">w {{ o.sign }}{% if o.house %}, {{ o.house }}. dom{% endif %}
|
||||||
|
{%- if o.direction == 'Rx' %} · Rx{% endif %}</span>
|
||||||
|
</h3>
|
||||||
|
{% for f in trafienia %}
|
||||||
|
<div class="sig">
|
||||||
|
<div class="sig-head">{{ f.label }} <span class="muted small">— {{ f.count }} dopasowań</span></div>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{% for s in f.samples %}
|
||||||
|
<tr>
|
||||||
|
<td class="key" title="{{ s.significator }}">{{ s.expanded }}</td>
|
||||||
|
<td>{{ s.effect }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if znaleziono.ile == 0 %}
|
||||||
|
<p class="muted">
|
||||||
|
Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę
|
||||||
|
zawierającą pasujące opisy.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user