Compare commits

...

12 Commits

Author SHA1 Message Date
gitea 338ec24e90 astroklient: własna nazwa produktu zamiast odziedziczonej po rdzeniu
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m20s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Testy astroklient (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 12s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Testy astroklient (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Astroklient przedstawiał się na stronie głównej jako „astrololo" — w tytule
karty i w nagłówku. To najpoważniejszy z możliwych śladów: mówi klientowi
wprost, że trzyma okrojoną wersję czegoś większego, i podaje frazę, po której da
się wyszukać, czego mu nie dano. Cała reszta izolacji przestaje mieć znaczenie,
gdy produkt sam się przedstawia nazwą rodzica.

Wzięło się stąd, że nazwa była wpisana na sztywno w szablony, a szablony są
WSPÓLNE — dokładnie ta sama przyczyna, co przy zaszytym w base.html warunku na
administratora. Nazwa mieszka teraz w module `produkt.py`, który nakładka
produktu nadpisuje; szablony o nią pytają, zamiast ją znać.

astrodemo NIE było dotknięte: ma własne kopie szablonów i pokazuje „astrodemo".

DRUGIE ŹRÓDŁO WYCIEKU, mniej oczywiste: nazwa siedziała też w identyfikatorach
JavaScriptu, które widzi każdy, kto otworzy narzędzia deweloperskie — zdarzenie
`astrololo:coords` i klucz `astrololo.form.v1` w localStorage. Przemianowane na
neutralne `kosmogram:*`; te identyfikatory nie mają powodu nosić nazwy rodziny
produktów. SKUTEK UBOCZNY: zmiana klucza w localStorage kasuje raz zapamiętane
wersje robocze formularza.

Sprawdzone, że NIE wyciekają: nagłówki X-Astrololo-* jadą wyłącznie między
warstwami, a dokumentacja OpenAPI (gdzie zostaje tytuł aplikacji FastAPI) oddaje
404 także zalogowanemu.

TEST rozdziela to, co widzi człowiek, od identyfikatorów wewnętrznych. Nazwa
rdzenia wolno występować w nazwach loggerów, materiale do wyprowadzania kluczy
i nagłówkach międzywarstwowych; nie wolno w szablonach, zasobach ani w treści
odpowiedzi. Zakaz na wszystko byłby albo obejściem przez zmianę nazw kluczy
kryptograficznych, albo martwą regułą wyłączaną przy pierwszej okazji.

Sprawdzony w obie strony: po cofnięciu nakładki test pada.

Testy: astroklient 15, presentation 368, astrodemo 28, logic 342, data 42,
render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:37:03 +02:00
gitea 54b40857d2 ci: przywróć uruchamianie testów astrodemo i wykrywaj puste kroki
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m24s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Testy astroklient (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build / build (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m20s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Testy astroklient (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Testy astrodemo padały na masterze, choć lokalnie przechodzą — bo w CI nie
uruchamiały się WCALE. Dopisując blok astroklienta rozciąłem blok sąsiada:
krok „Testy (pytest)" astrodemo został z samym `working-directory`, a jego
`env` i `run` przywędrowały do zadania astroklienta.

Skutek podwójny i gorszy, niż wygląda: astrodemo zgłaszało błąd (krok bez
polecenia), a astroklient przechodził Z NIEWŁAŚCIWEGO POWODU — na cudzym `run`.
Zielone CI, które nic nie uruchomiło, jest gorsze od czerwonego, bo nie ma czego
naprawiać.

Krok bez `run` i bez `uses` jest dla Gitei krokiem pustym. Dopisana kontrola
przechodzi wszystkie pliki workflow i przerywa, gdy taki krok się trafi albo
gdy zadanie nie ma kroków w ogóle. Sprawdzona w obie strony: przepuszcza obecny
stan i wykrywa dokładnie to rozcięcie, gdy je odtworzyć.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:59:36 +02:00
gitea 10970c579f astroklient: warstwa pośrednia — pełne astro, bez generowania i administracji (4/5)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m19s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Testy astrodemo (pull_request) Failing after 0s
Testy / Testy astroklient (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Failing after 0s
Testy / Testy astroklient (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Trzeci produkt drabiny: astrodemo (dwie funkcje) → astroklient → astrololo.
Po trzech poprzednich krokach jest cienki, bo jest ZŁOŻENIEM, a nie kopią:
własne main.py z ośmioma importami, a rdzeń — ekrany, szablony, zasoby — bierze
z warstwy prezentacji przy budowaniu obrazu. Jedno źródło, dwa produkty; inaczej
te same 2500 linii szablonów żyłyby w dwóch egzemplarzach i rozjechały się
w ciągu tygodni, po cichu.

CO MA: Horoskop, Interpretacje, Kalendarz, Synastria, Sygnifikatory, wgrywanie
plików. Wyszukiwarka miejsca i strefa czasowa zgodnie z ustaleniem.

CZEGO NIE MA I DLACZEGO NIE DA SIĘ WŁĄCZYĆ: plików usuniętych wg usun.txt nie ma
w obrazie. Nie istnieje uprawnienie, którym dałoby się je odsłonić, bo katalog
funkcji składa się ze ZGŁOSZEŃ ekranów obecnych w obrazie. To dlatego „każde
konto dostaje wszystko, co ta usługa umie" jest tu bezpieczne i nie wymaga
wypisywania listy: zbiór liczy się z katalogu, więc opisuje ten produkt.

KONTA jak w astrodemo: z konfiguracji środowiska (ASTROKLIENT_USERS), jeden
poziom dostępu, bez pliku kont i bez ekranu ich zakładania. Konta rozdziela się
po to, żeby każde miało własną pulę plików.

PULE PER KONTO — tu była realna dziura. Warstwa logiczna przenosiła pulę tylko
przy raporcie i operacjach na plikach, więc Kalendarz i Sygnifikatory czytałyby
CAŁY udział: jedno konto widziałoby pliki drugiego, mimo obietnicy izolacji.
Domknięte: TimelineRequest i QueryRequest niosą teraz pulę, a QueryService buduje
klienta danych na żądanie.

Pula jedzie w każdym żądaniu w dół i bierze się z kontekstu ustawianego przy
wejściu, nigdy z formularza. Test podstawia `tenant=ktos-inny` w POST i sprawdza,
że w dół poszedł login zalogowanego.

WARSTWA WSPÓLNA ROZDZIELONA OD POJĘCIA ADMINISTRATORA. base.html miał wpisany na
sztywno warunek `can(request, 'admin')` i odsyłacz do ekranu kont — czyli
w produkcie bez tego ekranu zostawał martwy link i nazwa czegoś, czego nie ma.
Rejestr niesie teraz wymagane uprawnienie, a szablon dostaje gotową listę.
Podstawa przestała też importować moduły służące jednemu ekranowi (konta, stany
plików), bo produkt bez tego ekranu wlókł ich zależności.

ZAPORA SŁOWNIKOWA NAD REALNYM DRZEWEM. Test buduje złożenie tak samo jak
Dockerfile i szuka słów o funkcjach, których nie ma — w odpowiedziach ORAZ
w plikach. Pierwsza wersja znalazła dziesięć trafień, w tym trzy moje własne
docstringi WYLICZAJĄCE nieobecne funkcje: zdanie „nie ma tu generowania tekstu"
mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak przycisk.
Po poprawkach: zero. Test ma kontrolę negatywną — podrzucony plik ma go wywrócić.

usun.txt jest DANYMI, nie tekstem w Dockerfile: czyta go też test pilnujący, żeby
zgadzał się ze złożeniem w main.py. Rozjazd znaczyłby albo martwy kod w obrazie,
albo błąd dopiero przy uruchomieniu.

CI: astroklient buduje się z KORZENIA repozytorium (jego Dockerfile sięga po
rdzeń), a zmiana w warstwie prezentacji też go przebudowuje — bez tego jego obraz
zostawałby ze starymi ekranami, a różnicy nie byłoby widać do zgłoszenia
użytkownika.

Testy: astroklient 12, presentation 368, astrodemo 28, logic 342, data 42,
render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:40:38 +02:00
gitea cf24c4d473 ci: buduj tylko zmienione usługi i sprzątaj po sobie na runnerze
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m22s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build-render / build (push) Successful in 2m39s
build / build (push) Successful in 10s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m20s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Rejestr obrazów rósł, bo `build.yaml` budował CZTERY obrazy przy każdym pushu do
mastera, niezależnie od tego, co się zmieniło. Zmiana w samej prezentacji dawała
nowe wersje data, logic i astrodemo — identyczne co do zawartości, różniące się
wyłącznie tagiem.

Render i silnik B miały filtry ścieżek od początku (build-render.yaml,
build-swisseph.yaml) i budują się tylko przy własnych zmianach. Ta zmiana
przenosi tę samą zasadę do głównego pipeline'u, licząc różnicę wobec poprzedniego
commita. Kontekstem budowania każdego obrazu jest wyłącznie `./services/<nazwa>`,
więc porównanie ścieżek jest dokładne, a nie przybliżone.

Sprawdzone na ośmiu ostatnich commitach mastera: dwa refaktory prezentacji dają
sam `presentation`, poprawka Dockerfile'a astrodemo daje samo `astrodemo`,
a commit ruszający link_crypto w pięciu usługach — komplet. Bez porównania
(pierwszy commit, przepisana historia) budujemy wszystko: lepiej zbudować za
dużo niż wypuścić obraz ze starym kodem pod nowym tagiem.

SKUTEK UBOCZNY DO ŚWIADOMOŚCI: tagi w kustomization mogą się teraz rozjechać
między usługami, bo nie każda dostaje nową wersję z każdego commita. Tak ma być —
image-updater śledzi każdy obraz osobno i przypina to, co istnieje.

ZABEZPIECZENIE PRZED CICHYM POMINIĘCIEM. Usługa, której nie ma na żadnej liście,
nigdy się nie zbuduje i nikt tego nie zauważy: brak obrazu wygląda potem na
problem z rejestrem albo z siecią. Ten sam mechanizm kosztował nas już raz, przy
liście obserwowanych obrazów w image-updaterze. Pętla po `services/*/` zatrzymuje
budowanie i mówi wprost, co dopisać. Sprawdzone także od strony negatywnej —
podstawiony katalog nowej usługi kończy przebieg kodem 1.

SPRZĄTANIE NA RUNNERZE, w build.yaml i w build-render.yaml, z `if: always()` —
bo to po NIEUDANYM budowaniu zostaje najwięcej śmieci, a kolejny przebieg
zaczyna od mniejszego zapasu miejsca niż poprzedni. Tak zatkał się dysk przy
renderze z TeX Live. Filtr `until=168h` zostawia tydzień, więc warstwy bazowe
i cache przeżywają i build nie zaczyna od zera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:13:27 +02:00
gitea fd79513ce2 astrololo: ekrany i eksport jako moduły, katalog z rejestracji (3/5)
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Failing after 3s
Testy / Testy astrodemo (pull_request) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 2s
Testy / Kontrola składni wszystkich warstw (pull_request) Failing after 3s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
build / build (push) Successful in 9s
Trzeci z pięciu kroków. Zmieniłem jego zakres wobec planu i warto wiedzieć
dlaczego: pierwotnie miała to być deduplikacja pięciu kopii link_crypto.py, ale
po zrobieniu PR 2 widać, że astroklienta blokuje co innego — ekrany, których nie
ma mieć, siedzą wewnątrz jednego main.py. Deduplikacja kryptografii jest realnym
długiem, ale niczego nie blokuje.

PODZIAŁ. main.py (1023 linie) rozpadł się na podstawa.py (wspólne obiekty
i pomocnicy), jedenaście modułów w app/ekrany/ i main.py, który jest już samym
ZŁOŻENIEM: lista importów JEST definicją produktu. Podział zrobiony mechanicznie,
z osobnym sprawdzeniem, że żadna sekcja nie wołała pomocnika z innej (nie wołała).

KATALOG Z REJESTRACJI. Dotąd wszystkie funkcje były wypisane w features.py, więc
obraz produktu, który części z nich nie ma, i tak niósł ich nazwy — spis funkcji,
których nie ma jak włączyć. Teraz ekran zgłasza siebie, swoje trasy i swoje
zasoby przy imporcie własnego modułu, a features.py nie wymienia ani jednego
ekranu. Kolejność w nawigacji jest jawna (`kolejnosc`), żeby nie rządziła nią
kolejność importów.

To samo dotyczy nawigacji administratora: odsyłacz do ekranu kont był wpisany na
sztywno w base.html, więc w węższym produkcie zostawał martwy link i nazwa
ekranu, którego nie ma.

EKSPORT JAKO MODUŁ. Zgodnie z ustaleniem eksport jest funkcją administracyjną,
więc musi dać się usunąć. app/moduly/eksport/ zabiera arkusz, trasę PDF-a i akcję
formularza. „Można, ale nie temu kontu" i „nie ma takiej możliwości" to dwie
różne gwarancje, a eksport wynosi najwięcej treści baz naraz.

MOST ODKRYWA MODUŁY. Skoro modułów jest więcej niż jeden, most nie może ich znać
z nazwy — nazwa nieobecnego modułu jechałaby do obrazu, w którym go nie ma.
Przechodzi więc po podkatalogach app/moduly/ i pyta każdy, co wnosi. Katalog
generowania przeniesiony z app/dodatki na app/moduly/dodatki.

ZNALEZIONE PRZY OKAZJI. Po wydzieleniu eksportu okazało się, że jego ścieżki
SZCZĘŚLIWEJ nie sprawdzał żaden test — badano wyłącznie odmowę dla konta bez
uprawnienia. Moduł dostaje zależności z wywołania montującego, więc brak jednej
z nich wyszedłby dopiero przy pierwszym kliknięciu. Dopisany test funkcjonalny
(realny arkusz, sprawdzany aż do nagłówka ZIP-a) i brakujące zależności.

Cztery komentarze w plikach współdzielonych wymieniały zakładkę „Skompiluj",
w tym wheelzoom.js ze wzmianką o „przyszłej zakładce" — to samo zgłoszenie, które
audyt podnosił wcześniej.

TEST ZŁOŻENIA. Buduje węższy produkt NAPRAWDĘ: kopiuje drzewo, usuwa cztery
ekrany i oba moduły, uruchamia aplikację w OSOBNYM PROCESIE (importy są
zapamiętywane, więc sprawdzanie tego w procesie, który moduł już zaimportował,
dawałoby wynik fałszywie pozytywny) i sprawdza, że wstaje, że zachowane ekrany
oddają 200, że usunięte oddają 404 (nie 403 i nie 500), że katalog opisuje ten
obraz, i że w nawigacji nie ma martwych odsyłaczy.

Napisałem najpierw ostrzejszy test — „nazwa ekranu nie pada poza jego modułem" —
i go wyrzuciłem: zgłaszał wzmianki o Horoskopie w plikach współdzielonych, choć
astroklient Horoskop MA. Ślad ma znaczenie wyłącznie wobec konkretnego złożenia,
więc sprawdzenie należy do produktu, nie do mechanizmu.

Testy: presentation 368, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:32:49 +02:00
gitea 62d6f9d4d5 astrololo: generowanie tekstu jako moduł odłączalny (2/5)
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
Drugi z pięciu kroków budowy trzech produktów. Czysty refaktor — zachowanie
aplikacji się nie zmienia, liczba testów rośnie tylko o nowe.

DLACZEGO WARUNEK W SZABLONIE NIE WYSTARCZA. Dotąd generowanie było chowane przez
`{% if can(request, 'ai') %}`. To sprawia, że funkcji nie WIDAĆ, ale nie że jej
NIE MA: plik szablonu dalej leży w obrazie i dalej zawiera jej nazwy, więc `grep`
po kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie. Astroklient
ma nie mieć śladu, nie mieć wyłączoną funkcję — więc granica musi być KATALOGIEM.

CO SIĘ PRZENIOSŁO do app/dodatki/: dwa szablony, pięć plików statycznych, trasa
strumienia, cztery metody klienta warstwy logicznej, wpis w katalogu funkcji,
wpisy tras i zasobów, akcje formularza i reguły CSS nazywające funkcję
(`textarea.prompt`).

JAK APLIKACJA O TO PYTA. Neutralny most `app/rozszerzenia.py` odpowiada wyłącznie
na pytanie „czy coś jest podpięte i co wnosi". Sam nie wymienia ani jednej nazwy —
pilnuje tego osobny test, bo most jest w KAŻDYM obrazie. Dlatego też katalog
nazywa się `dodatki`, a nie `ai`: nazwa w instrukcji importu byłaby dokładnie tym
śladem, którego wydzielanie ma się pozbyć.

TRZY MIEJSCA, KTÓRE OKAZAŁY SIĘ TRUDNE:

1. Pola formularza. FastAPI czyta je z SYGNATURY, a wspólny handler nie może
   wymieniać `prompt_budget` ani `llm_provider`. Rozwiązane zależnością: moduł
   deklaruje własne pola u siebie, handler wie tylko, że dostaje słownik.
2. Klient warstwy logicznej jest wspólny, więc metody `prompt`/`horoscope`/
   `llm_models` musiały z niego wyjść. Wspólny klient daje teraz samą drogę
   w dół (`wywolaj`, `pobierz`, `strumien`) — z szyfrowaniem łącza i tokenem
   międzywarstwowym; co nią pojedzie, jest sprawą modułu.
3. Podtytuł ekranu Skompiluj miał wariant „z AI" i wariant bez. Zamiast warunku
   jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie.

DOCKERFILE. `COPY . .` wnosiło do obrazu także testy — a plik testowy nazywa
funkcje wprost. Teraz wchodzi wyłącznie `app/`.

TEST GRANICY. `test_ai_tylko_w_module.py` przechodzi po wszystkim poza modułem
i szuka siedemnastu słów. Znalazł dwanaście resztek, których nie widziałem:
komentarze w main.py i security.py, przykład `models.js` w komentarzu features.py,
komentarze w compile.js i w trzech arkuszach, oraz regułę `textarea.prompt`,
która przy rozbijaniu CSS trafiła do arkuszy ekranów zamiast do modułu.

Ma też kontrolę pozytywną (te słowa MAJĄ padać w module — inaczej test
przechodziłby, gdyby funkcję wydrążono) i test odłączalności mostu.

SPRAWDZONE NA KOPII BEZ MODUŁU: aplikacja startuje, wszystkie ekrany oddają 200,
katalog ma 13 funkcji zamiast 14, trasy strumienia nie ma, spreparowane
`action=prompt` wraca do akcji domyślnej bez śladu, a `grep` po drzewie nie
znajduje ani jednego ze słów.

Testy: presentation 362, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:25:56 +00:00
gitea 10ade55525 astrodemo: do obrazu wchodzi wyłącznie kod aplikacji
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Failing after 4s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Failing after 4s
Testy / Testy astrodemo (pull_request) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Failing after 3s
build-render / build (push) Failing after 8s
build / build (push) Successful in 20s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
`COPY . .` wnosiło do obrazu cały katalog usługi, razem z tests/. Leży tam plik
pilnujący, żeby pewne słowa nie padły — wypisujący je wprost, bo inaczej nie da
się ich sprawdzić. Trafiając do obrazu, stawał się dokładnie tym wyciekiem,
przed którym broni: „pełna aplikacja", „astrololo", nazwy nieobecnych funkcji.

Obraz tej usługi się KOMUŚ ODDAJE, więc waży to więcej niż gdzie indziej.
To samo zawężenie zrobiłem wcześniej w warstwie prezentacji; astrodemo zostało
przeoczone, bo powstało przed tamtą zmianą.

Test pilnuje tego na przyszłość i czyta same instrukcje, bez komentarzy —
komentarz obok cytuje dawną postać, więc szukanie po całym pliku zgłaszałoby
własne wyjaśnienie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:50:53 +02:00
gitea 2e9d3706ec astrodemo: zmiana nazwy, rebase na mastera i domknięcie wycieków
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m50s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 8s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
Pierwszy z pięciu kroków budowy trzech produktów: astrodemo (dwie funkcje) →
astroklient (pełne astro bez AI) → astrololo (wszystko). Nazwa „astroklient"
zostaje zwolniona dla warstwy pośredniej, więc dotychczasowe astroklient-demo
nazywa się teraz astrodemo.

REBASE. Cztery commity demo przeniesione na aktualnego mastera. Konflikt był
jeden — rejestr wymagań (xlsx, binarny, git go nie scali). Master dodał LOG-34,
gałąź demo PRE-28 i PRE-29; żaden wspólny wiersz się nie różnił, więc scalone
ręcznie: 148 pozycji, wszystkie trzy obecne.

ZMIANA NAZWY. Katalog, ciasteczko sesji (astrodemo_sesja), zmienne
ASTRODEMO_USERS/USER/PASSWORD, CI, README, docstringi. Ponieważ PR z demo nigdy
nie został zmergowany, usługa nie jest nigdzie wdrożona — zmiana nazw niczego
nie migruje i nikogo nie wylogowuje.

WYCIEKI. astrodemo powstało przed audytem z PRE-27, więc miało komplet tych
samych dziur:

- /static omijało bramkę (PUBLIC_PREFIXES), a pierwszy komentarz w styles.css
  brzmiał „Nie kopiujemy stylów pełnej aplikacji" — czyli anonimowy curl
  dowiadywał się, że istnieje pełna aplikacja. Zasoby idą teraz trasą z jawną
  listą, komentarze są zdejmowane przy serwowaniu.
- Komunikat awarii wypisywał na ekran treść wyjątku httpx, z nazwą usługi
  i portem. Teraz jedno neutralne zdanie, szczegóły do dziennika.
- /health oddawał nazwę warstwy. Teraz samo „ok".
- Dziesięć komentarzy i docstringów tłumaczyło decyzje przez porównanie
  z „pełną aplikacją". Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie,
  przeczyta też komentarze. Przepisane tak, żeby opisywały tę usługę samą
  w sobie.
- Nagłówek main.py twierdził, że demo dzieli pulę plików z produkcją. To
  nieprawda od PRE-29 (pule per konto) — opis poprawiony.

ZAPORA SŁOWNIKOWA, dwupoziomowa. Poziom „wszędzie" (także w kodzie serwera, bo
obraz się oddaje) obejmuje wzmianki o większym rodzeństwie, o modelu językowym
i o funkcjach, których tu nie ma. Poziom „do przeglądarki" dokłada słownictwo
mechanizmów. Test sprawdza odpowiedzi ORAZ drzewo plików.

Jeden wyjątek jest jawny i opisany: stałe protokołu łącza (X-Astrololo-Token,
X-Astrololo-Enc, typ treści, etykieta HKDF) niosą nazwę rodziny produktów. Są
wspólne z warstwą logiczną, więc zmiana wymaga jednoczesnej podmiany we
wszystkich usługach i rotacji — osobna decyzja. Osobny test pilnuje warunku, pod
jakim to zostaje: że nie docierają do przeglądarki. Wcześniej przechodziły tylko
dlatego, że regex nie dopasowywał po myślniku — przypadek, nie decyzja.

Przy okazji: komunikat „ramka bez znacznika astrololo" zmieniony na neutralny we
WSZYSTKICH PIĘCIU kopiach link_crypto.py (presentation, astrodemo, logic, data,
render), żeby nie rozjechały się przed scaleniem w rdzeń. Te kopie to 2625 linii
tego samego kodu.

Testy: astrodemo 27, presentation 358, logic 342, data 42, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:05:40 +02:00
gitea c73ba6a964 feat(astroklient-demo): sesje logowania zamiast HTTP Basic (LOG-34)
Demo idzie szeroko i do różnych osób, często na cudzych komputerach — więc
wyjście z aplikacji jest tu potrzebne bardziej niż w pełnej wersji, a Basic go
nie miał: przeglądarka zapamiętuje hasło i dosyła je sama przy każdym żądaniu.
Pierwszy klient zostawiał otwartą sesję drugiemu.

Ta sama konstrukcja co w pełnej aplikacji: własny ekran logowania, podpisane
ciasteczko (HMAC-SHA256), HttpOnly + SameSite=Strict, wylogowanie POST-em, kres
bezczynności i twardy, sito na adres powrotu, zdarzenia w dzienniku bez haseł.
Moduł session.py skopiowany, tak samo jak link_crypto — usługi są osobnymi
obrazami i nie importują się nawzajem.

DWIE RÓŻNICE WOBEC PEŁNEJ WERSJI, obie wynikające z tego, że demo nie ma
własnego wolumenu:

  * nazwa ciasteczka jest inna. Gdyby obie aplikacje stanęły kiedyś pod jedną
    domeną, ciasteczka o tej samej nazwie nadpisywałyby się i człowiek wypadałby
    z jednej, logując się do drugiej.
  * nie ma licznika pokolenia sesji, bo nie ma go gdzie zapisać. Zdalne
    unieważnienie robi się przez DEMO_USERS: usunięcie konta albo zmiana hasła
    NATYCHMIAST ubija jego otwarte sesje, bo odcisk poświadczenia w ciasteczku
    przestaje pasować. Osobny test tego pilnuje. Wylogowanie i tak działa
    natychmiast, bo polega na skasowaniu ciasteczka.

Klucz podpisu jest WŁASNY, nie ten z pełnej aplikacji: demo i produkcja nie mają
powodu uznawać nawzajem swoich sesji, a wspólny klucz znaczyłby, że sesja z demo
bywa ważna tam, gdzie nie powinna.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:57:06 +02:00
gitea 16cf25468f refactor: astroklient → astroklient-demo
Nazwa `astroklient` zostaje zarezerwowana dla przyszłej wersji produkcyjnej
programu; obecna, demonstracyjna nazywa się od teraz `astroklient-demo`.

Zmiana obejmuje katalog usługi, nazwę pliku testów, obraz w rejestrze
(astrololo-astroklient-demo), job w CI, pętlę budowania obrazów, tytuł i nagłówek
strony, nazwy loggerów, realm logowania, pole `layer` w /health oraz wymagania
PRE-28/29 w xlsx.

DWIE PUŁAPKI PODMIANY, obie sprawdzone po fakcie:

Zdublowany przyrostek. `astroklient-demo` zawiera `astroklient`, więc powtórna
podmiana dałaby `astroklient-demo-demo`. Sprawdziłem najpierw, że nigdzie nie ma
jeszcze nowej nazwy, i dopiero wtedy podmieniłem raz.

Polska odmiana. Ślepa podmiana zamieniła „astroklienta" na „astroklient-demoa”
w czterech miejscach; poprawione na „astroklienta-demo". Tytuł FastAPI wyszedłby
jako „astroklient-demo · demo", a nazwa jobu jako „Testy astroklienta-demo
(wersja demo)" — oba skrócone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:57:06 +02:00
gitea caf4fd80d1 feat(astroklient): pule plików per konto i izolacja od produkcji (PRE-29)
Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto
na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby
dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy.

IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo
deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych —
demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.

PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli;
puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic
nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych
baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda.

PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta
trafiłby z cache do drugiego — cicha wymiana treści baz między klientami,
niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje.

PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany
per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła
z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user`
i `login` w polach formularza i sprawdza, że nie mają na nią wpływu.

Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba
by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach
nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią
zapomnieć i sięgnąć nie tam.

Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby
wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji.

Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty
wgrań — procedurę importu opisuje runbook w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:57:05 +02:00
gitea e7b6d5013b 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>
2026-08-26 11:56:45 +02:00
132 changed files with 6182 additions and 1409 deletions
+11
View File
@@ -32,3 +32,14 @@ jobs:
docker push $IMG:$TAG
docker push $IMG:latest
echo "Zbudowano i wypchnieto: $IMG:$TAG (+ latest)"
# Sprzątanie ZAWSZE, także po nieudanym budowaniu. Ten obraz dźwiga TeX Live
# i to właśnie na nim runner zatkał się kiedyś na „no space left on device" —
# a po nieudanym buildzie zostaje najwięcej śmieci.
- name: Sprzątanie po budowaniu
if: always()
run: |
docker system df || true
docker system prune -af --filter "until=168h" || true
docker builder prune -f --filter "until=168h" || true
docker system df || true
+96 -3
View File
@@ -1,19 +1,112 @@
name: build
# Buduje obrazy usług, które dzielą warstwę logiczną i łącze: data, logic,
# presentation, astrodemo. Render i silnik B mają własne pipeline'y, bo są ciężkie
# i rzadko się zmieniają (build-render.yaml, build-swisseph.yaml).
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Dwa commity, bo porównujemy z poprzednim. Domyślny płytki klon ma
# jeden i `HEAD^` w nim nie istnieje.
fetch-depth: 2
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push (data, logic, presentation)
- name: Build & push (tylko usługi, które się zmieniły)
run: |
set -eu
TAG=${GITHUB_SHA::8}
for SVC in data logic presentation; do
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
USLUGI="data logic presentation astrodemo astroklient"
# Usługi z własnym pipeline'em — celowo poza tą pętlą.
OSOBNE="render engine-swisseph"
# Usługa, której nie ma na żadnej z list, nigdy się nie zbuduje i NIKT
# tego nie zauważy: brak obrazu wygląda potem na problem z rejestrem.
# Ten sam mechanizm już nas kosztował przy liście obserwowanych obrazów
# w image-updaterze. Lepiej zatrzymać budowanie i powiedzieć wprost.
for KATALOG in services/*/; do
NAZWA=$(basename "$KATALOG")
case " $USLUGI $OSOBNE " in
*" $NAZWA "*) ;;
*) echo "BŁĄD: usługa '$NAZWA' nie jest na żadnej liście budowania."
echo "Dopisz ją do USLUGI w tym pliku albo daj jej własny workflow"
echo "(jak render i engine-swisseph), zależnie od tego, czy ma"
echo "powstawać z każdego commita produktu."
exit 1 ;;
esac
done
# Kontekstem budowania każdego obrazu jest WYŁĄCZNIE ./services/<nazwa>,
# więc zmiana poza tym katalogiem nie może wpłynąć na jego zawartość.
# Dzięki temu porównanie ścieżek jest dokładne, a nie przybliżone.
if POPRZEDNI=$(git rev-parse --verify HEAD^ 2>/dev/null); then
ZMIENIONE=$(git diff --name-only "$POPRZEDNI" HEAD)
else
# Pierwszy commit albo przepisana historia — nie ma z czym porównać,
# więc budujemy wszystko. Lepiej zbudować za dużo niż wypuścić obraz
# ze starym kodem pod nowym tagiem.
echo "Brak poprzedniego commita — buduję komplet."
ZMIENIONE=""
fi
# Zmiana w samym pliku workflow dotyczy wszystkich obrazów naraz.
if [ -z "$ZMIENIONE" ] || echo "$ZMIENIONE" | grep -q '^\.gitea/workflows/build\.yaml$'; then
DO_BUDOWY="$USLUGI"
else
DO_BUDOWY=""
for SVC in $USLUGI; do
if echo "$ZMIENIONE" | grep -q "^services/$SVC/"; then
DO_BUDOWY="$DO_BUDOWY $SVC"
fi
done
# astroklient bierze rdzeń z warstwy prezentacji, więc zmiana TAMTEJ
# też go dotyczy. Bez tego jego obraz zostawałby ze starymi ekranami,
# a różnicy nie byłoby widać aż do zgłoszenia użytkownika.
if echo "$ZMIENIONE" | grep -q "^services/presentation/" \
&& ! echo "$DO_BUDOWY" | grep -q "astroklient"; then
DO_BUDOWY="$DO_BUDOWY astroklient"
fi
fi
if [ -z "$(echo "$DO_BUDOWY" | tr -d ' ')" ]; then
echo "Żadna z usług się nie zmieniła — nie ma czego budować."
echo "Wdrożone tagi zostają na poprzednich wersjach, i tak ma być."
exit 0
fi
echo "Buduję:$DO_BUDOWY (tag $TAG)"
for SVC in $DO_BUDOWY; do
# astroklient buduje się z KORZENIA repozytorium, bo jego Dockerfile
# sięga po rdzeń do services/presentation. Pozostałe mają kontekst
# ograniczony do własnego katalogu — i tak ma zostać, bo to właśnie
# ten kontekst gwarantuje, że nie wciągną niczego spoza siebie.
if [ "$SVC" = "astroklient" ]; then
docker build -f services/astroklient/Dockerfile \
-t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG .
else
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
fi
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
done
echo "Tag: $TAG"
# Sprzątanie ZAWSZE, także po nieudanym budowaniu — to właśnie po awarii
# zostaje najwięcej śmieci, a kolejny przebieg zaczyna od mniejszego zapasu
# miejsca niż poprzedni. Tak zatkał się dysk przy budowaniu rendera.
- name: Sprzątanie po budowaniu
if: always()
run: |
docker system df || true
# `until=168h` zostawia tydzień: warstwy bazowe i cache z ostatnich dni
# przeżywają, więc kolejny build nie zaczyna od zera, a stare znikają.
docker system prune -af --filter "until=168h" || true
docker builder prune -f --filter "until=168h" || true
docker system df || true
+72
View File
@@ -91,6 +91,48 @@ jobs:
PYTHONPATH: .
run: pytest tests -q -rs
astrodemo-tests:
name: Testy astrodemo
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/astrodemo/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/astrodemo/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/astrodemo
env:
PYTHONPATH: .
run: pytest tests -q -rs
astroklient-tests:
name: Testy astroklient
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
# Testy budują drzewo tak samo, jak robi to Dockerfile: rdzeń z warstwy
# prezentacji, minus usun.txt, plus nakładka. Sprawdzają WYNIK złożenia,
# bo tylko on trafia do obrazu.
- 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
@@ -219,3 +261,33 @@ jobs:
# silnika swisseph, który nie wchodzi do produktu).
- name: py_compile
run: python -m compileall -q services
# Krok bez `run` i bez `uses` jest dla Gitei krokiem PUSTYM: zadanie mija
# go i kończy się powodzeniem, nie uruchomiwszy niczego. Tak właśnie
# zniknęły na jeden commit testy astrodemo — blok został rozcięty przy
# dopisywaniu sąsiedniego, a jego `run` przywędrował do cudzego zadania,
# które przez to przechodziło z niewłaściwego powodu.
#
# Zielone CI, które nic nie uruchomiło, jest gorsze od czerwonego.
- name: Kontrola kroków w workflow
run: |
python - <<'KONTROLA'
import pathlib, sys, yaml
grzechy = []
for f in sorted(pathlib.Path(".gitea/workflows").glob("*.y*ml")):
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
for nazwa, job in (d.get("jobs") or {}).items():
kroki = job.get("steps") or []
if not kroki:
grzechy.append(f"{f.name}: zadanie '{nazwa}' nie ma kroków")
for k in kroki:
if "run" not in k and "uses" not in k:
grzechy.append(
f"{f.name}: '{nazwa}' → krok "
f"'{k.get('name','(bez nazwy)')}' nie ma ani run, ani uses")
if grzechy:
print("PUSTE KROKI — zadanie przeszłoby nic nie robiąc:")
print("\n".join(" " + g for g in grzechy))
sys.exit(1)
print("Wszystkie kroki mają co uruchomić.")
KONTROLA
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też katalog
# tests/ — a leży w nim lista słów, które w tej usłudze nie mają prawa paść,
# wypisanych wprost. Obraz tej usługi się KOMUŚ ODDAJE, więc niósłby dokładnie
# to, co ten test ma pilnować. Przy okazji obraz jest mniejszy.
COPY app ./app
EXPOSE 8005
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"]
+91
View File
@@ -0,0 +1,91 @@
# astrodemo — 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.
## Izolacja: własne dane, pula na konto
```
astrodemo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie)
└── klientA/ ← pula konta
└── klientB/ ← pula konta
```
**Oryginalne bazy interpretacyjne są dla demo nieosiągalne.** Nie chodzi
o uprawnienia: demo ma własną warstwę danych, pracującą na osobnym udziale.
Osobna jest też warstwa logiczna, bo zna ona JEDEN adres warstwy danych — demo
korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.
**Każde konto ma własną pulę** plików: swój podkatalog na tym udziale. Konta nie
widzą swoich baz nawzajem ani w liście, ani w wynikach wyszukiwania. Dlatego demo
można rozdawać szeroko.
Pula bierze się **z loginu zalogowanej osoby** i z niczego innego. Gdyby
przychodziła z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze
bazy — osobny test wysyła `tenant`, `user` i `login` w formularzu i sprawdza, że
nie mają na nią wpływu.
Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział.
**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza
utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`,
`astrololo/README-astrodemo.md`).
## Konfiguracja
| zmienna | znaczenie |
|---|---|
| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) |
| `ASTRODEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików |
| `ASTRODEMO_USER` / `ASTRODEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `ASTRODEMO_USERS` jest puste. **Brak obu = 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/astrodemo
pip install -r requirements-dev.txt
ASTRODEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \
uvicorn app.main:app --port 8005
```
Testy:
```bash
cd services/astrodemo && 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.
View File
@@ -0,0 +1,79 @@
"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28).
Ma tu być wyłącznie to, czego ta usługa 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:
"""Rozmówca warstwy logicznej ZWIĄZANY Z PULĄ KONTA (PRE-29).
Pulę podaje się przy tworzeniu klienta, a bierze się ją WYŁĄCZNIE z loginu
zalogowanej osoby — nigdy z pola formularza. Gdyby przychodziła z żądania,
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
def __init__(self, tenant: str = "", base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
self.tenant = tenant or ""
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 astrodemo zadaje o treść — bez grupowania,
bez grupowania i bez żadnej obróbki treści."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"limit": settings.report_limit, "group": False,
"tenant": self.tenant}
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, "tenant": self.tenant},
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, "tenant": self.tenant},
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()
+22
View File
@@ -0,0 +1,22 @@
"""Konfiguracja astrodemo — wersji demonstracyjnej (PRE-28).
Zna WYŁĄCZNIE adres warstwy logicznej. 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()
+525
View File
@@ -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 protokołu")
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")
+255
View File
@@ -0,0 +1,255 @@
"""astrodemo — 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. Ukrycie a nieobecność to
dwie różne rzeczy. Tutaj niczego poza tymi dwiema funkcjami NIE MA W OBRAZIE: nie
ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Obraz
tej usługi można komuś oddać i nie oddaje się przy tym niczego ponad to, co widać
na ekranie — łącznie z komentarzami w tym pliku, bo one też jadą razem z obrazem.
PULE SĄ ROZDZIELONE (PRE-29). Każde konto ma własny, izolowany zbiór plików,
niewidoczny dla pozostałych kont; pula bierze się WYŁĄCZNIE z loginu zalogowanej
osoby, nigdy z pola formularza. Konta są osobne (ASTRODEMO_USERS) — żeby dało się
odciąć tę usługę jedną zmienną, bez
ruszania kont głównej aplikacji.
"""
from __future__ import annotations
import base64
import logging
import re
from functools import lru_cache
from pathlib import Path
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app import link_crypto, security, session
from app.clients.logic_client import LogicClient
# Automatyczna dokumentacja WYŁĄCZONA. /docs wypisałoby komplet tras, a ta usługa
# ma nie zdradzać nawet własnej powierzchni.
app = FastAPI(title="astrodemo", docs_url=None, redoc_url=None, openapi_url=None)
security.install(app)
# ── zasoby (PRE-27) ────────────────────────────────────────────────────────
# Katalog NIE jest montowany. Montowanie serwuje wszystko, co w nim leży, każdemu
# i bez śladu w dzienniku — a komentarz w arkuszu stylów mówił wprost, że istnieje
# więcej, niż widać na ekranie. Tu jest jawna lista: arkusz logowania jest
# publiczny, bo
# potrzebuje go strona logowania, a cokolwiek innego jest nieznane i nie istnieje.
ZASOBY_PUBLICZNE = frozenset({"styles.css"})
_KATALOG_ZASOBOW = Path("app/static")
def _bez_komentarzy(tresc: str) -> str:
"""Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt.
W repozytorium zostaje — do odpowiedzi nie ma po co trafiać."""
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
@lru_cache(maxsize=None)
def _zasob(nazwa: str) -> bytes | None:
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
if nazwa not in ZASOBY_PUBLICZNE:
return None
sciezka = (_KATALOG_ZASOBOW / nazwa).resolve()
# Wyjście poza katalog: `..` w ścieżce albo dowiązanie na zewnątrz.
if _KATALOG_ZASOBOW.resolve() not in sciezka.parents or not sciezka.is_file():
return None
if sciezka.suffix in (".css", ".js"):
return _bez_komentarzy(sciezka.read_text(encoding="utf-8")).encode("utf-8")
return sciezka.read_bytes()
@app.get("/static/{nazwa:path}")
def zasob(nazwa: str):
tresc = _zasob(nazwa)
if tresc is None:
raise HTTPException(status_code=404)
typ = "text/css; charset=utf-8" if nazwa.endswith(".css") else "application/octet-stream"
return Response(tresc, media_type=typ,
headers={"Cache-Control": "public, max-age=3600"})
templates = Jinja2Templates(directory="app/templates")
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _blad).
log = logging.getLogger("astrodemo")
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
templates.env.globals["auth_on"] = security.auth_enabled
def _logic(request: Request) -> LogicClient:
"""Klient związany z pulą ZALOGOWANEGO konta.
Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się
zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state,
które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
return LogicClient(tenant=getattr(request.state, "user", "") or "")
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
# tutaj wprost, bo astrodemo 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")
# Jedno zdanie dla wszystkich awarii i CELOWO żadnego szczegółu. Treść wyjątku
# niosła nazwę usługi, jej port i nazwy jej tras — czyli mapę tego, co stoi za tym
# ekranem, podaną komuś, kto ma widzieć wyłącznie dwa formularze.
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
def _blad(e: Exception) -> str:
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran."""
log.warning("operacja nieudana: %s: %s", type(e).__name__, e)
return _AWARIA
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(request).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.
Jedna czynność, nie dwie: „dodać plik" ma tu znaczyć, że od razu bierze udział
w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i wyglądałoby to
na zepsute.
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
zapisany i czeka."""
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(request).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(request).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)
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
# Bez własnego wolumenu nie ma gdzie trzymać licznika pokolenia sesji, więc
# „wyrzuć ze wszystkich urządzeń" robi się zmianą hasła w ASTRODEMO_USERS.
# Wylogowanie działa natychmiast, bo polega na skasowaniu ciasteczka.
def _bezpieczne_dokad(dokad: str) -> str:
"""Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl`
zamieniłby ekran logowania w narzędzie do wyłudzania haseł."""
dokad = (dokad or "").strip()
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
return "/"
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
@app.get("/logowanie", response_class=HTMLResponse)
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
if security.auth_enabled() and security.principal(request):
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
return templates.TemplateResponse(request, "logowanie.html",
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
@app.post("/logowanie")
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
dokad: str = Form("/")):
cel = _bezpieczne_dokad(dokad)
kto = security.verify(login.strip(), haslo)
if kto is None:
security.audit_login(request, login.strip(), udane=False)
return templates.TemplateResponse(
request, "logowanie.html",
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
status_code=401)
security.audit_login(request, kto, udane=True)
odpowiedz = RedirectResponse(cel, status_code=303)
odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto),
max_age=session.max_age(),
**session.cookie_params(secure=security.cookies_secure()))
return odpowiedz
@app.post("/wyloguj")
def wyloguj(request: Request):
"""POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie,
żeby wylogować kogoś bez jego wiedzy."""
security.audit_logout(request)
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
odpowiedz.delete_cookie(session.COOKIE,
**session.cookie_params(secure=security.cookies_secure()))
return odpowiedz
@app.get("/health")
def health() -> dict:
# Sama „ok". Nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa;
# kubernetes i compose czytają wyłącznie kod odpowiedzi.
return {"status": "ok"}
+266
View File
@@ -0,0 +1,266 @@
"""Logowanie do astrodemo (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ń. Ta usługa 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, RedirectResponse
log = logging.getLogger("astrodemo.security")
audit_log = logging.getLogger("astrodemo.audit")
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$"
# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być
# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji.
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/styles.css"})
# Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy
# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani
# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony
# z nazwy, bo potrzebuje go ekran logowania.
PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {}
MAX_TRACKED_CLIENTS = 4096
def accounts() -> dict[str, str]:
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_USERS.
WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29).
Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem
swoje wgrania.
Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten
sam skrypt make_user.py, co w głównej aplikacji.
Zgodność wstecz: pojedyncze ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS
jest puste."""
out: dict[str, str] = {}
for entry in os.getenv("ASTRODEMO_USERS", "").split(","):
entry = entry.strip()
if not entry:
continue
user, sep, secret = entry.partition(":")
if sep and user.strip() and secret.strip():
out[user.strip()] = secret.strip()
if out:
return out
single = os.getenv("ASTRODEMO_PASSWORD", "")
return {os.getenv("ASTRODEMO_USER", "demo"): single} if single else {}
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(accounts())
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 verify(login: str, password: str) -> str | None:
"""Sprawdza login i hasło; zwraca login albo None.
Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
konto istnieje, a przy demo rozdawanym szeroko to realna informacja."""
spec = accounts().get(login)
if spec is None:
return None
return login if _secret_matches(spec, password) else None
def issue_session(login: str) -> str:
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie
konta z ASTRODEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny
sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu,
bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów
byłby pomieszaniem dwóch zupełnie różnych rzeczy."""
from app import session
return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0)
def principal(request: Request) -> str | None:
"""Ciasteczko → login zalogowanej osoby albo None."""
from app import session
dane = session.read(request.cookies.get(session.COOKIE))
if dane is None:
return None
spec = accounts().get(dane["login"])
if spec is None or session.fingerprint(spec) != dane["odcisk"]:
return None
return dane["login"]
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 cookies_secure() -> bool:
"""Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http —
inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa"
bez żadnego komunikatu."""
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
def audit_login(request: Request, login: str, udane: bool) -> None:
"""Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza
nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak,
hasło NIGDY."""
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
"logowanie-ok" if udane else "logowanie-odmowa",
login or "-", client_ip(request))
def audit_logout(request: Request) -> None:
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
getattr(request.state, "user", "") or "-", client_ip(request))
def install(app) -> None:
from app import session
_setup_audit_logging()
# Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od
# podrobionych. Lepiej, żeby usługa nie wstała.
if auth_enabled() and not session.available():
raise session.SessionError(
"Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
"podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32")
if not auth_enabled():
log.warning(
"UWAGA: brak kont (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo stoi OTWARTY. "
"Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym "
"zbiorze baz, widząc nawzajem swoje wgrania."
)
@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, odswiez = "", None
if auth_enabled():
user = principal(request) or ""
if not user:
from urllib.parse import quote
cel = request.url.path
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
from app import session as _s
dane = _s.read(request.cookies.get(_s.COOKIE))
odswiez = _s.refresh(dane) if dane else None
request.state.user = user
response = await call_next(request)
if odswiez:
from app import session as _s
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
**_s.cookie_params(secure=cookies_secure()))
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
+190
View File
@@ -0,0 +1,190 @@
"""Sesje logowania — podpisane ciasteczko (LOG-34).
Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi
obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka.
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
w środku, i może tę decyzję cofnąć.
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
o tym — i też nie wymaga magazynu.
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
nie poda licznika, poziomy 1 i 2 nadal działają.
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
dostępu nie odbierałoby dostępu.
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
w PRE-27 i AES w PRE-16.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
# z jednej, logując się do drugiej.
COOKIE = "astrodemo_sesja"
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
REFRESH_AFTER = 300
class SessionError(RuntimeError):
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, "") or default)
except ValueError:
return default
def max_age() -> int:
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
def idle_timeout() -> int:
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
def secret() -> bytes:
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
raw = os.getenv("SESSION_SECRET", "").strip()
if not raw:
raise SessionError(
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
"Wygeneruj: openssl rand -hex 32")
if len(raw) < 32:
raise SessionError(
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
return raw.encode("utf-8")
def available() -> bool:
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
try:
secret()
except SessionError:
return False
return True
def fingerprint(secret_spec: str) -> str:
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
unieważniły jego sesje same z siebie."""
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
def _sign(payload: bytes) -> bytes:
return hmac.new(secret(), payload, hashlib.sha256).digest()
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
"""Nowe ciasteczko sesji dla konta."""
teraz = int(now if now is not None else time.time())
payload = json.dumps(
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def read(token: str | None, *, now: float | None = None) -> dict | None:
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
if not token or "." not in token:
return None
surowy, _, podpis = token.rpartition(".")
try:
payload = _unb64(surowy)
oczekiwany = _sign(payload)
podany = _unb64(podpis)
except (ValueError, SessionError):
return None
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
if not hmac.compare_digest(oczekiwany, podany):
return None
try:
dane = json.loads(payload.decode("utf-8"))
login, odcisk = str(dane["u"]), str(dane["f"])
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
except (ValueError, KeyError, TypeError):
return None
teraz = int(now if now is not None else time.time())
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
# w obu wypadkach nie ma powodu mu ufać.
if wyd > teraz + 60:
return None
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
return None
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
def refresh(dane: dict, *, now: float | None = None) -> str | None:
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
Set-Cookie — także po arkusze stylów i obrazki."""
teraz = int(now if now is not None else time.time())
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
return None
payload = json.dumps(
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
"wyd": dane["wydano"], "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def cookie_params(secure: bool = True) -> dict:
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
+63
View File
@@ -0,0 +1,63 @@
/* Arkusz CELOWO minimalny: dwa formularze i tabela wyników. Każda klasa ponad to
niosłaby nazwę funkcji, której 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);
display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.kto { display: flex; align-items: center; gap: .5rem; }
.whoami { color: var(--muted); font-size: .85rem; }
.wyloguj-form { display: inline; margin: 0; }
button.wyloguj {
background: none; border: 1px solid var(--line); color: var(--muted);
font: inherit; font-weight: 400; padding: .35rem .8rem; border-radius: 8px; cursor: pointer;
}
button.wyloguj:hover { color: var(--fg); }
/* Ekran logowania — wąski, bez niczego poza formularzem. */
.login-card label { display: block; margin-top: .75rem; }
.login-card input { width: 100%; }
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,30 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrodemo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main>
<header class="topbar">
<h1>astrodemo</h1>
{# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach,
więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #}
{% if auth_on() %}
<div class="kto">
<span class="whoami">{{ current_user(request) }}</span>
<form method="post" action="/wyloguj" class="wyloguj-form">
<button type="submit" class="wyloguj">Wyloguj</button>
</form>
</div>
{% endif %}
{# 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,36 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrodemo · logowanie</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="login-main">
<header class="topbar"><h1>astrodemo</h1></header>
{% if request.query_params.get('wylogowano') %}
<p class="ok">Wylogowano.</p>
{% endif %}
{% if blad %}<p class="alert">{{ blad }}</p>{% endif %}
<form method="post" action="/logowanie" class="card login-card">
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
<input type="hidden" name="dokad" value="{{ dokad }}">
<label>Login
<input type="text" name="login" value="{{ login or '' }}" required
autocomplete="username" autofocus></label>
<label>Hasło
<input type="password" name="haslo" required autocomplete="current-password"></label>
<div class="actions"><button type="submit">Zaloguj</button></div>
</form>
<p class="muted small login-note">
Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała
Ci dostęp — kont nie da się odzyskać samodzielnie.
</p>
</main>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
+11
View File
@@ -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
+301
View File
@@ -0,0 +1,301 @@
"""astrodemo — 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
from app import security
@pytest.fixture()
def env(monkeypatch):
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB")
monkeypatch.delenv("ASTRODEMO_USER", raising=False)
monkeypatch.delenv("ASTRODEMO_PASSWORD", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
def _auth(user="demo", password=""):
"""Nagłówek z WAŻNĄ SESJĄ — odpowiednik bycia zalogowanym.
Po przejściu z Basic na sesje (LOG-34) „zalogowany" znaczy „ma podpisane
ciasteczko", nie „ma nagłówek z hasłem". Hasło przyjmowane tylko po to, żeby
nie przepisywać wszystkich wywołań — sprawdzanie poświadczeń ma własne testy."""
from app import security, session
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
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, upload=None):
"""Podmieniamy METODY KLASY, nie obiekt modułowy: klient jest budowany per
żądanie, żeby pula wynikała z zalogowanego konta. Dzięki temu podglądamy
też `self.tenant`, czyli to, do której puli faktycznie poszło zapytanie."""
from starlette.testclient import TestClient
from app.clients.logic_client import LogicClient
from app.main import app
calls = calls if calls is not None else []
domyslny = {"path": "nowa.xlsx", "name": "nowa.xlsx", "accepted": True}
monkeypatch.setattr(LogicClient, "report",
lambda self, **kw: (calls.append(("report", self.tenant, kw)), REPORT)[1])
monkeypatch.setattr(LogicClient, "files_upload",
lambda self, *a, **kw: (calls.append(("upload", self.tenant, a)),
upload or domyslny)[1])
monkeypatch.setattr(LogicClient, "files_status",
lambda self, *a, **kw: (calls.append(("status", self.tenant, a)), {})[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"),
("GET", "/logowanie"), ("POST", "/logowanie"), ("POST", "/wyloguj")}
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")):
r = getattr(c, method)(path, follow_redirects=False)
assert r.status_code == 303, path
assert "/logowanie" in r.headers["location"], 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 astrodemo 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 security.verify("astrololo", "haslo-glownej-aplikacji") is None
assert security.verify("demo", "tajne-demo") == "demo"
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
monkeypatch.setenv("ASTRODEMO_USERS", "demo:zażółć-gęślą")
c, _ = _client(monkeypatch)
# ── 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][2][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
c, calls = _client(monkeypatch, upload={"path": "zla.xlsx", "name": "zla.xlsx",
"accepted": False})
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][2]["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.clients.logic_client import LogicClient
c, _ = _client(monkeypatch)
monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw(
httpx.ConnectError("brak połączenia")))
r = c.post("/", headers=_auth(), data=FORM)
# Komunikat jest CELOWO nieinformacyjny: treść wyjątku niosła nazwę usługi,
# jej port i nazwy tras, czyli mapę tego, co stoi za tym ekranem.
assert r.status_code == 200
assert "Nie udało się teraz wykonać tej operacji" in r.text
assert "Warstwa logiczna" not in r.text and "8001" not 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"
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
# Demo jest udostępniane szeroko i różnym osobom, więc pula musi wynikać
# z tego, KTO SIĘ ZALOGOWAŁ — i z niczego innego. Wyciek byłby cichy: klient
# zobaczyłby cudze bazy jako własne.
def test_the_pool_is_taken_from_the_login(env, monkeypatch):
c, calls = _client(monkeypatch)
c.post("/", headers=_auth("klientA", "hasloA"), data=FORM)
c.post("/", headers=_auth("klientB", "hasloB"), data=FORM)
assert [k[1] for k in calls] == ["klientA", "klientB"]
def test_uploads_go_to_the_pool_of_the_account_that_sent_them(env, monkeypatch):
c, calls = _client(monkeypatch)
c.post("/plik", headers=_auth("klientA", "hasloA"), follow_redirects=False,
files={"upload": ("baza.xlsx", b"x", "application/vnd.ms-excel")})
assert {k[1] for k in calls} == {"klientA"}, "wgranie trafiło do cudzej puli"
def test_the_pool_cannot_be_chosen_from_the_form(env, monkeypatch):
"""Gdyby pula przychodziła z żądania, wystarczyłoby podstawić cudzy login."""
c, calls = _client(monkeypatch)
c.post("/", headers=_auth("klientA", "hasloA"),
data={**FORM, "tenant": "klientB", "user": "klientB", "login": "klientB"})
assert [k[1] for k in calls] == ["klientA"], "pula dała się podmienić z formularza"
def test_every_account_has_its_own_pool_by_construction(env, monkeypatch):
"""Każdy login = inna pula. Dwa konta nie mogą dzielić zbioru baz."""
c, calls = _client(monkeypatch)
for login, haslo in (("demo", "tajne-demo"), ("klientA", "hasloA"), ("klientB", "hasloB")):
c.post("/", headers=_auth(login, haslo), data=FORM)
pule = [k[1] for k in calls]
assert len(set(pule)) == len(pule) == 3
def test_an_unknown_account_gets_nothing(env, monkeypatch):
"""Bez ważnej sesji żądanie nie schodzi niżej — nie wystarczy, że skończy
się przekierowaniem: nie może po drodze dotknąć warstwy logicznej."""
c, calls = _client(monkeypatch)
r = c.post("/", data=FORM, follow_redirects=False)
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
assert calls == [], "poszło zapytanie mimo braku sesji"
def test_logging_in_and_out_of_the_demo(env, monkeypatch):
"""Pełny obieg: demo bywa używane na cudzych komputerach, więc wyjście musi
naprawdę kończyć sesję, a nie tylko tak wyglądać."""
from app import session
c, _ = _client(monkeypatch)
r = c.post("/logowanie", follow_redirects=False,
data={"login": "klientA", "haslo": "hasloA", "dokad": "/"})
assert r.status_code == 303 and session.COOKIE in r.cookies
wyjscie = c.post("/wyloguj", headers=_auth("klientA"), follow_redirects=False)
assert wyjscie.status_code == 303
ustawione = wyjscie.headers.get("set-cookie", "")
assert session.COOKIE in ustawione and "Max-Age=0" in ustawione
def test_removing_an_account_kills_its_open_session(env, monkeypatch):
"""Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi
się przez ASTRODEMO_USERS — usunięcie albo zmiana hasła ubija sesję odciskiem."""
c, _ = _client(monkeypatch)
ciasteczko = _auth("klientA")
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
def test_the_demo_login_cannot_be_turned_into_a_redirector(env, monkeypatch):
c, _ = _client(monkeypatch)
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x"):
r = c.post("/logowanie", follow_redirects=False,
data={"login": "demo", "haslo": "tajne-demo", "dokad": zly})
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
@@ -0,0 +1,192 @@
"""Zapora słownikowa astrodemo: czego użytkownik demo nie może zobaczyć.
astrodemo stoi najniżej w drabinie trzech produktów (astrodemo → astroklient →
astrololo) i jest dawane najszerzej. Ma dwie funkcje i ma wyglądać, jakby to
było wszystko, co program potrafi.
Test nie sprawdza pojedynczych miejsc, tylko przechodzi WSZYSTKO, co da się
pobrać, i szuka słów, które nie mają prawa paść. Sprawdza też DRZEWO PLIKÓW
usługi — bo „nie ma śladu w kodzie" znaczy, że `grep` po obrazie ma nic nie
znaleźć, a nie tylko że odpowiedź jest czysta.
Powód, dla którego to powstało: komentarz w pierwszej linii styles.css mówił
„Nie kopiujemy stylów pełnej aplikacji", a arkusz pobierał anonim.
"""
import pathlib
import re
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
# (a) że istnieje wersja obszerniejsza i jak się nazywa
WIEKSZY_BRAT = ["pełna aplikacja", "pełnej aplikacji", "pełna wersja", "pełnej wersji",
"astrololo", "astroklient", "wersji podstawowej", "upgrade", "rozszerzon"]
# (b) że tekst pisze model językowy
# Bez samego „token": w tej usłudze znaczy token sesji i token międzywarstwowy,
# a nie jednostkę rozliczeniową modelu. Zamiast tego zwroty jednoznaczne.
MODEL = ["LLM", "anthropic", "openai", "gpt", "claude", "prompt",
"generowanie tekstu", "sztuczn", "okno kontekstu", "model językow"]
# (c) funkcje, których astrodemo nie ma
INNE_FUNKCJE = ["kosmogram", "aspektarian", "synastri", "kalendarz", "predykcj",
"skompiluj", "system domów", "systemy domów", "deklinacj", "antyscj"]
# (d) że jest ktoś, kto widzi więcej, i że produkt jest w budowie
MECHANIZMY = ["administrator", "uprawnieni", "walidacj", "kwarantann", "roadmap",
"w budowie", "widok testowy", "warstwa logiczna", "layer"]
# Dwa poziomy, bo dwie różne drogi wycieku.
#
# WSZĘDZIE — także w kodzie serwera, którego przeglądarka nigdy nie zobaczy.
# Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie, przeczyta też
# komentarze i docstringi. Zdanie „w pełnej aplikacji to działa inaczej" mówi mu
# wprost, że istnieje pełna aplikacja — a to jest dokładnie to, czego ma nie
# wiedzieć. Komentarz ma tłumaczyć TĘ usługę, bez powoływania się na rodzeństwo.
WSZEDZIE = WIEKSZY_BRAT + MODEL + INNE_FUNKCJE
# TYLKO DO PRZEGLĄDARKI — słownictwo inżynierskie opisujące własne działanie tej
# usługi. W komentarzu serwera jest na miejscu; w odpowiedzi HTTP nie ma czego
# szukać, bo mówi o mechanizmach, o których użytkownik nie ma wiedzieć.
DO_PRZEGLADARKI = WSZEDZIE + MECHANIZMY
ZAKAZANE = DO_PRZEGLADARKI
WZORCE = [(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania"),
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
(r"(?i)\bAI\b", "model językowy")]
def _grzechy(tresc: str, gdzie: str, slownik: list[str] | None = None) -> list[str]:
out = []
for igla in (slownik if slownik is not None else ZAKAZANE):
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
out.append(f"{gdzie}: „{igla}")
for wzor, powod in (WZORCE if slownik is None else WZORCE[-1:]):
m = re.search(wzor, tresc)
if m:
out.append(f"{gdzie}: „{m.group(0)}” — {powod}")
return out
@pytest.fixture()
def klient(monkeypatch):
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo")
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
monkeypatch.setenv("COOKIES_INSECURE", "1")
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
from starlette.testclient import TestClient
from app.main import app
return TestClient(app, follow_redirects=False)
def test_nothing_forbidden_reaches_an_anonymous_visitor(klient):
grzechy, obejrzane = [], 0
for adres in ("/logowanie", "/health", "/static/styles.css"):
r = klient.get(adres)
assert r.status_code == 200, f"{adres}: {r.status_code}"
obejrzane += 1
grzechy += _grzechy(r.text, adres)
assert obejrzane == 3
assert not grzechy, "\n" + "\n".join(grzechy)
def test_nothing_forbidden_reaches_a_logged_in_visitor(klient, monkeypatch):
from app import security, session
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
# Warstwa logiczna niedostępna CELOWO: ekran idzie wtedy ścieżką błędu,
# a to komunikaty o awarii wypisywały nazwy warstw i tras.
import httpx
from app.main import LogicClient
monkeypatch.setattr(LogicClient, "report",
lambda *a, **k: (_ for _ in ()).throw(httpx.ConnectError("nie da rady")))
grzechy = []
for adres in ("/", "/static/styles.css"):
r = klient.get(adres, headers=naglowki)
assert r.status_code == 200, f"{adres}: {r.status_code}"
grzechy += _grzechy(r.text, adres)
r = klient.post("/", headers=naglowki,
data={"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94"})
grzechy += _grzechy(r.text, "POST /")
assert not grzechy, "\n" + "\n".join(grzechy)
# Stałe protokołu łącza (nagłówki, typ treści, etykieta HKDF) niosą nazwę rodziny
# produktów, ale są WSPÓLNE z warstwą logiczną: zmiana wymaga jednoczesnej zmiany
# we wszystkich usługach i rotacji, więc to osobna decyzja. Nie idą do przeglądarki.
BEZ_KONTROLI = {"app/link_crypto.py"}
def test_the_source_tree_itself_carries_no_trace():
"""„Bez śladu w kodzie" znaczy: grep po obrazie ma nic nie znaleźć.
Odpowiedź może być czysta, a plik i tak nieść komentarz — dokładnie tak
wyciekał arkusz stylów, zanim komentarze zaczęły być zdejmowane."""
# Identyfikatory wymagań (PRE-28, LOG-34) zostają: to notatki inżynierskie
# o tej usłudze, nie informacja o istnieniu czegoś większego.
grzechy = []
for f in APP.rglob("*"):
wzgledna = str(f.relative_to(APP.parent))
if (not f.is_file() or "__pycache__" in str(f) or f.suffix == ".pyc"
or wzgledna in BEZ_KONTROLI):
continue
try:
grzechy += _grzechy(f.read_text(encoding="utf-8"),
str(f.relative_to(APP.parent)), WSZEDZIE)
except UnicodeDecodeError:
continue
assert not grzechy, "\n" + "\n".join(grzechy)
def test_an_unknown_asset_is_indistinguishable_from_one_that_never_existed(klient):
from app import security, session
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
nieistniejacy = klient.get("/static/nie-ma-takiego.js", headers=naglowki)
for adres in ("/static/main.py", "/static/../app/config.py", "/static/vendor/cokolwiek.js"):
r = klient.get(adres, headers=naglowki)
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
assert r.text == nieistniejacy.text
def test_the_protocol_constants_are_the_one_known_exception():
"""Świadomie zostawiona dziura — opisana, żeby nie była przypadkiem.
Stałe łącza międzywarstwowego (`X-Astrololo-Token`, `X-Astrololo-Enc`, typ
treści, etykieta HKDF) niosą nazwę rodziny produktów. Są WSPÓLNE z warstwą
logiczną, więc zmiana wymaga jednoczesnej podmiany we wszystkich usługach
i rotacji sekretów — to osobna decyzja, nie efekt uboczny tej zmiany.
Warunek, pod jakim to zostaje: te stałe nie mają prawa dotrzeć do
przeglądarki. Idą wyłącznie w nagłówkach żądań DO warstwy logicznej.
Ten test pilnuje właśnie tego warunku, a nie samego istnienia stałych.
"""
rdzen = "astrololo"
protokol = [f for f in APP.rglob("*.py")
if f.is_file() and rdzen in f.read_text(encoding="utf-8").lower()]
assert protokol, "stałe zniknęły — jeśli je przemianowano, ten wyjątek jest zbędny"
for f in protokol:
tresc = f.read_text(encoding="utf-8").lower()
for linia in tresc.splitlines():
if rdzen not in linia:
continue
# Każde wystąpienie musi być stałą protokołu albo nazwą kanału logów —
# nigdy tekstem, który mógłby trafić do szablonu.
assert any(z in linia for z in ("x-astrololo", "vnd.astrololo",
"astrololo/link/", 'getlogger("astrololo')), \
f"{f.name}: „{linia.strip()[:70]}” to nie jest stała protokołu"
def test_only_the_application_code_ships_in_the_image():
"""Obraz nie może wieźć tego testu.
Ironia, którą łatwo przeoczyć: plik pilnujący, żeby pewne słowa nie padły,
sam je wypisuje — więc gdy trafi do obrazu, staje się tym wyciekiem, przed
którym broni. `COPY . .` wnosiło cały katalog usługi, razem z tests/.
"""
# Same instrukcje, bez komentarzy: komentarz obok tej linii CYTUJE dawną
# postać, więc szukanie po całym pliku zgłaszałoby własne wyjaśnienie.
instrukcje = [l for l in (APP.parent / "Dockerfile").read_text(encoding="utf-8").splitlines()
if l.strip() and not l.lstrip().startswith("#")]
assert not any(l.strip() == "COPY . ." for l in instrukcje), "obraz wnosi wszystko, w tym testy"
assert any(l.strip() == "COPY app ./app" for l in instrukcje)
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim
WORKDIR /app
COPY services/astroklient/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Rdzeń wspólny z pełną aplikacją: ekrany astrologiczne, szablony, zasoby.
# Jedno źródło, dwa produkty — inaczej te same 2500 linii szablonów żyłyby
# w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu.
COPY services/presentation/app ./app
# …minus wszystko, czego ten produkt nie ma. Lista jest DANYMI, a nie tekstem
# w tym pliku: czyta ją także test, który pilnuje, żeby zgadzała się ze
# złożeniem w main.py. Rozjazd między nimi znaczyłby albo martwy kod w obrazie,
# albo brakujący plik i błąd dopiero przy uruchomieniu.
COPY services/astroklient/usun.txt .
RUN while read -r SCIEZKA; do \
[ -n "$SCIEZKA" ] || continue; \
test -e "app/$SCIEZKA" || { echo "usun.txt wskazuje nieistniejące: $SCIEZKA"; exit 1; }; \
rm -rf "app/$SCIEZKA"; \
done < usun.txt && rm usun.txt
# Nakładka: własne logowanie, własna pula plików, własny ekran wgrywania,
# własne złożenie. Nadpisuje pliki o tych samych nazwach.
COPY services/astroklient/app ./app
EXPOSE 8006
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8006"]
@@ -0,0 +1,174 @@
"""Klient warstwy logicznej — z PULĄ KONTA (PRE-29).
Każde konto ma własny, niewidoczny dla pozostałych zbiór plików. Pula jedzie
w każdym żądaniu w dół i bierze się WYŁĄCZNIE z kontekstu ustawianego przy
wejściu, nigdy z pola formularza: gdyby przychodziła z żądania, wystarczyłoby
podstawić cudzy login, żeby czytać cudze pliki.
Kontekst zamiast argumentu, bo ekrany są wspólne z pełną aplikacją i wołają
klienta jako obiekt modułowy. Przekazywanie puli przez wszystkie wywołania
znaczyłoby, że pominięcie jednego z nich cicho otwiera dostęp do całego udziału —
a takiego pominięcia nie widać.
"""
from __future__ import annotations
import contextvars
import os
from typing import Any
import httpx
# Login zalogowanego konta na czas obsługi żądania. Ustawia go bramka wejścia.
KONTO: contextvars.ContextVar[str] = contextvars.ContextVar("konto", default="")
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
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 _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
# Pula konta dokładana do KAŻDEGO żądania w dół. Bierze się z kontekstu
# ustawianego przy wejściu, nigdy z pola formularza — gdyby przychodziła
# z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze pliki.
# Trasy, których pula nie dotyczy, po prostu ignorują nadmiarowe pole.
payload = {**payload, "tenant": KONTO.get("")}
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
with httpx.Client(timeout=timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(),
link=_link())
# ── transport dla modułów opcjonalnych ─────────────────────────────────
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
return self._post(sciezka, payload, timeout)
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
headers=_auth_headers(), link=_link())
def strumien(self, sciezka: str, payload: dict[str, Any]):
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}{sciezka}",
payload=payload, headers=_auth_headers(), link=_link())
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
return self._post("/api/query", payload, settings.http_timeout)
def positions(
self,
when_utc_iso: str,
lat: float,
lon: float,
objects: list[str] | None = None,
house_system: str = "whole_sign",
stations: bool = False,
zodiac: str = "tropical",
tables: bool = False,
house_systems: list[str] | None = None,
aspect_orb: float = 8.0,
aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
"when_utc": when_utc_iso,
"lat": lat,
"lon": lon,
"objects": objects,
"house_system": house_system,
"house_systems": house_systems,
"aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
"stations": stations,
"zodiac": zodiac,
"tables": tables,
}
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
return self._post("/chart/positions", payload, timeout)
def synastry(
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
) -> dict[str, Any]:
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
payload = {
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
}
return self._post("/chart/synastry", payload, settings.http_timeout)
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
def bases(self) -> dict[str, Any]:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/upload",
{"filename": filename, "content_b64": content_b64, "by": by})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
payload = {**payload, "tenant": KONTO.get("")}
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
def timeline(
self, when_utc_iso: str, lat: float, lon: float,
from_date: str, to_date: str, interpret: bool = True,
) -> dict[str, Any]:
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
payload = {
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "interpret": interpret,
}
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
@@ -0,0 +1,75 @@
"""Wgrywanie plików: jedna czynność, nie dwie.
„Dodać plik" ma tu znaczyć, że od razu bierze udział w wyszukiwaniu — inaczej po
wgraniu nic by się nie zmieniło i wyglądałoby to na zepsute. Nie ma listy plików
ani ich stanów: ta usługa ma jeden poziom dostępu, więc nie ma komu ich
pokazywać ani po co.
"""
from __future__ import annotations
import base64
import httpx
from fastapi import File, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import app, features, logic, templates, _logic_error
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
# tutaj wprost, bo ta usługa tamtej warstwy nie importuje; literówka objawiłaby
# się dopiero na żywo, więc pilnuje jej test.
W_UZYCIU = "active"
@app.get("/pliki", response_class=HTMLResponse)
def wgrywanie_form(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "wgrywanie.html",
{"error": error, "done": done})
@app.post("/pliki/wgraj")
async def wgraj(request: Request, upload: UploadFile = File(...)):
"""Wgranie ORAZ włączenie do użytku.
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
zapisany i czeka. Komunikat nie mówi, CZYM się nie nadaje: różnica w treści
byłaby wyrocznią, z której da się odczytać, czego program oczekuje.
"""
raw = await upload.read()
if not raw:
return _wroc(error="Pusty plik.")
kto = getattr(request.state, "user", "") or "?"
try:
out = logic.files_upload(upload.filename or "dane.xlsx",
base64.b64encode(raw).decode("ascii"), by=kto)
except httpx.HTTPError as e:
return _wroc(error=_logic_error(e))
nazwa = out.get("name") or "plik"
if not out.get("accepted"):
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"], W_UZYCIU, by=kto)
except httpx.HTTPError as e:
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć.")
return _wroc(done=f"Dodano „{nazwa}”. Plik jest już używany 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"/pliki{'?' + q if q else ''}", status_code=303)
features.zarejestruj(
ekran=Feature("files", "Pliki", "Dodawanie plików używanych przy wyszukiwaniu.",
"Ekrany", "/pliki", kolejnosc=70),
trasy={("GET", "/pliki"): "files", ("POST", "/pliki/wgraj"): "files"},
zasoby={"s-wgrywanie.css": "files"},
)
+34
View File
@@ -0,0 +1,34 @@
"""Astroklient — ZŁOŻENIE produktu.
Pełne funkcje astrologiczne, bez generowania tekstu i bez administracji. Ten plik
nie zawiera żadnej trasy: odpowiada wyłącznie na pytanie, z czego składa się ten
produkt. Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją.
Poza tą listą nie ma nic. Nie chodzi o funkcje wyłączone: plików, których tu nie
widać, nie ma w obrazie (patrz usun.txt), więc nie istnieje uprawnienie, którym
dałoby się cokolwiek odsłonić — ani nazwa, po której dałoby się zgadnąć, czego
szukać.
"""
from __future__ import annotations
from app import rozszerzenia
from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error, app,
logic, static_url, templates)
from app.ekrany import ( # noqa: F401 - import rejestruje trasy
horoskop,
interpretacje,
kalendarz,
logowanie,
lokalizacja,
sygnifikatory,
synastria,
wgrywanie,
)
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
# Most modułów opcjonalnych zostaje CELOWO. W tym obrazie nie ma katalogu
# `app/moduly/`, więc odkrywa zero modułów i wszystkie odpowiedzi są puste —
# aplikacja zachowuje się tak, jakby takiego pojęcia nigdy nie było.
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
buduj_utc=_build_utc, blad=_logic_error)
+8
View File
@@ -0,0 +1,8 @@
"""Nazwa produktu — nakładka astroklienta.
Nadpisuje wersję z rdzenia. Test słownikowy pilnuje, żeby po nadpisaniu nazwa
tamtego produktu nie została nigdzie w szablonach, zasobach ani w odpowiedziach.
"""
from __future__ import annotations
NAZWA = "astroklient"
+384
View File
@@ -0,0 +1,384 @@
"""Logowanie i bramka uprawnień astroklienta.
Konta pochodzą WYŁĄCZNIE z konfiguracji środowiska (`ASTROKLIENT_USERS`), a każde
dostaje ten sam zestaw: wszystko, co ta usługa umie. Konta rozdziela się po to,
żeby każde miało własną, niewidoczną dla pozostałych pulę plików — nie po to, żeby
dawać im różne poziomy dostępu. Poziom jest jeden i nie ma go z czym porównywać.
Reszta jest wspólna z pozostałymi usługami i celowo nietknięta: hasła jako scrypt,
sesja w podpisanym ciasteczku, limit żądań, dziennik audytowy, oraz odmowa jako
404 zamiast 403 — bo 403 samo w sobie mówi, że coś tam jest.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import logging
import os
import secrets
import time
from collections import deque
from dataclasses import dataclass
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astrololo.security")
MAX_TRACKED_CLIENTS_DEFAULT = 4096
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
# ── konta ─────────────────────────────────────────────────────────────────
# Wyłącznie z konfiguracji środowiska. Konta rozdziela się po to, żeby każde
# miało własną pulę plików — nie po to, żeby różniły się tym, co widzą.
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$"
def hash_password(password: str, salt: bytes | None = None) -> str:
"""Hasło → wpis do ASTROKLIENT_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
salt = salt or secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
def accounts() -> dict[str, str]:
"""Konta z `ASTROKLIENT_USERS` — `login:sekret`, po przecinku.
Sekret to hash scrypt (`scrypt$sól$hash`), ten sam format co w pozostałych
usługach — jedna implementacja, więc nie ma czego rozjechać."""
out: dict[str, str] = {}
for entry in os.getenv("ASTROKLIENT_USERS", "").split(","):
entry = entry.strip()
if not entry:
continue
user, sep, secret = entry.partition(":")
if sep and user.strip() and secret.strip():
out[user.strip()] = secret.strip()
return out
def _secret_matches(spec: str, password: str) -> bool:
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
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)
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
def cookies_secure() -> bool:
"""Czy ciasteczko sesji ma być wyłącznie dla https.
Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej
przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa"
bez żadnego komunikatu, co jest wyjątkowo mylącą awarią."""
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego
# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
# jedynym publicznym zasobem — i dlatego nie wolno w nim trzymać niczego, co
# nazywa funkcję (patrz features.STATIC).
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/base.css"})
# Pusto CELOWO. Dopóki był tu "/static/", cały katalog omijał bramkę: każdy plik
# JS i arkusz stylów pobierał ktokolwiek, także niezalogowany, pod zgadywalnym
# adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie baz.
# Ruch do zasobów nie trafiał przy tym ani do dziennika, ani pod limit żądań,
# więc wyciek był dla właściciela niewidoczny.
PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(accounts())
def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
@dataclass(frozen=True)
class Principal:
"""Kto się zalogował i co wolno mu zobaczyć."""
login: str
permissions: frozenset[str]
is_admin: bool
def _uprawnienia() -> frozenset[str]:
"""Każde konto dostaje wszystko, co ta usługa umie — liczone z KATALOGU.
Katalog składa się ze zgłoszeń ekranów obecnych w obrazie, więc ten zbiór
opisuje dokładnie ten produkt, a nie listę wpisaną kiedyś ręcznie. Dopisanie
ekranu nie wymaga pamiętania o niczym; usunięcie go zabiera uprawnienie samo.
Nie ma tu uprawnienia administracyjnego i nie ma go skąd wziąć: `features.ADMIN`
celowo nie należy do GRANTABLE."""
from app import features
return frozenset(features.GRANTABLE)
def _sekret(login: str) -> str | None:
return accounts().get(login)
def verify(login: str, password: str) -> Principal | None:
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
konta istnieją, a to informacja warta czegoś dla zgadującego."""
spec = _sekret(login)
if spec is None or not _secret_matches(spec, password):
return None
return Principal(login, _uprawnienia(), False)
def issue_session(login: str) -> str:
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
i bez żadnego magazynu sesji."""
from app import session
# Wersja sesji zawsze 0: nie ma trwałego magazynu, więc nie ma gdzie trzymać
# licznika pokolenia. Wylogowanie i zmiana hasła i tak działają — pierwsze
# przez skasowanie ciasteczka, drugie przez odcisk, który przestaje pasować.
return session.issue(login, session.fingerprint(_sekret(login) or ""), 0)
def principal(request: Request) -> Principal | None:
"""Ciasteczko → kto to jest. None = nie wpuszczać."""
from app import session
dane = session.read(request.cookies.get(session.COOKIE))
if dane is None:
return None
spec = _sekret(dane["login"])
if spec is None:
return None
# Zmiana hasła w konfiguracji ubija sesje tego konta — to jest tutaj jedyny
# sposób na „wyloguj mnie ze wszystkich urządzeń", bo bez trwałego magazynu
# nie ma gdzie trzymać licznika pokolenia sesji.
if session.fingerprint(spec) != dane["odcisk"]:
return None
return Principal(dane["login"], _uprawnienia(), False)
def client_ip(request: Request) -> str:
"""Adres, po którym rozliczamy limit żądań.
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
wyklika limit, odcięłaby pozostałe.
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
klient, więc nie znaczą nic.
"""
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() # prosty reset zamiast nieograniczonego wzrostu
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
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
audit_log = logging.getLogger("astrololo.audit")
def _setup_audit_logging() -> None:
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń.
"""
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:
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził",
a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
try:
request.state.audit_records = int(count)
except (TypeError, ValueError):
pass
def audit_login(request: Request, login: str, udane: bool) -> None:
"""Zdarzenie logowania w dzienniku (PRE-17).
Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale
seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy
LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku."""
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
"logowanie-ok" if udane else "logowanie-odmowa",
login or "-", client_ip(request))
def audit_logout(request: Request) -> None:
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
getattr(request.state, "user", "") or "-", client_ip(request))
def _audit(request: Request, user: str, status: int, ms: float) -> None:
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,
status, "-" if records is None else records, ms,
)
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza publicznymi."""
from app import session
_setup_audit_logging()
# FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić
# ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała —
# i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka.
if auth_enabled() and not session.available():
raise session.SessionError(
"Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
"podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. "
"Wygeneruj: openssl rand -hex 32")
if not auth_enabled():
log.warning(
"UWAGA: brak kont (ASTROKLIENT_USERS) — usługa jest OTWARTA dla "
"każdego, kto ma do niej dostęp sieciowy, wraz z treścią plików."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if _is_public(request.url.path):
return await call_next(request)
started = time.monotonic()
if _rate_limited(client_ip(request)):
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
return JSONResponse(
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"},
)
from app import features
user, who, odswiez = "", None, None
if auth_enabled():
who = principal(request)
if who is None:
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
# Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce.
from urllib.parse import quote
cel = request.url.path
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
user = who.login
# Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy
# znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy
# każdym obrazku i arkuszu stylów.
from app import session as _s
dane = _s.read(request.cookies.get(_s.COOKIE))
odswiez = _s.refresh(dane) if dane else None
else:
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
who = Principal("", _uprawnienia(), False)
request.state.user = user
request.state.principal = who
# Pula plików na czas obsługi tego żądania. Ustawiana TU, w jednym
# miejscu, zaraz po ustaleniu kto to jest — klient czyta ją z kontekstu,
# więc żadne wywołanie w dół nie może o niej zapomnieć.
from app.clients.logic_client import KONTO
KONTO.set(user)
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
need = features.required(request.method, request.url.path)
if not features.can(who.permissions, need):
_audit(request, user, 404, (time.monotonic() - started) * 1000)
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
response = await call_next(request)
if odswiez:
from app import session as _s
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
**_s.cookie_params(secure=cookies_secure()))
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
return response
+186
View File
@@ -0,0 +1,186 @@
"""Sesje logowania — podpisane ciasteczko (LOG-34).
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
w środku, i może tę decyzję cofnąć.
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
o tym — i też nie wymaga magazynu.
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
nie poda licznika, poziomy 1 i 2 nadal działają.
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
dostępu nie odbierałoby dostępu.
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
w PRE-27 i AES w PRE-16.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
# z jednej, logując się do drugiej.
COOKIE = "astroklient_sesja"
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
REFRESH_AFTER = 300
class SessionError(RuntimeError):
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, "") or default)
except ValueError:
return default
def max_age() -> int:
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
def idle_timeout() -> int:
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
def secret() -> bytes:
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
raw = os.getenv("SESSION_SECRET", "").strip()
if not raw:
raise SessionError(
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
"Wygeneruj: openssl rand -hex 32")
if len(raw) < 32:
raise SessionError(
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
return raw.encode("utf-8")
def available() -> bool:
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
try:
secret()
except SessionError:
return False
return True
def fingerprint(secret_spec: str) -> str:
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
unieważniły jego sesje same z siebie."""
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
def _sign(payload: bytes) -> bytes:
return hmac.new(secret(), payload, hashlib.sha256).digest()
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
"""Nowe ciasteczko sesji dla konta."""
teraz = int(now if now is not None else time.time())
payload = json.dumps(
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def read(token: str | None, *, now: float | None = None) -> dict | None:
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
if not token or "." not in token:
return None
surowy, _, podpis = token.rpartition(".")
try:
payload = _unb64(surowy)
oczekiwany = _sign(payload)
podany = _unb64(podpis)
except (ValueError, SessionError):
return None
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
if not hmac.compare_digest(oczekiwany, podany):
return None
try:
dane = json.loads(payload.decode("utf-8"))
login, odcisk = str(dane["u"]), str(dane["f"])
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
except (ValueError, KeyError, TypeError):
return None
teraz = int(now if now is not None else time.time())
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
# w obu wypadkach nie ma powodu mu ufać.
if wyd > teraz + 60:
return None
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
return None
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
def refresh(dane: dict, *, now: float | None = None) -> str | None:
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
Set-Cookie — także po arkusze stylów i obrazki."""
teraz = int(now if now is not None else time.time())
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
return None
payload = json.dumps(
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
"wyd": dane["wydano"], "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def cookie_params(secure: bool = True) -> dict:
"""Parametry ciasteczka wspólne dla ustawiania i kasowania.
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
@@ -0,0 +1,4 @@
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 1rem 1.1rem; margin-top: .4rem; }
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% set nav_active = "files" %}
{% block title %}Pliki{% endblock %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-wgrywanie.css') }}">
{% endblock %}
{% block content %}
{# Opis czysto operacyjny: co robi wgranie, i nic o tym, do CZEGO te dane służą
ani co się z nimi dzieje dalej. #}
<p class="muted">Wgrany plik jest od razu używany przy wyszukiwaniu.</p>
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Wgraj plik</div>
<form method="post" action="/pliki/wgraj" enctype="multipart/form-data" class="card">
<div class="grid">
<label>Plik <input type="file" name="upload" required></label>
</div>
<div class="actions"><button type="submit">Wgraj</button></div>
</form>
{% endblock %}
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
+14
View File
@@ -0,0 +1,14 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
# bez systemowej bazy stref.
tzfpy>=0.15
tzdata>=2024.1
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
openpyxl>=3.1
+94
View File
@@ -0,0 +1,94 @@
"""Drzewo astroklienta budowane tak samo, jak buduje je Dockerfile.
Ta usługa nie ma własnego kompletu plików: bierze rdzeń z warstwy prezentacji,
usuwa z niego to, czego ten produkt nie ma (usun.txt), i nakłada własne. Testy
muszą patrzeć na WYNIK tego złożenia, a nie na katalog `app/` w repozytorium —
inaczej sprawdzałyby garść plików, a nie produkt.
"""
from __future__ import annotations
import json
import pathlib
import re
import shutil
import subprocess
import sys
import pytest
USLUGA = pathlib.Path(__file__).resolve().parents[1]
RDZEN = USLUGA.parents[0] / "presentation" / "app"
def zloz(cel: pathlib.Path) -> pathlib.Path:
"""Rdzeń minus usun.txt plus nakładka — dokładnie jak w Dockerfile."""
app = cel / "app"
shutil.copytree(RDZEN, app, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for linia in (USLUGA / "usun.txt").read_text(encoding="utf-8").splitlines():
sciezka = linia.strip()
if not sciezka:
continue
do_usuniecia = app / sciezka
assert do_usuniecia.exists(), f"usun.txt wskazuje nieistniejące: {sciezka}"
shutil.rmtree(do_usuniecia) if do_usuniecia.is_dir() else do_usuniecia.unlink()
shutil.copytree(USLUGA / "app", app, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
return cel
@pytest.fixture(scope="session")
def drzewo(tmp_path_factory) -> pathlib.Path:
return zloz(tmp_path_factory.mktemp("astroklient"))
SONDA = '''
import json, os, hashlib, secrets, re
sol = secrets.token_bytes(16)
dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32)
os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()),
SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0")
from starlette.testclient import TestClient
from app.main import app
from app import security, session, features
from app.clients import logic_client
c = TestClient(app, follow_redirects=False)
H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"}
strona = c.get("/", headers=H).text
zebrane = []
logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1]
c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20",
"tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"})
print("WYNIK:" + json.dumps({
"ekrany": [f.label for f in features.SCREENS],
"uprawnienia": sorted(security._uprawnienia()),
"dziala": {a: c.get(a, headers=H).status_code
for a in ("/", "/interpret", "/timeline", "/synastry",
"/significators", "/pliki")},
"nie_ma": {a: c.get(a, headers=H).status_code
for a in ("/compile", "/settings", "/accounts", "/files",
"/horoscope/stream", "/compile/pdf")},
"nawigacja": re.findall(r"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'),
"pula": (zebrane[0] or {}).get("tenant"),
"nazwa_w_naglowku": (re.search(r"<h1>(.*?)</h1>", strona) or [None, "?"])[1],
"nazwa_w_tytule": (re.search(r"<title>(.*?) ·", strona) or [None, "?"])[1],
"rdzen_w_tresci": "astrololo" in strona.lower(),
"dokumentacja": {a: c.get(a, headers=H).status_code
for a in ("/docs", "/openapi.json", "/redoc")},
"logowanie_dziala": bool(security.verify("klient", "tajne")),
"zle_haslo_odmowa": security.verify("klient", "inne") is None,
}))
'''
@pytest.fixture(scope="module")
def wynik(drzewo):
"""Aplikacja uruchomiona w OSOBNYM PROCESIE.
Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez
modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie
pozytywny."""
r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True,
text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"})
linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None)
assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}"
return json.loads(linia[len("WYNIK:"):])
@@ -0,0 +1,137 @@
"""Po czym astroklienta NIE MA — ani w odpowiedzi, ani w kodzie w obrazie.
Ta usługa dzieli kod z pełną aplikacją, więc granica między produktami nie jest
oczywista z samego patrzenia: przebiega przez listę importów w `main.py` i przez
`usun.txt`. Ten test sprawdza jej SKUTEK na złożonym drzewie — bo tylko skutek
trafia do obrazu, który komuś się oddaje.
„Bez śladu" znaczy tu również bez zaprzeczeń. Zdanie „nie ma tu generowania
tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak
przycisk. Pierwsza wersja tego testu wyłapała trzy moje własne docstringi
wyliczające nieobecne funkcje.
"""
from __future__ import annotations
import pathlib
import re
import pytest
# Funkcje, których ten produkt nie ma — ani ich słownictwo.
GENEROWANIE = ["prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
"anthropic", "openai", "gpt", "claude", "horoscope",
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js"]
NIEOBECNE_EKRANY = ["Skompiluj", "Ustawienia", "Konta i uprawnien", "Eksport plików"]
MECHANIZMY = ["administrator", "kwarantann", "walidacj", "reguły przyjmowania"]
SLOWA = GENEROWANIE + NIEOBECNE_EKRANY + MECHANIZMY
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
def _pliki(drzewo: pathlib.Path):
for f in sorted((drzewo / "app").rglob("*")):
if f.is_file() and f.suffix != ".pyc" and "__pycache__" not in str(f):
yield f
def test_the_shipped_tree_carries_no_trace(drzewo):
grzechy = []
for f in _pliki(drzewo):
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
gdzie = f.relative_to(drzewo)
for slowo in SLOWA:
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
grzechy.append(f"{gdzie}: „{slowo}")
for wzor, powod in WZORCE:
m = re.search(wzor, tresc)
if m:
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
assert not grzechy, (
"\nW drzewie astroklienta padły słowa o funkcjach, których nie ma. "
"`grep` po kontenerze ma nic nie znaleźć:\n " + "\n ".join(grzechy))
def test_the_check_would_notice_if_something_slipped_in(drzewo, tmp_path):
"""Kontrola negatywna: zapora ma coś łapać.
Bez niej test wyżej przechodziłby także wtedy, gdyby lista słów była pusta
albo gdyby przeszukiwanie trafiało w niewłaściwy katalog."""
podrzucony = drzewo / "app" / "podrzucony.py"
podrzucony.write_text("# generowanie tekstu przez model\n", encoding="utf-8")
try:
with pytest.raises(AssertionError):
test_the_shipped_tree_carries_no_trace(drzewo)
finally:
podrzucony.unlink()
def test_the_removal_list_matches_what_is_actually_composed(drzewo):
"""usun.txt i main.py muszą mówić to samo.
Rozjazd między nimi znaczy albo martwy kod w obrazie (moduł usunięty
z importów, ale nie z listy), albo błąd dopiero przy uruchomieniu (plik
usunięty, a wciąż importowany). Jedno i drugie wychodzi za późno.
"""
main = (drzewo / "app" / "main.py").read_text(encoding="utf-8")
zaimportowane = set(re.findall(r"^\s{4}(\w+),$", main, re.M))
obecne = {f.stem for f in (drzewo / "app" / "ekrany").glob("*.py")
if f.stem != "__init__"}
assert zaimportowane == obecne, (
f"ekrany w drzewie: {sorted(obecne)}\n"
f"ekrany importowane: {sorted(zaimportowane)}")
def test_no_optional_module_survived(drzewo):
"""Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero."""
assert not (drzewo / "app" / "moduly").exists()
# ── nazwa produktu, z którego ten produkt powstał ─────────────────────────
# Osobno od listy wyżej, bo granica przebiega gdzie indziej: „astrololo" wolno
# występować w identyfikatorach WEWNĘTRZNYCH (nazwy loggerów, materiał do
# wyprowadzania kluczy, nagłówki między warstwami), a nie wolno w niczym, co
# dociera do przeglądarki. Zakaz na wszystko byłby albo obejściem przez zmianę
# nazw kluczy kryptograficznych, albo martwą regułą wyłączaną przy pierwszej
# okazji.
NAZWA_RDZENIA = "astrololo"
WIDOCZNE_DLA_KLIENTA = ("templates", "static")
def test_the_parent_product_is_not_named_in_anything_the_browser_receives(drzewo):
"""Szablony i zasoby są WSPÓLNE z pełną aplikacją, więc jej nazwa jechała
z nimi do tego obrazu — i astroklient przedstawiał się jako astrololo.
To jest najpoważniejszy z możliwych śladów: mówi klientowi wprost, że trzyma
okrojoną wersję czegoś większego, i podaje frazę, po której da się wyszukać,
czego mu nie dano."""
grzechy = []
for katalog in WIDOCZNE_DLA_KLIENTA:
for f in sorted((drzewo / "app" / katalog).rglob("*")):
if not f.is_file() or "__pycache__" in str(f):
continue
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if NAZWA_RDZENIA in tresc.lower():
grzechy.append(str(f.relative_to(drzewo)))
assert not grzechy, (
f"\nNazwa „{NAZWA_RDZENIA}” pada w plikach, które dostaje "
f"przeglądarka:\n " + "\n ".join(grzechy))
def test_the_rendered_pages_carry_this_products_own_name(wynik):
"""Sprawdzane na ODPOWIEDZI, nie na szablonie: liczy się to, co dociera do
człowieka, a nie to, co jest w pliku przed złożeniem."""
assert wynik["nazwa_w_naglowku"] == "astroklient"
assert wynik["nazwa_w_tytule"] == "astroklient"
assert not wynik["rdzen_w_tresci"], (
"nazwa pełnej aplikacji pada w treści strony oddawanej klientowi")
def test_the_api_documentation_is_not_reachable(wynik):
"""Tytuł aplikacji FastAPI wciąż niesie nazwę rdzenia, więc dokumentacja
OpenAPI byłaby wyciekiem tylnymi drzwiami — nawet gdy szablony są czyste."""
assert all(k == 404 for k in wynik["dokumentacja"].values()), wynik["dokumentacja"]
@@ -0,0 +1,46 @@
"""Astroklient jest produktem, a nie pełną aplikacją z wyłączonymi kawałkami.
Sprawdzamy trzy rzeczy, i wszystkie na ZŁOŻONYM drzewie, nie na kodzie w repo:
czego w nim nie ma, że to, co zostało, działa, i że nie zostało po niczym słowo.
"""
from __future__ import annotations
def test_the_build_starts_at_all(wynik):
assert wynik["dziala"]["/"] == 200
def test_every_astrological_screen_works(wynik):
assert all(k == 200 for k in wynik["dziala"].values()), wynik["dziala"]
def test_what_it_lacks_does_not_exist_rather_than_being_refused(wynik):
"""404, nie 403 i nie 500. Odmowa z powodem jest informacją, że coś tam jest."""
assert all(k == 404 for k in wynik["nie_ma"].values()), wynik["nie_ma"]
def test_the_catalogue_describes_this_product(wynik):
assert set(wynik["ekrany"]) == {"Horoskop", "Interpretacje", "Kalendarz",
"Synastria", "Sygnifikatory", "Pliki"}
def test_no_account_can_hold_a_permission_this_product_does_not_have(wynik):
"""Uprawnienia liczą się z KATALOGU, więc opisują ten obraz. Gdyby była tu
lista wpisana ręcznie, zostałaby po usuniętych funkcjach."""
assert "ai" not in wynik["uprawnienia"]
assert "export" not in wynik["uprawnienia"]
assert "admin" not in wynik["uprawnienia"]
assert "chart" in wynik["uprawnienia"] and "significators" in wynik["uprawnienia"]
def test_navigation_has_no_dead_links(wynik):
assert wynik["nawigacja"] == len(wynik["ekrany"])
def test_login_still_tells_right_password_from_wrong(wynik):
assert wynik["logowanie_dziala"] and wynik["zle_haslo_odmowa"]
def test_the_pool_comes_from_the_session_not_from_the_form(wynik):
"""Pula jedzie z zalogowanego konta. Gdyby brała się z żądania, wystarczyłoby
podstawić cudzy login, żeby czytać cudze pliki — sonda właśnie to próbuje."""
assert wynik["pula"] == "klient"
+16
View File
@@ -0,0 +1,16 @@
moduly
ekrany/skompiluj.py
ekrany/ustawienia.py
ekrany/konta.py
ekrany/pliki.py
accounts.py
files_state.py
templates/compile.html
templates/settings.html
templates/accounts.html
templates/files.html
static/s-compile.css
static/s-settings.css
static/s-accounts.css
static/s-files.css
static/compile.js
+16
View File
@@ -36,6 +36,22 @@ from datetime import datetime, timezone
from pathlib import Path
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
# Nazwa puli może przyjść z zewnątrz (login konta w wersji demo), więc przechodzi
# przez sito: wyłącznie znaki bezpieczne w nazwie katalogu. Bez tego „../..” albo
# ukośnik wyprowadziłyby zapytanie poza udział — czyli wprost do cudzych baz.
_TENANT_OK = re.compile(r"[^A-Za-z0-9._-]")
def tenant_root(root: Path | str, tenant: str = "") -> Path:
"""Katalog, w którym mieszka pula danego konta.
Puste `tenant` = cały udział, czyli zachowanie produkcji. Nazwa jest
OCZYSZCZANA, a nie sprawdzana i odrzucana: sito zamienia wszystko podejrzane
na podkreślenie, więc żadne wejście nie wyprowadzi ścieżki wyżej."""
root = Path(root)
name = _TENANT_OK.sub("_", (tenant or "").strip()).strip("._")
return root / name if name else root
USABLE = frozenset({ACTIVE})
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
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")
raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
+13 -5
View File
@@ -71,29 +71,35 @@ def bases() -> dict:
class FilesQuery(BaseModel):
for_admin: bool = False
# Pula konta (PRE-29). Puste = cały udział — tak woła produkcja, która o pulach
# nic nie wie. Wersja demo podaje login, przez co widzi wyłącznie swój katalog.
tenant: str = ""
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
tenant: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
tenant: str = ""
class RulesUpdate(BaseModel):
rules: dict
tenant: str = ""
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
root = settings.excel_dir
root = files.tenant_root(settings.excel_dir, q.tenant)
return {"files": files.registry(root, for_admin=q.for_admin),
"rules": files.rules(root) if q.for_admin else {},
"root": str(root)}
@@ -102,7 +108,8 @@ def files_list(q: FilesQuery) -> dict:
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
try:
row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by)
row = files.set_status(files.tenant_root(settings.excel_dir, a.tenant),
a.path, a.status, by=a.by)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"path": a.path, "status": row.get("status")}
@@ -117,13 +124,14 @@ def files_upload(u: FileUpload) -> dict:
raw = base64.b64decode(u.content_b64, validate=True)
except (binascii.Error, ValueError) as e:
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by)
return files.store_upload(files.tenant_root(settings.excel_dir, u.tenant),
u.filename, raw, by=u.by)
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
try:
files.delete(settings.excel_dir, a.path)
files.delete(files.tenant_root(settings.excel_dir, a.tenant), a.path)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"deleted": a.path}
@@ -131,7 +139,7 @@ def files_delete(a: FileAction) -> dict:
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return {"rules": files.set_rules(settings.excel_dir, u.rules)}
return {"rules": files.set_rules(files.tenant_root(settings.excel_dir, u.tenant), u.rules)}
@app.get("/health", response_model=HealthInfo)
+5
View File
@@ -25,6 +25,11 @@ class SearchQuery(BaseModel):
fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
)
# PULA KONTA (PRE-29). Puste = cały udział, czyli zachowanie produkcji, która
# o pulach nic nie wie. Ustawione = wyszukiwanie widzi WYŁĄCZNIE podkatalog
# tego konta. Używa tego wersja demo, gdzie każde konto ma własny, niewidoczny
# dla pozostałych zbiór baz.
tenant: str = Field("", description="Pula plików konta; puste = cały udział.")
class SearchResult(BaseModel):
@@ -91,7 +91,7 @@ class ExcelDataProvider(DataProvider):
base = Path(self.s.excel_dir)
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
def _enabled_files(self, paths: list[str]) -> list[str]:
def _enabled_files(self, paths: list[str], tenant: str = "") -> list[str]:
"""Bazy biorące udział w wyszukiwaniu.
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
@@ -105,7 +105,7 @@ class ExcelDataProvider(DataProvider):
# przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych.
from app import bases, files
usable = set(files.usable_paths(self.s.excel_dir))
usable = set(files.usable_paths(files.tenant_root(self.s.excel_dir, tenant)))
out = [p for p in paths if p in usable]
entries = bases.disabled_entries()
if entries:
@@ -128,7 +128,11 @@ class ExcelDataProvider(DataProvider):
from app import bases
disabled = ",".join(bases.disabled_entries())
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
# PULA MUSI BYĆ W KLUCZU. Bez niej wynik policzony dla jednego konta
# trafiłby z cache do drugiego — czyli cicha wymiana treści baz między
# kontami, niewidoczna w logach i nie do wykrycia z zewnątrz.
cache_key = (f"{query.key}|{query.value}|{query.exact}|{query.limit}"
f"|{query.fields}|{disabled}|{query.tenant}")
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
if hit is not None:
@@ -140,7 +144,7 @@ class ExcelDataProvider(DataProvider):
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
candidates = [(p, "0") for p in self._excel_files()]
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
allowed = set(self._enabled_files([p for p, _ in candidates]))
allowed = set(self._enabled_files([p for p, _ in candidates], query.tenant))
candidates = [(p, s) for p, s in candidates if p in allowed]
rows: list[dict] = []
@@ -110,3 +110,80 @@ def test_switching_a_base_off_is_visible_in_both_places(excel_dir):
p2.warmup()
assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0
assert [b["enabled"] for b in p2.list_bases()] == [False]
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
# Najgroźniejsza rzecz w tej zmianie. Wyciek między pulami jest CICHY: klient
# zobaczyłby cudze opisy jako własny wynik i nikt by się nie zorientował.
def _pool(root, name, plik, tresc):
from app import files
pula = files.tenant_root(root, name)
pula.mkdir(parents=True, exist_ok=True)
_xlsx(pula / plik, rows=[(tresc, f"opis {tresc}")])
files.registry(pula) # przyjęcie zastanych
return pula
def test_each_account_sees_only_its_own_files(excel_dir):
from app import files
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
_pool(excel_dir, "klientB", "b.xlsx", "Ve Tau")
a = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientA"))]
b = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientB"))]
assert a == ["a.xlsx"] and b == ["b.xlsx"]
def test_search_never_reaches_another_account_pool(excel_dir):
from app.models import SearchQuery
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
_pool(excel_dir, "klientB", "b.xlsx", "Ve Tau")
p = _provider(excel_dir)
p.warmup()
swoje = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA"))
cudze = p.search(SearchQuery(key="significator", value="Ve Tau", limit=10, tenant="klientA"))
assert swoje.total >= 1, "konto nie widzi własnej bazy"
assert cudze.total == 0, "konto sięgnęło do puli innego konta"
def test_the_query_cache_cannot_serve_one_account_the_answer_of_another(excel_dir):
"""Bez puli w kluczu cache wynik policzony dla jednego konta trafiłby do
drugiego — cicha wymiana treści baz, niewidoczna w logach."""
from app.models import SearchQuery
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
_pool(excel_dir, "klientB", "b.xlsx", "Ma Ari") # TA SAMA szukana wartość
p = _provider(excel_dir)
p.warmup()
a = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA"))
b = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientB"))
assert a.total >= 1 and b.total >= 1
assert b.cache != "hit", "odpowiedź dla jednego konta wyszła z cache drugiego"
def test_a_crafted_pool_name_cannot_escape_the_share(excel_dir):
"""Nazwa puli przychodzi z zewnątrz (login), więc „../..” albo ukośnik
wyprowadziłyby zapytanie wprost do cudzych baz."""
from app import files
for zlosliwa in ("../..", "../klientB", "/etc", "a/../../b", "..", "."):
wynik = files.tenant_root(excel_dir, zlosliwa)
assert excel_dir in wynik.parents or wynik == excel_dir, f"ucieczka: {zlosliwa}{wynik}"
def test_no_pool_means_the_whole_share_so_production_is_untouched(excel_dir):
"""Produkcja nie wie o pulach i ma działać dokładnie jak dotąd."""
from app import files
from app.models import SearchQuery
_xlsx(excel_dir / "produkcyjna.xlsx")
p = _provider(excel_dir)
p.warmup()
assert files.tenant_root(excel_dir, "") == excel_dir
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1
+18 -7
View File
@@ -28,8 +28,18 @@ def _link() -> link_crypto.Link | None:
class DataClient:
def __init__(self, base_url: str | None = None) -> None:
"""Rozmówca warstwy danych, opcjonalnie ZWIĄZANY Z PULĄ KONTA (PRE-29).
Pula siedzi w INSTANCJI, nie w sygnaturach metod. Gdyby jechała argumentem,
trzeba by ją przeprowadzić przez protokół DataSource i przez build_report —
czyli przez kod, który o kontach nie ma prawa nic wiedzieć. A przy okazji
każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam.
Puste = cały udział, czyli zachowanie produkcji."""
def __init__(self, base_url: str | None = None, tenant: str = "") -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
self.tenant = tenant or ""
def search(
self,
@@ -39,7 +49,8 @@ class DataClient:
limit: int,
fields: list[str] | None = None,
) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
payload = {"key": key, "value": value, "exact": exact, "limit": limit,
"fields": fields, "tenant": self.tenant}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
payload=payload, headers=_auth_headers(),
@@ -58,20 +69,20 @@ class DataClient:
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
return self._files_post("/files", {"for_admin": for_admin})
return self._files_post("/files", {"for_admin": for_admin, "tenant": self.tenant})
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
return self._files_post("/files/status", {"path": path, "status": status, "by": by, "tenant": self.tenant})
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/upload",
{"filename": filename, "content_b64": content_b64, "by": by})
{"filename": filename, "content_b64": content_b64, "by": by, "tenant": self.tenant})
def files_delete(self, path: str) -> dict[str, Any]:
return self._files_post("/files/delete", {"path": path})
return self._files_post("/files/delete", {"path": path, "tenant": self.tenant})
def files_rules(self, rules: dict) -> dict[str, Any]:
return self._files_post("/files/rules", {"rules": rules})
return self._files_post("/files/rules", {"rules": rules, "tenant": self.tenant})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
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")
raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
+20 -8
View File
@@ -133,29 +133,33 @@ def bases() -> dict:
class FilesQuery(BaseModel):
for_admin: bool = False
tenant: str = ""
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
tenant: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
tenant: str = ""
class RulesUpdate(BaseModel):
rules: dict
tenant: str = ""
def _files_call(fn, *args, **kw) -> dict:
def _files_call(fn, *args, tenant: str = "", **kw) -> dict:
from app.clients.data_client import DataClient
try:
return fn(DataClient(), *args, **kw)
return fn(DataClient(tenant=tenant), *args, **kw)
except httpx.HTTPStatusError as e:
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
@@ -175,27 +179,27 @@ def _detail(e: httpx.HTTPStatusError) -> str:
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
return _files_call(lambda c: c.files_list(q.for_admin))
return _files_call(lambda c: c.files_list(q.for_admin), tenant=q.tenant)
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
return _files_call(lambda c: c.files_status(a.path, a.status, a.by))
return _files_call(lambda c: c.files_status(a.path, a.status, a.by), tenant=a.tenant)
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by))
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by), tenant=u.tenant)
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
return _files_call(lambda c: c.files_delete(a.path))
return _files_call(lambda c: c.files_delete(a.path), tenant=a.tenant)
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return _files_call(lambda c: c.files_rules(u.rules))
return _files_call(lambda c: c.files_rules(u.rules), tenant=u.tenant)
@app.post("/chart/synastry")
@@ -248,6 +252,10 @@ class ReportRequest(BaseModel):
lon: float = 0.0
limit: int = 5000
group: bool = False # grupowanie identycznych opisów
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
# prezentacji, bo to ona zna konta.
tenant: str = ""
@app.post("/chart/report")
@@ -263,7 +271,7 @@ def chart_report(req: ReportRequest) -> dict:
chart = build_chart(engine, moment) # pozycje z domami + aspekty
try:
report = build_report(
chart["positions"], DataClient(),
chart["positions"], DataClient(tenant=req.tenant),
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
per_object_limit=req.limit, group=req.group,
)
@@ -591,6 +599,10 @@ class TimelineRequest(BaseModel):
to_date: str
techniques: list[str] | None = None # profection | solar_return | solar_arc
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
# prezentacji, bo to ona zna konta.
tenant: str = ""
@app.post("/chart/timeline")
+4
View File
@@ -8,6 +8,10 @@ from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
# prezentacji, bo to ona zna konta.
tenant: str = ""
query: str = Field(..., min_length=1, description="Szukana fraza.")
field: str = Field("name", description="Po którym polu szukać.")
+6 -2
View File
@@ -14,6 +14,7 @@ from app.models import QueryRequest, QueryResponse
class QueryService:
def __init__(self, data_client: DataClient | None = None) -> None:
self._wstrzykniety = data_client is not None
self.data = data_client or DataClient()
def handle(self, req: QueryRequest) -> QueryResponse:
@@ -21,8 +22,11 @@ class QueryService:
value = req.query.strip()
key = req.field.strip().lower()
# 2) zapytanie w dół do warstwy bazodanowej
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 2) zapytanie w dół do warstwy bazodanowej — do PULI KONTA, jeśli podano.
# Klient budowany na żądanie, bo pula wynika z tego, kto pyta, a nie ze
# stanu procesu. Wstrzyknięty klient (testy) ma pierwszeństwo.
dane = self.data if (self._wstrzykniety or not req.tenant) else DataClient(tenant=req.tenant)
raw = dane.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
results = raw.get("rows", [])
+5 -1
View File
@@ -4,7 +4,11 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też testy,
# README i skrypty pomocnicze — a plik testowy nazywa funkcje wprost, więc
# w obrazie zbudowanym bez danego modułu byłby dokładnie tym śladem, którego
# wydzielenie ma się pozbyć. Przy okazji obraz jest mniejszy.
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -40,6 +40,29 @@ class LogicClient:
payload=payload, headers=_auth_headers(),
link=_link())
# ── transport dla modułów opcjonalnych ─────────────────────────────────
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
return self._post(sciezka, payload, timeout)
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
headers=_auth_headers(), link=_link())
def strumien(self, sciezka: str, payload: dict[str, Any]):
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}{sciezka}",
payload=payload, headers=_auth_headers(), link=_link())
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
return self._post("/api/query", payload, settings.http_timeout)
@@ -97,61 +120,6 @@ class LogicClient:
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
def prompt(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
provider: str | None = None, model: str | None = None,
) -> dict[str, Any]:
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
"model": model,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
def horoscope(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", provider: str | None = None, model: str | None = None,
from_date: str | None = None, to_date: str | None = None,
) -> dict[str, Any]:
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
"""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget,
}
if provider:
payload["provider"] = provider
if model:
payload["model"] = model
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
def horoscope_stream(self, payload: dict[str, Any]):
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
więc cisza na łączu nie zostanie wzięta za zerwanie.
Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
"""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}/chart/horoscope/stream",
payload=payload, headers=_auth_headers(), link=_link())
def bases(self) -> dict[str, Any]:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
with httpx.Client(timeout=settings.http_timeout) as client:
@@ -185,12 +153,6 @@ class LogicClient:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
headers=_auth_headers(), link=_link())
def timeline(
self, when_utc_iso: str, lat: float, lon: float,
from_date: str, to_date: str, interpret: bool = True,
@@ -0,0 +1,10 @@
"""Ekrany aplikacji — jeden moduł na ekran.
Ekran to trasy plus szablon. Import modułu rejestruje jego trasy (dekoratory
`@app.get`/`@app.post` wykonują się przy imporcie), więc o tym, CO aplikacja
umie, decyduje lista importów w `main.py` — a nie warunki rozsiane po kodzie.
Dzięki temu produkt buduje się przez ZŁOŻENIE: inny zestaw importów i inny
zestaw plików w obrazie to inny program, a nie ten sam program z wyłączonymi
kawałkami. Ekranu, którego moduł nie wszedł do obrazu, nie ma jak włączyć.
"""
@@ -0,0 +1,118 @@
"""Horoskop: pozycje, osie, domy, aspekty i kosmogram (strona główna)."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
chartwheel_mod,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_perms,
_limit_options,
_landing,
_build_utc,
_logic_error,
)
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
if "chart" not in _perms(request):
target = _landing(request)
if target:
return RedirectResponse(target, status_code=303)
# Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
# mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
# czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
# a twoje konto zostało okrojone.
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/", response_class=HTMLResponse)
def chart_compute(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables,
"wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=form["aspect_minor"],
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
)
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
orientation=form["wheel_orientation"])
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
if "extra_charts" in _perms(request):
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "chart.html", ctx)
# ── zgłoszenie do katalogu ─────────────────────────────────────────────────
# Ekran zgłasza siebie, swoje trasy i swoje zasoby. Wszystko o tym ekranie jest
# w tym pliku — więc obraz bez tego pliku nie wie o nim NIC.
features.zarejestruj(
ekran=Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.",
"Ekrany", "/", kolejnosc=10),
trasy={("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart"},
zasoby={"s-chart.css": "chart"},
)
features.zarejestruj(rozszerzenie=Feature(
"houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia", kolejnosc=10))
features.zarejestruj(rozszerzenie=Feature(
"extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia", kolejnosc=20))
features.zarejestruj(rozszerzenie=Feature(
"advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia", kolejnosc=30))
@@ -0,0 +1,81 @@
"""Interpretacje: wynik obliczeń szukany w bazach."""
from __future__ import annotations
from fastapi import Depends, Form, Request
from fastapi.responses import HTMLResponse, Response
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
security,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_build_utc,
_dozwolona_akcja,
_report_records,
_logic_error,
)
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request):
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/interpret", response_class=HTMLResponse)
def interpret_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
group: bool = Form(False),
action: str = Form("report"),
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, **dodatki}
action = _dozwolona_akcja(request, action, "report")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
**rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
# Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
# więc ekran robi to, co robi normalnie. Moduł może oddać słownik do
# kontekstu albo gotową odpowiedź — na przykład plik do pobrania.
wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
lat=lat, lon=lon, dodatki=dodatki,
request=request, group=group)
if isinstance(wynik, Response):
return wynik
if wynik is not None:
ctx["wynik_dodatku"] = wynik
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
security.audit_records(request, _report_records(ctx["result"]))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "interpret.html", ctx)
features.zarejestruj(
ekran=Feature("interpret", "Interpretacje", "Interpretacja natalna z baz.",
"Ekrany", "/interpret", kolejnosc=20),
trasy={("GET", "/interpret"): "interpret", ("POST", "/interpret"): "interpret"},
zasoby={"s-interpret.css": "interpret"},
)
@@ -0,0 +1,78 @@
"""Kalendarz: oś czasu z technik wraz z interpretacjami."""
from __future__ import annotations
from fastapi import Depends, Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_build_utc,
_dozwolona_akcja,
_logic_error,
)
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
@app.get("/timeline", response_class=HTMLResponse)
def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/timeline", response_class=HTMLResponse)
def timeline_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
from_date: str = Form(...),
to_date: str = Form(...),
action: str = Form("timeline"),
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date,
**dodatki}
action = _dozwolona_akcja(request, action, "timeline")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
**rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
wynik = rozszerzenia.obsluz(action, profil="period", iso_utc=iso_utc,
lat=lat, lon=lon, dodatki=dodatki,
od=from_date, do=to_date)
if wynik is not None:
ctx["wynik_dodatku"] = wynik
else:
ctx["result"] = logic.timeline(
when_utc_iso=iso_utc, lat=lat, lon=lon,
from_date=from_date, to_date=to_date, interpret=True,
)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "timeline.html", ctx)
features.zarejestruj(
ekran=Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.",
"Ekrany", "/timeline", kolejnosc=30),
trasy={("GET", "/timeline"): "timeline", ("POST", "/timeline"): "timeline"},
zasoby={"s-timeline.css": "timeline"},
)
+105
View File
@@ -0,0 +1,105 @@
"""Konta i uprawnienia (PRE-27) — ekran wyłącznie administracyjny."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app import accounts as accounts_store
from app.features import Feature
from app.podstawa import (app, templates, security, features)
# ---------------- Konta i uprawnienia (PRE-27) ----------------
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
# jest nie tak.
try:
users = accounts_store.all_users()
except accounts_store.AccountsUnavailable as e:
users, error = {}, str(e)
return {
"users": users,
"catalog": features.ALL,
"screens": features.SCREENS,
# Rejestr zawiera już wszystko, co ten obraz umie — łącznie z tym, co
# wniosły moduły opcjonalne. Administrator ma nadawać uprawnienia do
# funkcji, które REALNIE są, a nie do listy wpisanej kiedyś na sztywno.
"extras": features.EXTRAS,
"admin_login": security.app_user(),
"store_path": accounts_store.store_path(),
"error": error, "done": done,
}
@app.get("/accounts", response_class=HTMLResponse)
def accounts_view(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "accounts.html",
_accounts_context(request, error, done))
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
powtórzyć zakładania ani kasowania konta."""
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
@app.post("/accounts/create")
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.create(login, password, granted, note)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
@app.post("/accounts/update")
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.update(login, granted=granted, password=password, note=note)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
@app.post("/accounts/sesje")
def accounts_sesje(request: Request, login: str = Form(...)):
"""Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu.
Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek
wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany
komputer i nie chce wpisywać nowego hasła wszędzie."""
try:
accounts_store.bump_session(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.")
@app.post("/accounts/delete")
def accounts_delete(request: Request, login: str = Form(...)):
try:
accounts_store.delete(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
# Ekran wyłącznie administracyjny — nie ma pozycji w katalogu funkcji, bo ADMIN
# nie jest uprawnieniem do nadania (patrz features.GRANTABLE).
features.zarejestruj(
nawigacja=("accounts", "/accounts", "Konta", features.ADMIN),
trasy={("GET", "/accounts"): features.ADMIN,
("POST", "/accounts/create"): features.ADMIN,
("POST", "/accounts/update"): features.ADMIN,
("POST", "/accounts/delete"): features.ADMIN,
("POST", "/accounts/sesje"): features.ADMIN},
zasoby={"s-accounts.css": features.ADMIN},
)
@@ -0,0 +1,79 @@
"""Logowanie i wylogowanie (LOG-34)."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import (features, app, templates, security, session)
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
def _bezpieczne_dokad(dokad: str) -> str:
"""Adres powrotu po zalogowaniu, przepuszczony przez sito.
Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
do przekierowywania ludzi gdzie indziej klasyczna droga do wyłudzenia hasła,
bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
zacząć adres obcego serwera."""
dokad = (dokad or "").strip()
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
return "/"
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
@app.get("/logowanie", response_class=HTMLResponse)
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
# Zalogowanego nie ma po co pytać o hasło jeszcze raz.
if security.auth_enabled() and security.principal(request) is not None:
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
return templates.TemplateResponse(request, "logowanie.html",
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
@app.post("/logowanie")
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
dokad: str = Form("/")):
cel = _bezpieczne_dokad(dokad)
who = security.verify(login.strip(), haslo)
if who is None:
# JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby,
# które konta istnieją — a to informacja warta czegoś dla zgadującego.
security.audit_login(request, login.strip(), udane=False)
return templates.TemplateResponse(
request, "logowanie.html",
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
status_code=401)
security.audit_login(request, who.login, udane=True)
odpowiedz = RedirectResponse(cel, status_code=303)
odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login),
max_age=session.max_age(),
**session.cookie_params(secure=security.cookies_secure()))
return odpowiedz
@app.post("/wyloguj")
def wyloguj(request: Request):
"""Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe
i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać.
POST, nie GET: pod adresem GET wystarczyłby obrazek na obcej stronie, żeby
wylogować kogoś bez jego wiedzy. Uciążliwość, nie włamanie ale darmowa
do uniknięcia."""
security.audit_logout(request)
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
odpowiedz.delete_cookie(session.COOKIE, **session.cookie_params(
secure=security.cookies_secure()))
return odpowiedz
# Wyjście musi być dostępne dla każdego zalogowanego, niezależnie od tego, co mu
# przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
features.zarejestruj(
trasy={("GET", "/logowanie"): None, ("POST", "/logowanie"): None,
("POST", "/wyloguj"): None},
)
@@ -0,0 +1,58 @@
"""Wyszukiwarka miejsca i strefa czasowa (proxy OSM/Nominatim)."""
from __future__ import annotations
from datetime import timezone
from fastapi import HTTPException, Query
import httpx
from app.features import Feature
from app.podstawa import (features, app, geocode)
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
try:
return {"results": geocode.search(q)}
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/reverse")
def geocode_reverse(lat: float, lon: float):
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
try:
return geocode.reverse(lat, lon)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/timezone")
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
gdy strefy nie da się ustalić front zostawia wtedy ręczny offset."""
from app import timezone as tzmod
res = tzmod.resolve(lat, lon, date, time)
if not res:
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
return res
# Wyszukiwarka miejsca siedzi na kilku ekranach naraz, więc jej zasoby otwiera
# KTÓREKOLWIEK z nich — wymaganie wszystkich byłoby bez sensu.
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_KOLO = frozenset({"chart", "compile"})
features.zarejestruj(
trasy={("GET", "/geocode"): None, ("GET", "/reverse"): None,
("GET", "/timezone"): None},
zasoby={"geo.js": _LOKALIZACJA, "now.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
"formsync.js": _FORMULARZE,
"wheelzoom.js": _KOLO, "wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"})},
)
+170
View File
@@ -0,0 +1,170 @@
"""Pliki baz: wybór, wgrywanie, archiwizacja i reguły przyjmowania (DAN-27)."""
from __future__ import annotations
from fastapi import File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
import base64
import httpx
from app import files_state
from app.features import Feature
from app.podstawa import (app, templates, logic, features, _perms, _logic_error)
# Etykiety stanów pliku dla szablonu. Rejestrowane TUTAJ, a nie w podstawie:
# należą do tego ekranu, a produkt, który go nie ma, nie ma po co ich znać.
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
# ---------------- Pliki baz (DAN-27) ----------------
# Trzy poziomy dostępu, opisane w features.ROUTES:
# „files" — widzi listę i decyduje, z czego program korzysta,
# „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony
# ze znacznikiem czasu, znika tylko z użytku),
# administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
#
# Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE
# DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby
# takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać
# reguły — a te ma znać wyłącznie administrator.
def _files_context(request: Request, error: str = "", done: str = "") -> dict:
is_admin = features.ADMIN in _perms(request)
try:
data = logic.files_list(for_admin=is_admin)
except httpx.HTTPError as e:
return {"files": [], "rules": {}, "is_admin": is_admin,
"error": _logic_error(e), "done": ""}
return {"files": data.get("files") or [], "rules": data.get("rules") or {},
"is_admin": is_admin, "error": error, "done": done}
@app.get("/files", response_class=HTMLResponse)
def files_view(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "files.html",
_files_context(request, error, done))
def _files_redirect(error: str = "", done: str = "") -> RedirectResponse:
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303)
def _who(request: Request) -> str:
return getattr(request.state, "user", "") or "-"
@app.post("/files/use")
def files_use(request: Request, path: str = Form(...), use: str = Form("")):
"""Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji
w warstwie danych odmowa wraca BEZ POWODU, bo powód zdradzałby reguły."""
want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \
else files_state.READY
try:
logic.files_status(path, want, by=_who(request))
except httpx.HTTPStatusError as e:
detail = _http_detail(e)
if features.ADMIN not in _perms(request):
detail = "Tego pliku nie da się teraz włączyć do użytku."
return _files_redirect(error=detail)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano." if want == files_state.READY
else "Plik włączony do użytku.")
@app.post("/files/upload")
async def files_upload(request: Request, upload: UploadFile = File(...)):
"""Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji —
nie tracimy niczego, co ktoś wgrał."""
raw = await upload.read()
if not raw:
return _files_redirect(error="Pusty plik.")
try:
out = logic.files_upload(upload.filename or "plik.xlsx",
base64.b64encode(raw).decode("ascii"), by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
# DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
# komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
# odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
# Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
# musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
# i że plik przechodzi przez jakąś bramkę.
return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
@app.post("/files/archive")
def files_archive(request: Request, path: str = Form(...)):
"""Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko
z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane."""
try:
logic.files_status(path, files_state.ARCHIVED, by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.")
@app.post("/files/restore")
def files_restore(request: Request, path: str = Form(...)):
try:
logic.files_status(path, files_state.READY, by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Przywrócono „{path}” z archiwum.")
@app.post("/files/delete")
def files_delete(request: Request, path: str = Form(...)):
try:
logic.files_delete(path)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.")
@app.post("/files/rules")
def files_rules(request: Request, extensions: str = Form(".xlsx"),
max_size_mb: float = Form(50), min_rows: int = Form(1),
required_columns: str = Form(""),
reject_duplicate_content: str = Form("")):
rules = {
"extensions": [e.strip() for e in extensions.split(",") if e.strip()],
"max_size_mb": max_size_mb,
"min_rows": min_rows,
"required_columns": [c.strip() for c in required_columns.split(",") if c.strip()],
"reject_duplicate_content": reject_duplicate_content.strip().lower()
in {"1", "true", "on", "tak"},
}
try:
logic.files_rules(rules)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano reguły walidacji.")
def _http_detail(e: httpx.HTTPStatusError) -> str:
try:
return str(e.response.json().get("detail") or e)
except Exception: # noqa: BLE001
return str(e)
features.zarejestruj(
ekran=Feature("files", "Pliki", "Wybór plików, z których korzysta program.",
"Ekrany", "/files", kolejnosc=70),
# Kasowanie, przywracanie i REGUŁY PRZYJMOWANIA to wyłącznie administrator —
# o istnieniu reguł nikt poza nim nie ma skąd wiedzieć (DAN-27).
trasy={("GET", "/files"): "files", ("POST", "/files/use"): "files",
("POST", "/files/upload"): "files_input",
("POST", "/files/archive"): "files_input",
("POST", "/files/restore"): features.ADMIN,
("POST", "/files/delete"): features.ADMIN,
("POST", "/files/rules"): features.ADMIN},
zasoby={"s-files.css": "files"},
)
features.zarejestruj(rozszerzenie=Feature(
"files_input", "Wgrywanie i archiwizacja plików",
"Dodawanie nowych plików i wycofywanie ich z użytku (plik zostaje, zamrożony, "
"ze znacznikiem czasu). Kasować może wyłącznie administrator.",
"Rozszerzenia", kolejnosc=40))
@@ -0,0 +1,96 @@
"""Skompiluj: zbiorczy raport z policzonych części (PRE-23), wraz z PDF-em."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
chartwheel_mod,
DEFAULT_LOCATION_LABEL,
default_form,
_perms,
_limit_options,
_build_utc,
_logic_error,
)
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
@app.get("/compile", response_class=HTMLResponse)
def compile_form(request: Request):
return templates.TemplateResponse(
request, "compile.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/compile", response_class=HTMLResponse)
def compile_build(
request: Request,
person: str = Form(""),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
zodiac: str = Form("tropical"),
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
):
"""Składa raport: horoskop liczymy TU NA NOWO, a części z przeglądarki (interpretacja
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
Horoskop liczymy ponownie (czysta funkcja wejścia tanio powtórzyć, bez
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
wybrano przy horoskopie (stacje, tabele, porównanie domów) inaczej
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
zakładkami przez formsync.
"""
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables,
"wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=form["aspect_minor"],
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
)
from app import chartwheel
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
orientation=form["wheel_orientation"])
if "extra_charts" in _perms(request):
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "compile.html", ctx)
features.zarejestruj(
ekran=Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.",
"Ekrany", "/compile", kolejnosc=60),
trasy={("GET", "/compile"): "compile", ("POST", "/compile"): "compile"},
zasoby={"s-compile.css": "compile", "compile.js": "compile"},
)
@@ -0,0 +1,41 @@
"""Sygnifikatory: wyszukiwarka po bazach."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, security, _logic_error)
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
@app.get("/significators", response_class=HTMLResponse)
def significators_form(request: Request):
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
@app.post("/significators", response_class=HTMLResponse)
def significators_search(
request: Request,
query: str = Form(...),
field: str = Form("name"),
exact: bool = Form(False),
limit: int = Form(25),
):
form = {"query": query, "field": field, "exact": exact, "limit": limit}
ctx: dict = {"form": form, "result": None, "error": None}
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
features.zarejestruj(
ekran=Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach.",
"Ekrany", "/significators", kolejnosc=50),
trasy={("GET", "/significators"): "significators",
("POST", "/significators"): "significators"},
zasoby={"s-significators.css": "significators"},
)
@@ -0,0 +1,58 @@
"""Synastria: porównanie dwóch horoskopów."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, _build_utc, _logic_error)
# ---------------- Synastria (technika relacyjna) ----------------
@app.get("/synastry", response_class=HTMLResponse)
def synastry_form(request: Request):
# domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce)
form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0,
"aspect_orb": 8, "aspect_luminary_bonus": 2}
return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form})
@app.post("/synastry", response_class=HTMLResponse)
def synastry_run(
request: Request,
a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...),
a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0),
b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...),
b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0),
zodiac: str = Form("tropical"),
aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
):
"""Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami."""
form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset,
"a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date,
"b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon,
"zodiac": zodiac, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor}
ctx: dict = {"form": form, "result": None, "error": None}
try:
iso_a, _ = _build_utc(a_date, a_time, a_tz_offset)
iso_b, _ = _build_utc(b_date, b_time, b_tz_offset)
ctx["result"] = logic.synastry(
{"when_utc": iso_a, "lat": a_lat, "lon": a_lon},
{"when_utc": iso_b, "lat": b_lat, "lon": b_lon},
zodiac=zodiac, aspect_orb=aspect_orb,
aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor,
)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "synastry.html", ctx)
features.zarejestruj(
ekran=Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.",
"Ekrany", "/synastry", kolejnosc=40),
trasy={("GET", "/synastry"): "synastry", ("POST", "/synastry"): "synastry"},
zasoby={"s-synastry.css": "synastry"},
)
@@ -0,0 +1,32 @@
"""Ustawienia: podgląd baz widocznych na udziale (DAN-15/PRE-09)."""
from __future__ import annotations
from fastapi import Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, _logic_error)
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
@app.get("/settings", response_class=HTMLResponse)
def settings_view(request: Request):
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
Same metadane (nazwa, rozmiar, data) bez treści baz. Przełączniki
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
ctx: dict = {"result": None, "error": None}
try:
ctx["result"] = logic.bases()
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "settings.html", ctx)
features.zarejestruj(
ekran=Feature("settings", "Ustawienia", "Podgląd plików widocznych na udziale.",
"Ekrany", "/settings", kolejnosc=80),
trasy={("GET", "/settings"): "settings"},
zasoby={"s-settings.css": "settings"},
)
+61 -109
View File
@@ -21,6 +21,8 @@ from __future__ import annotations
from dataclasses import dataclass
from app import rozszerzenia
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
@@ -31,97 +33,76 @@ class Feature:
hint: str
group: str
href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki
kolejnosc: int = 0 # pozycja w nawigacji; nie zależy od kolejności importów
# ── ekrany (zakładki) ────────────────────────────────────────────────────
SCREENS: tuple[Feature, ...] = (
Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.", "Ekrany", "/"),
Feature("interpret", "Interpretacje", "Interpretacja natalna z baz i od modelu.", "Ekrany", "/interpret"),
Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.", "Ekrany", "/timeline"),
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
Feature("files", "Pliki", "Wybór baz, z których korzysta program.", "Ekrany", "/files"),
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
)
# ── rejestr: katalog składa się z tego, co zgłoszą ekrany ────────────────
# Dawniej wszystkie funkcje były wypisane TUTAJ. To znaczyło, że obraz produktu,
# który części z nich nie ma, i tak niósł ich nazwy — czyli spis funkcji, których
# nie ma jak włączyć. Teraz ekran zgłasza siebie sam, przy imporcie swojego
# modułu, więc katalog opisuje dokładnie ten produkt, który się zbudowało.
#
# `kolejnosc` jest jawna, bo o zawartości decyduje lista importów w `main.py`,
# a kolejność importów nie może rządzić kolejnością zakładek w nawigacji.
SCREENS: list[Feature] = []
# Pozycje nawigacji dla uprawnień NIENADAWALNYCH — tych, których nie ma w
# GRANTABLE, więc nie da się ich przyznać z żadnego ekranu. Osobno od SCREENS
# i wraz z wymaganym uprawnieniem, żeby szablon nie musiał go wypowiadać: wpisane
# w szablonie na sztywno zostawiało nazwę ekranu i martwy odsyłacz w produkcie,
# który tego ekranu nie ma.
NAWIGACJA_SPECJALNA: list[tuple[str, str, str, str]] = [] # (klucz, adres, etykieta, uprawnienie)
EXTRAS: list[Feature] = []
ALL: tuple[Feature, ...] = ()
BY_KEY: dict[str, Feature] = {}
GRANTABLE: frozenset[str] = frozenset()
# ── rozszerzenia (poziomy złożoności wewnątrz ekranów) ───────────────────
EXTRAS: tuple[Feature, ...] = (
Feature("houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia"),
Feature("extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia"),
Feature("advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia"),
Feature("ai", "Generowanie tekstu przez model",
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
"Rozszerzenia"),
Feature("files_input", "Wgrywanie i archiwizacja baz",
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
"zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
"Rozszerzenia"),
Feature("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
)
ROUTES: dict[tuple[str, str], str | None] = {}
STATIC: dict[str, frozenset[str] | str | None] = {}
ALL: tuple[Feature, ...] = SCREENS + EXTRAS
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
def _przelicz() -> None:
global ALL, BY_KEY, GRANTABLE
SCREENS.sort(key=lambda f: f.kolejnosc)
EXTRAS.sort(key=lambda f: f.kolejnosc)
ALL = tuple(SCREENS) + tuple(EXTRAS)
BY_KEY = {f.key: f for f in ALL}
GRANTABLE = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None = None,
trasy: dict[tuple[str, str], str | None] | None = None,
zasoby: dict | None = None,
nawigacja: tuple[str, str, str, str] | None = None) -> None:
"""Zgłoszenie funkcji, jej tras i jej zasobów.
Idempotentne po kluczu: `export` zgłaszają dwa ekrany (raport do PDF-a
i wyniki do arkusza), a wpis ma powstać raz."""
for pozycja, zbior in ((ekran, SCREENS), (rozszerzenie, EXTRAS)):
if pozycja and pozycja.key not in {f.key for f in zbior}:
zbior.append(pozycja)
if nawigacja and nawigacja[0] not in {p[0] for p in NAWIGACJA_SPECJALNA}:
NAWIGACJA_SPECJALNA.append(nawigacja)
ROUTES.update(trasy or {})
STATIC.update(zasoby or {})
_przelicz()
# Funkcje wnoszone przez moduły opcjonalne — zgłaszane tak samo jak ekrany,
# tylko przez neutralny most, żeby katalog nie musiał ich wymieniać z nazwy.
for _krotka in rozszerzenia.funkcje():
zarejestruj(rozszerzenie=Feature(*_krotka))
zarejestruj(trasy=rozszerzenia.trasy(), zasoby=rozszerzenia.zasoby())
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
ROUTES: dict[tuple[str, str], str | None] = {
("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart",
("GET", "/interpret"): "interpret",
("POST", "/interpret"): "interpret",
("GET", "/timeline"): "timeline",
("POST", "/timeline"): "timeline",
("GET", "/synastry"): "synastry",
("POST", "/synastry"): "synastry",
("GET", "/significators"): "significators",
("POST", "/significators"): "significators",
("GET", "/compile"): "compile",
("POST", "/compile"): "compile",
("POST", "/compile/pdf"): "export",
("GET", "/settings"): "settings",
# Zarządzanie plikami baz (DAN-27). Trzy poziomy: „files" wybiera, z czego
# program korzysta; „files_input" dokłada wgrywanie i archiwizację;
# kasowanie, przywracanie i REGUŁY WALIDACJI to wyłącznie administrator —
# o istnieniu walidacji nikt poza nim nie ma skąd wiedzieć.
("GET", "/files"): "files",
("POST", "/files/use"): "files",
("POST", "/files/upload"): "files_input",
("POST", "/files/archive"): "files_input",
("POST", "/files/restore"): ADMIN,
("POST", "/files/delete"): ADMIN,
("POST", "/files/rules"): ADMIN,
("POST", "/horoscope/stream"): "ai",
("GET", "/accounts"): ADMIN,
("POST", "/accounts/create"): ADMIN,
("POST", "/accounts/update"): ADMIN,
("POST", "/accounts/delete"): ADMIN,
("GET", "/geocode"): None,
("GET", "/reverse"): None,
("GET", "/timezone"): None,
("GET", "/health"): None,
# Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego,
# co mu przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
("GET", "/logowanie"): None,
("POST", "/logowanie"): None,
("POST", "/wyloguj"): None,
("POST", "/accounts/sesje"): ADMIN,
}
# ── zasoby statyczne ─────────────────────────────────────────────────────
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `models.js`
# wymienia dostawców modeli, `compile.js` — składanie raportu. Dlatego każdy
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `compile.js`
# zdradza składanie raportu, a `wheelzoom.js` — istnienie kosmogramu. Dlatego każdy
# zasób przechodzi przez tę samą bramkę co ekrany.
#
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
@@ -131,36 +112,6 @@ _FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_KOLO = frozenset({"chart", "compile"})
STATIC: dict[str, frozenset[str] | str | None] = {
# arkusze ekranów — jeden ekran, jedno uprawnienie
"s-chart.css": "chart",
"s-interpret.css": "interpret",
"s-timeline.css": "timeline",
"s-synastry.css": "synastry",
"s-significators.css": "significators",
"s-compile.css": "compile",
"s-files.css": "files",
"s-settings.css": "settings",
"s-accounts.css": ADMIN,
"x-ai.css": "ai",
# skrypty
"formsync.js": _FORMULARZE,
"now.js": _LOKALIZACJA,
"geo.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
"wheelzoom.js": _KOLO,
"wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"}),
"compile.js": "compile",
"models.js": "ai",
"progress.js": "ai",
"natal.js": "ai",
"predictions.js": "ai",
# base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
# publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
# niczego, co nazywa jakąkolwiek funkcję.
}
def static_required(name: str) -> frozenset[str] | str | None:
@@ -192,7 +143,8 @@ def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
def screens_for(perms: frozenset[str]) -> list[Feature]:
"""Zakładki do pokazania w nawigacji — w stałej kolejności katalogu."""
"""Zakładki do pokazania w nawigacji — w kolejności zadeklarowanej, nie w tej,
w której akurat wykonały się importy."""
return [f for f in SCREENS if f.key in perms]
+1 -1
View File
@@ -1,7 +1,7 @@
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
Nazwy były zaszyte w czterech szablonach naraz (selektor na Horoskop", selektor
na Skompiluj", checkboxy porównania, nagłówki tabeli). Przy trzech systemach
w raporcie zbiorczym, checkboxy porównania, nagłówki tabeli). Przy trzech systemach
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu wystarczy
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
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")
raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
"""Moduły opcjonalne — każdy podkatalog to jedna odłączalna część produktu.
O tym, co produkt umie, decyduje ZAWARTOŚĆ tego katalogu w obrazie. Most
(`app.rozszerzenia`) odkrywa moduły, przechodząc po podkatalogach, i nie zna
żadnej nazwy gdyby znał, nazwa nieobecnego modułu i tak jechałaby do obrazu,
w którym go nie ma.
Moduł może wnieść: pozycję do katalogu funkcji, trasy, zasoby, akcje formularza,
pola formularza, wpisy do kontekstu szablonu, własne szablony i własne statyki.
Wszystko jest opcjonalne most pyta o to, czego moduł nie ma, i dostaje pusto.
"""
@@ -0,0 +1,23 @@
"""Generowanie tekstu przez model językowy — moduł CAŁKOWICIE ODŁĄCZALNY.
DLACZEGO OSOBNY KATALOG, A NIE `{% if %}` W SZABLONACH. Warunek w szablonie
wystarcza, żeby funkcji nie było WIDAĆ, ale nie żeby jej NIE BYŁO. Plik szablonu
i tak leży w obrazie i i tak zawiera słowa opisujące funkcję `grep` po
kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie.
Astrololo zawiera ten katalog. Astroklient nie, i to jest jedyna różnica
między nimi po stronie funkcji astrologicznych. Dlatego granica MUSI być
katalogiem: co jest w środku, znika razem z nim, a co jest na zewnątrz, zostaje.
ZASADA DLA KOGOŚ, KTO TU DOPISUJE. Poza tym katalogiem nie wolno napisać ani
słowa o modelu, prompcie czy dostawcy nawet w komentarzu, nawet zaprzeczając.
Szablony wspólne mają neutralne gniazda (`_dodatki_*`), a plik wstawiany w
gniazdo sam sprawdza uprawnienie. Pilnuje tego test `test_ai_tylko_w_module.py`.
"""
from __future__ import annotations
from app.moduly.dodatki.katalog import AKCJE, FUNKCJA, TRASY, ZASOBY, katalog_dla, obsluz, pola
from app.moduly.dodatki.trasy import zamontuj
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "ZASOBY", "katalog_dla", "obsluz", "pola",
"zamontuj"]
@@ -0,0 +1,91 @@
"""Co moduł wnosi do aplikacji: funkcję, trasy, zasoby, akcje i pola formularza.
Wszystko, co gdziekolwiek indziej musiałoby paść z nazwy, jest zebrane TUTAJ.
Reszta aplikacji zna wyłącznie neutralny most (`app.rozszerzenia`) i pyta go
o dane, nigdy o generowanie" czy „model".
"""
from __future__ import annotations
from typing import Any
import httpx
from fastapi import Form, Request
from app.moduly.dodatki import klient
# Pozycja w katalogu funkcji — surowa krotka, żeby nie importować `features`
# (to zamknęłoby cykl features → most → moduł → features).
FUNKCJA: tuple[tuple[str, str, str, str], ...] = (
("ai", "Generowanie tekstu przez model",
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
"Rozszerzenia"),
)
TRASY: dict[tuple[str, str], str] = {("POST", "/horoscope/stream"): "ai"}
ZASOBY: dict[str, str] = {
"x-ai.css": "ai",
"models.js": "ai",
"progress.js": "ai",
"natal.js": "ai",
"predictions.js": "ai",
}
# Akcje formularza wnoszone przez moduł: nazwa → wymagane uprawnienie.
AKCJE: dict[str, str] = {"prompt": "ai", "horoscope": "ai"}
# Wstrzykiwane przez `zamontuj` z main.py — moduł nie importuje aplikacji,
# bo to byłby cykl, a przy okazji zmusiłoby aplikację do wiedzy o module.
_logic: Any = None
_szablony: Any = None
def ustaw_zaleznosci(*, logic: Any, templates: Any) -> None:
global _logic, _szablony
_logic, _szablony = logic, templates
def pola(prompt_budget: str = Form("medium"),
llm_provider: str = Form(""),
llm_model: str = Form("")) -> dict:
"""Pola formularza, których wspólny handler nie może wymienić w sygnaturze.
FastAPI czyta pola z sygnatury, więc zadeklarowanie ich we wspólnym handlerze
wpisałoby nazwy `llm_provider` i `prompt_budget` w kod, który trafia także do
obrazu bez tego modułu. Zależność przenosi deklarację tutaj."""
return {"prompt_budget": prompt_budget, "llm_provider": llm_provider,
"llm_model": llm_model}
def _katalog() -> dict:
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
try:
return klient.modele(_logic)
except httpx.HTTPError:
return {"providers": {}, "defaults": {}}
def katalog_dla(request: Request) -> dict:
"""Katalog modeli do kontekstu szablonu — pusty, gdy konto nie ma uprawnienia.
Katalog trafia do strony jako blok JSON, więc bez tego ograniczenia nazwy
dostawców, modeli i rozmiary okien kontekstu byłyby w źródle każdej strony,
także dla kont, które o modelach nie mają prawa wiedzieć."""
perms = getattr(getattr(request.state, "principal", None), "permissions", frozenset())
return {"llm_catalog": _katalog() if "ai" in perms else {}}
def obsluz(akcja: str, *, profil: str, iso_utc: str, lat: float, lon: float,
dodatki: dict, od: str = "", do: str = "", **_reszta) -> dict | None:
"""Wykonanie akcji modułu. None znaczy „to nie moja akcja"."""
if akcja not in AKCJE:
return None
metoda = klient.prompt if akcja == "prompt" else klient.horoscope
argumenty = {"logic": _logic, "profile": profil, "when_utc_iso": iso_utc, "lat": lat, "lon": lon,
"budget": dodatki.get("prompt_budget", "medium"),
"provider": dodatki.get("llm_provider", ""),
"model": dodatki.get("llm_model", "")}
if profil == "period":
argumenty["from_date"], argumenty["to_date"] = od, do
return metoda(**argumenty)
@@ -0,0 +1,62 @@
"""Rozmowa z warstwą logiczną w sprawach tego modułu.
Metody nie siedzą na wspólnym kliencie, tylko tutaj: gdyby siedziały tam, ich
nazwy (`prompt`, `horoscope`, `llm_models`) byłyby w pliku, który jedzie do
KAŻDEGO obrazu także tam, gdzie tego modułu nie ma. Wspólny klient daje samą
drogę w dół (`wywolaj`, `pobierz`, `strumien`), z szyfrowaniem łącza i tokenem
międzywarstwowym; co nią pojedzie, jest sprawą modułu.
"""
from __future__ import annotations
from typing import Any
from app.config import settings
def prompt(logic: Any, *, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None,
to_date: str | None = None, provider: str | None = None,
model: str | None = None) -> dict[str, Any]:
"""Gotowy prompt z wyliczeń (LOG-29/30).
`provider` jest potrzebny dla budżetu maksymalny kontekst modelu": limit
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso, "lat": lat, "lon": lon,
"budget": budget, "provider": provider, "model": model,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return logic.wywolaj("/chart/prompt", payload, max(settings.http_timeout, 60.0))
def horoscope(logic: Any, *, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", provider: str | None = None,
model: str | None = None, from_date: str | None = None,
to_date: str | None = None) -> dict[str, Any]:
"""Napisany horoskop (LOG-31). Długi timeout, bo pisanie trwa —
zwłaszcza na modelu lokalnym."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso, "lat": lat, "lon": lon,
"budget": budget,
}
if provider:
payload["provider"] = provider
if model:
payload["model"] = model
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return logic.wywolaj("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
def strumien(logic: Any, payload: dict[str, Any]):
"""Strumień postępu pisania (NDJSON), przekazywany do przeglądarki.
Strumień niesie heartbeat, więc cisza na łączu nie zostanie wzięta
za zerwanie."""
yield from logic.strumien("/chart/horoscope/stream", payload)
def modele(logic: Any) -> dict[str, Any]:
"""Katalog modeli per dostawca — podpowiedzi do pola wyboru."""
return logic.pobierz("/llm/models")
@@ -93,5 +93,5 @@
}
});
window.astrololoNatal = { read: read, clear: clear, key: KEY };
window.kosmogramNatal = { read: read, clear: clear, key: KEY };
})();
@@ -146,5 +146,5 @@
});
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
window.kosmogramPredictions = { read: read, remove: remove, key: KEY };
})();
@@ -29,3 +29,10 @@
.progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
@@ -0,0 +1,9 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
każdy sprawdza, czy jego kontener jest na stronie. #}
<script src="{{ static('natal.js') }}"></script>
<script src="{{ static('predictions.js') }}"></script>
{% endif %}
@@ -0,0 +1,4 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
@@ -0,0 +1,8 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
{# Części od modelu — wstawia przeglądarka z magazynu lokalnego. #}
<div id="reportNatal"></div>
<div id="reportPredictions"></div>
{% endif %}
@@ -0,0 +1,7 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
{% endif %}
@@ -0,0 +1,5 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}
<p class="muted small" id="natalNote"></p>{% endif %}
@@ -0,0 +1,9 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
<script src="{{ static('models.js') }}"></script>
<script src="{{ static('progress.js') }}"></script>
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
<script src="{{ static('natal.js') }}"></script>
{% endif %}
@@ -0,0 +1,4 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
@@ -0,0 +1,7 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
{% endif %}
@@ -0,0 +1,4 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
@@ -0,0 +1,9 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
<script src="{{ static('models.js') }}"></script>
<script src="{{ static('progress.js') }}"></script>
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
<script src="{{ static('predictions.js') }}"></script>
{% endif %}
@@ -0,0 +1,4 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
@@ -36,4 +36,6 @@
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
</p>
<div id="promptResult">{% include "_prompt_result.html" %}</div>
{# Wspólny handler nie zna nazwy „prompt_result" — oddaje wynik pod neutralnym
„wynik_dodatku". Tłumaczenie nazwy należy do modułu. #}
<div id="promptResult">{% with prompt_result = wynik_dodatku %}{% include "_prompt_result.html" %}{% endwith %}</div>
@@ -0,0 +1,85 @@
"""Trasy wnoszone przez moduł. Bez tego katalogu w obrazie nie istnieją."""
from __future__ import annotations
import json
from typing import Any
import httpx
from fastapi import Form
from fastapi.responses import JSONResponse, StreamingResponse
from app.moduly.dodatki import katalog, klient
_logic: Any = None
_szablony: Any = None
_buduj_utc: Any = None
_blad: Any = None
def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any,
**_reszta) -> None:
"""Podpięcie tras i przekazanie modułowi tego, czego potrzebuje.
Zależności przychodzą z zewnątrz, zamiast być importowane: moduł nie może
importować `app.main`, bo to cykl a przede wszystkim dlatego, że zależność
ma iść w JEDNĄ stronę. Aplikacja wie o istnieniu modułów opcjonalnych
(przez neutralny most), moduł nie wie nic o aplikacji.
"""
# `**_reszta` CELOWO: most podaje wszystkim modułom ten sam zestaw zależności,
# a każdy bierze z niego to, czego potrzebuje. Bez tego dołożenie zależności
# dla jednego modułu wywracałoby pozostałe.
global _logic, _szablony, _buduj_utc, _blad
_logic, _szablony, _buduj_utc, _blad = logic, templates, buduj_utc, blad
katalog.ustaw_zaleznosci(logic=logic, templates=templates)
@app.post("/horoscope/stream")
def horoscope_stream( # noqa: ANN202
profile: str = Form("natal"),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
from_date: str = Form(""),
to_date: str = Form(""),
):
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który
wyrenderowałoby przeładowanie strony jedno źródło prawdy dla wyglądu.
"""
try:
iso_utc, _ = _buduj_utc(date, time, tz_offset)
except ValueError as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"},
status_code=422)
payload: dict = {
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
}
if profile == "period" and from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
def relay():
try:
for raw in klient.strumien(_logic, payload):
try:
event = json.loads(raw)
except ValueError:
continue
if event.get("type") == "result":
event["html"] = _szablony.get_template("_prompt_result.html").render(
prompt_result=event.get("result") or {}
)
yield json.dumps(event, ensure_ascii=False) + "\n"
except httpx.HTTPError as e:
yield json.dumps({"type": "error", "message": _blad(e)},
ensure_ascii=False) + "\n"
return StreamingResponse(relay(), media_type="application/x-ndjson",
headers={"Cache-Control": "no-store",
"X-Accel-Buffering": "no"})
@@ -0,0 +1,13 @@
"""Wynoszenie treści na zewnątrz: arkusz z wynikami i raport w PDF-ie.
Moduł ODŁĄCZALNY. Jest w obrazie astrololo; w produktach, które nie mają wynosić
treści baz poza program, nie ma go wcale a nie ma go jak włączyć, bo nie ma
czego. Eksport wynosi NAJWIĘCEJ treści naraz, więc można, ale nie temu kontu"
i nie ma takiej możliwości" to dwie różne gwarancje.
"""
from __future__ import annotations
from app.moduly.eksport.katalog import AKCJE, FUNKCJA, TRASY, obsluz
from app.moduly.eksport.trasy import zamontuj
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "obsluz", "zamontuj"]
@@ -0,0 +1,47 @@
"""Co moduł wnosi: funkcję, trasy i akcję formularza."""
from __future__ import annotations
from typing import Any
from fastapi.responses import Response
FUNKCJA: tuple[tuple[str, str, str, str], ...] = (
("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako arkusz.", "Rozszerzenia"),
)
TRASY: dict[tuple[str, str], str] = {("POST", "/compile/pdf"): "export"}
# Akcja formularza: nazwa → wymagane uprawnienie.
AKCJE: dict[str, str] = {"export": "export"}
# Wstrzykiwane przy montowaniu — moduł nie importuje aplikacji.
_logic: Any = None
_security: Any = None
_licz_rekordy: Any = None
def ustaw_zaleznosci(*, logic: Any, security: Any, licz_rekordy: Any) -> None:
global _logic, _security, _licz_rekordy
_logic, _security, _licz_rekordy = logic, security, licz_rekordy
def obsluz(akcja: str, *, iso_utc: str = "", lat: float = 0.0, lon: float = 0.0,
request: Any = None, group: bool = False, **_reszta) -> Response | None:
# Wszystko po `akcja` ma wartość domyślną CELOWO: most woła po kolei każdy
# moduł, także z ekranu, który tych danych nie ma. Brakujący argument
# wywracałby wtedy ekran, zamiast po prostu nie pasować do akcji.
if akcja not in AKCJE:
return None
# Arkusz roboczy z wynikami (DAN-23/PRE-10) — pobranie pliku, nie strona.
from app.moduly.eksport.arkusz import report_to_xlsx
report = _logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
# Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać ślad
# w dzienniku (PRE-17): sama liczba rekordów, nigdy treść.
_security.audit_records(request, _licz_rekordy(report))
return Response(
content=report_to_xlsx(report),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
)
@@ -0,0 +1,114 @@
"""Trasy wnoszone przez moduł. Bez tego katalogu w obrazie nie istnieją."""
from __future__ import annotations
from typing import Any
import httpx
from fastapi.responses import JSONResponse, Response
from app.moduly.eksport import katalog
_logic: Any = None
_szablony: Any = None
_buduj_utc: Any = None
_blad: Any = None
_kolo: Any = None
def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any,
security: Any = None, licz_rekordy: Any = None, kolo: Any = None,
**_reszta) -> None:
"""Podpięcie trasy i przekazanie zależności. Idą z zewnątrz, bo zależność ma
biec w JEDNĄ stronę: aplikacja wie o modułach opcjonalnych (przez neutralny
most), moduł nie wie nic o aplikacji."""
global _logic, _szablony, _buduj_utc, _blad, _kolo
_logic, _szablony, _buduj_utc, _blad = logic, templates, buduj_utc, blad
_kolo = kolo
katalog.ustaw_zaleznosci(logic=logic, security=security, licz_rekordy=licz_rekordy)
# Nazwy, których używa przeniesiona trasa. `chartwheel` to czysty rysownik
# bez stanu, więc importujemy go wprost — wstrzykiwanie ma sens dla rzeczy
# związanych z aplikacją (klient, szablony), nie dla funkcji rysujących.
from app import chartwheel as chartwheel_mod
_build_utc = buduj_utc
_logic_error = blad
@app.post("/compile/pdf")
def compile_pdf(payload: dict):
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
Wejście z przeglądarki, bo część kawałków (interpretacja natalna i predykcje)
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
zawierał dokładnie to, co widać na stronie.
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVGPDF nie zna
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
"""
from fastapi.responses import Response
from app.clients.render_client import RenderClient
data = payload.get("data") or {}
try:
iso_utc, label = _build_utc(
str(data.get("date") or ""), str(data.get("time") or ""),
float(data.get("tz_offset") or 0.0),
)
except (ValueError, TypeError) as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
figures: list[dict] = []
warnings: list[str] = []
try:
chart = logic.positions(
when_utc_iso=iso_utc,
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
house_systems=[s for s in (data.get("house_systems") or []) if s],
aspect_orb=float(data.get("aspect_orb") or 8.0),
aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
aspect_minor=bool(data.get("aspect_minor")),
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
zodiac=str(data.get("zodiac") or "tropical"),
)
# Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
# nie da się poznać, że podział jest z innego systemu, niż zamówiono.
warnings = [w for w in (chart.get("house_warnings") or []) if w]
from app import chartwheel
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
# zmienne CSS i font glifów muszą być wprost w rysunku.
for svg, caption in (
(chartwheel.render(chart, theme="print",
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
"Kosmogram"),
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
):
if svg:
figures.append({"svg": svg, "caption": caption})
except httpx.HTTPError as e:
# Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
data = {**data, "wheel_error": _logic_error(e)}
report = {
"person": payload.get("person") or "",
"data": {**data, "moment_utc": label},
"figures": figures,
"warnings": warnings,
"natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [],
}
try:
pdf = RenderClient().pdf(report)
except httpx.HTTPError as e:
return JSONResponse(
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
status_code=502,
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
+284
View File
@@ -0,0 +1,284 @@
"""Podstawa aplikacji: wspólne obiekty i pomocnicy, z których korzystają ekrany.
Tu mieszka to, co ma KAŻDY produkt zbudowany z tego kodu: obiekt aplikacji,
szablony, klient warstwy logicznej, serwowanie zasobów i garść funkcji
pomocniczych. Ani jednej trasy ekranu te w `app/ekrany/*.py`, po jednym
module na ekran, a o tym, które wchodzą, decyduje lista importów w `main.py`.
Dlaczego tak: dopóki wszystko siedziało w jednym pliku, produkt bez tego ekranu"
znaczyło produkt z ekranem, tylko zablokowanym". Plik i tak jechał do obrazu,
razem z nazwami funkcji, których nie było jak włączyć.
"""
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import hashlib
import logging
import re
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response
from fastapi.templating import Jinja2Templates
from app import rozszerzenia
from app import chartwheel as chartwheel_mod
from app import features
from app import produkt
from app import session
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
from app import geocode, security
from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form
# Automatyczna dokumentacja WYŁĄCZONA. /docs, /redoc i /openapi.json wypisują
# KOMPLET tras aplikacji — czyli spis wszystkich funkcji, łącznie z tymi, o których
# konto ograniczone nie ma prawa wiedzieć (PRE-27). Ochrona zakładek nic by nie dała,
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
app = FastAPI(title="astrololo · warstwa prezentacji",
docs_url=None, redoc_url=None, openapi_url=None)
# Katalogi modułów opcjonalnych idą PRZED wspólnym: moduł może dołożyć szablon,
# którego wspólny zestaw nie ma. Wspólne szablony wołają je przez neutralne
# gniazda `{% include "_dodatki_*" ignore missing %}` — bez modułu gniazdo jest
# po prostu puste, a nie „wyłączone".
templates = Jinja2Templates(directory=[*rozszerzenia.katalog_szablonow(), "app/templates"])
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
log = logging.getLogger("astrololo.prezentacja")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
# ── cache-busting statyki (PRE-27) ─────────────────────────────────────────
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
# Hash liczony raz na proces (lru_cache) — nowy pod po deployu = świeży hash.
_STATIC_DIR = Path("app/static")
_TYPY_ZASOBOW = {
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
}
def _bez_komentarzy(tresc: str, sufiks: str) -> str:
"""Usuwa komentarze z arkusza albo skryptu przed wysłaniem.
Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt, a te
komentarze opisują funkcje pełnym zdaniem po polsku łącznie z tym, czego
konto ograniczone NIE widzi i kto to widzi. Zostają w repozytorium, bo tam
potrzebne; do odpowiedzi nie mają po co trafiać.
Kasujemy tylko komentarze blokowe i CAŁE wiersze `//`. Komentarz na końcu
wiersza zostaje CELOWO: żeby go usunąć, trzeba rozpoznać, czy `//` nie jest
w napisie albo w wyrażeniu regularnym a pomyłka tutaj psuje skrypt po
cichu. Test pilnuje słownika zakazanych słów, więc reszta by i tak wyszła.
"""
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
if sufiks == ".js":
tresc = re.sub(r"(?m)^[ \t]*//.*$\n?", "", tresc)
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
@lru_cache(maxsize=None)
def _asset_body(name: str) -> bytes | None:
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
for katalog in (*rozszerzenia.katalog_zasobow(), str(_STATIC_DIR)):
korzen = Path(katalog).resolve()
sciezka = (Path(katalog) / name).resolve()
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
if korzen in sciezka.parents and sciezka.is_file():
break
else:
return None
surowe = sciezka.read_bytes()
sufiks = sciezka.suffix.lower()
# Kod obcy zostaje nietknięty: minifikowany plik potrafi mieć `//` w napisie
# albo w wyrażeniu regularnym, a on i tak nie mówi nic o naszym programie.
if sufiks in (".css", ".js") and not name.startswith("vendor/"):
try:
return _bez_komentarzy(surowe.decode("utf-8"), sufiks).encode("utf-8")
except UnicodeDecodeError:
return surowe
return surowe
@lru_cache(maxsize=None)
def _asset_version(name: str) -> str:
tresc = _asset_body(name)
# Hash liczony z treści WYSYŁANEJ, nie z pliku na dysku: inaczej zmiana
# samego komentarza podbijałaby adres, choć odpowiedź jest ta sama.
return hashlib.md5(tresc).hexdigest()[:8] if tresc is not None else "0"
def static_url(name: str) -> str:
return f"/static/{name}?v={_asset_version(name)}"
@app.get("/static/{nazwa:path}")
def static_file(nazwa: str):
"""Zasoby idą przez bramkę uprawnień jak każda inna trasa.
Dopóki `/static/` było w PUBLIC_PREFIXES, katalog omijał całe sprawdzanie:
ukrycie przycisku w szablonie nic nie dawało, bo obok leżał skrypt tej samej
funkcji, pod nazwą, którą wystarczyło zgadnąć. Uprawnienie zasobu sprawdza
middleware (features.static_required); tutaj zostaje samo wydanie pliku.
"""
tresc = _asset_body(nazwa)
if tresc is None:
raise HTTPException(status_code=404)
return Response(
tresc,
media_type=_TYPY_ZASOBOW.get(Path(nazwa).suffix.lower(), "application/octet-stream"),
# Adres niesie hash treści, więc wersja pod danym adresem jest niezmienna.
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
# Nazwa produktu z modułu, nie z szablonu: szablony są wspólne dla kilku
# produktów, a nazwa nie.
templates.env.globals["produkt"] = produkt.NAZWA
templates.env.globals["static"] = static_url
# Pozycje specjalne przefiltrowane uprawnieniem — szablon dostaje gotową listę
# i nie musi wiedzieć, czego która wymaga.
templates.env.globals["nav_extra"] = lambda request: [
(k, a, e) for k, a, e, u in features.NAWIGACJA_SPECJALNA
if features.can(_perms(request), u)]
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
templates.env.globals["house_label"] = house_label
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
def _perms(request: Request) -> frozenset[str]:
"""Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to."""
who = getattr(request.state, "principal", None)
return who.permissions if who is not None else frozenset()
# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost.
# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
# nikt już nie pamięta.
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
# Kto jest zalogowany i czy logowanie w ogóle działa — pod przycisk wyjścia.
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
templates.env.globals["auth_on"] = security.auth_enabled
templates.env.globals["can"] = lambda request, key: key in _perms(request)
# Czy to konto ma uprawnienie NIENADAWALNE — czyli takie, którego nie da się
# przyznać z żadnego ekranu. Nazwa neutralna, bo szablon jest wspólny.
templates.env.globals["widok_diagnostyczny"] = lambda request: features.can(
_perms(request), features.ADMIN)
def _limit_options(request: Request, opts: dict) -> dict:
"""Ścina opcje, których konto nie ma prawa użyć — PO STRONIE SERWERA.
Ukrycie pola w formularzu chroni tylko przed przypadkiem. Kto zna nazwy pól,
wyśle je wprost a wtedy policzyłby funkcję, której mu nie przyznano, i to
jeszcze zobaczyłby w wyniku. Granicą jest ta funkcja, nie szablon.
Wartości zastępcze DOMYŚLNE, nie puste: konto ograniczone ma dostać
działający horoskop w podstawowej postaci, a nie komunikat o braku czegoś.
"""
perms = _perms(request)
if "advanced_calc" not in perms:
opts.update(stations=False, tables=False, aspect_minor=False, zodiac="tropical")
if "houses_compare" not in perms:
opts.update(house_system="whole_sign", house_systems=[],
wheel_orientation=chartwheel_mod.ASC_LEFT)
return opts
def _landing(request: Request) -> str | None:
"""Pierwszy ekran dostępny dla konta — dokąd odesłać z korzenia."""
allowed = features.screens_for(_perms(request))
return allowed[0].href if allowed else None
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny offset.
"""
local = datetime.fromisoformat(f"{date}T{time}")
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
label = utc.strftime("%Y-%m-%d %H:%M UTC")
return utc.isoformat(), label
# Akcja formularza → uprawnienie, którego wymaga. Akcji spoza tej mapy nie
# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak
# NIEZNANA — czyli wraca do zachowania domyślnego ekranu.
# Akcje własne tego ekranu plus te, które wnoszą moduły opcjonalne. Aplikacja
# nie wypisuje ich z nazwy — gdyby wypisywała, nazwa funkcji byłaby w kodzie
# także tam, gdzie modułu nie ma.
_AKCJE_POD_UPRAWNIENIEM = {"export": "export", **rozszerzenia.akcje()}
def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
"""Akcja, której konto nie ma prawa użyć, zachowuje się jak literówka.
NIE zwracamy błędu ani 404: komunikat brak uprawnień do generowania" sam
w sobie mówiłby, że taka funkcja istnieje (PRE-27). Ukrycie przycisku chroni
tylko przed przypadkiem kto zna nazwę pola, wyśle wprost, i to TU musi
się zatrzymać."""
wymagane = _AKCJE_POD_UPRAWNIENIEM.get(action)
if wymagane and wymagane not in _perms(request):
return domyslna
return action
def _report_records(report: dict | None) -> int:
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
Liczymy próbki we wszystkich fasetach: to one treścią z baz. Sama liczba,
nigdy treść dziennik ma pokazywać SKALĘ dostępu, nie powielać danych."""
total = 0
for o in (report or {}).get("objects") or []:
for f in o.get("facets") or []:
total += len(f.get("samples") or [])
return total
# Jedno zdanie dla wszystkich awarii — i CELOWO żadnego szczegółu.
# Poprzednia treść wypisywała na ekran nazwę endpointu (/chart/positions), nazwę
# podsystemu („silnik efemeryd"), nazwę gałęzi rozwojowej i — przez str(wyjątku
# httpx) — wewnętrzny adres z portem sąsiedniej usługi. Dla konta, które ma
# widzieć wyłącznie Pliki, to był komplet: co program liczy, że stoi za nim
# łańcuch usług, i gdzie zapukać.
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
def _logic_error(e: Exception) -> str:
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.
Odsiew siedzi TUTAJ, a nie w siedemnastu miejscach wywołania: gdyby zależał
od tego, czy ktoś pamiętał przekazać uprawnienia, prędzej czy później jedno
wywołanie zostałoby pominięte i nie byłoby tego jak zauważyć.
"""
log.warning("warstwa logiczna: %s: %s", type(e).__name__, e)
return _AWARIA
features.zarejestruj(trasy={("GET", "/health"): None})
@app.get("/health")
def health() -> dict:
# Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
# Kubernetes i compose czytają wyłącznie kod odpowiedzi.
return {"status": "ok"}
+12
View File
@@ -0,0 +1,12 @@
"""Nazwa produktu — to, co widzi człowiek w tytule i nagłówku.
Osobny moduł, bo szablony WSPÓLNE dla kilku produktów, a nazwa nie. Wpisana
w szablon na sztywno jechała z nim do każdego obrazu, który go bierze i tak
astroklient przedstawiał się jako astrololo, czyli mówił klientowi wprost, że
trzyma okrojoną wersję czegoś większego, i podawał frazę do wyszukania.
Produkt składający się z tego rdzenia NADPISUJE ten plik swoją nakładką.
"""
from __future__ import annotations
NAZWA = "astrololo"
+136
View File
@@ -0,0 +1,136 @@
"""Most do modułów opcjonalnych — CELOWO nie zna ani jednej ich nazwy.
Ten plik jest w KAŻDYM obrazie, także tam, gdzie nie ma żadnego modułu. Gdyby
wymieniał moduły z nazwy, nazwa nieobecnej funkcji i tak jechałaby do obrazu,
w którym tej funkcji nie ma czyli dokładnie ten ślad, którego całe wydzielanie
ma się pozbyć. Dlatego moduły ODKRYWANE: most przechodzi po podkatalogach
`app/moduly/` i pyta każdy, co wnosi.
Gdy nie ma żadnego, wszystkie odpowiedzi puste, a aplikacja zachowuje się
tak, jakby takiego pojęcia nigdy nie było nie jak aplikacja z wyłączoną
funkcją.
"""
from __future__ import annotations
import importlib
import pkgutil
from typing import Any
def _znalezione() -> list[Any]:
"""Moduły obecne w tym obrazie, w kolejności alfabetycznej katalogów.
Kolejność jest stała, żeby dwa obrazy z tym samym zestawem zachowywały się
identycznie inaczej wynik zależałby od kolejności odczytu katalogu."""
try:
paczka = importlib.import_module("app.moduly")
except ImportError:
return []
znalezione = []
for info in sorted(pkgutil.iter_modules(paczka.__path__), key=lambda i: i.name):
znalezione.append(importlib.import_module(f"app.moduly.{info.name}"))
return znalezione
_MODULY = _znalezione()
DOSTEPNE = bool(_MODULY)
def _zbierz(nazwa: str, domyslne):
"""Wartość atrybutu z każdego modułu, który go ma."""
return [getattr(m, nazwa) for m in _MODULY if hasattr(m, nazwa)] or domyslne
def funkcje() -> tuple[tuple[str, str, str, str], ...]:
"""Pozycje do katalogu funkcji — jako surowe krotki, nie obiekty.
Surowe, bo katalog buduje `features`, a gdyby moduł importował stamtąd klasę,
powstałby cykl: features most moduł features."""
out: list[tuple[str, str, str, str]] = []
for wartosc in _zbierz("FUNKCJA", []):
out.extend(wartosc)
return tuple(out)
def trasy() -> dict[tuple[str, str], str]:
out: dict[tuple[str, str], str] = {}
for wartosc in _zbierz("TRASY", []):
out.update(wartosc)
return out
def zasoby() -> dict[str, Any]:
out: dict[str, Any] = {}
for wartosc in _zbierz("ZASOBY", []):
out.update(wartosc)
return out
def akcje() -> dict[str, str]:
"""Akcje formularza wnoszone przez moduły: nazwa → wymagane uprawnienie."""
out: dict[str, str] = {}
for wartosc in _zbierz("AKCJE", []):
out.update(wartosc)
return out
def pola_formularza():
"""Zależność FastAPI deklarująca dodatkowe pola formularza.
Pola muszą być w SYGNATURZE, żeby FastAPI je odczytało, a sygnatura wspólnego
handlera nie może ich wymieniać. Zależność przenosi deklarację do modułu.
Gdy pola deklaruje więcej niż jeden moduł, trzeba je złożyć a składanie
sygnatur w locie to kod, który psuje się cicho. Póki nikt tego nie potrzebuje,
mówimy o tym wprost przy starcie, zamiast budować machinerię na zapas.
"""
deklarujace = _zbierz("pola", [])
if not deklarujace:
return lambda: {}
if len(deklarujace) > 1:
raise RuntimeError(
"Więcej niż jeden moduł deklaruje pola formularza. Trzeba je złożyć "
"w jedną zależność — patrz komentarz w app/rozszerzenia.py.")
return deklarujace[0]
def kontekst_szablonu(request) -> dict:
"""Co moduły dokładają do kontekstu szablonu. Bez modułów — nic."""
out: dict = {}
for funkcja in _zbierz("katalog_dla", []):
out.update(funkcja(request))
return out
def obsluz(akcja: str, **dane):
"""Wykonanie akcji modułu. None = żaden się do niej nie przyznaje.
Moduł może oddać słownik (trafi do kontekstu szablonu) albo gotową
odpowiedź HTTP na przykład plik do pobrania, który nie jest stroną."""
for funkcja in _zbierz("obsluz", []):
wynik = funkcja(akcja, **dane)
if wynik is not None:
return wynik
return None
def zamontuj(app, **zaleznosci) -> None:
"""Podpięcie tras modułów i przekazanie im tego, czego potrzebują."""
for funkcja in _zbierz("zamontuj", []):
funkcja(app, **zaleznosci)
def _katalogi(nazwa: str) -> list[str]:
import pathlib
return [str(pathlib.Path(m.__file__).parent / nazwa) for m in _MODULY
if (pathlib.Path(m.__file__).parent / nazwa).is_dir()]
def katalog_szablonow() -> list[str]:
"""Katalogi szablonów modułów — nakładane PRZED wspólnymi."""
return _katalogi("templates")
def katalog_zasobow() -> list[str]:
return _katalogi("static")
+2 -2
View File
@@ -1,7 +1,7 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
na wielu ścieżkach (`/significators`, `/interpret` i dalszych), więc BRAK
uwierzytelnienia oznacza, że każdy w sieci może je wypompować bez udziału
jakiegokolwiek modelu językowego. Ten moduł zamyka drogę.
@@ -24,7 +24,7 @@ DZIENNIK AUDYTOWY (PRE-17). Każde żądanie do chronionej ścieżki zostawia wp
kto, kiedy, jaka ścieżka, status, ile rekordów (gdy handler to poda przez
`audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez
osobę uprawnioną pojedyncze zapytanie wygląda niewinnie, suma już nie.
Świadomie NIE logujemy treści żądań, promptów ani rekordów logi to kolejny
Świadomie NIE logujemy treści żądań ani rekordów logi to kolejny
nośnik wycieku; liczby i metadane wystarczą do wykrycia nadużycia.
UWAGA: bez kont ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev). Wtedy przy
+1 -1
View File
@@ -175,7 +175,7 @@ def refresh(dane: dict, *, now: float | None = None) -> str | None:
def cookie_params(secure: bool = True) -> dict:
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
"""Parametry ciasteczka wspólne dla ustawiania i kasowania.
httponly JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
skrypt wynosi sesję. samesite=strict odcina większość ataków polegających
+7 -5
View File
@@ -2,8 +2,10 @@
//
// Raport ma trzy kawałki liczone w różnych miejscach:
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
// 2. interpretacja natalna — z zakładki Interpretacje,
// 3. predykcje okresowe — z zakładki Kalendarz.
// Kawałki 2 i 3 wnosi moduł opcjonalny. Gdy go nie ma, magazynów nie ma również
// i czytanie ich zwraca pusto — raport składa się wtedy z samego horoskopu.
//
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
@@ -20,11 +22,11 @@
}
function readNatal() {
return (window.astrololoNatal && window.astrololoNatal.read()) || null;
return (window.kosmogramNatal && window.kosmogramNatal.read()) || null;
}
function readPredictions() {
return (window.astrololoPredictions && window.astrololoPredictions.read()) || [];
return (window.kosmogramPredictions && window.kosmogramPredictions.read()) || [];
}
function item(ok, label, hint) {
@@ -86,7 +88,7 @@
}
// ── PDF (PRE-24) ────────────────────────────────────────────────────
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// Te kawałki mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// do serwera, który dokłada kosmogram i woła usługę render.
function formData() {
function v(name) {

Some files were not shown because too many files have changed in this diff Show More