Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10ade55525 | |||
| 2e9d3706ec | |||
| c73ba6a964 | |||
| 16cf25468f | |||
| caf4fd80d1 | |||
| e7b6d5013b | |||
| 320a0ab24e | |||
| 1d0ff2f72e | |||
| f0d07ee8c3 | |||
| 71bb3b9c0a | |||
| ee3c515d59 | |||
| ac8a0e9fc6 | |||
| b838cf4723 | |||
| e2b50b284f | |||
| 003deb9404 | |||
| 6466ab89a9 | |||
| a833965909 | |||
| baf4e0e38a | |||
| 8b6ecc727d | |||
| 21a00b0000 | |||
| 4d1daab35a | |||
| aec3f84331 |
@@ -9,10 +9,12 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Login
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||
- name: Build & push (data, logic, presentation)
|
||||
# astrodemo dołącza do tej samej pętli: dzieli warstwę logiczną i łącze,
|
||||
# więc jego obraz ma powstawać z tego samego commita co reszta produktu.
|
||||
- name: Build & push (data, logic, presentation, astrodemo)
|
||||
run: |
|
||||
TAG=${GITHUB_SHA::8}
|
||||
for SVC in data logic presentation; do
|
||||
for SVC in data logic presentation astrodemo; do
|
||||
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||
done
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Wyrocznia domów — nocny przemiał
|
||||
|
||||
# DLACZEGO TU, A NIE JAKO JOB W KLASTRZE
|
||||
# Pierwotny plan zakładał CronJob w k3s, bo „duży przemiał jest kosztowny".
|
||||
# Pomiar tego nie potwierdził: 500 000 przypadków × 13 systemów = 70 mln porównań
|
||||
# w 64 sekundy, skalowanie liniowe. Osobny obraz w rejestrze (który już raz zapchał
|
||||
# dysk hosta), manifest, CronJob i kopia harnessu poza repo byłyby infrastrukturą
|
||||
# do problemu, którego nie ma — a każda kopia harnessu poza repo to ryzyko cichego
|
||||
# rozjazdu z kodem, który ma testować.
|
||||
#
|
||||
# Zestaw brzegowy blokuje KAŻDY build (patrz tests.yml). Tutaj chodzi o co innego:
|
||||
# duża losowa próbka z INNYM ZIARNEM co noc, żeby z czasem przeczesać dziedzinę
|
||||
# gęściej, niż zrobi to pojedynczy przebieg.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 2 * * *' # 02:17 — poza godzinami budowania
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
count:
|
||||
description: 'Liczba przypadków'
|
||||
default: '500000'
|
||||
seed:
|
||||
description: 'Ziarno (puste = z daty)'
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Przemiał losowy (13 systemów)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Obraz wyroczni (silnik B + nasza logika + harness)
|
||||
run: |
|
||||
docker build -t astrololo/engine-swisseph:sweep services/engine-swisseph
|
||||
# Pliki WBUDOWANE, nie montowane: job Gitea Actions sam działa w kontenerze,
|
||||
# więc `-v $PWD/...` rozwiązałoby się na HOŚCIE (docker cicho tworzy pusty
|
||||
# katalog i skrypt „znika"). Kontekst builda jest strumieniowany do demona.
|
||||
docker build -t astrololo/oracle:sweep -f - . <<'DOCKERFILE'
|
||||
FROM astrololo/engine-swisseph:sweep
|
||||
COPY services/logic /logic
|
||||
COPY tests/oracle /oracle
|
||||
ENV LOGIC_PATH=/logic
|
||||
DOCKERFILE
|
||||
|
||||
- name: Przemiał
|
||||
run: |
|
||||
COUNT="${{ inputs.count }}"; COUNT="${COUNT:-500000}"
|
||||
SEED="${{ inputs.seed }}"; SEED="${SEED:-$(date -u +%Y%m%d)}"
|
||||
echo "count=$COUNT seed=$SEED"
|
||||
# Kod wyjścia 1 przy przekroczeniu tolerancji ALBO niezgodności dziedziny,
|
||||
# więc job czerwieni się sam — bez parsowania tekstu raportu.
|
||||
docker run --rm astrololo/oracle:sweep \
|
||||
python /oracle/run.py --mode sweep --count "$COUNT" --seed "$SEED"
|
||||
|
||||
# Runner miał już incydent „no space left on device" — sprzątamy zawsze.
|
||||
- name: Usuń obrazy pomocnicze
|
||||
if: always()
|
||||
run: docker rmi -f astrololo/oracle:sweep astrololo/engine-swisseph:sweep 2>/dev/null || true
|
||||
@@ -91,6 +91,27 @@ 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
|
||||
|
||||
swisseph-image:
|
||||
name: Build obrazu silnika B (swisseph)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -138,6 +159,37 @@ jobs:
|
||||
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
||||
|
||||
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
||||
|
||||
# /houses — kontrakt parzystosci (LOG-28) po stronie DOMOW. Sprawdzamy
|
||||
# nie tylko, ze liczy, ale i ze ODMAWIA tam, gdzie system nie istnieje:
|
||||
# ciche podstawienie innego systemu byloby niewykrywalne dla wolajacego.
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.main import _HOUSE_CODES, HousesRequest, houses
|
||||
|
||||
WHEN = "1984-04-30T09:20:00Z"
|
||||
h = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system="campanus"))
|
||||
assert h["engine"] == "swisseph" and h["system"] == "campanus"
|
||||
assert len(h["cusps"]) == 12, h["cusps"]
|
||||
assert [c["house"] for c in h["cusps"]] == list(range(1, 13))
|
||||
assert all(0.0 <= c["longitude"] < 360.0 for c in h["cusps"])
|
||||
assert {"Asc", "MC", "ARMC"} <= set(h["angles"]), h["angles"]
|
||||
|
||||
# kazdy ogloszony system musi dac 12 cuspow tam, gdzie ma definicje
|
||||
for name in _HOUSE_CODES:
|
||||
out = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system=name))
|
||||
assert len(out["cusps"]) == 12, name
|
||||
print(f"/houses: {len(_HOUSE_CODES)} systemow OK")
|
||||
|
||||
for bad, why in ((dict(lat=69.65, lon=18.96, system="placidus"), "Tromso/placidus"),
|
||||
(dict(lat=50.0, lon=19.0, system="nie-ma-takiego"), "nieznany system")):
|
||||
try:
|
||||
houses(HousesRequest(when_utc=WHEN, **bad))
|
||||
raise AssertionError(f"{why}: mialo byc 422, a przeszlo")
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 422, (why, e.status_code)
|
||||
print("/houses: odmowy poza dziedzina OK")
|
||||
|
||||
print("SMOKE OK")
|
||||
PY
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ services/logic/.ephemeris/
|
||||
|
||||
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
||||
services/data/data_files/*.xlsx
|
||||
# Rejestr plików (DAN-27) powstaje przy uruchomieniu — to stan, nie kod.
|
||||
services/data/data_files/.files-state.json
|
||||
!services/data/data_files/.gitkeep
|
||||
|
||||
# Narzędzia
|
||||
|
||||
@@ -11,8 +11,10 @@ services:
|
||||
volumes:
|
||||
- ./services/data/data_files:/app/data_files
|
||||
- data_cache:/app/.cache
|
||||
ports:
|
||||
- "8002:8002"
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
logic:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
||||
@@ -25,8 +27,10 @@ services:
|
||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||
depends_on:
|
||||
- data
|
||||
ports:
|
||||
- "8001:8001"
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||
# docker compose --profile comparison up
|
||||
@@ -36,8 +40,10 @@ services:
|
||||
container_name: astrololo-engine-swisseph
|
||||
restart: unless-stopped
|
||||
profiles: ["comparison"]
|
||||
ports:
|
||||
- "8003:8003"
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
presentation:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
||||
|
||||
+12
-6
@@ -9,8 +9,10 @@ services:
|
||||
volumes:
|
||||
- ./services/data/data_files:/app/data_files
|
||||
- data_cache:/app/.cache
|
||||
ports:
|
||||
- "8002:8002"
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
logic:
|
||||
build: ./services/logic
|
||||
@@ -21,8 +23,10 @@ services:
|
||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||
depends_on:
|
||||
- data
|
||||
ports:
|
||||
- "8001:8001"
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||
# docker compose --profile comparison up
|
||||
@@ -30,8 +34,10 @@ services:
|
||||
engine-swisseph:
|
||||
build: ./services/engine-swisseph
|
||||
profiles: ["comparison"]
|
||||
ports:
|
||||
- "8003:8003"
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
presentation:
|
||||
build: ./services/presentation
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,168 @@
|
||||
# Konta i uprawnienia (PRE-27)
|
||||
|
||||
Rozszerzenie kont imiennych z [PRE-17](konta-i-audyt.md): konta zakłada się
|
||||
**z aplikacji**, a każde dostaje własny zestaw funkcji.
|
||||
|
||||
## Dwie zasady, z których wynika reszta
|
||||
|
||||
**1. Konto ograniczone widzi program KOMPLETNY — tylko mniejszy.**
|
||||
Nic nie może zdradzać, że istnieje coś więcej. Żadnych wyszarzonych zakładek,
|
||||
żadnego „brak uprawnień", żadnego 403 — bo **403 samo w sobie jest informacją**,
|
||||
że pod tym adresem coś jest. Ścieżka bez uprawnienia odpowiada **404**, tak samo
|
||||
jak adres, którego nie ma.
|
||||
|
||||
Z tej zasady wynikło też wyłączenie `/docs`, `/redoc` i `/openapi.json`.
|
||||
Automatyczna dokumentacja FastAPI wypisuje komplet tras — czyli spis wszystkich
|
||||
funkcji programu. Ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog.
|
||||
(Znalezione testem, nie przeglądem kodu.)
|
||||
|
||||
**2. Konto administracyjne pochodzi WYŁĄCZNIE ze środowiska.**
|
||||
`APP_USER` / `APP_PASSWORD` (albo `APP_USERS`) — jak dotąd. To konto ma wszystkie
|
||||
uprawnienia i jako jedyne zarządza pozostałymi. **Nie leży w pliku kont**, więc
|
||||
nie da się go skasować ani ograniczyć z ekranu — nawet przez pomyłkę, nawet
|
||||
spreparowanym żądaniem. Konto założone w pliku o tym samym loginie **nie
|
||||
przesłoni** administracyjnego (kolejność sprawdzania jest odwrotna).
|
||||
|
||||
## Podział funkcji
|
||||
|
||||
**Ekrany** — zakładki widoczne w nawigacji:
|
||||
|
||||
| klucz | zakładka |
|
||||
|---|---|
|
||||
| `chart` | Horoskop |
|
||||
| `interpret` | Interpretacje |
|
||||
| `timeline` | Kalendarz |
|
||||
| `synastry` | Synastria |
|
||||
| `significators` | Sygnifikatory |
|
||||
| `compile` | Skompiluj |
|
||||
| `settings` | Ustawienia |
|
||||
|
||||
**Rozszerzenia** — poziomy złożoności wewnątrz ekranów:
|
||||
|
||||
| klucz | co daje |
|
||||
|---|---|
|
||||
| `houses_compare` | wybór systemu domów, zestawienie kilku obok siebie, obrót koła |
|
||||
| `extra_charts` | aspektarian, wykres deklinacji, oś antyscji |
|
||||
| `advanced_calc` | stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne |
|
||||
| `ai` | horoskopy pisane przez model językowy (**każde użycie kosztuje**) |
|
||||
| `export` | pobieranie PDF i Excela |
|
||||
|
||||
Konto bez `houses_compare` dostaje horoskop w Whole Sign i **nie widzi**, że
|
||||
systemów jest trzynaście. Konto bez `ai` nie zobaczy przycisku generowania ani
|
||||
nie wywoła go z pominięciem interfejsu.
|
||||
|
||||
## Gdzie leży granica
|
||||
|
||||
W handlerze, nie w szablonie. Ukrycie pola w formularzu chroni przed przypadkiem,
|
||||
ale nie przed kimś, kto zna nazwy pól — dlatego `_limit_options()` ścina opcje
|
||||
**po stronie serwera**, a rysunki dodatkowe bez uprawnienia w ogóle nie powstają
|
||||
(nie ma ich nawet w źródle strony).
|
||||
|
||||
Mapa `trasa → uprawnienie` jest **jedna**, w `app/features.py`. Rozproszenie jej
|
||||
po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
|
||||
jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wymaga
|
||||
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
|
||||
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
|
||||
|
||||
### Gdy jedna trasa robi kilka rzeczy
|
||||
|
||||
Mapa tras nie wystarcza tam, gdzie jedna trasa obsługuje kilka funkcji naraz.
|
||||
`POST /interpret` liczy horoskop, ale to samo pole `action` prosi o wygenerowanie
|
||||
promptu, napisanie horoskopu przez model albo eksport arkusza. Konto, które ma
|
||||
mieć Interpretacje bez generowania, musi dostać tę trasę — więc granica przebiega
|
||||
wewnątrz niej, po akcjach: `_AKCJE_POD_UPRAWNIENIEM` przypisuje akcji uprawnienie,
|
||||
a `_dozwolona_akcja()` sprowadza żądanie bez uprawnienia do akcji domyślnej ekranu.
|
||||
|
||||
Sprowadza — nie odrzuca. Komunikat „brak uprawnień do generowania" sam w sobie
|
||||
mówiłby, że taka funkcja istnieje, czyli łamałby zasadę drugą po to, żeby
|
||||
wyegzekwować pierwszą. Akcja bez uprawnienia ma wyglądać na literówkę w formularzu.
|
||||
|
||||
### Ślad to nie tylko przycisk
|
||||
|
||||
Wymaganie brzmi „nie może być śladu", i to jest mocniejsze niż schowanie kontrolki.
|
||||
Największym wyciekiem po stronie generowania nie był przycisk, tylko **katalog
|
||||
modeli** — nazwy dostawców, nazwy modeli i rozmiary okien kontekstu — wstrzykiwany
|
||||
w stronę blokiem JSON na każdym ekranie z generowaniem, niezależnie od uprawnień.
|
||||
Dlatego `_llm_catalog_for()` oddaje pusty katalog kontu bez uprawnienia, a szablony
|
||||
trzymają pod bramką także znaczniki (`natalNote`, `reportNatal`), pliki skryptów
|
||||
(`models.js`, `progress.js`, `natal.js`, `predictions.js`) i **zdania opisujące
|
||||
funkcję** — podtytuł ekranu Skompiluj wymieniał interpretację od AI z nazwy.
|
||||
|
||||
Testu na to nie da się napisać przez „sprawdź, czy przycisku nie ma": trzeba
|
||||
sprawdzić, że w źródle strony nie ma żadnego z tych śladów, i mieć kontrolę
|
||||
pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy,
|
||||
gdy generowanie jest zepsute dla wszystkich.
|
||||
|
||||
## Pełna paranoja: ukrywanie jest nadrzędne
|
||||
|
||||
Właściciel produktu postawił to wyżej niż wygodę i wyżej niż czytelność
|
||||
komunikatów: *„nie chcę, żeby osoba wrzucająca bazy wiedziała, po co to robi
|
||||
i jak będzie w przyszłości działał program, bo to rozgada"*. Persona nazywa się
|
||||
**wgrywacz** — konto z uprawnieniami `files` + `files_input` i niczym więcej.
|
||||
|
||||
Nie ma dowiedzieć się: jakie inne funkcje istnieją, że teksty pisze model
|
||||
językowy i u jakiego dostawcy, do czego służą wgrywane pliki, co jest planowane,
|
||||
że istnieje walidacja plików, ani że istnieje konto, które widzi więcej.
|
||||
|
||||
### Wyciek prawie nigdy nie siedzi tam, gdzie się go szuka
|
||||
|
||||
Audyt sześciu kanałów potwierdził 26 wycieków. Ani jeden nie był przyciskiem.
|
||||
|
||||
| Kanał | Co wyciekało |
|
||||
|---|---|
|
||||
| `/static/**` poza bramką | komplet skryptów i arkuszy dla **niezalogowanego** |
|
||||
| komentarze w CSS/JS | pełne zdania po polsku o funkcjach, o kwarantannie i o tym, że administrator widzi więcej |
|
||||
| `styles.css` jako jeden plik | nazwy selektorów = spis funkcji programu |
|
||||
| `base.html` | skrypty kosmogramu na **każdej** stronie, łącznie z „przyszłą zakładką" |
|
||||
| komunikaty błędu | nazwa trasy, nazwa podsystemu, nazwa gałęzi rozwojowej, wewnętrzny `host:port` |
|
||||
| komunikat po wgraniu | słowo „administrator" — i **dwie różne treści**, czyli wyrocznia do odgadywania reguł walidacji |
|
||||
| `/openapi.json` warstw wewnętrznych | katalog wszystkich funkcji, bez logowania |
|
||||
|
||||
### Trzy zasady, które z tego wynikają
|
||||
|
||||
**Zasób jest częścią funkcji.** Skrypt i arkusz przechodzą przez tę samą bramkę
|
||||
co ekran (`features.STATIC`). Nazwa pliku jest zgadywalna, więc plik publiczny
|
||||
opowiada o funkcji równie dokładnie jak przycisk. Publiczny został jeden
|
||||
`base.css` — bo potrzebuje go ekran logowania — i dlatego nie wolno w nim
|
||||
umieścić niczego, co nazywa funkcję.
|
||||
|
||||
**Komentarz nie jedzie na drut.** `_asset_body()` usuwa komentarze przy
|
||||
serwowaniu. Zostają w repozytorium, gdzie są potrzebne. Ta jedna zmiana zamyka
|
||||
cztery z sześciu kanałów naraz.
|
||||
|
||||
**Różnica jest informacją.** Dwa różne komunikaty po wgraniu pliku były
|
||||
wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać odpowiedź. Teraz
|
||||
komunikat jest jeden, niezależnie od wyniku. Z tego samego powodu odmowa to
|
||||
404 identyczne z „nie ma takiej trasy", a akcja bez uprawnienia cofa się do
|
||||
domyślnej zamiast tłumaczyć, czego brakuje.
|
||||
|
||||
### Zapora słownikowa
|
||||
|
||||
Łatanie punkt po punkcie przegrywa z następną zmianą. Dlatego
|
||||
`test_slownik_zakazany.py` nie sprawdza miejsc, tylko przechodzi **wszystko**,
|
||||
co dane konto może pobrać, i szuka słów, które nie mają prawa paść.
|
||||
|
||||
Dwie z trzech list biorą się wprost z katalogu funkcji — nazwa funkcji, adres
|
||||
jej ekranu i nazwy jej zasobów — więc dopisanie funkcji automatycznie dopisuje
|
||||
je do tego, czego konto bez niej nie może zobaczyć. Trzecia lista, słownictwo
|
||||
dziedziny i mechanizmów, jest pisana ręcznie, bo katalog jej nie zna.
|
||||
|
||||
Test ma kontrolę pozytywną: dla administratora te same słowa **muszą** się
|
||||
pojawiać. Bez niej przechodziłby także wtedy, gdyby program był pusty.
|
||||
|
||||
## Gdzie leżą konta
|
||||
|
||||
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
|
||||
na NFS — **własny podkatalog prezentacji**, nie katalog z bazami: zamontowanie
|
||||
tutaj całego udziału obeszłoby bokiem zamknięcie dostępu z DAN-25.
|
||||
|
||||
Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co `APP_USERS` — jedna
|
||||
implementacja, więc nie ma czego rozjechać. Zapis jest **atomowy** (plik
|
||||
tymczasowy + `os.replace` w tym samym katalogu): przerwanie zapisu nie obetnie
|
||||
pliku, czyli nie skasuje wszystkich kont naraz.
|
||||
|
||||
## Czego ten mechanizm NIE robi
|
||||
|
||||
Nie zastępuje ochrony baz na poziomie sieci ani NFS (DAN-25). Ktoś z dostępem do
|
||||
udziału albo do warstwy danych nadal je odczyta — uprawnienia w aplikacji
|
||||
ograniczają to, co widać **przez aplikację**, i tyle.
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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": "/"}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
@@ -0,0 +1,11 @@
|
||||
# CELOWO KRÓTSZA niż w warstwie prezentacji. Astroklient ma dwie funkcje, więc nie
|
||||
# potrzebuje ani Excela (brak eksportu), ani stref czasowych z lokalizacji (offset
|
||||
# podaje się wprost), ani niczego pod kosmogram — a każda zbędna zależność w obrazie
|
||||
# demo to kolejna rzecz do pilnowania i kolejna droga do środka.
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
httpx>=0.28
|
||||
jinja2>=3.1
|
||||
python-multipart>=0.0.20
|
||||
# Szyfrowanie łącza do warstwy logicznej (PRE-16): AES-256-GCM + HKDF
|
||||
cryptography>=44.0
|
||||
@@ -0,0 +1,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)
|
||||
@@ -37,6 +37,9 @@ class Settings:
|
||||
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
|
||||
|
||||
# Warstwa SQL (po migracji)
|
||||
# Domyślnie SQLite w cache — do testów i pracy lokalnej, bez stawiania bazy.
|
||||
# Na klastrze SQL_URL wskazuje Postgresa i przychodzi z SEKRETU, bo niesie
|
||||
# hasło (patrz deploy: astrololo/README-postgres.md).
|
||||
sql_url: str = field(
|
||||
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Rejestr plików baz: stan użycia, wgrywanie, archiwizacja, walidacja (DAN-27).
|
||||
|
||||
CO SIĘ ZMIENIA WZGLĘDEM DAN-15. Dotąd włączanie i wyłączanie baz szło przez
|
||||
zmienną `DISABLED_BASES` — deklaratywnie, bo warstwa danych nie miała gdzie
|
||||
zapisywać stanu (udział read-only, cache jako emptyDir). Teraz stan jest KLIKANY,
|
||||
więc musi być trwały: udział jest zapisywalny, a stan leży w pliku obok baz.
|
||||
|
||||
STANY PLIKU
|
||||
active — bierze udział w wyszukiwaniu,
|
||||
ready — sprawny, ale świadomie odstawiony; można włączyć jednym kliknięciem,
|
||||
archived — ZAMROŻONY: nie bierze udziału, ma znacznik czasu archiwizacji,
|
||||
sam plik zostaje nietknięty. To jedyna forma „usuwania" dostępna
|
||||
osobie wgrywającej dane,
|
||||
quarantine — wgrany, ale nie przeszedł walidacji. NIE JEST TRACONY; decyzję,
|
||||
czy go skasować, podejmuje wyłącznie administrator.
|
||||
|
||||
DLACZEGO KWARANTANNA JEST NIEWIDOCZNA POZA ADMINISTRATOREM. Zasada z PRE-27 mówi,
|
||||
że konto ograniczone nie ma skąd wiedzieć o mechanizmach, których nie obsługuje.
|
||||
Gdyby plik w kwarantannie był widoczny z powodem odrzucenia, każdy wgrywający
|
||||
poznałby reguły walidacji — a te są narzędziem administratora. Osoba wgrywająca
|
||||
widzi więc plik jako „oczekuje na zatwierdzenie", bez powodu i bez reguł.
|
||||
|
||||
REGUŁY WALIDACJI są danymi, nie kodem: administrator ustawia je z ekranu. Trzymamy
|
||||
je w tym samym pliku stanu, bo stan i reguły zmieniają się razem i muszą przetrwać
|
||||
restart tak samo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
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.
|
||||
# Kwarantanna świadomie poza listą — patrz nagłówek modułu.
|
||||
VISIBLE_TO_EVERYONE = frozenset({ACTIVE, READY, ARCHIVED})
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
DEFAULT_RULES: dict = {
|
||||
"extensions": [".xlsx"],
|
||||
"max_size_mb": 50,
|
||||
"min_rows": 1,
|
||||
"required_columns": [], # puste = bez wymagań co do nagłówków
|
||||
"reject_duplicate_content": True,
|
||||
}
|
||||
|
||||
|
||||
def state_path(root: Path | str) -> Path:
|
||||
"""Plik stanu — obok baz, chyba że wskazano inaczej.
|
||||
|
||||
Sprawdzamy NAPIS ze środowiska, nie Path(napis): Path("") to Path("."),
|
||||
czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna` zawsze
|
||||
wybierało pustą zmienną i zapisywało stan do katalogu bieżącego."""
|
||||
override = os.getenv("FILES_STATE", "").strip()
|
||||
return Path(override) if override else Path(root) / ".files-state.json"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def sha256_of(path: Path | str) -> str:
|
||||
"""Skrót treści pliku — tożsamość pliku niezależna od nazwy.
|
||||
|
||||
Przyda się też krokowi drugiemu (lustro w SQL): to po nim poznamy, że plik
|
||||
na dysku rozjechał się z tym, co wczytano do bazy."""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# ── stan ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _read_state(root: Path) -> dict:
|
||||
try:
|
||||
with open(state_path(root), encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {}
|
||||
files = data.get("files")
|
||||
rules = data.get("rules")
|
||||
return {
|
||||
"files": files if isinstance(files, dict) else {},
|
||||
"rules": {**DEFAULT_RULES, **(rules if isinstance(rules, dict) else {})},
|
||||
}
|
||||
|
||||
|
||||
def _write_state(root: Path, data: dict) -> None:
|
||||
path = state_path(root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomowo: plik stanu opisuje CAŁY zbiór baz, więc obcięcie go w połowie
|
||||
# zapisu skasowałoby wiedzę o wszystkich naraz.
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def rules(root: Path | str) -> dict:
|
||||
return _read_state(Path(root))["rules"]
|
||||
|
||||
|
||||
def set_rules(root: Path | str, new: dict) -> dict:
|
||||
root = Path(root)
|
||||
with _lock:
|
||||
data = _read_state(root)
|
||||
merged = {**data["rules"]}
|
||||
for key, value in (new or {}).items():
|
||||
if key in DEFAULT_RULES:
|
||||
merged[key] = value
|
||||
data["rules"] = merged
|
||||
_write_state(root, data)
|
||||
return merged
|
||||
|
||||
|
||||
# ── walidacja ────────────────────────────────────────────────────────────
|
||||
|
||||
def validate(path: Path | str, root: Path | str, *, digest: str = "",
|
||||
known_digests: dict[str, str] | None = None) -> list[str]:
|
||||
"""Lista POWODÓW odrzucenia. Pusta lista = plik nadaje się do użytku.
|
||||
|
||||
Zwracamy powody, a nie samo „tak/nie", bo administrator ma zobaczyć, CZEGO
|
||||
plikowi brakuje — inaczej poprawianie bazy byłoby zgadywanką. Poza konto
|
||||
administracyjne ta lista nie wychodzi."""
|
||||
p, rs = Path(path), rules(root)
|
||||
why: list[str] = []
|
||||
|
||||
exts = [str(e).lower() for e in rs.get("extensions") or []]
|
||||
if exts and p.suffix.lower() not in exts:
|
||||
why.append(f"rozszerzenie {p.suffix or '(brak)'} spoza dozwolonych: {', '.join(exts)}")
|
||||
|
||||
try:
|
||||
size_mb = p.stat().st_size / (1024 * 1024)
|
||||
except OSError:
|
||||
return why + ["pliku nie da się odczytać"]
|
||||
cap = float(rs.get("max_size_mb") or 0)
|
||||
if cap and size_mb > cap:
|
||||
why.append(f"rozmiar {size_mb:.1f} MB przekracza limit {cap:g} MB")
|
||||
|
||||
if rs.get("reject_duplicate_content") and known_digests:
|
||||
digest = digest or sha256_of(p)
|
||||
twin = next((name for name, d in known_digests.items()
|
||||
if d == digest and name != p.name), None)
|
||||
if twin:
|
||||
why.append(f"treść identyczna z plikiem „{twin}”")
|
||||
|
||||
required = [str(c).strip() for c in (rs.get("required_columns") or []) if str(c).strip()]
|
||||
min_rows = int(rs.get("min_rows") or 0)
|
||||
if required or min_rows:
|
||||
why += _inspect_workbook(p, required, min_rows)
|
||||
return why
|
||||
|
||||
|
||||
def _inspect_workbook(path: Path, required: list[str], min_rows: int) -> list[str]:
|
||||
"""Zagląda do arkusza: nagłówki i liczba wierszy.
|
||||
|
||||
read_only + tylko pierwszy arkusz — plik bazy potrafi mieć kilkadziesiąt MB,
|
||||
a wczytanie go w całości przy każdym wgraniu zatkałoby usługę."""
|
||||
try:
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
except Exception as e: # noqa: BLE001 — każdy błąd = powód
|
||||
return [f"nie udało się otworzyć arkusza ({type(e).__name__})"]
|
||||
why: list[str] = []
|
||||
try:
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = [str(c).strip().lower() for c in (next(rows, ()) or ()) if c is not None]
|
||||
missing = [c for c in required if c.strip().lower() not in header]
|
||||
if missing:
|
||||
why.append(f"brak wymaganych kolumn: {', '.join(missing)}")
|
||||
if min_rows:
|
||||
seen = sum(1 for i, _ in enumerate(rows) if i < min_rows)
|
||||
if seen < min_rows:
|
||||
why.append(f"za mało wierszy danych ({seen} < {min_rows})")
|
||||
finally:
|
||||
wb.close()
|
||||
return why
|
||||
|
||||
|
||||
# ── rejestr ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _scan(root: Path) -> list[Path]:
|
||||
"""Pliki na udziale, bez śmieci technicznych.
|
||||
|
||||
Pomijamy nie tylko ukryte PLIKI, ale i wszystko, co leży w ukrytym KATALOGU:
|
||||
filtr po samej nazwie pliku wciągał do rejestru zawartość `.cache`, bo pliki
|
||||
w środku nie zaczynają się od kropki. Efekt: cache podawany jako baza, a przy
|
||||
pierwszym uruchomieniu jeszcze przyjmowany jako aktywny."""
|
||||
def ukryta_sciezka(p: Path) -> bool:
|
||||
return any(part.startswith(".") for part in p.relative_to(root).parts[:-1])
|
||||
|
||||
return [p for p in sorted(root.glob("**/*"))
|
||||
if p.is_file() and not p.name.startswith((".", "~$"))
|
||||
and not ukryta_sciezka(p)]
|
||||
|
||||
|
||||
def _adopt_existing(root: Path) -> dict:
|
||||
"""Pierwsze uruchomienie: bazy zastane na udziale są OD RAZU w użyciu.
|
||||
|
||||
Bez tego wdrożenie DAN-27 wyłączyłoby wyszukiwanie. Dotąd bazy działały
|
||||
domyślnie (wyłączało się je jawnie przez DISABLED_BASES); po przejściu na
|
||||
rejestr plik bez wpisu dostaje `ready`, czyli NIE w użyciu — więc pusty stan
|
||||
po wdrożeniu oznaczałby, że program nagle niczego nie znajduje. Ta cicha
|
||||
zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza.
|
||||
|
||||
Rozróżnienie jest celowe: `ready` dotyczy plików WGRANYCH przez ekran (te
|
||||
ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr.
|
||||
|
||||
Zapis stanu może się nie udać (udział read-only) — wtedy trudno, przy każdym
|
||||
uruchomieniu przyjmiemy je na nowo. Zachowanie jest to samo, koszt żaden."""
|
||||
files = {str(p.relative_to(root)): {"status": ACTIVE, "adopted_at": _now()}
|
||||
for p in _scan(root)}
|
||||
data = {"files": files, "rules": {**DEFAULT_RULES}}
|
||||
try:
|
||||
_write_state(root, data)
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]:
|
||||
"""Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody.
|
||||
|
||||
Filtrowanie siedzi TUTAJ, a nie w szablonie: gdyby pliki w kwarantannie
|
||||
dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd
|
||||
źródła strony, żeby poznać reguły walidacji."""
|
||||
root = Path(root)
|
||||
# Brak PLIKU stanu = pierwsze uruchomienie. Pusty słownik przy istniejącym
|
||||
# pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy.
|
||||
data = _read_state(root) if state_path(root).exists() else _adopt_existing(root)
|
||||
out: list[dict] = []
|
||||
for p in _scan(root):
|
||||
rel = str(p.relative_to(root))
|
||||
row = data["files"].get(rel, {})
|
||||
status = row.get("status") or READY
|
||||
if status == QUARANTINE and not for_admin:
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
size_mb = round(st.st_size / (1024 * 1024), 2)
|
||||
modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
except OSError:
|
||||
size_mb, modified = None, None
|
||||
entry = {
|
||||
"name": p.name, "path": rel, "size_mb": size_mb, "modified": modified,
|
||||
"status": status, "in_use": status in USABLE,
|
||||
# `enabled` to TA SAMA informacja pod nazwą, której używa reszta
|
||||
# świata: endpoint /bases, warstwa logiczna i ekran „Ustawienia"
|
||||
# (DAN-15/PRE-09). Rejestr wszedł w miejsce starej listy baz, więc
|
||||
# musi mówić jej językiem — inaczej każdy odbiorca dostaje KeyError,
|
||||
# a to była właśnie awaria /bases po wdrożeniu DAN-27.
|
||||
"enabled": status in USABLE,
|
||||
"archived_at": row.get("archived_at") or "",
|
||||
"uploaded_at": row.get("uploaded_at") or "",
|
||||
"uploaded_by": row.get("uploaded_by") or "",
|
||||
"sha256": row.get("sha256") or "",
|
||||
}
|
||||
if for_admin:
|
||||
entry["rejected_for"] = list(row.get("rejected_for") or [])
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def usable_paths(root: Path | str) -> list[str]:
|
||||
"""Ścieżki baz, które FAKTYCZNIE biorą udział w wyszukiwaniu."""
|
||||
root = Path(root)
|
||||
return [str(root / e["path"]) for e in registry(root, for_admin=True) if e["in_use"]]
|
||||
|
||||
|
||||
def _touch(root: Path, rel: str, **fields) -> dict:
|
||||
with _lock:
|
||||
data = _read_state(root)
|
||||
row = {**data["files"].get(rel, {}), **fields}
|
||||
data["files"][rel] = row
|
||||
_write_state(root, data)
|
||||
return row
|
||||
|
||||
|
||||
def set_status(root: Path | str, rel: str, status: str, *, by: str = "") -> dict:
|
||||
"""Zmienia stan pliku. Włączyć do użytku można TYLKO plik, który przeszedł
|
||||
walidację — to jest właśnie ta bramka, o której mowa w wymaganiu."""
|
||||
root = Path(root)
|
||||
target = root / rel
|
||||
if not target.is_file():
|
||||
raise ValueError(f"Nie ma pliku „{rel}”.")
|
||||
if status not in {ACTIVE, READY, ARCHIVED, QUARANTINE}:
|
||||
raise ValueError(f"Nieznany stan: {status}")
|
||||
|
||||
data = _read_state(root)
|
||||
current = (data["files"].get(rel) or {}).get("status") or READY
|
||||
if status == ACTIVE:
|
||||
if current == QUARANTINE:
|
||||
raise ValueError("Plik nie może trafić do użytku.")
|
||||
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"]}
|
||||
why = validate(target, root, known_digests=known)
|
||||
if why:
|
||||
_touch(root, rel, status=QUARANTINE, rejected_for=why, checked_at=_now())
|
||||
raise ValueError("Plik nie może trafić do użytku.")
|
||||
|
||||
fields = {"status": status, "changed_at": _now(), "changed_by": by}
|
||||
if status == ARCHIVED:
|
||||
# Znacznik czasu archiwizacji to wymóg: „zamrożona forma z timestampem".
|
||||
fields["archived_at"] = _now()
|
||||
elif status == ACTIVE:
|
||||
fields["archived_at"] = ""
|
||||
fields["rejected_for"] = []
|
||||
return _touch(root, rel, **fields)
|
||||
|
||||
|
||||
def store_upload(root: Path | str, filename: str, content: bytes, *, by: str = "") -> dict:
|
||||
"""Zapisuje wgrany plik i od razu go sprawdza.
|
||||
|
||||
Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś
|
||||
wgrał. Zmienia się tylko to, czy da się go włączyć do użytku."""
|
||||
root = Path(root)
|
||||
safe = re.sub(r"[^A-Za-z0-9._ -]", "_", Path(filename or "").name).strip() or "plik"
|
||||
target = root / safe
|
||||
stem, suffix, n = Path(safe).stem, Path(safe).suffix, 1
|
||||
while target.exists(): # nie nadpisujemy cudzej bazy
|
||||
target = root / f"{stem}-{n}{suffix}"
|
||||
n += 1
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
|
||||
rel = str(target.relative_to(root))
|
||||
digest = sha256_of(target)
|
||||
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True)
|
||||
if e["sha256"] and e["path"] != rel}
|
||||
why = validate(target, root, digest=digest, known_digests=known)
|
||||
_touch(root, rel, status=QUARANTINE if why else READY, rejected_for=why,
|
||||
sha256=digest, uploaded_at=_now(), uploaded_by=by, checked_at=_now())
|
||||
return {"path": rel, "name": target.name, "accepted": not why}
|
||||
|
||||
|
||||
def delete(root: Path | str, rel: str) -> None:
|
||||
"""Nieodwracalne skasowanie pliku — wyłącznie dla administratora."""
|
||||
root = Path(root)
|
||||
target = root / rel
|
||||
if not target.is_file():
|
||||
raise ValueError(f"Nie ma pliku „{rel}”.")
|
||||
target.unlink()
|
||||
with _lock:
|
||||
data = _read_state(root)
|
||||
data["files"].pop(rel, None)
|
||||
_write_state(root, data)
|
||||
@@ -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")
|
||||
|
||||
@@ -7,13 +7,16 @@ ani prezentacji.
|
||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
from app import canary, link_crypto, security
|
||||
from app import canary, files, link_crypto, security
|
||||
from app.config import settings
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from pydantic import BaseModel
|
||||
from app.providers.factory import build_provider
|
||||
|
||||
provider = build_provider(settings)
|
||||
@@ -25,7 +28,12 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan,
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
||||
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||
@@ -54,6 +62,86 @@ def bases() -> dict:
|
||||
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
||||
|
||||
|
||||
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
|
||||
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
|
||||
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
|
||||
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
|
||||
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
|
||||
# z nią wyłącznie warstwa logiczna.
|
||||
|
||||
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 = 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)}
|
||||
|
||||
|
||||
@app.post("/files/status")
|
||||
def files_status(a: FileAction) -> dict:
|
||||
try:
|
||||
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")}
|
||||
|
||||
|
||||
@app.post("/files/upload")
|
||||
def files_upload(u: FileUpload) -> dict:
|
||||
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
|
||||
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
|
||||
byłby obejściem PRE-16."""
|
||||
try:
|
||||
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(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(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}
|
||||
|
||||
|
||||
@app.post("/files/rules")
|
||||
def files_rules(u: RulesUpdate) -> dict:
|
||||
return {"rules": files.set_rules(files.tenant_root(settings.excel_dir, u.tenant), u.rules)}
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthInfo)
|
||||
def health() -> HealthInfo:
|
||||
return provider.health()
|
||||
|
||||
@@ -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,21 +91,34 @@ 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]:
|
||||
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
|
||||
w interpretacji, choć fizycznie leżą na udziale."""
|
||||
from app import bases
|
||||
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,
|
||||
trwały na udziale. Zmienna DISABLED_BASES z DAN-15 zostaje jako awaryjne
|
||||
wyłączenie z konfiguracji: gdy jest ustawiona, odsiewa DODATKOWO. Nie
|
||||
odwrotnie — inaczej ktoś z dostępem do ekranu mógłby włączyć bazę
|
||||
wyłączoną świadomie na poziomie wdrożenia.
|
||||
"""
|
||||
# `bases` MUSI być zaimportowane tutaj — modułowego importu nie ma,
|
||||
# a przepisując tę funkcję pod rejestr usunąłem lokalny. Efekt: NameError
|
||||
# przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych.
|
||||
from app import bases, files
|
||||
|
||||
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 not entries:
|
||||
return paths
|
||||
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
|
||||
if entries:
|
||||
out = [p for p in out if bases.is_enabled(p, self.s.excel_dir, entries)]
|
||||
return out
|
||||
|
||||
def list_bases(self) -> list[dict]:
|
||||
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
|
||||
from app import bases
|
||||
|
||||
return bases.list_bases(self.s.excel_dir, self._excel_files())
|
||||
from app import files
|
||||
|
||||
return files.registry(self.s.excel_dir, for_admin=True)
|
||||
|
||||
# ---- publiczne API ----
|
||||
def search(self, query: SearchQuery) -> SearchResult:
|
||||
@@ -115,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:
|
||||
@@ -127,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] = []
|
||||
|
||||
@@ -6,6 +6,11 @@ pandas>=2.2
|
||||
openpyxl>=3.1
|
||||
pyarrow>=18.0
|
||||
SQLAlchemy>=2.0
|
||||
# Sterownik Postgresa (DAN-28). Sam SQLAlchemy nie rozmawia z bazą — bez tego
|
||||
# `postgresql+psycopg://…` wywala się dopiero przy PIERWSZYM połączeniu, już na
|
||||
# klastrze, komunikatem o braku modułu. [binary] = gotowe koło, bez kompilacji
|
||||
# libpq w obrazie.
|
||||
psycopg[binary]>=3.2
|
||||
pydantic>=2.10
|
||||
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||
cryptography>=44.0
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Rejestr plików baz: stany, walidacja, archiwizacja (DAN-27).
|
||||
|
||||
Testujemy tu RDZEŃ — bez HTTP i bez uprawnień, bo uprawnienia rozstrzyga
|
||||
prezentacja (patrz services/presentation/tests/test_pliki.py). Tutaj chodzi
|
||||
o to, żeby żadna operacja nie gubiła pliku i żeby bramka „do użytku tylko po
|
||||
walidacji" faktycznie trzymała.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app import files
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def root(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("FILES_STATE", raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _xlsx(path, rows=3, header=("id", "opis")):
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(list(header))
|
||||
for i in range(rows):
|
||||
ws.append([i, f"treść {i}"])
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
# ── rejestr i stany ──────────────────────────────────────────────────────
|
||||
|
||||
def test_a_file_dropped_on_the_share_is_adopted_as_active(root):
|
||||
"""Baza położona na udziale poza aplikacją (np. przez NFS) ma działać —
|
||||
tak było przed DAN-27 i tak ma zostać. Plik WGRANY EKRANEM to inna sprawa:
|
||||
ten wymaga świadomego włączenia (patrz test niżej)."""
|
||||
_xlsx(root / "baza.xlsx")
|
||||
entry = files.registry(root)[0]
|
||||
assert entry["status"] == files.ACTIVE
|
||||
assert entry["in_use"] is True
|
||||
|
||||
|
||||
def test_only_active_files_reach_the_search(root):
|
||||
_xlsx(root / "a.xlsx")
|
||||
_xlsx(root / "b.xlsx")
|
||||
files.registry(root) # przyjęcie zastanych
|
||||
files.set_status(root, "b.xlsx", files.READY) # świadome odstawienie
|
||||
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"]
|
||||
files.set_status(root, "b.xlsx", files.ACTIVE)
|
||||
assert len(files.usable_paths(root)) == 2
|
||||
|
||||
|
||||
def test_state_survives_a_restart(root):
|
||||
"""Stan jest KLIKANY, więc musi być trwały — inaczej restart poda po cichu
|
||||
przywracałby bazy wyłączone świadomie."""
|
||||
_xlsx(root / "a.xlsx")
|
||||
files.set_status(root, "a.xlsx", files.ACTIVE)
|
||||
assert files.state_path(root).exists()
|
||||
assert files.registry(root)[0]["in_use"] is True
|
||||
|
||||
|
||||
# ── archiwizacja ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_archiving_freezes_the_file_but_never_removes_it(root):
|
||||
"""To jest najdalej idąca operacja osoby wgrywającej dane: plik ZOSTAJE."""
|
||||
p = _xlsx(root / "stara.xlsx")
|
||||
files.set_status(root, "stara.xlsx", files.ACTIVE)
|
||||
files.set_status(root, "stara.xlsx", files.ARCHIVED, by="dane")
|
||||
entry = files.registry(root)[0]
|
||||
assert p.exists(), "plik zniknął z dysku — archiwizacja ma go zachować"
|
||||
assert entry["status"] == files.ARCHIVED
|
||||
assert entry["in_use"] is False
|
||||
assert entry["archived_at"], "brak znacznika czasu archiwizacji"
|
||||
|
||||
|
||||
def test_archived_file_cannot_slip_back_into_use_by_itself(root):
|
||||
_xlsx(root / "stara.xlsx")
|
||||
files.set_status(root, "stara.xlsx", files.ARCHIVED)
|
||||
assert files.usable_paths(root) == []
|
||||
|
||||
|
||||
# ── walidacja: bramka do użytku ──────────────────────────────────────────
|
||||
|
||||
def test_upload_keeps_a_file_that_fails_validation(root):
|
||||
"""Rzecz najważniejsza: wgranego pliku NIE TRACIMY, choćby nie przeszedł."""
|
||||
files.set_rules(root, {"extensions": [".xlsx"]})
|
||||
out = files.store_upload(root, "notatka.txt", "to nie jest baza".encode("utf-8"), by="dane")
|
||||
assert out["accepted"] is False
|
||||
assert (root / out["path"]).exists(), "plik odrzucony zniknął z dysku"
|
||||
admin_view = files.registry(root, for_admin=True)[0]
|
||||
assert admin_view["status"] == files.QUARANTINE
|
||||
assert admin_view["rejected_for"], "administrator ma widzieć powód"
|
||||
|
||||
|
||||
def test_a_held_file_is_invisible_without_admin_rights(root):
|
||||
"""Gdyby plik wstrzymany był widoczny z powodem odrzucenia, każdy wgrywający
|
||||
poznałby reguły walidacji — a te są narzędziem administratora."""
|
||||
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
|
||||
assert files.registry(root, for_admin=False) == []
|
||||
assert len(files.registry(root, for_admin=True)) == 1
|
||||
|
||||
|
||||
def test_a_held_file_cannot_be_switched_into_use(root):
|
||||
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
|
||||
rel = files.registry(root, for_admin=True)[0]["path"]
|
||||
with pytest.raises(ValueError):
|
||||
files.set_status(root, rel, files.ACTIVE)
|
||||
|
||||
|
||||
def test_activation_revalidates_and_holds_a_file_that_stopped_qualifying(root):
|
||||
"""Reguły mogą się zmienić PO wgraniu — bramka sprawdza w chwili włączania,
|
||||
a nie tylko przy wgrywaniu."""
|
||||
_xlsx(root / "mala.xlsx", rows=2)
|
||||
files.set_status(root, "mala.xlsx", files.ACTIVE)
|
||||
files.set_rules(root, {"min_rows": 500})
|
||||
files.set_status(root, "mala.xlsx", files.READY)
|
||||
with pytest.raises(ValueError):
|
||||
files.set_status(root, "mala.xlsx", files.ACTIVE)
|
||||
assert (root / "mala.xlsx").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule,value,bad", [
|
||||
("extensions", [".xlsx"], "plik.csv"),
|
||||
("max_size_mb", 0.000001, "plik.xlsx"),
|
||||
])
|
||||
def test_rules_reject_what_they_are_meant_to(root, rule, value, bad):
|
||||
files.set_rules(root, {rule: value})
|
||||
out = files.store_upload(root, bad, b"x" * 2048)
|
||||
assert out["accepted"] is False
|
||||
|
||||
|
||||
def test_required_columns_are_checked_inside_the_workbook(root):
|
||||
files.set_rules(root, {"required_columns": ["id", "znaczenie"]})
|
||||
_xlsx(root / "tmp.xlsx", header=("id", "opis"))
|
||||
why = files.validate(root / "tmp.xlsx", root)
|
||||
assert why and "znaczenie" in why[0]
|
||||
|
||||
|
||||
def test_duplicate_content_is_rejected_by_hash_not_by_name(root):
|
||||
files.set_rules(root, {"reject_duplicate_content": True})
|
||||
data = _xlsx(root / "wzor.xlsx").read_bytes()
|
||||
first = files.store_upload(root, "pierwsza.xlsx", data)
|
||||
assert first["accepted"] is True
|
||||
second = files.store_upload(root, "inna-nazwa.xlsx", data)
|
||||
assert second["accepted"] is False
|
||||
|
||||
|
||||
def test_upload_never_overwrites_someone_elses_base(root):
|
||||
files.store_upload(root, "baza.xlsx", _xlsx(root / "w.xlsx").read_bytes())
|
||||
(root / "w.xlsx").unlink()
|
||||
files.set_rules(root, {"reject_duplicate_content": False})
|
||||
out = files.store_upload(root, "baza.xlsx", "inna treść".encode("utf-8"))
|
||||
assert out["name"] != "baza.xlsx"
|
||||
assert (root / "baza.xlsx").exists() and (root / out["path"]).exists()
|
||||
|
||||
|
||||
# ── kasowanie ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_delete_removes_the_file_and_its_entry(root):
|
||||
_xlsx(root / "a.xlsx")
|
||||
files.set_status(root, "a.xlsx", files.ACTIVE)
|
||||
files.delete(root, "a.xlsx")
|
||||
assert not (root / "a.xlsx").exists()
|
||||
assert files.registry(root, for_admin=True) == []
|
||||
assert files.usable_paths(root) == []
|
||||
|
||||
|
||||
# ── przejście na rejestr nie może wyłączyć wyszukiwania ─────────────────
|
||||
|
||||
def test_bases_already_on_the_share_stay_in_use_after_the_switch(root):
|
||||
"""Dotąd bazy działały domyślnie (wyłączało się je przez DISABLED_BASES).
|
||||
Po przejściu na rejestr pusty stan oznaczałby, że program nagle niczego nie
|
||||
znajduje — cicha zmiana zachowania gorsza od awarii, bo wygląda jak pusta baza."""
|
||||
_xlsx(root / "main_base.xlsx")
|
||||
_xlsx(root / "zodiac_pl.xlsx")
|
||||
assert len(files.usable_paths(root)) == 2, "zastane bazy wypadły z wyszukiwania"
|
||||
assert all(e["in_use"] for e in files.registry(root))
|
||||
|
||||
|
||||
def test_adoption_happens_once_and_respects_later_decisions(root):
|
||||
"""Po przyjęciu stan jest zapisany, więc świadome odstawienie bazy ZOSTAJE —
|
||||
kolejny odczyt nie może jej wskrzesić."""
|
||||
_xlsx(root / "a.xlsx")
|
||||
_xlsx(root / "b.xlsx")
|
||||
files.registry(root) # przyjęcie
|
||||
files.set_status(root, "a.xlsx", files.READY) # świadome odstawienie
|
||||
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["b.xlsx"]
|
||||
files.set_status(root, "b.xlsx", files.READY) # odstawiamy wszystko
|
||||
assert files.usable_paths(root) == [], "pusty wybór został wskrzeszony"
|
||||
|
||||
|
||||
def test_uploaded_files_still_need_an_explicit_switch_on(root):
|
||||
"""Przyjęcie dotyczy TYLKO baz zastanych. Plik wgrany ekranem ktoś musi
|
||||
świadomie włączyć — inaczej nowa baza wchodziłaby do wyników sama."""
|
||||
_xlsx(root / "zastana.xlsx")
|
||||
files.registry(root)
|
||||
out = files.store_upload(root, "nowa.xlsx", _xlsx(root / "tmp.xlsx").read_bytes())
|
||||
assert out["accepted"] is True
|
||||
names = [pathlib.Path(p).name for p in files.usable_paths(root)]
|
||||
assert "nowa.xlsx" not in names, "wgrana baza weszła do wyników bez decyzji"
|
||||
|
||||
|
||||
def test_adoption_survives_a_read_only_share(root, monkeypatch):
|
||||
"""Na udziale tylko do odczytu stanu nie da się zapisać — zachowanie ma
|
||||
zostać to samo, tylko przyjęcie powtórzy się przy każdym uruchomieniu."""
|
||||
_xlsx(root / "a.xlsx")
|
||||
|
||||
def boom(*a, **kw):
|
||||
raise OSError("read-only file system")
|
||||
|
||||
monkeypatch.setattr(files, "_write_state", boom)
|
||||
assert len(files.usable_paths(root)) == 1
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Rejestr plików a RESZTA warstwy danych — punkty styku (DAN-27).
|
||||
|
||||
DLACZEGO OSOBNY PLIK. test_files.py sprawdza sam rejestr w izolacji i przechodził
|
||||
na zielono, podczas gdy na produkcji leżało wyszukiwanie (500) i lista baz (502).
|
||||
Rejestr wszedł w miejsce starego mechanizmu włączania baz, więc groźne jest nie
|
||||
to, co robi w środku, tylko czy MÓWI TYM SAMYM JĘZYKIEM, co jego odbiorcy.
|
||||
|
||||
Oba tamte błędy były jednolinijkowe i oba niewidoczne dla testów jednostkowych:
|
||||
* NameError, bo przepisując `_enabled_files` usunąłem lokalny import `bases`,
|
||||
* KeyError, bo rejestr oddawał `in_use`, a endpoint /bases czytał `enabled`.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def excel_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("EXCEL_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("CACHE_DIR", str(tmp_path / ".cache"))
|
||||
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||
monkeypatch.delenv("FILES_STATE", raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _xlsx(path, rows=(("Ma Ari", "odważny"), ("Ve Tau", "zgodny"))):
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["significator", "effect"])
|
||||
for r in rows:
|
||||
ws.append(list(r))
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _provider(excel_dir):
|
||||
from app.config import Settings
|
||||
from app.providers.excel_provider import ExcelDataProvider
|
||||
|
||||
return ExcelDataProvider(Settings())
|
||||
|
||||
|
||||
def test_search_does_not_explode_on_the_registry(excel_dir):
|
||||
"""Regresja: `_enabled_files` wołało bases.disabled_entries() bez importu,
|
||||
więc KAŻDE wyszukiwanie kończyło się NameError → 500 z warstwy danych."""
|
||||
from app.models import SearchQuery
|
||||
|
||||
_xlsx(excel_dir / "baza.xlsx")
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
|
||||
assert out.total >= 1, "zastana baza nie weszła do wyszukiwania"
|
||||
|
||||
|
||||
def test_search_still_works_with_disabled_bases_set(excel_dir, monkeypatch):
|
||||
"""DISABLED_BASES zostaje jako awaryjne wyłączenie i ma odsiewać DODATKOWO —
|
||||
to właśnie ta gałąź kodu wywalała się na braku importu."""
|
||||
from app.models import SearchQuery
|
||||
|
||||
_xlsx(excel_dir / "a.xlsx")
|
||||
_xlsx(excel_dir / "b.xlsx")
|
||||
monkeypatch.setenv("DISABLED_BASES", "b.xlsx")
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
|
||||
assert out.total >= 1
|
||||
|
||||
|
||||
def test_registry_speaks_the_language_the_bases_endpoint_reads(excel_dir):
|
||||
"""Regresja: endpoint /bases liczy `b["enabled"]`, rejestr oddawał `in_use`.
|
||||
KeyError → 500 z danych → 502 z logiki → „Warstwa logiczna niedostępna"
|
||||
na ekranie Ustawienia."""
|
||||
_xlsx(excel_dir / "baza.xlsx")
|
||||
items = _provider(excel_dir).list_bases()
|
||||
assert items, "lista baz jest pusta"
|
||||
for row in items:
|
||||
for key in ("name", "path", "enabled", "in_use", "size_mb", "modified"):
|
||||
assert key in row, f"brak pola `{key}` — odbiorca dostanie KeyError"
|
||||
assert row["enabled"] == row["in_use"], "dwa pola, jedna prawda"
|
||||
|
||||
|
||||
def test_bases_endpoint_answers_end_to_end(excel_dir):
|
||||
"""Przez TRASĘ, nie przez dostawcę: to ona wywalała się na produkcji."""
|
||||
_xlsx(excel_dir / "baza.xlsx")
|
||||
import importlib
|
||||
|
||||
from app import main as data_main
|
||||
|
||||
importlib.reload(data_main)
|
||||
body = data_main.bases()
|
||||
assert body["total"] == 1
|
||||
assert body["enabled"] == 1, "zastana baza powinna być włączona po adopcji"
|
||||
assert body["bases"][0]["name"] == "baza.xlsx"
|
||||
|
||||
|
||||
def test_switching_a_base_off_is_visible_in_both_places(excel_dir):
|
||||
"""Odstawienie bazy ma zniknąć i z wyszukiwania, i z licznika na Ustawieniach."""
|
||||
from app import files
|
||||
from app.models import SearchQuery
|
||||
|
||||
_xlsx(excel_dir / "baza.xlsx")
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1
|
||||
|
||||
files.set_status(excel_dir, "baza.xlsx", files.READY)
|
||||
p2 = _provider(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
|
||||
@@ -15,12 +15,17 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import swisseph as swe
|
||||
|
||||
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
|
||||
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
||||
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
||||
@@ -69,6 +74,67 @@ def positions(req: PositionsRequest) -> dict:
|
||||
return {"engine": "swisseph", "positions": rows}
|
||||
|
||||
|
||||
# Kody systemów domów w Swiss Ephemeris. Nazwy po LEWEJ są nasze — te same,
|
||||
# których używa houses.SYSTEMS w warstwie logicznej — żeby wołający nie musiał
|
||||
# znać liter swissepha. Lista celowo pokrywa się 1:1 z naszą: rozjazd oznaczałby,
|
||||
# że kontrakt parzystości (LOG-28) przestał obejmować część systemów.
|
||||
_HOUSE_CODES = {
|
||||
"whole_sign": b"W", "whole_sign_aries": b"N",
|
||||
"equal": b"E", "equal_mc": b"D",
|
||||
"porphyry": b"O", "vehlow": b"V", "morinus": b"M",
|
||||
"regiomontanus": b"R", "campanus": b"C", "alcabitus": b"B",
|
||||
"topocentric": b"T", "placidus": b"P", "koch": b"K",
|
||||
}
|
||||
|
||||
# Kolejność, w jakiej swe_houses zwraca punkty w tablicy ascmc.
|
||||
_ASCMC = ("Asc", "MC", "ARMC", "Vertex", "equatorial_asc",
|
||||
"co_asc_koch", "co_asc_munkasey", "polar_asc")
|
||||
|
||||
|
||||
class HousesRequest(BaseModel):
|
||||
when_utc: datetime
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
system: str = "whole_sign"
|
||||
|
||||
|
||||
@app.post("/houses")
|
||||
def houses(req: HousesRequest) -> dict:
|
||||
"""Cuspy domów i osie policzone przez silnik B — do porównania z własnym.
|
||||
|
||||
Domyka kontrakt parzystości (LOG-28) po stronie domów: dotąd obejmował
|
||||
wyłącznie pozycje obiektów, więc błąd w podziale na domy przechodził przez
|
||||
porównanie silników niezauważony. Błąd w domach jest CICHY — wykres wygląda
|
||||
poprawnie, tylko planety siedzą gdzie indziej — więc akurat tu warto mieć
|
||||
drugie zdanie.
|
||||
|
||||
Placidus i Koch nie istnieją powyżej koła podbiegunowego i swisseph zgłasza
|
||||
tam wyjątek. Oddajemy to jako 422 z czytelnym powodem, a NIE podstawiamy po
|
||||
cichu innego systemu: cicha podmiana jest nie do wykrycia po stronie
|
||||
wołającego, a to on ma zdecydować, co z tym zrobić.
|
||||
"""
|
||||
code = _HOUSE_CODES.get(req.system)
|
||||
if code is None:
|
||||
raise HTTPException(422, f"nieznany system domów: {req.system!r} "
|
||||
f"(znane: {', '.join(sorted(_HOUSE_CODES))})")
|
||||
d = req.when_utc
|
||||
ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0
|
||||
jd = swe.julday(d.year, d.month, d.day, ut_hours)
|
||||
try:
|
||||
cusps, ascmc = swe.houses(jd, req.lat, req.lon, code)
|
||||
except Exception as e: # poza dziedziną systemu
|
||||
raise HTTPException(
|
||||
422, f"system {req.system!r} nie ma definicji dla φ={req.lat}: {e}") from e
|
||||
|
||||
return {
|
||||
"engine": "swisseph",
|
||||
"system": req.system,
|
||||
"cusps": [{"house": i + 1, "longitude": c % 360.0} for i, c in enumerate(cusps)],
|
||||
"angles": {name: ascmc[i] % 360.0 for i, name in enumerate(_ASCMC)
|
||||
if i < len(ascmc) and name in ("Asc", "MC", "ARMC", "Vertex")},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
||||
|
||||
@@ -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(),
|
||||
@@ -51,6 +62,33 @@ class DataClient:
|
||||
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_list(self, for_admin: bool = False) -> dict[str, Any]:
|
||||
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, "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, "tenant": self.tenant})
|
||||
|
||||
def files_delete(self, path: str) -> dict[str, Any]:
|
||||
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, "tenant": self.tenant})
|
||||
|
||||
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
||||
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 health(self) -> dict[str, Any]:
|
||||
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||
# nie mają, a nie przechodzi tędy nic z baz.
|
||||
|
||||
@@ -60,7 +60,15 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
||||
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
||||
# z surowych długości/szerokości silnika.
|
||||
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||
# ε PRAWDZIWE — to samo, którym liczymy domy, bo pozycje ze Skyfielda są
|
||||
# POZORNE (uwzględniają nutację). Silnik bez sidereal() nutacji nie poda;
|
||||
# wtedy zostaje średnie, z jawnym oznaczeniem w wyniku.
|
||||
if hasattr(engine, "sidereal"):
|
||||
eps = engine.sidereal(moment)[1]
|
||||
result["obliquity_kind"] = "true"
|
||||
else:
|
||||
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||
result["obliquity_kind"] = "mean"
|
||||
result["obliquity"] = round(eps, 6)
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
dec = declination(obj.longitude, obj.latitude, eps)
|
||||
|
||||
@@ -14,6 +14,8 @@ from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||
|
||||
WHOLE_SIGN = "whole_sign"
|
||||
EQUAL = "equal"
|
||||
EQUAL_MC = "equal_mc" # równe domy zakotwiczone na MC, nie na Asc
|
||||
WHOLE_SIGN_ARIES = "whole_sign_aries" # znaki jako domy, ale dom I to ZAWSZE Baran
|
||||
PORPHYRY = "porphyry"
|
||||
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
||||
# iteracyjnego i dochodzą osobno.
|
||||
@@ -25,14 +27,12 @@ ALCABITUS = "alcabitus"
|
||||
TOPOCENTRIC = "topocentric"
|
||||
PLACIDUS = "placidus"
|
||||
KOCH = "koch"
|
||||
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris co do zera
|
||||
# (tests/oracle). TOPOCENTRIC celowo POZA listą: implementacja jest zgodna
|
||||
# z wyrocznią wszędzie poza otoczeniem bieguna (|φ| ≈ 89,9° przy RAMC 90°/270°),
|
||||
# gdzie kolejność domów się odwraca i konstrukcja traci sens — jego „biegun"
|
||||
# atan(tan(89,9°)/3) to już 89,7°. Nie zawężamy dziedziny wyłącznie po to, by
|
||||
# testy przeszły; system dołączy, gdy będzie poprawny na całej dziedzinie.
|
||||
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS,
|
||||
REGIOMONTANUS, CAMPANUS, ALCABITUS, PLACIDUS, KOCH)
|
||||
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
|
||||
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
|
||||
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
|
||||
SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
|
||||
MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
|
||||
PLACIDUS, KOCH)
|
||||
|
||||
|
||||
def mean_obliquity(tt_jd: float) -> float:
|
||||
@@ -289,44 +289,70 @@ def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
|
||||
|
||||
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
||||
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
||||
_TOPO_POLE_FRACTION = (1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3,
|
||||
1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3)
|
||||
# Polich–Page: dom pośredni to Ascendent policzony pod własnym „biegunem"
|
||||
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
|
||||
# przy k=0 (biegun 0, przesunięcie −90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
|
||||
# Ascendent, a domy 11 i 12 leżą po drodze.
|
||||
#
|
||||
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
|
||||
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
|
||||
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
|
||||
# krokami) myliły się na 6–11% przypadków powyżej ~70°, bo każda z nich rozstrzyga
|
||||
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
|
||||
#
|
||||
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
|
||||
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
|
||||
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
|
||||
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
|
||||
# i bez zawężania szerokości.
|
||||
|
||||
|
||||
def _asc_under_pole_raw(ramc_deg: float, eps_deg: float, pole_deg: float) -> float:
|
||||
"""Surowy wzór na ascendent pod zadanym „biegunem", BEZ korekty gałęzi.
|
||||
|
||||
Korektę stosuje wywołujący — względem PRAWDZIWEGO MC horoskopu. `compute_asc`
|
||||
poprawia gałąź względem MC dla PRZESUNIĘTEGO RAMC, co dla cuspu domu jest złym
|
||||
punktem odniesienia i w okolicach biegunów dawało obrót o 180°."""
|
||||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(pole_deg)
|
||||
return norm360(math.degrees(math.atan2(
|
||||
math.cos(r), -(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)))))
|
||||
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
|
||||
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
|
||||
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
|
||||
|
||||
|
||||
def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Polich–Page: dom pośredni to ASCENDENT policzony pod własnym „biegunem"
|
||||
tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu.
|
||||
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
|
||||
|
||||
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 — to nie skrót,
|
||||
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
|
||||
|
||||
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
||||
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
||||
konstrukcji „ascendent pod biegunem".
|
||||
|
||||
Liczymy tylko domy 11, 12, 2, 3, a 5, 6, 8, 9 bierzemy jako ich OPOZYCJE —
|
||||
to nie skrót, lecz własność tych systemów: przeciwległe domy leżą na tym samym
|
||||
kole wielkim, więc ich cuspy są dokładnie antypodyczne."""
|
||||
tan_lat = math.tan(math.radians(lat))
|
||||
# Gałąź liczymy względem MC GÓRUJĄCEGO, nie matematycznego: gdy za kołem
|
||||
# podbiegunowym te dwa się rozjeżdżają, cała czwórka domów pośrednich musi
|
||||
# obrócić się razem z dziesiątym domem.
|
||||
konstrukcji „ascendent pod biegunem"."""
|
||||
# Cuspy topocentrica są poprawne na CAŁEJ dziedzinie (zgodne z wyrocznią co do
|
||||
# zera), ale powyżej koła podbiegunowego przestają DZIELIĆ OKRĄG: domy nachodzą
|
||||
# na siebie, bo cusp VII (= I + 180°) wypada przed cuspem VI. Przypisanie planety
|
||||
# do domu traci wtedy sens — co potwierdza sama wyrocznia, której swe_house_pos
|
||||
# przeczy tam własnym cuspom (100% zgodności do 62°, 83,9% przy 66°, ok. 50%
|
||||
# przy 72°). Odmawiamy, zamiast zwracać liczbę bez znaczenia.
|
||||
#
|
||||
# To INNY rodzaj granicy niż u Placidusa i Kocha: tam nie istnieją same cuspy,
|
||||
# tu istnieją, tylko nie tworzą podziału. Próg jest wyprowadzony z warunku
|
||||
# „dwanaście cuspów sumuje się do 360°", nie dobrany pod wynik testu — i wypada
|
||||
# na kole podbiegunowym (zmierzone: 100% podziałów do 65°, 78% w pasie 66-67°).
|
||||
if abs(lat) >= polar_circle(eps):
|
||||
raise HouseSystemUndefined(
|
||||
f"φ={lat:.4f}° powyżej koła podbiegunowego ({polar_circle(eps):.4f}° dla "
|
||||
f"ε={eps:.4f}°): cuspy topocentryczne przestają dzielić okrąg, "
|
||||
f"domy nachodzą na siebie")
|
||||
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
|
||||
epole = _ecliptic_pole(er)
|
||||
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
|
||||
# cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
|
||||
culminating = _culminating_mc(mc, eps, lat)
|
||||
sign = 1.0 if abs(((culminating - mc + 180.0) % 360.0) - 180.0) > 90.0 else -1.0
|
||||
|
||||
out = [0.0] * 12
|
||||
for i in (10, 11, 1, 2): # domy 11, 12, 2, 3
|
||||
pole = math.degrees(math.atan(tan_lat * _TOPO_POLE_FRACTION[i]))
|
||||
lon = _asc_under_pole_raw(ramc + 30.0 * i, eps, pole)
|
||||
if ((lon - culminating) % 360.0 < 180.0) != _INTERMEDIATE[i]:
|
||||
lon = norm360(lon + 180.0)
|
||||
for i, (offset, fraction) in _TOPO_STEP.items():
|
||||
th = math.radians(ramc + offset)
|
||||
pole = math.atan(tan_lat * fraction)
|
||||
zenith = (math.cos(pole) * math.cos(th),
|
||||
math.cos(pole) * math.sin(th),
|
||||
math.sin(pole))
|
||||
v = _cross(zenith, epole)
|
||||
lon = _equatorial_to_lon(tuple(sign * x for x in v), er)
|
||||
out[i] = lon
|
||||
out[(i + 6) % 12] = norm360(lon + 180.0)
|
||||
out[0], out[3] = asc, norm360(culminating + 180.0)
|
||||
@@ -451,6 +477,14 @@ def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
|
||||
mc = compute_mc(ramc, eps)
|
||||
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
|
||||
return cusps(asc, mc, system)
|
||||
if system == WHOLE_SIGN_ARIES:
|
||||
# Znaki jako domy, ale numeracja rusza od Barana niezależnie od Ascendentu.
|
||||
# Wariant spotykany w tradycji indyjskiej i w części szkół hellenistycznych.
|
||||
return [norm360(30.0 * i) for i in range(12)]
|
||||
if system == EQUAL_MC:
|
||||
# Równe domy jak `equal`, ale zakotwiczone na MC: dom X zaczyna się
|
||||
# DOKŁADNIE na MC, więc oś południka wypada na granicy domu, a nie w środku.
|
||||
return [norm360(mc + 90.0 + 30.0 * i) for i in range(12)]
|
||||
if system == VEHLOW:
|
||||
# equal, ale Ascendent leży w ŚRODKU domu I, nie na jego początku
|
||||
return [norm360(asc - 15.0 + 30.0 * i) for i in range(12)]
|
||||
@@ -516,14 +550,38 @@ def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
|
||||
FALLBACK_SYSTEM, system, str(e))
|
||||
|
||||
|
||||
def _runs_forward(cusp_list: list[float]) -> bool:
|
||||
"""Czy domy biegną w stronę rosnących długości ekliptycznych.
|
||||
|
||||
Zwykle tak — ale NIE ZAWSZE. Przy dużych szerokościach systemy dzielące koła
|
||||
wielkie (regiomontanus, campanus, topocentric) mają kolejność ODWRÓCONĄ:
|
||||
przy φ = −84,3° cusp domu I wypada na 174,2°, a domu II na 165,3°. To nie
|
||||
jest błąd — wyrocznia zwraca dokładnie te same wartości.
|
||||
|
||||
Rozstrzygamy sumą przeskoków „do przodu": dwanaście cuspów dzieli okrąg, więc
|
||||
idąc we WŁAŚCIWĄ stronę zsumują się do 360°. Idąc pod prąd każdy przeskok
|
||||
obchodzi koło dookoła i suma wychodzi 11 × 360° = 3960°."""
|
||||
total = sum((cusp_list[(i + 1) % 12] - cusp_list[i]) % 360.0 for i in range(12))
|
||||
return abs(total - 360.0) < abs(total - 3960.0)
|
||||
|
||||
|
||||
def assign_house(lon: float, cusp_list: list[float]) -> int:
|
||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
|
||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna.
|
||||
|
||||
Kierunek liczenia bierzemy z samych cuspów. Zaszycie „zawsze do przodu"
|
||||
dawało przy |φ| powyżej koła podbiegunowego złe domy dla regiomontanusa,
|
||||
campanusa i topocentrica — mimo cuspów zgodnych z wyrocznią co do zera.
|
||||
Błąd był CICHY: wykres wyglądał poprawnie, tylko planety siedziały gdzie
|
||||
indziej. Sprawdzane wobec swe_house_pos (tests/oracle)."""
|
||||
lon = norm360(lon)
|
||||
forward = _runs_forward(cusp_list)
|
||||
for i in range(12):
|
||||
start = cusp_list[i]
|
||||
end = cusp_list[(i + 1) % 12]
|
||||
span = (end - start) % 360.0
|
||||
offset = (lon - start) % 360.0
|
||||
if forward:
|
||||
span, offset = (end - start) % 360.0, (lon - start) % 360.0
|
||||
else:
|
||||
span, offset = (start - end) % 360.0, (start - lon) % 360.0
|
||||
if offset < span:
|
||||
return i + 1
|
||||
return 12
|
||||
|
||||
@@ -117,12 +117,28 @@ class SkyfieldEngine(EphemerisEngine):
|
||||
|
||||
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
|
||||
"""
|
||||
from app.engine.houses import mean_obliquity
|
||||
|
||||
t = self.ts.from_datetime(moment.when_utc)
|
||||
ramc = norm360(t.gast * 15.0 + moment.lon)
|
||||
eps = mean_obliquity(t.tt)
|
||||
return ramc, eps
|
||||
return ramc, true_obliquity(t)
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||
|
||||
|
||||
def true_obliquity(t) -> float:
|
||||
"""ε PRAWDZIWE (średnie + nutacja w nachyleniu), w stopniach.
|
||||
|
||||
MUSI być prawdziwe, nie średnie. RAMC liczymy z `t.gast` — czasu gwiazdowego
|
||||
POZORNEGO, mierzonego od równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej
|
||||
samej równonocy ma ε z nutacją; sparowanie GAST z ε średnim miesza dwa układy
|
||||
odniesienia. Kosztowało to ~3,2″ na cuspach domów i było niewidoczne dla
|
||||
frameworka wyroczni, bo ten z założenia podaje to samo ε obu stronom
|
||||
(izoluje samą funkcję domów) — patrz tests/oracle/README.md.
|
||||
|
||||
Nutacja z serii IAU 2000A, czyli z tego samego źródła, którego Skyfield
|
||||
używa do policzenia GAST — dzięki temu oba są spójne z definicji."""
|
||||
from skyfield.nutationlib import iau2000a, mean_obliquity
|
||||
|
||||
mean_arcsec = float(mean_obliquity(t.tdb))
|
||||
d_eps_arcsec = float(iau2000a(t.tt)[1]) * 1e-7 # jednostki 0,1 µas
|
||||
return (mean_arcsec + d_eps_arcsec) / 3600.0
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -18,7 +18,12 @@ from app.clients.data_client import DataClient
|
||||
from app.models import QueryRequest, QueryResponse
|
||||
from app.service import QueryService
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||
app = FastAPI(title="astrololo · warstwa logiczna",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
service = QueryService()
|
||||
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
||||
@@ -121,6 +126,82 @@ def bases() -> dict:
|
||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||
|
||||
|
||||
# ── zarządzanie plikami baz (DAN-27) — czysty przelot ───────────────────
|
||||
# Warstwa logiczna niczego tu nie rozstrzyga: uprawnienia zna PREZENTACJA
|
||||
# (PRE-27), właścicielem plików jest warstwa DANYCH. Ta warstwa tylko przenosi,
|
||||
# bo prezentacja nie ma prawa rozmawiać z danymi wprost.
|
||||
|
||||
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, tenant: str = "", **kw) -> dict:
|
||||
from app.clients.data_client import DataClient
|
||||
|
||||
try:
|
||||
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
|
||||
# się pokazać powód zamiast „usługa niedostępna".
|
||||
raise HTTPException(status_code=e.response.status_code,
|
||||
detail=_detail(e)) from e
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||
|
||||
|
||||
def _detail(e: httpx.HTTPStatusError) -> str:
|
||||
try:
|
||||
return str(e.response.json().get("detail") or e)
|
||||
except Exception: # noqa: BLE001
|
||||
return str(e)
|
||||
|
||||
|
||||
@app.post("/files")
|
||||
def files_list(q: FilesQuery) -> dict:
|
||||
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), 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), tenant=u.tenant)
|
||||
|
||||
|
||||
@app.post("/files/delete")
|
||||
def files_delete(a: FileAction) -> dict:
|
||||
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), tenant=u.tenant)
|
||||
|
||||
|
||||
@app.post("/chart/synastry")
|
||||
def chart_synastry(req: SynastryRequest) -> dict:
|
||||
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
||||
@@ -171,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")
|
||||
@@ -186,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,
|
||||
)
|
||||
|
||||
@@ -83,7 +83,8 @@ def test_polar_circle_moves_with_obliquity():
|
||||
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
|
||||
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
|
||||
|
||||
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus")
|
||||
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus",
|
||||
"topocentric")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", EXOTIC)
|
||||
@@ -196,3 +197,152 @@ def test_polar_circle_boundary_moves_with_the_date():
|
||||
with pytest.raises(H.HouseSystemUndefined):
|
||||
H.cusps_for(100.0, 23.4393, lat, "placidus")
|
||||
assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12
|
||||
|
||||
|
||||
def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
|
||||
"""Regresja na to, co topocentrica psuło najdłużej — wybór gałęzi.
|
||||
|
||||
Cuspy 11 i 12 to punkty CIĄGŁEJ rodziny biegnącej od MC do Ascendentu, więc
|
||||
czwórka MC → 11 → 12 → Asc leży na okręgu w kolejności, bez wyprzedzania się.
|
||||
Zły wybór gałęzi przerzuca któryś punkt o 180° i kolejność pęka.
|
||||
|
||||
Kierunek celowo NIE jest ustalony: przy dużych szerokościach domy biegną
|
||||
wstecz (przy φ=−79,55° MC=306,8°, a dom 11 to 291,4°) i to jest poprawne —
|
||||
wyrocznia zwraca tam dokładnie to samo. Sprawdzamy więc, że kolejność trzyma
|
||||
się w JEDNĄ ze stron, a nie że rośnie."""
|
||||
def d(a, b):
|
||||
return H.norm360(b - a)
|
||||
|
||||
for lat in (-66.0, -50.0, -20.0, 0.0, 20.0, 50.06, 66.0):
|
||||
c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
|
||||
mc, c11, c12, asc = c[9], c[10], c[11], c[0]
|
||||
forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
|
||||
backward = d(c11, mc) + d(c12, c11) + d(asc, c12)
|
||||
assert (abs(forward - d(mc, asc)) < 1e-9
|
||||
or abs(backward - d(asc, mc)) < 1e-9), \
|
||||
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
|
||||
|
||||
|
||||
def test_topocentric_refuses_where_its_houses_stop_partitioning_the_circle():
|
||||
"""Topocentric ma granicę INNEGO rodzaju niż Placidus i Koch.
|
||||
|
||||
Same cuspy są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej
|
||||
koła podbiegunowego przestają dzielić okrąg — cusp VII (= I + 180°) wypada
|
||||
przed cuspem VI i domy nachodzą na siebie. Przypisanie planety do domu traci
|
||||
wtedy sens, więc odmawiamy zamiast zwracać liczbę bez znaczenia.
|
||||
|
||||
Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°",
|
||||
a nie dobrany pod wynik testu — i wypada na kole podbiegunowym."""
|
||||
for lat in (0.0, 50.06, 66.0):
|
||||
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
|
||||
for lat in (69.65, 89.9, -89.9):
|
||||
with pytest.raises(H.HouseSystemUndefined):
|
||||
H.cusps_for(100.0, 23.4393, lat, "topocentric")
|
||||
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
|
||||
assert cs.is_fallback and cs.notice and "topocentric" in cs.notice
|
||||
|
||||
|
||||
def test_whole_sign_aries_ignores_the_ascendant():
|
||||
"""Wariant „od Barana": dom I zaczyna się na 0° Barana niezależnie od tego,
|
||||
co wschodzi. To odróżnia go od zwykłego whole sign."""
|
||||
for lat in (0.0, 50.06, -33.9):
|
||||
c = H.cusps_for(100.0, 23.4393, lat, "whole_sign_aries")
|
||||
assert c == [pytest.approx(30.0 * i) for i in range(12)]
|
||||
|
||||
|
||||
def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
|
||||
"""W `equal` MC leży GDZIEŚ w domu X; w `equal_mc` zaczyna go dokładnie."""
|
||||
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
c = H.cusps_for(ramc, eps, lat, "equal_mc")
|
||||
assert c[9] == pytest.approx(mc, abs=1e-9) # dom X rusza na MC
|
||||
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
|
||||
for i in range(12): # nadal równe 30°
|
||||
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
|
||||
|
||||
|
||||
# ── nachylenie ekliptyki: prawdziwe, nie średnie ─────────────────────────
|
||||
|
||||
def test_sidereal_returns_true_obliquity_not_mean():
|
||||
"""RAMC liczymy z GAST — czasu gwiazdowego POZORNEGO, mierzonego od
|
||||
równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej samej równonocy ma ε
|
||||
Z NUTACJĄ. Sparowanie GAST z ε średnim mieszało dwa układy odniesienia
|
||||
i przesuwało cuspy domów o ~3″.
|
||||
|
||||
Framework wyroczni tego NIE wykryje: z założenia podaje to samo ε obu
|
||||
stronom, żeby izolować samą funkcję domów. Dlatego sprawdzian jest tutaj,
|
||||
ze Skyfieldem jako niezależnym autorytetem."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from skyfield.api import load
|
||||
from skyfield.nutationlib import mean_obliquity
|
||||
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.skyfield_engine import SkyfieldEngine, true_obliquity
|
||||
|
||||
when = datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc)
|
||||
t = load.timescale().from_datetime(when)
|
||||
_, eps = SkyfieldEngine().sidereal(ChartMoment(when_utc=when, lat=50.06, lon=19.94))
|
||||
|
||||
assert eps == pytest.approx(true_obliquity(t), abs=1e-12)
|
||||
eps_mean = float(mean_obliquity(t.tdb)) / 3600.0
|
||||
assert eps != pytest.approx(eps_mean, abs=1e-9), "ε jest średnie — nutacja zgubiona"
|
||||
# nutacja w nachyleniu nie przekracza ~9,2″; większa różnica to nie nutacja
|
||||
assert abs(eps - eps_mean) * 3600.0 < 9.3
|
||||
|
||||
|
||||
def test_true_obliquity_stays_in_a_sane_band_across_the_date_range():
|
||||
"""ε maleje ~0,013°/wiek. Na całym zakresie programu (370 p.n.e. – 2270 n.e.)
|
||||
musi zostać w widełkach 23,2–23,8° — wyjście oznacza błąd jednostek."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from skyfield.api import load
|
||||
|
||||
from app.engine.skyfield_engine import true_obliquity
|
||||
|
||||
ts = load.timescale()
|
||||
for year in (1600, 1900, 2000, 2100):
|
||||
t = ts.from_datetime(datetime(year, 6, 1, tzinfo=timezone.utc))
|
||||
assert 23.2 < true_obliquity(t) < 23.8, year
|
||||
|
||||
|
||||
# ── przypisanie obiektu do domu ──────────────────────────────────────────
|
||||
|
||||
REVERSED = (108.456, 23.4393, -84.324) # tu domy biegną WSTECZ
|
||||
|
||||
|
||||
def test_cusps_can_run_backwards_at_extreme_latitudes():
|
||||
"""Nie jest to błąd, tylko własność tych systemów — wyrocznia zwraca to samo.
|
||||
Test istnieje po to, żeby nikt tego „nie naprawił"."""
|
||||
ramc, eps, lat = REVERSED
|
||||
c = H.cusps_for(ramc, eps, lat, "regiomontanus")
|
||||
assert c[1] < c[0] and c[2] < c[1], "kolejność miała być odwrócona"
|
||||
assert not H._runs_forward(c)
|
||||
assert H._runs_forward(H.cusps_for(ramc, eps, 50.0, "regiomontanus"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", H.SYSTEMS)
|
||||
def test_every_point_lands_in_exactly_one_house(system):
|
||||
"""Cuspy dzielą okrąg bez luk i bez zakładek, więc każdy punkt ma dokładnie
|
||||
jeden dom, a cusp trafia do domu, który zaczyna. Sprawdzian SAMOSPÓJNOŚCI —
|
||||
działa bez swissepha, także tam, gdzie sama wyrocznia przeczy sobie
|
||||
(topocentric powyżej koła podbiegunowego)."""
|
||||
for ramc, eps, lat in (REVERSED, (100.0, 23.4393, 50.06), (210.33, 23.4393, 72.675)):
|
||||
try:
|
||||
cusps = H.cusps_for(ramc, eps, lat, system)
|
||||
except H.HouseSystemUndefined:
|
||||
continue
|
||||
for i, cusp in enumerate(cusps):
|
||||
assert H.assign_house(cusp, cusps) == i + 1, f"{system}: cusp {i+1}"
|
||||
seen = {H.assign_house(lon / 4.0, cusps) for lon in range(0, 1440)}
|
||||
assert seen == set(range(1, 13)), f"{system}: puste albo zdublowane domy {sorted(seen)}"
|
||||
|
||||
|
||||
def test_assign_house_follows_the_cusps_not_a_fixed_direction():
|
||||
"""Regresja: zaszyte „zawsze do przodu" dawało przy |φ| za kołem podbiegunowym
|
||||
złe domy dla systemów dzielących koła wielkie — mimo cuspów zgodnych
|
||||
z wyrocznią co do zera. Błąd był cichy: wykres wyglądał poprawnie."""
|
||||
ramc, eps, lat = REVERSED
|
||||
cusps = H.cusps_for(ramc, eps, lat, "regiomontanus")
|
||||
# 1,7822° leży między cuspem 6 (4,458°) a 7 (354,228°) — patrząc WSTECZ
|
||||
assert H.assign_house(1.7822, cusps) == 6
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Magazyn kont zarządzanych z aplikacji (PRE-27).
|
||||
|
||||
KONTO ADMINISTRACYJNE NIE LEŻY TUTAJ. Pochodzi z konfiguracji środowiska
|
||||
(APP_USER/APP_PASSWORD albo APP_USERS) i dlatego NIE DA SIĘ go skasować ani
|
||||
ograniczyć z poziomu ekranu — nawet przez pomyłkę, nawet spreparowanym żądaniem.
|
||||
Ekran zarządzania kontami operuje wyłącznie na tym pliku.
|
||||
|
||||
Format: JSON `{"users": {"login": {"secret": "scrypt$...", "features": [...],
|
||||
"note": "...", "created": "ISO"}}}`. Hasła WYŁĄCZNIE jako hash scrypt — ten sam
|
||||
mechanizm, co dla kont z APP_USERS (security.hash_password), więc nie ma tu
|
||||
drugiej implementacji do rozjechania.
|
||||
|
||||
Zapis jest ATOMOWY (plik tymczasowy + os.replace) i pod blokadą procesu. Plik
|
||||
leży na NFS, dzieli go kilka replik, a zwykły zapis „w miejscu" po przerwaniu
|
||||
zostawiłby plik obcięty — czyli utratę WSZYSTKICH kont naraz. os.replace jest
|
||||
w obrębie jednego katalogu operacją niepodzielną.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app import features
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class AccountsUnavailable(RuntimeError):
|
||||
"""Pliku kont nie da się odczytać albo zapisać — problem z magazynem.
|
||||
|
||||
Osobny wyjątek, bo to NIE JEST błąd danych: konta mogą być w porządku, tylko
|
||||
katalog jest niedostępny (złe prawa na udziale, nieprzemontowany wolumen,
|
||||
brak miejsca). Ekran ma wtedy POWIEDZIEĆ, co i gdzie jest nie tak — gołe 500
|
||||
zostawia administratora z niczym, a to jedyny ekran, z którego może to
|
||||
naprawić."""
|
||||
|
||||
|
||||
def store_path() -> str:
|
||||
"""Ścieżka pliku kont.
|
||||
|
||||
Domyślnie WŁASNY podkatalog prezentacji na NFS, nie katalog z bazami:
|
||||
prezentacja nie ma powodu widzieć baz interpretacyjnych, a zamontowanie ich
|
||||
tutaj obeszłoby bokiem zamknięcie dostępu z DAN-25."""
|
||||
return os.getenv("ACCOUNTS_FILE", "/app/state/accounts.json")
|
||||
|
||||
|
||||
def _read() -> dict:
|
||||
path = store_path()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
# Pierwsze uruchomienie: pliku jeszcze nie ma i to jest normalne.
|
||||
return {"users": {}}
|
||||
except json.JSONDecodeError:
|
||||
# Plik jest, ale nieczytelny. NIE nadpisujemy go pustym zbiorem —
|
||||
# to skasowałoby wszystkie konta przy pierwszym zapisie.
|
||||
raise AccountsUnavailable(
|
||||
f"Plik kont {path} jest uszkodzony (niepoprawny JSON). "
|
||||
f"Nic nie zostało zmienione — napraw albo usuń plik ręcznie.") from None
|
||||
except OSError as e:
|
||||
raise AccountsUnavailable(
|
||||
f"Nie mogę odczytać pliku kont {path}: {e.strerror or e}. "
|
||||
f"Najczęstsza przyczyna: prawa do katalogu na udziale albo "
|
||||
f"niezamontowany wolumen.") from e
|
||||
users = data.get("users")
|
||||
return {"users": users if isinstance(users, dict) else {}}
|
||||
|
||||
|
||||
def _write(data: dict) -> None:
|
||||
path = store_path()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
except OSError as e:
|
||||
raise AccountsUnavailable(
|
||||
f"Nie mogę utworzyć katalogu na plik kont ({os.path.dirname(path)}): "
|
||||
f"{e.strerror or e}.") from e
|
||||
# Plik tymczasowy MUSI powstać w tym samym katalogu — os.replace jest
|
||||
# niepodzielne tylko w obrębie jednego systemu plików.
|
||||
# mkstemp też musi być w klamrze: przy katalogu tylko do odczytu wywala się
|
||||
# ONO pierwsze, jeszcze zanim dojdzie do zapisu i podmiany.
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp")
|
||||
except OSError as e:
|
||||
raise AccountsUnavailable(
|
||||
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
|
||||
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
|
||||
f"prawa katalogu. Nic nie zostało zmienione.") from e
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise AccountsUnavailable(
|
||||
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
|
||||
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
|
||||
f"prawa katalogu. Nic nie zostało zmienione.") from e
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def all_users() -> dict[str, dict]:
|
||||
"""Konta zarządzane: login → {features, note, created}. BEZ sekretów.
|
||||
|
||||
Sekret nie wychodzi z tego modułu — ekran nigdy go nie potrzebuje, a to, czego
|
||||
nie ma w kontekście szablonu, nie wycieknie do HTML-a przez pomyłkę."""
|
||||
out = {}
|
||||
for login, row in _read()["users"].items():
|
||||
out[login] = {
|
||||
"features": sorted(features.normalise(row.get("features"))),
|
||||
"session_version": int(row.get("session_version") or 0),
|
||||
"note": str(row.get("note") or ""),
|
||||
"created": str(row.get("created") or ""),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def secret_of(login: str) -> str | None:
|
||||
return (_read()["users"].get(login) or {}).get("secret")
|
||||
|
||||
|
||||
def session_version(login: str) -> int:
|
||||
"""Numer pokolenia sesji konta (LOG-34).
|
||||
|
||||
Podbicie unieważnia WSZYSTKIE otwarte sesje tego konta, także na innych
|
||||
urządzeniach — bez zmiany hasła. Zmiana hasła robi to samo niejako przy
|
||||
okazji (zmienia się odcisk w ciasteczku), więc licznik jest potrzebny tylko
|
||||
do „wyrzuć zewsząd, ale hasło zostaw"."""
|
||||
try:
|
||||
return int((_read()["users"].get(login) or {}).get("session_version") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def bump_session(login: str) -> int:
|
||||
"""Unieważnia wszystkie sesje konta. Podnosi ValueError, gdy konta nie ma."""
|
||||
with _lock:
|
||||
data = _read()
|
||||
row = data["users"].get(login)
|
||||
if row is None:
|
||||
raise ValueError(f"Nie ma konta „{login}”.")
|
||||
row["session_version"] = session_version(login) + 1
|
||||
_write(data)
|
||||
return row["session_version"]
|
||||
|
||||
|
||||
def permissions_of(login: str) -> frozenset[str]:
|
||||
return features.normalise((_read()["users"].get(login) or {}).get("features"))
|
||||
|
||||
|
||||
def exists(login: str) -> bool:
|
||||
return login in _read()["users"]
|
||||
|
||||
|
||||
def create(login: str, password: str, granted, note: str = "") -> None:
|
||||
"""Zakłada konto. Podnosi ValueError, gdy login zajęty albo dane puste."""
|
||||
from app.security import hash_password
|
||||
|
||||
login = (login or "").strip()
|
||||
if not login:
|
||||
raise ValueError("Login nie może być pusty.")
|
||||
if not (password or "").strip():
|
||||
raise ValueError("Hasło nie może być puste.")
|
||||
with _lock:
|
||||
data = _read()
|
||||
if login in data["users"]:
|
||||
raise ValueError(f"Konto „{login}” już istnieje.")
|
||||
data["users"][login] = {
|
||||
"secret": hash_password(password),
|
||||
"features": sorted(features.normalise(granted)),
|
||||
"note": (note or "").strip(),
|
||||
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
_write(data)
|
||||
|
||||
|
||||
def update(login: str, granted=None, password: str = "", note: str | None = None) -> None:
|
||||
"""Zmiana uprawnień, hasła albo opisu. Puste hasło = bez zmiany hasła."""
|
||||
from app.security import hash_password
|
||||
|
||||
with _lock:
|
||||
data = _read()
|
||||
row = data["users"].get(login)
|
||||
if row is None:
|
||||
raise ValueError(f"Nie ma konta „{login}”.")
|
||||
if granted is not None:
|
||||
row["features"] = sorted(features.normalise(granted))
|
||||
if (password or "").strip():
|
||||
row["secret"] = hash_password(password)
|
||||
if note is not None:
|
||||
row["note"] = note.strip()
|
||||
_write(data)
|
||||
|
||||
|
||||
def delete(login: str) -> None:
|
||||
with _lock:
|
||||
data = _read()
|
||||
if data["users"].pop(login, None) is None:
|
||||
raise ValueError(f"Nie ma konta „{login}”.")
|
||||
_write(data)
|
||||
@@ -114,8 +114,15 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
|
||||
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
||||
|
||||
|
||||
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
|
||||
phi = math.radians(180.0 - (lon - asc))
|
||||
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc").
|
||||
ASC_LEFT = "asc"
|
||||
ARIES_LEFT = "aries"
|
||||
ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej"))
|
||||
|
||||
|
||||
def _pt(lon: float, ref: float, r: float) -> tuple[float, float]:
|
||||
"""Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku."""
|
||||
phi = math.radians(180.0 - (lon - ref))
|
||||
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
||||
|
||||
|
||||
@@ -206,12 +213,19 @@ def _delta(a: float, b: float) -> float:
|
||||
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
||||
|
||||
|
||||
def render(chart: dict, theme: str = "screen") -> str:
|
||||
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
|
||||
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
||||
|
||||
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
||||
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
||||
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
||||
|
||||
orientation — co stoi po LEWEJ stronie koła (LOG-05, warianty rysunku):
|
||||
'asc' — Ascendent (domyślne; tak rysuje większość szkół zachodnich,
|
||||
dom I zawsze zaczyna się w tym samym miejscu rysunku),
|
||||
'aries' — 0° Barana (koło nieruchome względem zodiaku, więc dwa horoskopy
|
||||
da się porównywać „na oko"; osie wypadają za to gdzie indziej).
|
||||
Zmienia się WYŁĄCZNIE obrót rysunku — żadna liczba nie jest przeliczana.
|
||||
"""
|
||||
T = _THEMES.get(theme, _THEMES["screen"])
|
||||
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
||||
@@ -223,6 +237,9 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
return ""
|
||||
|
||||
asc = float(angles["Asc"]["decimal"])
|
||||
# Oś obrotu rysunku. Długości ekliptyczne zostają nietknięte — przesuwamy
|
||||
# tylko punkt, który ląduje po lewej stronie koła.
|
||||
ref = 0.0 if orientation == ARIES_LEFT else asc
|
||||
parts: list[str] = []
|
||||
|
||||
# tło i okręgi
|
||||
@@ -244,8 +261,8 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
continue
|
||||
color = T["aspects"].get(asp.get("aspect"), T["muted"])
|
||||
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
||||
ax1, ay1 = _pt(la, asc, R_HUB)
|
||||
bx1, by1 = _pt(lb, asc, R_HUB)
|
||||
ax1, ay1 = _pt(la, ref, R_HUB)
|
||||
bx1, by1 = _pt(lb, ref, R_HUB)
|
||||
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
|
||||
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
|
||||
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
||||
@@ -253,17 +270,17 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
||||
for deg in range(0, 360, 5):
|
||||
r_in = R_TICK if deg % 10 else R_TICK - 4
|
||||
x1, y1 = _pt(deg, asc, R_ZOD)
|
||||
x2, y2 = _pt(deg, asc, r_in)
|
||||
x1, y1 = _pt(deg, ref, R_ZOD)
|
||||
x2, y2 = _pt(deg, ref, r_in)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
||||
|
||||
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
||||
for i, sg in enumerate(signs):
|
||||
b = 30.0 * i
|
||||
x1, y1 = _pt(b, asc, R_ZOD)
|
||||
x2, y2 = _pt(b, asc, R_OUT)
|
||||
x1, y1 = _pt(b, ref, R_ZOD)
|
||||
x2, y2 = _pt(b, ref, R_OUT)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
||||
gx, gy = _pt(b + 15.0, asc, R_SIGN)
|
||||
gx, gy = _pt(b + 15.0, ref, R_SIGN)
|
||||
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
||||
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
||||
|
||||
@@ -271,31 +288,31 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
n = len(cusps)
|
||||
for i, c in enumerate(cusps):
|
||||
lon = float(c["decimal"])
|
||||
x1, y1 = _pt(lon, asc, R_ZOD)
|
||||
x2, y2 = _pt(lon, asc, R_HUB)
|
||||
x1, y1 = _pt(lon, ref, R_ZOD)
|
||||
x2, y2 = _pt(lon, ref, R_HUB)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
||||
nxt = float(cusps[(i + 1) % n]["decimal"])
|
||||
span = (nxt - lon) % 360.0 or 360.0
|
||||
mid = lon + span / 2.0
|
||||
hx, hy = _pt(mid, asc, R_HNUM)
|
||||
hx, hy = _pt(mid, ref, R_HNUM)
|
||||
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
||||
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
|
||||
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
|
||||
# w systemach kwadratowych ta liczba realnie coś mówi.
|
||||
deg_in_sign = lon % 30.0
|
||||
if deg_in_sign >= 0.5:
|
||||
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
|
||||
cx1, cy1 = _pt(lon, ref, R_ZOD - 9)
|
||||
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
||||
|
||||
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
||||
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
||||
lon_a = float(angles[a]["decimal"])
|
||||
ax, ay = _pt(lon_a, asc, R_ZOD)
|
||||
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
|
||||
ax, ay = _pt(lon_a, ref, R_ZOD)
|
||||
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
|
||||
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
||||
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
||||
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
|
||||
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
|
||||
lax, lay = _pt(lon_a, ref, R_AXIS_LABEL)
|
||||
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
|
||||
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
||||
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
||||
|
||||
@@ -310,15 +327,15 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
||||
retro = obj.get("direction") == "Rx"
|
||||
|
||||
mx1, my1 = _pt(true_lon, asc, R_ZOD)
|
||||
mx2, my2 = _pt(true_lon, asc, R_MARK)
|
||||
mx1, my1 = _pt(true_lon, ref, R_ZOD)
|
||||
mx2, my2 = _pt(true_lon, ref, R_MARK)
|
||||
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
||||
|
||||
if _delta(draw_lon, true_lon) > 0.4:
|
||||
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
|
||||
lx, ly = _pt(draw_lon, ref, R_PLANET + 12)
|
||||
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
||||
|
||||
gx, gy = _pt(draw_lon, asc, R_PLANET)
|
||||
gx, gy = _pt(draw_lon, ref, R_PLANET)
|
||||
tip = obj.get("name") or ""
|
||||
if obj.get("in_sign"):
|
||||
tip += f" · {obj['in_sign']}"
|
||||
@@ -332,7 +349,7 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
||||
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
||||
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
||||
dx, dy = _pt(draw_lon, asc, R_DEG)
|
||||
dx, dy = _pt(draw_lon, ref, R_DEG)
|
||||
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
||||
parts.append(_text(dx, dy, label, size=8,
|
||||
fill=T["retro"] if retro else T["muted"]))
|
||||
@@ -346,10 +363,10 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
if not glyph or lot.get("longitude") is None:
|
||||
continue
|
||||
lon = float(lot["longitude"])
|
||||
mx1, my1 = _pt(lon, asc, R_ZOD)
|
||||
mx2, my2 = _pt(lon, asc, R_MARK)
|
||||
mx1, my1 = _pt(lon, ref, R_ZOD)
|
||||
mx2, my2 = _pt(lon, ref, R_MARK)
|
||||
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
||||
lx, ly = _pt(lon, asc, R_LOT)
|
||||
lx, ly = _pt(lon, ref, R_LOT)
|
||||
tip = lot.get("name") or ""
|
||||
if lot.get("in_sign"):
|
||||
tip += f" · {lot['in_sign']}"
|
||||
@@ -367,7 +384,6 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
|
||||
ASP_CELL = 30.0 # bok komórki siatki aspektów
|
||||
|
||||
|
||||
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
||||
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
|
||||
|
||||
|
||||
@@ -158,6 +158,33 @@ class LogicClient:
|
||||
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_list(self, for_admin: bool = False) -> dict[str, Any]:
|
||||
return self._files_post("/files", {"for_admin": for_admin})
|
||||
|
||||
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_delete(self, path: str) -> dict[str, Any]:
|
||||
return self._files_post("/files/delete", {"path": path})
|
||||
|
||||
def files_rules(self, rules: dict) -> dict[str, Any]:
|
||||
return self._files_post("/files/rules", {"rules": rules})
|
||||
|
||||
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
||||
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 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:
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Katalog funkcji i uprawnień (PRE-27).
|
||||
|
||||
DWIE ZASADY, KTÓRE RZĄDZĄ TYM MODUŁEM
|
||||
|
||||
1. Konto ograniczone ma widzieć program KOMPLETNY — tylko mniejszy. Nic nie może
|
||||
sugerować, że istnieje coś więcej: żadnych wyszarzonych pozycji, żadnego
|
||||
„brak uprawnień", żadnego 403. Ścieżka bez uprawnienia zwraca **404**, bo 403
|
||||
jest samo w sobie informacją, że coś tam jest.
|
||||
|
||||
2. Mapa trasa → uprawnienie jest JEDNA i leży tutaj. Rozproszenie jej po
|
||||
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka
|
||||
dziura jest niewidoczna, dopóki ktoś jej nie znajdzie. Test przechodzi po
|
||||
wszystkich trasach aplikacji i wymaga, żeby każda była tu wymieniona.
|
||||
|
||||
PODZIAŁ NA GRUPY. Ekrany odpowiadają zakładkom — to naturalna jednostka, bo
|
||||
zakładka jest tym, co widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI
|
||||
wewnątrz ekranów: ktoś może liczyć horoskopy, nie mając dostępu do porównywania
|
||||
systemów domów ani do generowania tekstu przez model (co kosztuje pieniądze).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Feature:
|
||||
key: str
|
||||
label: str
|
||||
hint: str
|
||||
group: str
|
||||
href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki
|
||||
|
||||
|
||||
# ── 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"),
|
||||
)
|
||||
|
||||
# ── 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"),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── 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
|
||||
# zasób przechodzi przez tę samą bramkę co ekrany.
|
||||
#
|
||||
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
|
||||
# siedzi na trzech ekranach i konto z którymkolwiek z nich musi ją dostać.
|
||||
# `None` = każdy zalogowany. Zasób spoza mapy wymaga admina — jak trasa.
|
||||
_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:
|
||||
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
|
||||
return STATIC.get(name, ADMIN)
|
||||
|
||||
|
||||
def required(method: str, path: str) -> frozenset[str] | str | None:
|
||||
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
|
||||
|
||||
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
|
||||
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
|
||||
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
|
||||
if path.startswith("/static/"):
|
||||
return static_required(path[len("/static/"):])
|
||||
return ROUTES.get((method.upper(), path), ADMIN)
|
||||
|
||||
|
||||
def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
|
||||
"""Czy te uprawnienia otwierają tę funkcję.
|
||||
|
||||
Zbiór znaczy „wystarczy którekolwiek": jeden zasób bywa wspólny dla kilku
|
||||
ekranów i nie ma powodu wymagać ich wszystkich naraz."""
|
||||
if feature is None:
|
||||
return True
|
||||
if isinstance(feature, str):
|
||||
return feature in perms
|
||||
return bool(perms & feature)
|
||||
|
||||
|
||||
def screens_for(perms: frozenset[str]) -> list[Feature]:
|
||||
"""Zakładki do pokazania w nawigacji — w stałej kolejności katalogu."""
|
||||
return [f for f in SCREENS if f.key in perms]
|
||||
|
||||
|
||||
def normalise(keys) -> frozenset[str]:
|
||||
"""Zbiór uprawnień z wejścia formularza: tylko znane klucze, bez ADMIN-a.
|
||||
|
||||
ADMIN odsiewamy TUTAJ, a nie w handlerze: konto administracyjne pochodzi
|
||||
wyłącznie z konfiguracji środowiska, więc żadne wysłanie formularza — nawet
|
||||
spreparowane — nie może go nadać."""
|
||||
return frozenset(k for k in (keys or ()) if k in GRANTABLE)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Nazwy stanów pliku bazy — wspólne dla prezentacji i warstwy danych (DAN-27).
|
||||
|
||||
Osobny, króciutki moduł, bo prezentacja NIE MOŻE importować warstwy danych (to
|
||||
inna usługa i inny obraz), a wpisanie tych napisów wprost w handlerach skończyłoby
|
||||
się literówką, która przejdzie testy i wyjdzie dopiero na produkcji.
|
||||
"""
|
||||
ACTIVE = "active"
|
||||
READY = "ready"
|
||||
ARCHIVED = "archived"
|
||||
QUARANTINE = "quarantine"
|
||||
|
||||
LABELS = {
|
||||
ACTIVE: "w użyciu",
|
||||
READY: "gotowa, odstawiona",
|
||||
ARCHIVED: "zarchiwizowana",
|
||||
QUARANTINE: "wstrzymana",
|
||||
}
|
||||
@@ -15,12 +15,15 @@ from __future__ import annotations
|
||||
HOUSE_SYSTEMS: list[tuple[str, str]] = [
|
||||
("placidus", "Placidus"),
|
||||
("whole_sign", "Whole Sign"),
|
||||
("whole_sign_aries", "Whole Sign (od Barana)"),
|
||||
("koch", "Koch"),
|
||||
("equal", "Equal"),
|
||||
("equal", "Equal (od Asc)"),
|
||||
("equal_mc", "Equal (od MC)"),
|
||||
("porphyry", "Porphyry"),
|
||||
("regiomontanus", "Regiomontanus"),
|
||||
("campanus", "Campanus"),
|
||||
("alcabitus", "Alcabitus"),
|
||||
("topocentric", "Topocentric"),
|
||||
("morinus", "Morinus"),
|
||||
("vehlow", "Vehlow"),
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -12,30 +12,44 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
import base64
|
||||
|
||||
from app import accounts as accounts_store
|
||||
from app import chartwheel as chartwheel_mod
|
||||
from app import features
|
||||
from app import session
|
||||
from app import files_state
|
||||
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
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
# 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)
|
||||
templates = Jinja2Templates(directory="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-26) ─────────────────────────────────────────
|
||||
# ── 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).
|
||||
@@ -43,23 +57,135 @@ security.install(app) # logowanie + limit żądań (LOG-32)
|
||||
_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
|
||||
są 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."""
|
||||
sciezka = (_STATIC_DIR / name).resolve()
|
||||
korzen = _STATIC_DIR.resolve()
|
||||
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
|
||||
if korzen not in sciezka.parents or not sciezka.is_file():
|
||||
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:
|
||||
try:
|
||||
return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
|
||||
except OSError:
|
||||
return "0" # brak pliku nie może wywrócić strony
|
||||
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"},
|
||||
)
|
||||
|
||||
|
||||
templates.env.globals["static"] = static_url
|
||||
# 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
|
||||
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 ją w wyniku. Granicą jest ta funkcja, nie szablon.
|
||||
|
||||
Wartości zastępcze są 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]:
|
||||
@@ -73,6 +199,35 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
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_POD_UPRAWNIENIEM = {"prompt": "ai", "horoscope": "ai", "export": "export"}
|
||||
|
||||
|
||||
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 ją 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 _llm_catalog_for(request: Request) -> dict:
|
||||
"""Katalog modeli — pusty, gdy konto nie generuje tekstu przez model.
|
||||
|
||||
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
|
||||
Interpretacji i Kalendarza, także dla kont, które o modelach nie mają prawa
|
||||
wiedzieć."""
|
||||
return _llm_catalog() if "ai" in _perms(request) else {}
|
||||
|
||||
|
||||
def _llm_catalog() -> 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."""
|
||||
@@ -94,22 +249,45 @@ def _report_records(report: dict | None) -> int:
|
||||
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:
|
||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||||
return (
|
||||
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
|
||||
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
|
||||
)
|
||||
return f"Warstwa logiczna niedostępna: {e}"
|
||||
"""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
|
||||
|
||||
|
||||
# ---------------- 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,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
"llm_catalog": _llm_catalog_for(request)},
|
||||
)
|
||||
|
||||
|
||||
@@ -130,32 +308,35 @@ def chart_compute(
|
||||
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}
|
||||
"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=house_system, house_systems=house_systems,
|
||||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||
aspect_minor=aspect_minor,
|
||||
stations=stations, zodiac=zodiac, tables=tables,
|
||||
aspect_minor=form["aspect_minor"],
|
||||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||
)
|
||||
# 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 # kosmogram (PRE-12), SVG po stronie serwera
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
||||
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:
|
||||
@@ -189,6 +370,7 @@ def compile_build(
|
||||
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 od AI (interpretacja
|
||||
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||
@@ -203,23 +385,27 @@ def compile_build(
|
||||
"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}
|
||||
"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=house_system, house_systems=house_systems,
|
||||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||
aspect_minor=aspect_minor,
|
||||
stations=stations, zodiac=zodiac, tables=tables,
|
||||
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"])
|
||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
||||
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:
|
||||
@@ -265,13 +451,19 @@ def compile_pdf(payload: dict):
|
||||
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"), "Kosmogram"),
|
||||
(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"),
|
||||
@@ -392,7 +584,7 @@ def interpret_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "interpret.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
"llm_catalog": _llm_catalog_for(request)},
|
||||
)
|
||||
|
||||
|
||||
@@ -414,8 +606,9 @@ def interpret_run(
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
action = _dozwolona_akcja(request, action, "report")
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog()}
|
||||
"llm_catalog": _llm_catalog_for(request)}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
@@ -460,7 +653,7 @@ def timeline_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "timeline.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
"llm_catalog": _llm_catalog_for(request)},
|
||||
)
|
||||
|
||||
|
||||
@@ -483,8 +676,9 @@ def timeline_run(
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
action = _dozwolona_akcja(request, action, "timeline")
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog()}
|
||||
"llm_catalog": _llm_catalog_for(request)}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
@@ -600,6 +794,291 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00")
|
||||
return res
|
||||
|
||||
|
||||
# ---------------- 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)
|
||||
|
||||
|
||||
# ---------------- 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,
|
||||
"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}”.")
|
||||
|
||||
|
||||
# ---------------- 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
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "layer": "presentation"}
|
||||
# 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"}
|
||||
|
||||
@@ -42,9 +42,10 @@ import os
|
||||
import secrets
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
log = logging.getLogger("astrololo.security")
|
||||
|
||||
@@ -113,12 +114,35 @@ 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"}
|
||||
|
||||
|
||||
PUBLIC_PATHS = frozenset({"/health"})
|
||||
PUBLIC_PREFIXES = ("/static/",)
|
||||
# /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]] = {}
|
||||
|
||||
@@ -131,23 +155,109 @@ def _is_public(path: str) -> bool:
|
||||
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
||||
|
||||
|
||||
def authenticate(header: str | None) -> str | None:
|
||||
"""Nagłówek Basic → LOGIN zalogowanej osoby albo None.
|
||||
@dataclass(frozen=True)
|
||||
class Principal:
|
||||
"""Kto się zalogował i co wolno mu zobaczyć."""
|
||||
|
||||
login: str
|
||||
permissions: frozenset[str]
|
||||
is_admin: bool
|
||||
|
||||
|
||||
def _admin_permissions() -> frozenset[str]:
|
||||
"""Administrator widzi wszystko — z definicji, bez wypisywania listy ręcznie.
|
||||
|
||||
Liczone z katalogu, więc dopisanie funkcji NIE wymaga pamiętania o koncie
|
||||
administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja byłaby
|
||||
niewidoczna dla jedynej osoby, która ma ją komu nadać."""
|
||||
from app import features
|
||||
|
||||
return frozenset(features.GRANTABLE) | {features.ADMIN}
|
||||
|
||||
|
||||
def _admin_spec(login: str) -> str | None:
|
||||
"""Sekret konta administracyjnego z KONFIGURACJI ŚRODOWISKA albo None.
|
||||
|
||||
Sprawdzane ZAWSZE PIERWSZE i bez dotykania pliku kont — to jest cała
|
||||
separacja konta administracyjnego (LOG-34):
|
||||
|
||||
* konto z pliku o tym samym loginie nie przesłoni administratora, bo do
|
||||
pliku w ogóle nie zaglądamy, gdy login pasuje do konfiguracji,
|
||||
* administrator zaloguje się nawet wtedy, gdy plik kont jest uszkodzony
|
||||
albo nieczytelny — czyli w jedynej sytuacji, w której ktoś musi wejść,
|
||||
żeby to naprawić. Trzymanie jego stanu w tym samym pliku dawałoby
|
||||
zakleszczenie: nie da się naprawić, bo nie da się wejść.
|
||||
"""
|
||||
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 = _admin_spec(login)
|
||||
if spec is not None:
|
||||
return Principal(login, _admin_permissions(), True) if _secret_matches(spec, password) else None
|
||||
|
||||
from app import accounts as store
|
||||
|
||||
Zwracamy login (nie bool), bo dziennik audytowy musi wiedzieć KTO — to sedno
|
||||
PRE-17. Nieznany login i złe hasło traktujemy tak samo (bez podpowiadania,
|
||||
które konto istnieje)."""
|
||||
if not header or not header.lower().startswith("basic "):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||
user, _, password = raw.partition(":")
|
||||
except (binascii.Error, UnicodeDecodeError, IndexError):
|
||||
zapisany = store.secret_of(login)
|
||||
except store.AccountsUnavailable:
|
||||
# Magazyn kont niedostępny — konta zarządzane nie mogą się zalogować,
|
||||
# ale administrator (wyżej) już przeszedł, więc jest kto to naprawi.
|
||||
return None
|
||||
spec = accounts().get(user)
|
||||
if spec is None:
|
||||
if zapisany is None or not _secret_matches(zapisany, password):
|
||||
return None
|
||||
return user if _secret_matches(spec, password) else None
|
||||
return Principal(login, store.permissions_of(login), 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 accounts as store
|
||||
from app import session
|
||||
|
||||
spec = _admin_spec(login)
|
||||
if spec is not None:
|
||||
return session.issue(login, session.fingerprint(spec), 0)
|
||||
return session.issue(login, session.fingerprint(store.secret_of(login) or ""),
|
||||
store.session_version(login))
|
||||
|
||||
|
||||
def principal(request: Request) -> Principal | None:
|
||||
"""Ciasteczko → kto to jest i co mu wolno. None = nie wpuszczać."""
|
||||
from app import session
|
||||
|
||||
dane = session.read(request.cookies.get(session.COOKIE))
|
||||
if dane is None:
|
||||
return None
|
||||
login = dane["login"]
|
||||
|
||||
spec = _admin_spec(login)
|
||||
if spec is not None:
|
||||
# Zmiana APP_PASSWORD ubija sesje administratora — to jest jego
|
||||
# odpowiednik „wyloguj mnie ze wszystkich urządzeń".
|
||||
if session.fingerprint(spec) != dane["odcisk"]:
|
||||
return None
|
||||
return Principal(login, _admin_permissions(), True)
|
||||
|
||||
from app import accounts as store
|
||||
|
||||
try:
|
||||
zapisany = store.secret_of(login)
|
||||
wersja = store.session_version(login)
|
||||
except store.AccountsUnavailable:
|
||||
return None
|
||||
if zapisany is None or session.fingerprint(zapisany) != dane["odcisk"]:
|
||||
return None
|
||||
if wersja != dane["wersja"]:
|
||||
return None
|
||||
return Principal(login, store.permissions_of(login), False)
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
@@ -225,6 +335,22 @@ def audit_records(request: Request, count: int) -> None:
|
||||
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(
|
||||
@@ -235,8 +361,20 @@ def _audit(request: Request, user: str, status: int, ms: float) -> None:
|
||||
|
||||
|
||||
def install(app) -> None:
|
||||
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
|
||||
"""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 (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
|
||||
@@ -264,16 +402,48 @@ def install(app) -> None:
|
||||
status_code=429, headers={"Retry-After": "60"},
|
||||
)
|
||||
|
||||
user = ""
|
||||
from app import features
|
||||
|
||||
user, who, odswiez = "", None, None
|
||||
if auth_enabled():
|
||||
user = authenticate(request.headers.get("authorization")) or ""
|
||||
if not user:
|
||||
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
|
||||
return HTMLResponse(
|
||||
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
||||
)
|
||||
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("", _admin_permissions(), True)
|
||||
|
||||
request.state.user = user
|
||||
request.state.principal = who
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""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
|
||||
|
||||
COOKIE = "astrololo_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": "/"}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Powłoka strony i ekran logowania — JEDYNY arkusz publiczny.
|
||||
Nie wolno tu wstawić niczego, co nazywa funkcję: ten plik pobiera
|
||||
każdy, także niezalogowany, bo potrzebuje go ekran logowania. */
|
||||
|
||||
:root {
|
||||
--bg: #0f1020;
|
||||
--panel: #1a1c33;
|
||||
--ink: #e8e8f0;
|
||||
--muted: #9aa0c0;
|
||||
--accent: #8b7bf0;
|
||||
--line: #2a2d4a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; justify-content: center; padding: 3rem 1rem;
|
||||
}
|
||||
main { width: 100%; max-width: 880px; }
|
||||
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
|
||||
.topbar nav { display: flex; gap: .25rem; }
|
||||
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
|
||||
.topbar nav a:hover { color: var(--ink); }
|
||||
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
|
||||
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
|
||||
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||
input, select, button {
|
||||
font: inherit; padding: .6rem .75rem; border-radius: 8px;
|
||||
border: 1px solid var(--line); background: #12132a; color: var(--ink);
|
||||
}
|
||||
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
|
||||
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
|
||||
podział domów pochodzi z innego systemu, niż zamówiono. */
|
||||
.notice-warn {
|
||||
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
|
||||
border: 1px solid var(--warn-border, #b8860b);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--warn-bg, rgba(184, 134, 11, .10));
|
||||
color: var(--warn-fg, inherit);
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .85rem; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
|
||||
.card {
|
||||
margin-top: 1rem; padding: 1rem 1.25rem;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
}
|
||||
.card .actions { margin-top: .9rem; }
|
||||
/* Potwierdzenie operacji. Odróżnia się od .notice-warn, bo tamto ostrzega,
|
||||
a to tylko potwierdza — pomylenie ich uczy ignorować oba. */
|
||||
.done-note {
|
||||
margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
|
||||
border: 1px solid var(--line); border-left: 4px solid var(--accent);
|
||||
border-radius: 4px; background: rgba(255, 255, 255, .03);
|
||||
}
|
||||
/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */
|
||||
/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce,
|
||||
a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy
|
||||
marginesem, nie rozpychaczem — `nav` ma szerokość treści, więc nie ma czego
|
||||
rozpychać. */
|
||||
.whoami { color: var(--muted); font-size: .85rem; align-self: center;
|
||||
margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
|
||||
/* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak
|
||||
reszta paska, nie jak przycisk akcji w formularzu. */
|
||||
.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; }
|
||||
button.wyloguj {
|
||||
background: none; border: 1px solid transparent; color: var(--muted);
|
||||
font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
button.wyloguj:hover { color: var(--ink); }
|
||||
/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */
|
||||
.login-main { max-width: 26rem; }
|
||||
.login-card label { display: block; margin-top: .75rem; }
|
||||
.login-card input { width: 100%; }
|
||||
.login-note { margin-top: 1.25rem; }
|
||||
@@ -0,0 +1,24 @@
|
||||
/* Arkusz ekranu „admin” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
.loc { margin-top: .8rem; }
|
||||
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.row input[type=text] { flex: 1; }
|
||||
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
||||
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
||||
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
||||
kolumn jest po prostu mniej. */
|
||||
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||
gap: .35rem 1rem; align-items: start; }
|
||||
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
||||
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
||||
.opts .hint { font-style: normal; opacity: .75; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
|
||||
.account-note input { width: 100%; }
|
||||
button.danger { background: #8b2f2f; }
|
||||
@@ -0,0 +1,119 @@
|
||||
/* Arkusz ekranu „chart” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
.loc { margin-top: .8rem; }
|
||||
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||
2) font-variant-emoji:text — nowsza własność CSS,
|
||||
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||
"DejaVu Sans", sans-serif; }
|
||||
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
||||
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
||||
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
||||
.wheel-fig figcaption { margin-top: .4rem; }
|
||||
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||
svg.wheel text.glyph { font-size: inherit; }
|
||||
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
|
||||
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
|
||||
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
|
||||
.aspectarian-fig figcaption { margin-top: .4rem; }
|
||||
svg.aspectarian text.glyph { font-size: inherit; }
|
||||
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
|
||||
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
|
||||
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
|
||||
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
|
||||
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
|
||||
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
||||
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
||||
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
||||
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||
zostać na wierzchu. */
|
||||
.wheel-fig svg.wheel { cursor: zoom-in; }
|
||||
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||
.wheel-fig.zoomed {
|
||||
position: fixed; inset: 0; z-index: 1100; margin: 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: .5rem; padding: 1rem; background: var(--bg);
|
||||
}
|
||||
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
|
||||
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
|
||||
.wheel-fig.zoomed svg.wheel {
|
||||
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
||||
}
|
||||
body.wheel-zoom-open { overflow: hidden; }
|
||||
/* nie przewijaj treści pod spodem */
|
||||
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
|
||||
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
|
||||
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
|
||||
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
|
||||
.wheel-tip {
|
||||
position: fixed; z-index: 1150; pointer-events: none;
|
||||
max-width: 22rem; padding: .4rem .6rem;
|
||||
background: var(--panel); color: var(--fg, inherit);
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
font-size: .85rem; line-height: 1.35;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.wheel-tip[hidden] { display: none; }
|
||||
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
||||
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
||||
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
||||
kolumn jest po prostu mniej. */
|
||||
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||
gap: .35rem 1rem; align-items: start; }
|
||||
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
||||
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
||||
.opts .hint { font-style: normal; opacity: .75; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
||||
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
||||
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
||||
się sama tabela, nigdy strona. */
|
||||
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
||||
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
||||
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
||||
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
||||
nie wiadomo, który to wiersz. */
|
||||
.table-scroll th:first-child, .table-scroll td:first-child {
|
||||
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
||||
box-shadow: 1px 0 0 var(--line);
|
||||
}
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||
.geo { margin-top: .9rem; }
|
||||
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||
.geo-results li:last-child { border-bottom: none; }
|
||||
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.geo-map[hidden] { display: none; }
|
||||
.geo-attrib { margin: .4rem 0 0; }
|
||||
.geo-attrib a { color: var(--accent); }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
button.toggle {
|
||||
background: none; border: 1px solid var(--line); border-radius: 50%;
|
||||
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
|
||||
}
|
||||
button.toggle.on { color: var(--accent); border-color: var(--accent); }
|
||||
button.toggle.off { color: var(--muted); }
|
||||
@@ -0,0 +1,114 @@
|
||||
/* Arkusz ekranu „compile” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
.loc { margin-top: .8rem; }
|
||||
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||
2) font-variant-emoji:text — nowsza własność CSS,
|
||||
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||
"DejaVu Sans", sans-serif; }
|
||||
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
||||
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
||||
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
||||
.wheel-fig figcaption { margin-top: .4rem; }
|
||||
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||
svg.wheel text.glyph { font-size: inherit; }
|
||||
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
|
||||
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
|
||||
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
|
||||
.aspectarian-fig figcaption { margin-top: .4rem; }
|
||||
svg.aspectarian text.glyph { font-size: inherit; }
|
||||
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
|
||||
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
|
||||
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
|
||||
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
|
||||
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
|
||||
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
||||
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
||||
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
||||
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||
zostać na wierzchu. */
|
||||
.wheel-fig svg.wheel { cursor: zoom-in; }
|
||||
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||
.wheel-fig.zoomed {
|
||||
position: fixed; inset: 0; z-index: 1100; margin: 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: .5rem; padding: 1rem; background: var(--bg);
|
||||
}
|
||||
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
|
||||
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
|
||||
.wheel-fig.zoomed svg.wheel {
|
||||
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
||||
}
|
||||
body.wheel-zoom-open { overflow: hidden; }
|
||||
/* nie przewijaj treści pod spodem */
|
||||
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
|
||||
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
|
||||
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
|
||||
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
|
||||
.wheel-tip {
|
||||
position: fixed; z-index: 1150; pointer-events: none;
|
||||
max-width: 22rem; padding: .4rem .6rem;
|
||||
background: var(--panel); color: var(--fg, inherit);
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
font-size: .85rem; line-height: 1.35;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.wheel-tip[hidden] { display: none; }
|
||||
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
||||
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
||||
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
||||
kolumn jest po prostu mniej. */
|
||||
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||
gap: .35rem 1rem; align-items: start; }
|
||||
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
||||
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
||||
.opts .hint { font-style: normal; opacity: .75; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
||||
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
||||
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
||||
się sama tabela, nigdy strona. */
|
||||
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
||||
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
||||
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
||||
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
||||
nie wiadomo, który to wiersz. */
|
||||
.table-scroll th:first-child, .table-scroll td:first-child {
|
||||
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
||||
box-shadow: 1px 0 0 var(--line);
|
||||
}
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
|
||||
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
|
||||
.readiness li { padding: .2rem 0; }
|
||||
.readiness .rdy-ok { color: #7fd18b; }
|
||||
.readiness .rdy-missing { color: #e0c060; }
|
||||
.report-head { margin: 1.5rem 0 .5rem; }
|
||||
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
||||
/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
|
||||
bez przepisywania ich na HTML. */
|
||||
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
||||
button.toggle {
|
||||
background: none; border: 1px solid var(--line); border-radius: 50%;
|
||||
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
|
||||
}
|
||||
button.toggle.on { color: var(--accent); border-color: var(--accent); }
|
||||
button.toggle.off { color: var(--muted); }
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
||||
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
||||
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
||||
się sama tabela, nigdy strona. */
|
||||
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
||||
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
||||
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
||||
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
||||
nie wiadomo, który to wiersz. */
|
||||
.table-scroll th:first-child, .table-scroll td:first-child {
|
||||
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
||||
box-shadow: 1px 0 0 var(--line);
|
||||
}
|
||||
button.danger { background: #8b2f2f; }
|
||||
/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
|
||||
form.inline { display: inline; }
|
||||
td.ops form.inline + form.inline { margin-left: .4rem; }
|
||||
button.toggle {
|
||||
background: none; border: 1px solid var(--line); border-radius: 50%;
|
||||
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
|
||||
}
|
||||
button.toggle.on { color: var(--accent); border-color: var(--accent); }
|
||||
button.toggle.off { color: var(--muted); }
|
||||
tr.row-archived td { opacity: .55; }
|
||||
/* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma
|
||||
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
|
||||
tr.row-held td { background: rgba(184, 134, 11, .07); }
|
||||
@@ -0,0 +1,42 @@
|
||||
/* Arkusz ekranu „interpret” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.sig-item { margin-top: 1.25rem; }
|
||||
.sig-head { padding: .5rem 0; }
|
||||
.samples { margin-top: .3rem; }
|
||||
.samples td { font-size: .92rem; vertical-align: top; }
|
||||
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||
.facet { margin: .4rem 0 .6rem 1rem; }
|
||||
.facet-head { color: var(--ink); font-size: .9rem; }
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||
.geo { margin-top: .9rem; }
|
||||
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||
.geo-results li:last-child { border-bottom: none; }
|
||||
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.geo-map[hidden] { display: none; }
|
||||
.geo-attrib { margin: .4rem 0 0; }
|
||||
.geo-attrib a { color: var(--accent); }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
/* 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,7 @@
|
||||
/* Arkusz ekranu „settings” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Arkusz ekranu „significators” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.row input[type=text] { flex: 1; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.empty { color: var(--muted); }
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Arkusz ekranu „synastry” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||
2) font-variant-emoji:text — nowsza własność CSS,
|
||||
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||
"DejaVu Sans", sans-serif; }
|
||||
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||
svg.aspectarian text.glyph { font-size: inherit; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Arkusz ekranu „timeline” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.sig-item { margin-top: 1.25rem; }
|
||||
.sig-head { padding: .5rem 0; }
|
||||
.samples { margin-top: .3rem; }
|
||||
.samples td { font-size: .92rem; vertical-align: top; }
|
||||
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||
.geo { margin-top: .9rem; }
|
||||
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||
.geo-results li:last-child { border-bottom: none; }
|
||||
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.geo-map[hidden] { display: none; }
|
||||
.geo-attrib { margin: .4rem 0 0; }
|
||||
.geo-attrib a { color: var(--accent); }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
/* 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; }
|
||||
@@ -1,191 +0,0 @@
|
||||
:root {
|
||||
--bg: #0f1020;
|
||||
--panel: #1a1c33;
|
||||
--ink: #e8e8f0;
|
||||
--muted: #9aa0c0;
|
||||
--accent: #8b7bf0;
|
||||
--line: #2a2d4a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; justify-content: center; padding: 3rem 1rem;
|
||||
}
|
||||
main { width: 100%; max-width: 880px; }
|
||||
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
|
||||
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
|
||||
.topbar nav { display: flex; gap: .25rem; }
|
||||
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
|
||||
.topbar nav a:hover { color: var(--ink); }
|
||||
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
|
||||
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
|
||||
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
.loc { margin-top: .8rem; }
|
||||
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||
2) font-variant-emoji:text — nowsza własność CSS,
|
||||
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||
"DejaVu Sans", sans-serif; }
|
||||
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
||||
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
||||
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
||||
.wheel-fig figcaption { margin-top: .4rem; }
|
||||
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||
svg.wheel text.glyph { font-size: inherit; }
|
||||
|
||||
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
|
||||
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
|
||||
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
|
||||
.aspectarian-fig figcaption { margin-top: .4rem; }
|
||||
svg.aspectarian text.glyph { font-size: inherit; }
|
||||
|
||||
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
|
||||
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
|
||||
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
|
||||
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
|
||||
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
|
||||
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
||||
|
||||
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
||||
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
||||
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||
zostać na wierzchu. */
|
||||
.wheel-fig svg.wheel { cursor: zoom-in; }
|
||||
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||
.wheel-fig.zoomed {
|
||||
position: fixed; inset: 0; z-index: 1100; margin: 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: .5rem; padding: 1rem; background: var(--bg);
|
||||
}
|
||||
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
|
||||
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
|
||||
.wheel-fig.zoomed svg.wheel {
|
||||
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
||||
}
|
||||
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
|
||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.row input[type=text] { flex: 1; }
|
||||
input, select, button {
|
||||
font: inherit; padding: .6rem .75rem; border-radius: 8px;
|
||||
border: 1px solid var(--line); background: #12132a; color: var(--ink);
|
||||
}
|
||||
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||
|
||||
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
|
||||
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
|
||||
podział domów pochodzi z innego systemu, niż zamówiono. */
|
||||
.house-warning {
|
||||
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
|
||||
border: 1px solid var(--warn-border, #b8860b);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--warn-bg, rgba(184, 134, 11, .10));
|
||||
color: var(--warn-fg, inherit);
|
||||
}
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.empty { color: var(--muted); }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .85rem; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.sig-item { margin-top: 1.25rem; }
|
||||
.sig-head { padding: .5rem 0; }
|
||||
.samples { margin-top: .3rem; }
|
||||
.samples td { font-size: .92rem; vertical-align: top; }
|
||||
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||
.facet { margin: .4rem 0 .6rem 1rem; }
|
||||
.facet-head { color: var(--ink); font-size: .9rem; }
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||
|
||||
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||
.geo { margin-top: .9rem; }
|
||||
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||
.geo-results li:last-child { border-bottom: none; }
|
||||
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.geo-map[hidden] { display: none; }
|
||||
.geo-attrib { margin: .4rem 0 0; }
|
||||
.geo-attrib a { color: var(--accent); }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* Okno postępu przy generowaniu horoskopu */
|
||||
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
||||
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
||||
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
||||
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
||||
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1200; padding: 1rem; }
|
||||
.progress-overlay[hidden] { display: none; }
|
||||
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
||||
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
||||
color: var(--muted); font-size: .9rem; }
|
||||
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
||||
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||
animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
||||
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
||||
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
||||
max-height: 15rem; overflow-y: auto;
|
||||
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
||||
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
||||
.progress-log li.log-ok { color: #7fd18b; }
|
||||
.progress-log li.log-warn { color: #e0c060; }
|
||||
.progress-log li.log-err { color: #ef6b6b; }
|
||||
|
||||
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
|
||||
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
|
||||
.readiness li { padding: .2rem 0; }
|
||||
.readiness .rdy-ok { color: #7fd18b; }
|
||||
.readiness .rdy-missing { color: #e0c060; }
|
||||
.report-head { margin: 1.5rem 0 .5rem; }
|
||||
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
||||
/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
|
||||
bez przepisywania ich na HTML. */
|
||||
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
||||
@@ -0,0 +1,97 @@
|
||||
// Dymki na kosmogramie — własne, zamiast natywnych z <title> (PRE-12/PRE-25).
|
||||
//
|
||||
// DLACZEGO NIE ZOSTAWIAMY NATYWNYCH. Rysunek niesie <title> przy każdym obiekcie
|
||||
// i każdej linii aspektu i to wystarczało, dopóki koło było statyczne. Po dodaniu
|
||||
// powiększania na pełne okno dymki przestały się pokazywać — a niezależnie od tego,
|
||||
// która reguła przeglądarki je tłumi, natywny dymek i tak jest tu kiepskim
|
||||
// narzędziem: pojawia się po sekundzie zwłoki, nie da się go stylować, nie działa
|
||||
// na dotyku i nie ma go czym wywołać z klawiatury.
|
||||
//
|
||||
// Sprawdzone w przeglądarce (1280×900, komplet 13 systemów): elementy z <title>
|
||||
// SĄ osiągalne kursorem — 19 z 20 trafień w hit-teście — więc nic ich nie zasłania
|
||||
// i problem nie leży w geometrii ani w pointer-events.
|
||||
//
|
||||
// <title> ZOSTAJE w rysunku: to nazwa dostępna dla czytników ekranu, a PDF składa
|
||||
// się po stronie serwera, więc ta ścieżka jest nietknięta. Na czas najechania
|
||||
// odpinamy go i wieszamy z powrotem po zejściu kursora — dzięki temu nigdy nie
|
||||
// widać dwóch dymków naraz, gdyby przeglądarka jednak swój pokazała.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var OFFSET = 14; // odsunięcie od kursora, żeby dymek nie siedział pod nim
|
||||
var stash = null; // { owner, node } — odpięty <title> do przywieszenia
|
||||
|
||||
function ready(fn) {
|
||||
if (document.readyState !== 'loading') fn();
|
||||
else document.addEventListener('DOMContentLoaded', fn);
|
||||
}
|
||||
|
||||
ready(function () {
|
||||
var wheels = document.querySelectorAll('svg.wheel');
|
||||
if (!wheels.length) return;
|
||||
|
||||
var tip = document.createElement('div');
|
||||
tip.className = 'wheel-tip';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
tip.hidden = true;
|
||||
document.body.appendChild(tip);
|
||||
|
||||
function restore() {
|
||||
if (!stash) return;
|
||||
stash.owner.appendChild(stash.node);
|
||||
stash = null;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
tip.hidden = true;
|
||||
restore();
|
||||
}
|
||||
|
||||
// Element z własnym <title>, licząc od celu zdarzenia w górę. Aspekty to
|
||||
// cienkie linie, a glify siedzą w <text> — oba trzymają <title> jako dziecko.
|
||||
function owner(el, root) {
|
||||
while (el && el !== root) {
|
||||
if (el.querySelector && el.querySelector(':scope > title')) return el;
|
||||
el = el.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function place(x, y) {
|
||||
// Ustawiamy najpierw, potem mierzymy — inaczej nie znamy rozmiaru dymka.
|
||||
tip.style.left = '0px';
|
||||
tip.style.top = '0px';
|
||||
var w = tip.offsetWidth, h = tip.offsetHeight;
|
||||
var left = x + OFFSET, top = y + OFFSET;
|
||||
if (left + w > window.innerWidth - 4) left = x - OFFSET - w; // odbij w lewo
|
||||
if (top + h > window.innerHeight - 4) top = y - OFFSET - h; // odbij w górę
|
||||
tip.style.left = Math.max(4, left) + 'px';
|
||||
tip.style.top = Math.max(4, top) + 'px';
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(wheels, function (svg) {
|
||||
svg.addEventListener('mousemove', function (e) {
|
||||
var el = owner(e.target, svg);
|
||||
if (!el) { hide(); return; }
|
||||
|
||||
if (!stash || stash.owner !== el) {
|
||||
restore();
|
||||
var node = el.querySelector(':scope > title');
|
||||
tip.textContent = node.textContent;
|
||||
stash = { owner: el, node: node };
|
||||
el.removeChild(node);
|
||||
tip.hidden = false;
|
||||
}
|
||||
place(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
svg.addEventListener('mouseleave', hide);
|
||||
});
|
||||
|
||||
// Dymek nie może przeżyć przewinięcia ani powiększenia — zostałby wiszący
|
||||
// w miejscu, w którym nic już nie ma.
|
||||
window.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
document.addEventListener('click', hide, true);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
/* Generowanie tekstu przez model — dokładany wyłącznie, gdy konto ma
|
||||
uprawnienie „ai”. Selektory nazywają funkcję, więc arkusz nie może
|
||||
wchodzić do arkuszy ekranów, na których ta funkcja bywa wyłączona. */
|
||||
|
||||
/* Okno postępu przy generowaniu horoskopu */
|
||||
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
||||
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
||||
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
||||
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
||||
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1200; padding: 1rem; }
|
||||
.progress-overlay[hidden] { display: none; }
|
||||
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
||||
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
||||
color: var(--muted); font-size: .9rem; }
|
||||
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
||||
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||
animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
||||
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
||||
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
||||
max-height: 15rem; overflow-y: auto;
|
||||
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
||||
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
||||
.progress-log li.log-ok { color: #7fd18b; }
|
||||
.progress-log li.log-warn { color: #e0c060; }
|
||||
.progress-log li.log-err { color: #ef6b6b; }
|
||||
@@ -1,21 +1,38 @@
|
||||
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik,
|
||||
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
|
||||
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
|
||||
{% if can(request, 'advanced_calc') %}
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||
licz stacje planet (wolniejsze)</label>
|
||||
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
||||
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
||||
</div>
|
||||
<div class="opts" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
|
||||
{% endif %}
|
||||
{% if can(request, 'houses_compare') %}
|
||||
<div class="opts systems" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
|
||||
{% set chosen = form.house_systems or [] %}
|
||||
<span class="muted small">Porównaj systemy domów:</span>
|
||||
<span class="muted small opts-label">Porównaj systemy domów
|
||||
<em class="hint">— gwiazdka: nie działa za kołem podbiegunowym (ok. 66,5°); tam policzymy Porphyry i wyraźnie o tym napiszemy</em>
|
||||
</span>
|
||||
{% for value, name in HOUSE_SYSTEMS %}
|
||||
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can(request, 'houses_compare') %}
|
||||
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
|
||||
{% set wo = form.wheel_orientation or 'asc' %}
|
||||
<span class="muted small">Kosmogram — po lewej stronie:</span>
|
||||
{% for value, name in WHEEL_ORIENTATIONS %}
|
||||
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can(request, 'advanced_calc') %}
|
||||
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
|
||||
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
|
||||
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
|
||||
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
|
||||
{% if result.house_warnings %}
|
||||
{% for w in result.house_warnings %}
|
||||
<p class="house-warning">{{ w }}</p>
|
||||
<p class="notice-warn">{{ w }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
|
||||
{% if result.house_systems and result.house_systems | length > 1 %}
|
||||
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
|
||||
{# Przy komplecie systemów tabela jest szersza niż okno — przewija się SAMA,
|
||||
zamiast rozpychać stronę (numer domu zostaje przyklejony do lewej). #}
|
||||
<div class="table-scroll">
|
||||
<table class="angles">
|
||||
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>
|
||||
@@ -41,6 +44,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<table>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% set nav_active = "accounts" %}
|
||||
{% block title %}Konta{% endblock %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-accounts.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">
|
||||
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
|
||||
pochodzi z konfiguracji środowiska, ma wszystkie uprawnienia i jest jedynym,
|
||||
które może zarządzać pozostałymi — dlatego nie ma go na tej liście i nie da się
|
||||
go stąd skasować.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
Osoba na koncie ograniczonym widzi wyłącznie przyznane jej zakładki i opcje.
|
||||
Nic w programie nie pokazuje jej, że istnieje coś więcej — niedostępne adresy
|
||||
odpowiadają tak samo jak nieistniejące.
|
||||
</p>
|
||||
|
||||
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
|
||||
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||
|
||||
<div class="meta">Konta ({{ users | length }})</div>
|
||||
{% if not users %}
|
||||
<p class="muted">Nie ma jeszcze żadnego konta poza administracyjnym.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for login, row in users | dictsort %}
|
||||
<form method="post" action="/accounts/update" class="card">
|
||||
<input type="hidden" name="login" value="{{ login }}">
|
||||
<div class="account-head">
|
||||
<strong>{{ login }}</strong>
|
||||
<span class="muted small">{{ row.created[:10] }}</span>
|
||||
</div>
|
||||
<label class="account-note">Opis
|
||||
<input type="text" name="note" value="{{ row.note }}" placeholder="np. do czego to konto służy">
|
||||
</label>
|
||||
|
||||
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
|
||||
<div class="opts systems" title="{{ 'Zakładki widoczne w nawigacji.' if group == 'Ekrany' else 'Poziomy złożoności wewnątrz ekranów.' }}">
|
||||
<span class="muted small opts-label">{{ group }}</span>
|
||||
{% for f in items %}
|
||||
<label title="{{ f.hint }}">
|
||||
<input type="checkbox" name="granted" value="{{ f.key }}"
|
||||
{{ 'checked' if f.key in row.features else '' }}> {{ f.label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="opts">
|
||||
<label>Nowe hasło (puste = bez zmiany)
|
||||
<input type="password" name="password" autocomplete="new-password" placeholder="••••••">
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">Zapisz</button>
|
||||
<button type="submit" formaction="/accounts/sesje" class="ghost"
|
||||
title="Zamyka otwarte sesje tego konta na wszystkich urządzeniach. Hasło zostaje bez zmian.">
|
||||
Zamknij sesje
|
||||
</button>
|
||||
<button type="submit" formaction="/accounts/delete" class="danger"
|
||||
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
|
||||
Skasuj konto
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
|
||||
<div class="meta">Nowe konto</div>
|
||||
<form method="post" action="/accounts/create" class="card">
|
||||
<div class="grid">
|
||||
<label>Login <input type="text" name="login" required autocomplete="off"></label>
|
||||
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
|
||||
<label>Opis <input type="text" name="note" placeholder="np. do czego to konto służy"></label>
|
||||
</div>
|
||||
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
|
||||
<div class="opts systems">
|
||||
<span class="muted small opts-label">{{ group }}</span>
|
||||
{% for f in items %}
|
||||
<label title="{{ f.hint }}"><input type="checkbox" name="granted" value="{{ f.key }}"> {{ f.label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="actions"><button type="submit">Załóż konto</button></div>
|
||||
</form>
|
||||
|
||||
<details class="loc">
|
||||
<summary>Gdzie to jest zapisane</summary>
|
||||
<p class="muted small">
|
||||
Konta trzymamy w pliku <code>{{ store_path }}</code> — hasła wyłącznie jako
|
||||
hash scrypt, nigdy jawnie. Zapis jest atomowy, więc przerwanie w trakcie nie
|
||||
obetnie pliku i nie skasuje wszystkich kont naraz.
|
||||
</p>
|
||||
</details>
|
||||
{% endblock %}
|
||||
@@ -4,34 +4,58 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>astrololo · {% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ static('styles.css') }}">
|
||||
{# Wspólne dane formularza między zakładkami (PRE-21). W <head> z `defer`
|
||||
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży
|
||||
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu
|
||||
we właściwym miejscu, bez przestawiania. #}
|
||||
<script defer src="{{ static('formsync.js') }}"></script>
|
||||
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
|
||||
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
|
||||
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||
{# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa
|
||||
jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`.
|
||||
Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści
|
||||
programu: nazwa selektora opowiada o funkcji tak samo dobrze jak przycisk. #}
|
||||
<link rel="stylesheet" href="{{ static('base.css') }}">
|
||||
{% block styles %}{% endblock %}
|
||||
{# Skrypty dokłada ekran, który ich potrzebuje. Ładowane stąd docierały na
|
||||
KAŻDĄ stronę — także do konta, które ma wyłącznie Pliki — a ich treść
|
||||
wymienia z nazwy ekrany, których takie konto nie ma prawa znać.
|
||||
Blok jest w <head>, bo `defer` wykonuje skrypty w kolejności dokumentu:
|
||||
formsync musi odtworzyć współrzędne, ZANIM geo.js zbuduje mapę. #}
|
||||
{% block head_scripts %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<h1>astrololo</h1>
|
||||
{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych —
|
||||
ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że
|
||||
program umie więcej, niż pokazuje (PRE-26). #}
|
||||
<nav>
|
||||
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
||||
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
||||
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
|
||||
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
|
||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
|
||||
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
|
||||
{% for tab in nav_screens(request) %}
|
||||
<a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
|
||||
{% endfor %}
|
||||
{% if can(request, 'admin') %}
|
||||
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
|
||||
{% endif %}
|
||||
{# Wyjście na końcu paska i odsunięte: to nie jest zakładka, tylko
|
||||
czynność — pomylenie go z zakładką kosztuje przypadkowe wylogowania.
|
||||
Pokazujemy też KTO jest zalogowany, bo bez tego przycisk wyjścia jest
|
||||
w połowie bezużyteczny: na wspólnym komputerze nie wiadomo, kogo się
|
||||
wylogowuje. Bez włączonego logowania nie ma z czego wychodzić. #}
|
||||
{% if auth_on() %}
|
||||
<span class="whoami" title="Zalogowano jako">{{ current_user(request) }}</span>
|
||||
{# POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej
|
||||
stronie, żeby wylogować kogoś bez jego wiedzy. #}
|
||||
<form method="post" action="/wyloguj" class="wyloguj-form">
|
||||
<button type="submit" class="wyloguj">Wyloguj</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
{# Stopka wymieniała warstwy architektury i nazywała całość „widokiem
|
||||
testowym" — czyli mówiła, że to nie jest gotowy produkt i że za nim stoi
|
||||
łańcuch usług. Dla konta ograniczonego program ma wyglądać na kompletny,
|
||||
więc ten opis widzi wyłącznie administrator. #}
|
||||
{% if can(request, 'admin') %}
|
||||
<footer class="foot">
|
||||
prezentacja → logika → dane · widok testowy
|
||||
</footer>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Horoskop{% endblock %}
|
||||
{% block nav_chart %}active{% endblock %}
|
||||
{% set nav_active = "chart" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-chart.css') }}">
|
||||
{% endblock %}
|
||||
{% block head_scripts %}
|
||||
<script defer src="{{ static('formsync.js') }}"></script>
|
||||
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||
<script defer src="{{ static('wheeltip.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||
@@ -31,6 +39,7 @@
|
||||
<label>Długość (lon, + E)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
{% if can(request, 'houses_compare') %}
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
@@ -38,8 +47,9 @@
|
||||
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if can(request, 'advanced_calc') %}
|
||||
<label>Zodiak
|
||||
<select name="zodiac">
|
||||
{% set zd = form.zodiac or 'tropical' %}
|
||||
@@ -50,6 +60,7 @@
|
||||
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "_location_picker.html" %}
|
||||
{% include "_form_options.html" %}
|
||||
@@ -68,7 +79,7 @@
|
||||
{% if result %}
|
||||
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
|
||||
{% for w in result.house_warnings or [] %}
|
||||
<p class="house-warning">{{ w }}</p>
|
||||
<p class="notice-warn">{{ w }}</p>
|
||||
{% endfor %}
|
||||
<div class="meta">
|
||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Skompiluj{% endblock %}
|
||||
{% block nav_compile %}active{% endblock %}
|
||||
{% set nav_active = "compile" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-compile.css') }}">
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
{% endblock %}
|
||||
{% block head_scripts %}
|
||||
<script defer src="{{ static('formsync.js') }}"></script>
|
||||
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||
<script defer src="{{ static('wheeltip.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI
|
||||
wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu
|
||||
jest takim samym śladem jak przycisk. #}
|
||||
{% if can(request, 'ai') %}
|
||||
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
|
||||
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
|
||||
{% else %}
|
||||
<p class="sub">Składa policzony horoskop w jeden raport. Dane pobiera z pozostałych
|
||||
zakładek — nie trzeba nic przepisywać.</p>
|
||||
{% endif %}
|
||||
|
||||
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
|
||||
kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
|
||||
@@ -36,6 +53,7 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
<label>Długość (lon, + E)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
{% if can(request, 'houses_compare') %}
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
@@ -43,8 +61,9 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if can(request, 'advanced_calc') %}
|
||||
<label>Zodiak
|
||||
<select name="zodiac">
|
||||
{% set zd = form.zodiac or 'tropical' %}
|
||||
@@ -55,15 +74,16 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między
|
||||
zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #}
|
||||
{% include "_form_options.html" %}
|
||||
<div class="actions">
|
||||
<button type="submit">Złóż podsumowanie</button>
|
||||
<button type="button" id="pdfBtn" class="ghost"
|
||||
{% if can(request, 'export') %}<button type="button" id="pdfBtn" class="ghost"
|
||||
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
|
||||
<span id="pdfNote" class="muted small"></span>
|
||||
<span id="pdfNote" class="muted small"></span>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -117,14 +137,18 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
|
||||
{% include "_result_tables.html" %}
|
||||
|
||||
{% if can(request, 'ai') %}
|
||||
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
|
||||
<div id="reportNatal"></div>
|
||||
<div id="reportPredictions"></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% 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 (tu go nie ma). #}
|
||||
<script src="{{ static('natal.js') }}"></script>
|
||||
<script src="{{ static('predictions.js') }}"></script>
|
||||
{% endif %}
|
||||
<script src="{{ static('compile.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends "base.html" %}
|
||||
{% set nav_active = "files" %}
|
||||
{% block title %}Pliki{% endblock %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-files.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Opis czysto operacyjny: co robi zaznaczenie, i nic o tym, do CZEGO te dane
|
||||
służą. Poprzednia treść nazywała je „bazami interpretacyjnymi" i mówiła
|
||||
o „wyszukiwaniu" — czyli zdradzała i dziedzinę programu, i istnienie ekranu
|
||||
wyszukiwarki. To jest jedyny ekran, który ogląda konto wgrywające dane, więc
|
||||
każde zdanie na nim waży najwięcej. #}
|
||||
<p class="muted">
|
||||
Zaznaczony plik jest w użyciu. Odznaczony zostaje na dysku, ale nie jest używany.
|
||||
</p>
|
||||
|
||||
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
|
||||
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||
|
||||
<div class="meta">Pliki ({{ files | length }})</div>
|
||||
{% if not files %}
|
||||
<p class="muted">Nie ma jeszcze żadnego pliku.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>W użyciu</th><th>Plik</th><th>Stan</th><th>Rozmiar</th><th>Zmieniony</th>
|
||||
{% if can(request, 'files_input') or is_admin %}<th>Operacje</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in files %}
|
||||
<tr class="{{ 'row-archived' if f.status == 'archived' else '' }}{{ ' row-held' if f.status == 'quarantine' else '' }}">
|
||||
<td>
|
||||
{# Archiwum jest ZAMROŻONE — nie da się go włączyć bez przywrócenia,
|
||||
a przywrócić może wyłącznie administrator. #}
|
||||
{% if f.status in ('active', 'ready') %}
|
||||
<form method="post" action="/files/use" class="inline">
|
||||
<input type="hidden" name="path" value="{{ f.path }}">
|
||||
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
|
||||
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
|
||||
title="{{ 'Odstaw ten plik' if f.in_use else 'Włącz ten plik do użytku' }}">
|
||||
{{ '●' if f.in_use else '○' }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="mono">{{ f.name }}</td>
|
||||
<td>
|
||||
{{ STATUS_LABELS.get(f.status, f.status) }}
|
||||
{% if f.status == 'archived' and f.archived_at %}
|
||||
<span class="muted small">({{ f.archived_at[:16] | replace('T', ' ') }})</span>
|
||||
{% endif %}
|
||||
{% if is_admin and f.rejected_for %}
|
||||
<div class="muted small">nie przeszedł: {{ f.rejected_for | join('; ') }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono">{{ '%.2f'|format(f.size_mb) if f.size_mb is not none else '—' }} MB</td>
|
||||
<td class="mono">{{ f.modified or '—' }}</td>
|
||||
{% if can(request, 'files_input') or is_admin %}
|
||||
<td class="ops">
|
||||
{% if can(request, 'files_input') and f.status in ('active', 'ready') %}
|
||||
<form method="post" action="/files/archive" class="inline">
|
||||
<input type="hidden" name="path" value="{{ f.path }}">
|
||||
<button type="submit" class="ghost"
|
||||
title="Plik zostaje na dysku, zamrożony ze znacznikiem czasu — znika tylko z użytku.">Archiwizuj</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
{% if f.status in ('archived', 'quarantine') %}
|
||||
<form method="post" action="/files/restore" class="inline">
|
||||
<input type="hidden" name="path" value="{{ f.path }}">
|
||||
<button type="submit" class="ghost">Przywróć</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/files/delete" class="inline">
|
||||
<input type="hidden" name="path" value="{{ f.path }}">
|
||||
<button type="submit" class="danger"
|
||||
onclick="return confirm('Skasować {{ f.name }} bezpowrotnie? Pliku nie da się odzyskać.')">Skasuj</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if can(request, 'files_input') %}
|
||||
<div class="meta">Wgraj plik</div>
|
||||
<form method="post" action="/files/upload" 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>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
{# Reguły walidacji widzi WYŁĄCZNIE administrator — cała ta sekcja nie trafia
|
||||
nawet do źródła strony dla pozostałych kont. #}
|
||||
<div class="meta">Reguły przyjmowania baz</div>
|
||||
<p class="muted small">
|
||||
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
|
||||
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
|
||||
</p>
|
||||
<form method="post" action="/files/rules" class="card">
|
||||
<div class="grid">
|
||||
<label>Dozwolone rozszerzenia (po przecinku)
|
||||
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
|
||||
<label>Maksymalny rozmiar (MB)
|
||||
<input type="number" name="max_size_mb" step="1" min="0" value="{{ rules.max_size_mb }}"></label>
|
||||
<label>Minimalna liczba wierszy
|
||||
<input type="number" name="min_rows" step="1" min="0" value="{{ rules.min_rows }}"></label>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Wymagane kolumny (po przecinku, puste = bez wymagań)
|
||||
<input type="text" name="required_columns"
|
||||
value="{{ (rules.required_columns or []) | join(', ') }}"></label>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="reject_duplicate_content" value="1"
|
||||
{{ 'checked' if rules.reject_duplicate_content else '' }}>
|
||||
odrzucaj pliki o treści identycznej z już wgraną</label>
|
||||
</div>
|
||||
<div class="actions"><button type="submit">Zapisz reguły</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interpretacje{% endblock %}
|
||||
{% block nav_interp %}active{% endblock %}
|
||||
{% set nav_active = "interpret" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-interpret.css') }}">
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
{% endblock %}
|
||||
{% block head_scripts %}
|
||||
<script defer src="{{ static('formsync.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||
@@ -35,14 +42,19 @@
|
||||
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
||||
</div>
|
||||
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||
{% include "_prompt_block.html" %}
|
||||
<p class="muted small" id="natalNote"></p>
|
||||
{# Cały blok generowania — budżet, dostawca, model i wynik — istnieje TYLKO
|
||||
dla kont z tym uprawnieniem. Nie ukrywamy go stylem: bez uprawnienia nie ma
|
||||
go w źródle strony, razem z katalogiem modeli, który blok wstrzykuje. #}
|
||||
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
||||
{% if can(request, 'ai') %}<p class="muted small" id="natalNote"></p>{% endif %}
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||
<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>
|
||||
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
|
||||
{% 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 %}
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
@@ -99,8 +111,12 @@
|
||||
|
||||
<script src="{{ static('now.js') }}"></script>
|
||||
<script src="{{ static('copy.js') }}"></script>
|
||||
{# Skrypty obsługujące WYŁĄCZNIE generowanie przez model. Bez uprawnienia nie ma
|
||||
ich nawet w odsyłaczach — sam adres pliku podpowiadałby, co program potrafi. #}
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>astrololo · logowanie</title>
|
||||
{# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc
|
||||
linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
|
||||
<link rel="stylesheet" href="{{ static('base.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-main">
|
||||
<header class="topbar"><h1>astrololo</h1></header>
|
||||
|
||||
{% if request.query_params.get('wylogowano') %}
|
||||
<p class="done-note">Wylogowano.</p>
|
||||
{% endif %}
|
||||
{% if blad %}<p class="notice-warn">{{ 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">
|
||||
{# Bez wskazywania, że dostęp ktoś „zakłada": to mówi wprost, że istnieje
|
||||
konto o wyższych uprawnieniach i że własne jest okrojone. #}
|
||||
Nie pamiętasz hasła? Skontaktuj się z osobą, od której masz dane logowania.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ustawienia{% endblock %}
|
||||
{% block nav_settings %}active{% endblock %}
|
||||
{% set nav_active = "settings" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-settings.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sygnifikatory{% endblock %}
|
||||
{% block nav_sig %}active{% endblock %}
|
||||
{% set nav_active = "significators" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-significators.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Synastria{% endblock %}
|
||||
{% block nav_synastry %}active{% endblock %}
|
||||
{% set nav_active = "synastry" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-synastry.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Kalendarz{% endblock %}
|
||||
{% block nav_timeline %}active{% endblock %}
|
||||
{% set nav_active = "timeline" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-timeline.css') }}">
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
{% endblock %}
|
||||
{% block head_scripts %}
|
||||
<script defer src="{{ static('formsync.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||
@@ -40,12 +47,14 @@
|
||||
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||
</label>
|
||||
</div>
|
||||
{% include "_prompt_block.html" %}
|
||||
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
||||
{% 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 %}
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
@@ -93,8 +102,10 @@
|
||||
|
||||
<script src="{{ static('now.js') }}"></script>
|
||||
<script src="{{ static('copy.js') }}"></script>
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,6 @@ krytyczne i CICHE, gdy się zepsują: (1) odebranie dostępu jednej osobie musi
|
||||
faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać
|
||||
treści baz (byłby wtedy kolejnym nośnikiem wycieku).
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
@@ -13,9 +12,13 @@ import pytest
|
||||
from app import security
|
||||
|
||||
|
||||
def _basic(user: str, password: str) -> dict:
|
||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {raw}"}
|
||||
def _kto(user: str, password: str) -> str | None:
|
||||
"""Login, gdy poświadczenia pasują; None przy odmowie.
|
||||
|
||||
Po przejściu na sesje (LOG-34) sprawdzanie hasła robi `verify`, a nie
|
||||
parsowanie nagłówka Basic — samego nagłówka nie ma już w ogóle."""
|
||||
who = security.verify(user, password)
|
||||
return who.login if who else None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -57,29 +60,29 @@ def test_named_accounts_are_parsed(monkeypatch):
|
||||
|
||||
def test_login_with_hashed_password(monkeypatch):
|
||||
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
|
||||
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
|
||||
assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None
|
||||
assert _kto("alicja", "tajne") == "alicja"
|
||||
assert _kto("alicja", "złe") is None
|
||||
|
||||
|
||||
def test_unknown_user_is_denied(monkeypatch):
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||
assert security.authenticate(_basic("obcy", "tajne")["Authorization"]) is None
|
||||
assert _kto("obcy", "tajne") is None
|
||||
|
||||
|
||||
def test_revoking_one_account_leaves_others_untouched(monkeypatch):
|
||||
"""Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym."""
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne")
|
||||
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) == "bartek"
|
||||
assert _kto("bartek", "inne") == "bartek"
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka
|
||||
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None
|
||||
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
|
||||
assert _kto("bartek", "inne") is None
|
||||
assert _kto("alicja", "tajne") == "alicja"
|
||||
|
||||
|
||||
def test_shared_password_still_works_alone(monkeypatch):
|
||||
"""Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji."""
|
||||
monkeypatch.setenv("APP_USER", "astrololo")
|
||||
monkeypatch.setenv("APP_PASSWORD", "wspolne")
|
||||
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) == "astrololo"
|
||||
assert _kto("astrololo", "wspolne") == "astrololo"
|
||||
|
||||
|
||||
def test_named_accounts_disable_the_shared_password(monkeypatch):
|
||||
@@ -88,7 +91,7 @@ def test_named_accounts_disable_the_shared_password(monkeypatch):
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||
monkeypatch.setenv("APP_USER", "astrololo")
|
||||
monkeypatch.setenv("APP_PASSWORD", "wspolne")
|
||||
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) is None
|
||||
assert _kto("astrololo", "wspolne") is None
|
||||
|
||||
|
||||
def test_no_accounts_means_auth_disabled(monkeypatch):
|
||||
@@ -97,28 +100,82 @@ def test_no_accounts_means_auth_disabled(monkeypatch):
|
||||
|
||||
# ── dziennik audytowy ──
|
||||
|
||||
def _client():
|
||||
def _client(monkeypatch):
|
||||
# Klucz MUSI być w środowisku ZANIM zaimportujemy app.main: security.install
|
||||
# biegnie przy imporcie i bez klucza celowo nie pozwala usłudze wstać.
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
return TestClient(app)
|
||||
return TestClient(app, follow_redirects=False)
|
||||
|
||||
|
||||
class _dziennik:
|
||||
"""Przechwytuje wpisy audytowe.
|
||||
|
||||
`caplog` podpina się pod korzeń, a dziennik audytowy ma celowo
|
||||
`propagate=False` — żeby wpisy nie dublowały się w logach usługi. Trzeba więc
|
||||
podpiąć uchwyt WPROST pod niego, inaczej test widzi pustkę i wygląda to na
|
||||
brak wpisów zamiast na brak podsłuchu."""
|
||||
|
||||
def __init__(self, caplog):
|
||||
self.caplog = caplog
|
||||
|
||||
def __enter__(self):
|
||||
security._setup_audit_logging()
|
||||
security.audit_log.setLevel(logging.INFO)
|
||||
security.audit_log.addHandler(self.caplog.handler)
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
security.audit_log.removeHandler(self.caplog.handler)
|
||||
|
||||
@property
|
||||
def tekst(self) -> str:
|
||||
return "\n".join(r.getMessage() for r in self.caplog.records)
|
||||
|
||||
|
||||
def _sesja(user: str) -> dict:
|
||||
from app import session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
def test_audit_logs_who_and_how_many(monkeypatch, caplog):
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
|
||||
_client().get("/synastry", headers=_basic("alicja", "tajne"))
|
||||
entry = "\n".join(r.getMessage() for r in caplog.records)
|
||||
c = _client(monkeypatch)
|
||||
with _dziennik(caplog) as d:
|
||||
c.get("/synastry", headers=_sesja("alicja"))
|
||||
entry = d.tekst
|
||||
assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry
|
||||
|
||||
|
||||
def test_audit_marks_failed_login_without_naming_the_account(monkeypatch, caplog):
|
||||
def test_audit_marks_a_request_without_a_session(monkeypatch, caplog):
|
||||
"""Odesłanie na ekran logowania też zostawia ślad — bez tego seria prób
|
||||
dobicia się do chronionej ścieżki byłaby w dzienniku niewidoczna."""
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
|
||||
_client().get("/synastry", headers=_basic("alicja", "złe"))
|
||||
entry = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "status=401" in entry and "user=-" in entry
|
||||
c = _client(monkeypatch)
|
||||
with _dziennik(caplog) as d:
|
||||
c.get("/synastry")
|
||||
entry = d.tekst
|
||||
assert "status=303" in entry and "user=-" in entry
|
||||
|
||||
|
||||
def test_failed_login_is_logged_without_saying_which_account_exists(monkeypatch, caplog):
|
||||
"""Nieudane próby są w dzienniku ważniejsze od udanych: pojedyncza nic nie
|
||||
znaczy, ale seria pod jednym adresem to jedyny widoczny ślad zgadywania
|
||||
haseł. Hasło ani jego fragment NIE mogą tam trafić."""
|
||||
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
|
||||
c = _client(monkeypatch)
|
||||
with _dziennik(caplog) as d:
|
||||
r = c.post("/logowanie", data={"login": "alicja", "haslo": "zgaduje-haslo"})
|
||||
assert r.status_code == 401
|
||||
entry = d.tekst
|
||||
assert "logowanie-odmowa" in entry
|
||||
assert "zgaduje-haslo" not in entry, "hasło trafiło do dziennika"
|
||||
|
||||
|
||||
def test_audit_logger_actually_emits(monkeypatch):
|
||||
|
||||
@@ -16,11 +16,11 @@ TEMPLATES = pathlib.Path(__file__).resolve().parents[1] / "app" / "templates"
|
||||
|
||||
|
||||
def test_static_url_carries_content_hash():
|
||||
assert re.fullmatch(r"/static/styles\.css\?v=[0-9a-f]{8}", static_url("styles.css"))
|
||||
assert re.fullmatch(r"/static/base\.css\?v=[0-9a-f]{8}", static_url("base.css"))
|
||||
|
||||
|
||||
def test_version_is_content_based_so_differs_between_files():
|
||||
assert _asset_version("styles.css") != _asset_version("formsync.js")
|
||||
assert _asset_version("base.css") != _asset_version("formsync.js")
|
||||
|
||||
|
||||
def test_missing_file_does_not_crash():
|
||||
@@ -35,7 +35,17 @@ def test_no_template_serves_raw_static_paths():
|
||||
assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
|
||||
|
||||
|
||||
def test_base_uses_helper_for_stylesheet_and_scripts():
|
||||
def test_base_uses_helper_for_the_shared_stylesheet():
|
||||
base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
|
||||
assert "static('styles.css')" in base
|
||||
assert "static('formsync.js')" in base
|
||||
assert "static('base.css')" in base
|
||||
|
||||
|
||||
def test_version_follows_what_is_sent_not_what_is_on_disk():
|
||||
"""Adres niesie hash treści WYSYŁANEJ, bo komentarze są przy serwowaniu
|
||||
usuwane. Gdyby hash szedł z pliku na dysku, poprawka w samym komentarzu
|
||||
podbijałaby adres i kasowała cache przeglądarki bez żadnej zmiany dla niej."""
|
||||
import hashlib
|
||||
|
||||
from app.main import _asset_body
|
||||
|
||||
assert _asset_version("base.css") == hashlib.md5(_asset_body("base.css")).hexdigest()[:8]
|
||||
|
||||
@@ -31,7 +31,16 @@ def _client():
|
||||
# ───────────────────────────────── nawigacja i trasa ─────────────────────
|
||||
|
||||
def test_tab_is_in_the_menu():
|
||||
assert 'href="/compile"' in BASE and "Skompiluj" in BASE
|
||||
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
|
||||
|
||||
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
|
||||
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
|
||||
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
|
||||
from app import features
|
||||
|
||||
tab = features.BY_KEY["compile"]
|
||||
assert tab in features.SCREENS
|
||||
assert tab.href and tab.label
|
||||
|
||||
|
||||
def test_get_renders_empty_form():
|
||||
@@ -76,16 +85,45 @@ def test_compile_page_renders_all_figures():
|
||||
assert var in MAIN, f"/compile nie ustawia {var}"
|
||||
|
||||
|
||||
def test_pdf_bundles_all_figures_in_print_theme():
|
||||
"""compile_pdf składa KOMPLET rysunków (motyw druku) i wysyła jako `figures`."""
|
||||
assert '"figures": figures' in MAIN
|
||||
for call in (
|
||||
'render(chart, theme="print")',
|
||||
'render_aspectarian(chart, theme="print")',
|
||||
'render_declination(chart, theme="print")',
|
||||
'render_antiscia(chart, theme="print")',
|
||||
):
|
||||
assert call in MAIN, f"PDF nie składa: {call}"
|
||||
def test_pdf_bundles_all_figures_in_print_theme(monkeypatch):
|
||||
"""compile_pdf składa KOMPLET rysunków w motywie DRUKU.
|
||||
|
||||
Sprawdzamy przez WYWOŁANIE trasy, nie przez szukanie tekstu w main.py:
|
||||
poprzednia wersja greppowała `render(chart, theme="print")` i pękała przy
|
||||
każdym dopisaniu argumentu, mimo że zachowanie zostawało poprawne."""
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
from app import chartwheel
|
||||
from app.clients.render_client import RenderClient
|
||||
from app.main import logic
|
||||
|
||||
for name in ("render", "render_aspectarian", "render_declination", "render_antiscia"):
|
||||
original = getattr(chartwheel, name)
|
||||
|
||||
def spy(chart, theme="screen", _n=name, _o=original, **kw):
|
||||
seen[_n] = theme
|
||||
return _o(chart, theme=theme, **kw)
|
||||
|
||||
monkeypatch.setattr(chartwheel, name, spy)
|
||||
|
||||
monkeypatch.setattr(logic, "positions", lambda **kw: _pdf_sample_chart())
|
||||
monkeypatch.setattr(RenderClient, "pdf", lambda self, report: b"%PDF-1.4 stub")
|
||||
|
||||
r = _client().post("/compile/pdf", json={"person": "Jan", "data": {
|
||||
"date": "1984-04-30", "time": "11:20", "tz_offset": 2, "lat": 50.06, "lon": 19.94}})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert set(seen) == {"render", "render_aspectarian",
|
||||
"render_declination", "render_antiscia"}, seen
|
||||
assert set(seen.values()) == {"print"}, seen
|
||||
|
||||
|
||||
def _pdf_sample_chart() -> dict:
|
||||
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
|
||||
"sign_glyph": "♈"} for i in range(12)]
|
||||
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
|
||||
"sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")}
|
||||
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
|
||||
"sign_glyphs": [{"sign": "Aries", "glyph": "♈"}], "house_system": "equal"}
|
||||
|
||||
|
||||
# ──────────────────────── zbieranie materiału z magazynów ────────────────
|
||||
@@ -174,11 +212,25 @@ def test_compile_form_carries_the_options():
|
||||
assert f in opts, f"brak opcji we wspólnym pliku: {f}"
|
||||
|
||||
|
||||
def test_compile_handler_recomputes_with_all_options():
|
||||
assert "house_systems: list[str] = Form([])" in MAIN
|
||||
assert "stations: bool = Form(False)" in MAIN
|
||||
assert "house_systems=house_systems" in MAIN # przekazane do logiki
|
||||
assert "stations=stations, zodiac=zodiac, tables=tables" in MAIN
|
||||
def test_compile_handler_recomputes_with_all_options(monkeypatch):
|
||||
"""„Skompiluj" przelicza horoskop z KOMPLETEM opcji z formularza.
|
||||
|
||||
To jest ta regresja z 2026-07-28: podsumowanie liczyło się z okrojonym
|
||||
zestawem i miało braki względem tego, co widać na „Horoskopie". Sprawdzamy
|
||||
przez wywołanie trasy, nie przez szukanie napisów w main.py."""
|
||||
seen = {}
|
||||
from app.main import app, logic
|
||||
|
||||
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _pdf_sample_chart())[1])
|
||||
r = _client().post("/compile", data={
|
||||
"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||
"lat": "50.06", "lon": "19.94", "house_system": "koch",
|
||||
"house_systems": ["koch", "equal"], "stations": "true", "tables": "true",
|
||||
"zodiac": "sidereal_lahiri", "aspect_minor": "true"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert seen["house_systems"] == ["koch", "equal"]
|
||||
assert seen["stations"] is True and seen["tables"] is True
|
||||
assert seen["zodiac"] == "sidereal_lahiri" and seen["aspect_minor"] is True
|
||||
|
||||
|
||||
def test_compile_pdf_recomputes_with_all_options():
|
||||
|
||||
@@ -65,12 +65,19 @@ def test_every_shared_field_is_synced(key):
|
||||
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
|
||||
|
||||
|
||||
def test_sync_script_loaded_before_map_script():
|
||||
"""KOLEJNOŚĆ MA ZNACZENIE: formsync w <head> z `defer` wykonuje się przed
|
||||
geo.js (skrypty defer idą w kolejności dokumentu), więc mapa startuje już
|
||||
z odtworzonymi współrzędnymi zamiast skakać po chwili."""
|
||||
assert "formsync.js" in BASE_HTML, "formsync.js nie jest ładowany globalnie"
|
||||
assert "defer" in BASE_HTML.split("formsync.js")[0].rsplit("<script", 1)[-1] + "defer"
|
||||
@pytest.mark.parametrize("name", WITH_FORM)
|
||||
def test_sync_script_loaded_before_map_script(name):
|
||||
"""KOLEJNOŚĆ MA ZNACZENIE: formsync z `defer` wykonuje się przed geo.js
|
||||
(skrypty defer idą w kolejności dokumentu), więc mapa startuje już
|
||||
z odtworzonymi współrzędnymi zamiast skakać po chwili.
|
||||
|
||||
Skrypt siedzi teraz w bloku `head_scripts` EKRANU, nie w base.html. Ładowany
|
||||
globalnie docierał także do konta, które ma wyłącznie Pliki — a jego treść
|
||||
wymienia z nazwy zakładki, których takie konto nie ma."""
|
||||
html = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||
assert "head_scripts" in html and "formsync.js" in html
|
||||
przed_geo = html.split("formsync.js")[0]
|
||||
assert "defer" in przed_geo.rsplit("<script", 1)[-1] + "defer"
|
||||
|
||||
|
||||
def test_sync_reacts_to_other_browser_tab():
|
||||
|
||||
@@ -5,6 +5,8 @@ systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
|
||||
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
|
||||
@@ -32,7 +34,12 @@ def _render(name: str, **ctx) -> str:
|
||||
|
||||
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
|
||||
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
|
||||
house_label=house_label, static=lambda p: p)
|
||||
house_label=house_label, static=lambda p: p,
|
||||
# Domyślnie renderujemy jak dla konta z PEŁNYMI uprawnieniami —
|
||||
# inaczej testy opcji sprawdzałyby pusty formularz. Ograniczenia
|
||||
# mają własne testy w test_kontrola_dostepu.py.
|
||||
can=lambda request, key: ctx.pop("_denied", None) != key,
|
||||
nav_screens=lambda request: [])
|
||||
return env.get_template(name).render(**ctx)
|
||||
|
||||
|
||||
@@ -55,9 +62,26 @@ def test_form_marks_systems_with_a_domain_limit():
|
||||
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
|
||||
|
||||
|
||||
def test_handler_accepts_and_passes_house_systems():
|
||||
assert "house_systems: list[str] = Form([])" in MAIN
|
||||
assert "house_systems=house_systems" in MAIN # przekazane do logiki
|
||||
def test_handler_accepts_and_passes_house_systems(monkeypatch):
|
||||
"""Zaznaczone systemy DOCHODZĄ do warstwy logicznej.
|
||||
|
||||
Poprzednia wersja szukała napisu `house_systems=house_systems` w main.py
|
||||
i pękała, gdy argument zaczął przechodzić przez słownik opcji — mimo że
|
||||
zachowanie zostało poprawne. Sprawdzamy więc, co naprawdę wyszło na dół."""
|
||||
seen = {}
|
||||
|
||||
def spy(**kw):
|
||||
seen.update(kw)
|
||||
return _minimal_chart()
|
||||
|
||||
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||
from app.main import logic
|
||||
|
||||
monkeypatch.setattr(logic, "positions", spy)
|
||||
r = c.post("/", data={**FORM, "house_systems": ["koch", "campanus"]})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert seen["house_systems"] == ["koch", "campanus"]
|
||||
assert seen["house_system"] == "placidus"
|
||||
|
||||
|
||||
def test_client_forwards_house_systems_to_logic():
|
||||
@@ -89,10 +113,127 @@ def test_fallback_notice_is_shown_prominently():
|
||||
sygnałem, że podział domów jest z innego systemu."""
|
||||
html = _render("_result_tables.html",
|
||||
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
|
||||
assert "house-warning" in html
|
||||
# Klasa nazywa się neutralnie: `house-warning` siedziała też na ekranie
|
||||
# logowania i na ekranie plików, czyli nazwa z astrologii lądowała tam,
|
||||
# gdzie astrologii ma nie być widać.
|
||||
assert "notice-warn" in html
|
||||
assert "UWAGA: system domów nie ma definicji" in html
|
||||
|
||||
|
||||
def test_no_notice_when_nothing_was_substituted():
|
||||
html = _render("_result_tables.html", result={"house_warnings": []})
|
||||
assert "house-warning" not in html
|
||||
|
||||
|
||||
# ── ścieżka PRZEZ handlery, nie obok nich ───────────────────────────────
|
||||
# Dwa błędy przeszły przez komplet testów szablonowych, bo żaden nie wywołał
|
||||
# POST-a: odwołanie do nieistniejącej zmiennej w handlerze strony głównej oraz
|
||||
# użycie parametru formularza, którego w sygnaturze nie było. Oba dają 500 na
|
||||
# żywo i oba łapie dopiero prawdziwe żądanie.
|
||||
|
||||
def _client_with_stub_logic(monkeypatch, chart):
|
||||
import os
|
||||
|
||||
os.environ.pop("APP_PASSWORD", None)
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app, logic
|
||||
|
||||
monkeypatch.setattr(logic, "positions", lambda **kw: chart)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _minimal_chart():
|
||||
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0°00'00''",
|
||||
"decimal": float(i * 30), "sign_glyph": "♈"} for i in range(12)]
|
||||
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0°00'00''", "decimal": 0.0,
|
||||
"sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")}
|
||||
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
|
||||
"sign_glyphs": [{"sign": "Aries", "glyph": "♈"}],
|
||||
"house_system": "porphyry", "house_system_requested": "placidus",
|
||||
"house_warnings": ["UWAGA: system domów „placidus” nie ma definicji"]}
|
||||
|
||||
|
||||
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||
"lat": "50.06", "lon": "19.94", "house_system": "placidus"}
|
||||
|
||||
|
||||
def test_chart_page_survives_a_real_post(monkeypatch):
|
||||
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||
r = c.post("/", data=FORM)
|
||||
assert r.status_code == 200, r.text[:400]
|
||||
assert "nie ma definicji" in r.text, "ostrzeżenie o fallbacku nie dotarło na stronę"
|
||||
|
||||
|
||||
def test_compile_page_survives_a_real_post(monkeypatch):
|
||||
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||
r = c.post("/compile", data=FORM)
|
||||
assert r.status_code == 200, r.text[:400]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("orientation", ("asc", "aries"))
|
||||
def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
|
||||
"""Wybór obrotu koła musi dojść z formularza do rysunku — i wrócić
|
||||
zaznaczony, żeby nie gubił się przy przeliczeniu."""
|
||||
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||
r = c.post("/", data={**FORM, "wheel_orientation": orientation})
|
||||
assert r.status_code == 200, r.text[:400]
|
||||
assert f'value="{orientation}" checked' in r.text
|
||||
|
||||
|
||||
# ── układ strony przy komplecie systemów ────────────────────────────────
|
||||
# Regresja z pomiaru na żywej stronie: przy 13 zaznaczonych systemach tabela
|
||||
# porównania miała 14 kolumn i 1863 px szerokości, a że stała w rodzicu bez
|
||||
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
|
||||
# poziomy pasek przewijania na body i rozjechany układ.
|
||||
|
||||
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||
# pilnuje osobny test pokrycia.
|
||||
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||
for f in sorted((APP / "static").glob("*.css")))
|
||||
|
||||
|
||||
def test_comparison_table_scrolls_instead_of_widening_the_page():
|
||||
"""Tabela ma własny kontener przewijany — przewija się ona, nie strona."""
|
||||
html = _render("_result_tables.html", result={
|
||||
"house_systems": [{"system": v, "cusps": [
|
||||
{"sign_glyph": "♈", "in_sign": "0°00'00''"} for _ in range(12)]}
|
||||
for v, _ in HOUSE_SYSTEMS],
|
||||
"house_warnings": []})
|
||||
assert '<div class="table-scroll">' in html
|
||||
before = html.index('class="table-scroll"')
|
||||
assert html.index("<table", before) < html.index("</div>", before), \
|
||||
"tabela musi być WEWNĄTRZ kontenera przewijanego"
|
||||
|
||||
|
||||
def test_scroll_container_actually_clips_and_pins_the_house_number():
|
||||
"""Sam znacznik nie wystarczy — reguła musi istnieć w arkuszu.
|
||||
|
||||
Geometrii nie da się sprawdzić bez przeglądarki, więc sprawdzamy kontrakt:
|
||||
kontener przewija się w poziomie, a pierwsza kolumna zostaje przyklejona
|
||||
(bez tego po przewinięciu w prawo nie wiadomo, który to dom)."""
|
||||
assert ".table-scroll { overflow-x: auto;" in CSS
|
||||
assert ".table-scroll th:first-child, .table-scroll td:first-child {" in CSS
|
||||
assert "position: sticky" in CSS
|
||||
|
||||
|
||||
def test_system_checkboxes_use_a_grid_not_a_ragged_flex_wrap():
|
||||
"""13 pozycji na flexie zawijało się w poszarpane wiersze o różnej długości."""
|
||||
html = _render("_form_options.html", form={})
|
||||
assert 'class="opts systems"' in html
|
||||
assert ".opts.systems { display: grid;" in CSS
|
||||
|
||||
|
||||
def test_polar_circle_note_is_explained_once_next_to_the_asterisks():
|
||||
"""Przypis siedział WEWNĄTRZ <label> selektora i łamał się na dwie linie,
|
||||
przez co wiersz siatki rósł z 66 do 108 px i rozjeżdżał się z sąsiednim
|
||||
polem. Wyjaśnienie stoi teraz raz, przy checkboxach z gwiazdkami."""
|
||||
opts = _render("_form_options.html", form={})
|
||||
assert "kołem podbiegunowym" in opts
|
||||
for name in ("chart.html", "compile.html"):
|
||||
tpl = (APP / "templates" / name).read_text(encoding="utf-8")
|
||||
select = tpl[tpl.index("<select name=\"house_system\""):]
|
||||
assert "podbiegunow" not in select[:select.index("</label>")], \
|
||||
f"{name}: przypis wrócił do etykiety selektora"
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Kontrola dostępu: konta, uprawnienia i niewidzialność funkcji (PRE-27).
|
||||
|
||||
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
|
||||
|
||||
1. Konto widzi dokładnie to, co mu przyznano — ani mniej, ani więcej. Sprawdzane
|
||||
przez PRAWDZIWE żądania, nie przez czytanie szablonów: ukrycie pola w formularzu
|
||||
nie chroni przed kimś, kto zna nazwy pól.
|
||||
|
||||
2. Konto ograniczone nie ma SKĄD wiedzieć, że program umie więcej. To własność
|
||||
negatywna — łatwo ją zepsuć przez dobre intencje („dodajmy czytelny komunikat
|
||||
o braku uprawnień"), więc testy są tu wprost o tym: 404 zamiast 403, brak
|
||||
pozycji w menu, brak rysunków w źródle strony.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app import accounts as store
|
||||
from app import features, security
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
"""Świeży plik kont + konto administracyjne ze środowiska."""
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||
monkeypatch.setenv("APP_USER", "szef")
|
||||
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||
monkeypatch.delenv("APP_USERS", 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 # limit nie jest tu przedmiotem badania
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _client(monkeypatch, chart=None):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app, logic
|
||||
|
||||
monkeypatch.setattr(logic, "positions", lambda **kw: chart or _chart())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth(user, password=""):
|
||||
"""Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
|
||||
|
||||
Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma
|
||||
nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne
|
||||
i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań —
|
||||
sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie."""
|
||||
from app import security, session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
def _chart():
|
||||
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
|
||||
"sign_glyph": "♈"} for i in range(12)]
|
||||
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
|
||||
"sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")}
|
||||
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
|
||||
"sign_glyphs": [{"sign": "Aries", "glyph": "♈"}], "house_system": "whole_sign",
|
||||
"aspects": [], "house_systems": []}
|
||||
|
||||
|
||||
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||
"lat": "50.06", "lon": "19.94"}
|
||||
|
||||
|
||||
# ── katalog i mapa tras ──────────────────────────────────────────────────
|
||||
|
||||
def test_every_route_declares_the_permission_it_needs():
|
||||
"""Trasa bez wpisu w ROUTES to dziura, której nie widać.
|
||||
|
||||
Idziemy po TRASACH APLIKACJI, nie po wpisach mapy — inaczej test
|
||||
potwierdzałby tylko sam siebie i nie zauważyłby nowej, nieopisanej trasy."""
|
||||
from app.main import app
|
||||
|
||||
missing = []
|
||||
for route in app.routes:
|
||||
path = getattr(route, "path", None)
|
||||
methods = getattr(route, "methods", None) or set()
|
||||
if not path or path.startswith("/static"):
|
||||
continue
|
||||
for method in methods & {"GET", "POST"}:
|
||||
if (method, path) not in features.ROUTES:
|
||||
missing.append(f"{method} {path}")
|
||||
assert not missing, f"trasy bez zadeklarowanego uprawnienia: {missing}"
|
||||
|
||||
|
||||
def test_unknown_route_defaults_to_the_narrowest_permission():
|
||||
"""Przeoczenie ma ZAMYKAĆ, nie otwierać."""
|
||||
assert features.required("GET", "/cokolwiek-nowego") == features.ADMIN
|
||||
|
||||
|
||||
def test_admin_permission_cannot_be_granted_from_a_form():
|
||||
"""Nawet spreparowane żądanie nie nada uprawnień administracyjnych —
|
||||
konto administracyjne pochodzi wyłącznie ze środowiska."""
|
||||
assert features.ADMIN not in features.normalise([features.ADMIN, "chart"])
|
||||
assert features.normalise(["chart", "wymyślone"]) == frozenset({"chart"})
|
||||
|
||||
|
||||
# ── magazyn kont ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_password_is_stored_only_as_a_hash(env):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
raw = pathlib.Path(store.store_path()).read_text(encoding="utf-8")
|
||||
assert "hasło-ali" not in raw
|
||||
assert json.loads(raw)["users"]["ala"]["secret"].startswith("scrypt$")
|
||||
|
||||
|
||||
def test_listing_accounts_never_exposes_secrets(env):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
assert "secret" not in store.all_users()["ala"]
|
||||
|
||||
|
||||
def test_create_update_delete(env):
|
||||
store.create("ala", "x", ["chart"], note="do testów")
|
||||
assert store.permissions_of("ala") == frozenset({"chart"})
|
||||
store.update("ala", granted=["chart", "ai"])
|
||||
assert store.permissions_of("ala") == frozenset({"chart", "ai"})
|
||||
with pytest.raises(ValueError):
|
||||
store.create("ala", "y", []) # login zajęty
|
||||
store.delete("ala")
|
||||
assert not store.exists("ala")
|
||||
|
||||
|
||||
def test_empty_password_on_update_keeps_the_old_one(env):
|
||||
store.create("ala", "stare", ["chart"])
|
||||
before = store.secret_of("ala")
|
||||
store.update("ala", granted=["chart"], password="")
|
||||
assert store.secret_of("ala") == before
|
||||
|
||||
|
||||
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
|
||||
|
||||
def test_environment_account_is_the_administrator(env):
|
||||
who = security.verify("szef", "tajne-szefa")
|
||||
assert who and who.is_admin
|
||||
assert features.ADMIN in who.permissions
|
||||
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
|
||||
|
||||
|
||||
def test_managed_account_gets_exactly_its_own_permissions(env):
|
||||
store.create("ala", "hasło-ali", ["chart", "ai"])
|
||||
who = security.verify("ala", "hasło-ali")
|
||||
assert who and not who.is_admin
|
||||
assert who.permissions == frozenset({"chart", "ai"})
|
||||
|
||||
|
||||
def test_managed_account_cannot_shadow_the_administrator(env):
|
||||
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
|
||||
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
|
||||
store.create("szef", "podszywam-się", [])
|
||||
who = security.verify("szef", "tajne-szefa")
|
||||
assert who and who.is_admin and features.ADMIN in who.permissions
|
||||
|
||||
|
||||
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
assert security.verify("ala", "złe") is None
|
||||
assert security.verify("nie-ma-takiego", "cokolwiek") is None
|
||||
|
||||
|
||||
# ── niewidzialność funkcji ───────────────────────────────────────────────
|
||||
|
||||
def test_forbidden_screen_answers_404_not_403(env, monkeypatch):
|
||||
"""403 samo w sobie mówi „tu coś jest". Ma być nie do odróżnienia od
|
||||
adresu, którego nie ma."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
for path in ("/significators", "/interpret", "/timeline", "/synastry",
|
||||
"/compile", "/settings", "/accounts"):
|
||||
r = c.get(path, headers=_auth("ala", "x"))
|
||||
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||||
assert c.get("/nie-ma-takiego-adresu", headers=_auth("ala", "x")).status_code == 404
|
||||
|
||||
|
||||
def test_menu_shows_only_granted_screens(env, monkeypatch):
|
||||
store.create("ala", "x", ["chart", "significators"])
|
||||
c = _client(monkeypatch)
|
||||
html = c.get("/", headers=_auth("ala", "x")).text
|
||||
assert 'href="/significators"' in html
|
||||
for absent in ('href="/interpret"', 'href="/timeline"', 'href="/synastry"',
|
||||
'href="/compile"', 'href="/settings"', 'href="/accounts"'):
|
||||
assert absent not in html, f"menu zdradza {absent}"
|
||||
for word in ("Interpretacje", "Kalendarz", "Synastria", "Skompiluj", "Konta"):
|
||||
assert word not in html, f"nazwa „{word}” nie powinna paść"
|
||||
|
||||
|
||||
def test_administrator_sees_everything_including_the_accounts_tab(env, monkeypatch):
|
||||
c = _client(monkeypatch)
|
||||
html = c.get("/", headers=_auth("szef", "tajne-szefa")).text
|
||||
for tab in features.SCREENS:
|
||||
assert f'href="{tab.href}"' in html, tab.key
|
||||
assert 'href="/accounts"' in html
|
||||
|
||||
|
||||
def test_root_lands_on_the_first_granted_screen(env, monkeypatch):
|
||||
"""Konto bez „Horoskopu" nie może zobaczyć 404 pod adresem głównym —
|
||||
wyglądałoby to na zepsuty program, a nie na węższy zestaw funkcji."""
|
||||
store.create("ala", "x", ["significators"])
|
||||
c = _client(monkeypatch)
|
||||
r = c.get("/", headers=_auth("ala", "x"), follow_redirects=False)
|
||||
assert r.status_code == 303 and r.headers["location"] == "/significators"
|
||||
|
||||
|
||||
# ── ograniczenia opcji egzekwowane NA SERWERZE ──────────────────────────
|
||||
|
||||
def test_crafted_request_cannot_buy_options_the_account_lacks(env, monkeypatch):
|
||||
"""Ukrycie pola w formularzu chroni przed przypadkiem, nie przed kimś,
|
||||
kto zna nazwy pól. Granicą jest handler."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
seen = {}
|
||||
from app.main import app, logic
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _chart())[1])
|
||||
r = TestClient(app).post("/", headers=_auth("ala", "x"), data={
|
||||
**FORM, "stations": "true", "tables": "true", "aspect_minor": "true",
|
||||
"zodiac": "sidereal_lahiri", "house_system": "koch",
|
||||
"house_systems": ["koch", "campanus"]})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert seen["stations"] is False and seen["tables"] is False
|
||||
assert seen["aspect_minor"] is False and seen["zodiac"] == "tropical"
|
||||
assert seen["house_system"] == "whole_sign" and seen["house_systems"] == []
|
||||
|
||||
|
||||
def test_extra_charts_are_not_even_in_the_page_source(env, monkeypatch):
|
||||
"""Nie chodzi o ukrycie rysunków stylem — nie mają w ogóle powstać."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
html = c.post("/", headers=_auth("ala", "x"), data=FORM).text
|
||||
for word in ("Aspektarian", "deklinacj", "ntyscj"):
|
||||
assert word not in html, f"źródło strony zdradza „{word}”"
|
||||
|
||||
|
||||
def test_the_same_account_with_the_extra_gets_them(env, monkeypatch):
|
||||
"""Kontrola pozytywna: bez niej powyższy test przechodziłby też wtedy,
|
||||
gdyby rysunki były zepsute dla wszystkich."""
|
||||
store.create("ola", "x", ["chart", "extra_charts"])
|
||||
c = _client(monkeypatch, chart=_chart_with_objects())
|
||||
html = c.post("/", headers=_auth("ola", "x"), data=FORM).text
|
||||
assert "Aspektarian" in html
|
||||
|
||||
|
||||
def _chart_with_objects():
|
||||
"""Horoskop na tyle bogaty, żeby rysunki dodatkowe w ogóle powstały."""
|
||||
base = _chart()
|
||||
base["positions"] = [
|
||||
{"name": n, "glyph": g, "sign": "Aries", "sign_glyph": "♈", "in_sign": "0",
|
||||
"decimal": float(i * 30), "direction": "D", "speed": 1.0,
|
||||
"declination": 10.0, "house": i + 1}
|
||||
for i, (n, g) in enumerate((("Sun", "☉"), ("Moon", "☽"), ("Mars", "♂")))]
|
||||
base["aspects"] = [{"obj1": "Sun", "obj2": "Moon", "aspect": "trine",
|
||||
"orb": 1.0, "allowed": 8.0}]
|
||||
return base
|
||||
|
||||
|
||||
def test_only_the_administrator_can_manage_accounts(env, monkeypatch):
|
||||
store.create("ala", "x", ["chart", "ai", "export", "extra_charts"])
|
||||
c = _client(monkeypatch)
|
||||
for method, path in (("get", "/accounts"), ("post", "/accounts/create"),
|
||||
("post", "/accounts/update"), ("post", "/accounts/delete")):
|
||||
kwargs = {"headers": _auth("ala", "x")}
|
||||
if method == "post":
|
||||
kwargs["data"] = {"login": "ktoś"}
|
||||
r = getattr(c, method)(path, **kwargs)
|
||||
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||||
assert c.get("/accounts", headers=_auth("szef", "tajne-szefa")).status_code == 200
|
||||
|
||||
|
||||
# ── pełny obieg: założenie konta z ekranu i zalogowanie się na nie ───────
|
||||
|
||||
def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypatch):
|
||||
"""Nagłówek całej funkcji: administrator zakłada konto z ekranu, a osoba na
|
||||
tym koncie loguje się i dostaje DOKŁADNIE przyznany zestaw — bez restartu
|
||||
aplikacji i bez dotykania konfiguracji środowiska."""
|
||||
c = _client(monkeypatch)
|
||||
admin = _auth("szef", "tajne-szefa")
|
||||
|
||||
r = c.post("/accounts/create", headers=admin, follow_redirects=False, data={
|
||||
"login": "nowa", "password": "jej-hasło", "note": "praktykantka",
|
||||
"granted": ["chart", "significators", "extra_charts"]})
|
||||
assert r.status_code == 303
|
||||
|
||||
listing = c.get("/accounts", headers=admin).text
|
||||
assert "nowa" in listing and "praktykantka" in listing
|
||||
assert "jej-hasło" not in listing, "hasło nie ma prawa trafić na ekran"
|
||||
|
||||
her = _auth("nowa", "jej-hasło")
|
||||
html = c.get("/", headers=her).text
|
||||
assert 'href="/significators"' in html
|
||||
assert 'href="/accounts"' not in html and 'href="/compile"' not in html
|
||||
assert c.get("/compile", headers=her).status_code == 404
|
||||
|
||||
# odebranie uprawnienia działa od razu
|
||||
c.post("/accounts/update", headers=admin, follow_redirects=False,
|
||||
data={"login": "nowa", "granted": ["chart"]})
|
||||
assert c.get("/significators", headers=her).status_code == 404
|
||||
|
||||
# skasowanie konta ubija też OTWARTĄ sesję — bez tego odebranie dostępu
|
||||
# działałoby dopiero po jej wygaśnięciu
|
||||
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
|
||||
assert c.get("/", headers=her, follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
|
||||
"""Konto administracyjne nie leży w pliku, więc nie ma czego skasować —
|
||||
ale próba nie może też wywalić aplikacji ani skasować czegoś innego."""
|
||||
c = _client(monkeypatch)
|
||||
admin = _auth("szef", "tajne-szefa")
|
||||
store.create("ala", "x", ["chart"])
|
||||
r = c.post("/accounts/delete", headers=admin, follow_redirects=False,
|
||||
data={"login": "szef"})
|
||||
assert r.status_code == 303
|
||||
assert store.exists("ala"), "kasowanie nieistniejącego konta ruszyło inne"
|
||||
assert c.get("/", headers=admin).status_code == 200
|
||||
|
||||
|
||||
# ── awaria magazynu kont ─────────────────────────────────────────────────
|
||||
# Ekran kont to JEDYNE miejsce, z którego administrator może naprawić problem
|
||||
# z magazynem — więc musi na nim przeczytać, co i gdzie jest nie tak. Gołe 500
|
||||
# (tak było na pierwszym wdrożeniu) zostawia go z niczym.
|
||||
#
|
||||
# AWARIĘ WYMUSZAMY PODMIANĄ, NIE PRAWAMI PLIKU. Pierwsza wersja robiła chmod 000
|
||||
# i chmod 555 — i przechodziła lokalnie, a padała w CI, bo CI działa jako ROOT,
|
||||
# a root omija bity uprawnień w Linuksie. Test zależny od tego, kto go uruchamia,
|
||||
# jest gorszy niż jego brak: daje fałszywe poczucie pokrycia i zapala się
|
||||
# w miejscu, które z badaną rzeczą nie ma nic wspólnego.
|
||||
#
|
||||
# Podmiana jest przy okazji CELNIEJSZA: sprawdzamy dokładnie ten punkt, w którym
|
||||
# system plików mówi „nie", zamiast liczyć, że da się go do tego zmusić.
|
||||
|
||||
def _fail_reading_accounts(monkeypatch, exc=PermissionError(13, "Permission denied")):
|
||||
"""Odczyt PLIKU KONT wywala się; wszystko inne (szablony!) czyta normalnie."""
|
||||
import builtins
|
||||
|
||||
from app import accounts as store
|
||||
|
||||
real_open, target = builtins.open, store.store_path()
|
||||
|
||||
def guarded(file, *a, **kw):
|
||||
if str(file) == target:
|
||||
raise exc
|
||||
return real_open(file, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", guarded)
|
||||
|
||||
|
||||
def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "stan" / "accounts.json"))
|
||||
_fail_reading_accounts(monkeypatch)
|
||||
|
||||
c = _client(monkeypatch)
|
||||
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
||||
assert r.status_code == 200, "problem z magazynem nie może wywalać strony"
|
||||
assert "Nie mogę odczytać pliku kont" in r.text
|
||||
assert "accounts.json" in r.text, "komunikat ma podać ŚCIEŻKĘ"
|
||||
|
||||
|
||||
def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch):
|
||||
"""Podmieniamy mkstemp, bo to ON wywala się PIERWSZY przy katalogu tylko do
|
||||
odczytu — jeszcze zanim dojdzie do zapisu i podmiany pliku. Obudowanie samego
|
||||
os.replace niczego by nie dało i właśnie tego dotyczyła poprawka."""
|
||||
import tempfile
|
||||
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "stan" / "accounts.json"))
|
||||
monkeypatch.setattr(tempfile, "mkstemp",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
PermissionError(13, "Read-only file system")))
|
||||
|
||||
c = _client(monkeypatch)
|
||||
r = c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
|
||||
follow_redirects=False,
|
||||
data={"login": "ala", "password": "x", "granted": ["chart"]})
|
||||
assert r.status_code == 303
|
||||
# Komunikat jedzie w parametrze zapytania, więc jest zakodowany —
|
||||
# porównanie na surowym nagłówku sprawdzałoby procenty, nie treść.
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
assert "Nie mogę zapisać pliku kont" in unquote_plus(r.headers["location"])
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() == 0,
|
||||
reason="root omija bity uprawnień — sprawdzian nic by nie znaczył")
|
||||
def test_real_file_permissions_produce_the_same_message(env, tmp_path, monkeypatch):
|
||||
"""Kontrola, że podmiana wyżej odpowiada RZECZYWISTOŚCI, a nie tylko sama
|
||||
sobie. Pomijana przy uruchomieniu z roota — czyli w CI."""
|
||||
d = tmp_path / "stan"
|
||||
d.mkdir()
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json"))
|
||||
d.chmod(0o000)
|
||||
try:
|
||||
c = _client(monkeypatch)
|
||||
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
||||
assert r.status_code == 200
|
||||
assert "Nie mogę odczytać pliku kont" in r.text
|
||||
finally:
|
||||
d.chmod(0o755)
|
||||
|
||||
|
||||
def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch):
|
||||
"""Nadpisanie uszkodzonego pliku pustym zbiorem skasowałoby WSZYSTKIE konta.
|
||||
Lepiej odmówić i powiedzieć, co jest nie tak."""
|
||||
path = tmp_path / "accounts.json"
|
||||
path.write_text("{to nie jest json", encoding="utf-8")
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(path))
|
||||
|
||||
c = _client(monkeypatch)
|
||||
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
||||
assert r.status_code == 200 and "uszkodzony" in r.text
|
||||
|
||||
c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
|
||||
follow_redirects=False, data={"login": "ala", "password": "x"})
|
||||
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
|
||||
"uszkodzony plik został nadpisany — konta by zniknęły"
|
||||
|
||||
|
||||
# ── logowanie i wylogowanie (LOG-34) ─────────────────────────────────────
|
||||
# Sesja zastąpiła HTTP Basic. Basic nie miał wylogowania, bo przeglądarka
|
||||
# dosyłała hasło sama przy każdym żądaniu; teraz to serwer decyduje, czy dana
|
||||
# przeglądarka jest w środku, i może to cofnąć.
|
||||
|
||||
def test_logging_in_hands_out_a_session_and_takes_you_where_you_were_going(env, monkeypatch):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
r = c.post("/logowanie", follow_redirects=False,
|
||||
data={"login": "ala", "haslo": "hasło-ali", "dokad": "/"})
|
||||
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||
from app import session
|
||||
|
||||
assert session.COOKIE in r.cookies, "nie wydano ciasteczka sesji"
|
||||
|
||||
|
||||
def test_a_bad_password_says_the_same_thing_as_an_unknown_login(env, monkeypatch):
|
||||
"""Rozróżnienie mówiłoby, które konta istnieją."""
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
zle = c.post("/logowanie", data={"login": "ala", "haslo": "złe"})
|
||||
obcy = c.post("/logowanie", data={"login": "nie-ma-takiego", "haslo": "cokolwiek"})
|
||||
assert zle.status_code == obcy.status_code == 401
|
||||
# Odpowiedzi różnią się WYŁĄCZNIE odbitym polem loginu — czyli tym, co sam
|
||||
# wpisał pytający. Wszystko poza tym musi być identyczne, inaczej dałoby się
|
||||
# wyliczyć, które konta istnieją.
|
||||
bez_loginu = lambda t, login: t.replace(f'value="{login}"', 'value="X"')
|
||||
assert "Nieprawidłowy login lub hasło" in zle.text
|
||||
assert bez_loginu(zle.text, "ala") == bez_loginu(obcy.text, "nie-ma-takiego")
|
||||
|
||||
|
||||
def test_logging_out_drops_the_session(env, monkeypatch):
|
||||
"""Sedno całej zmiany: po wylogowaniu przeglądarka nie ma już czego dosyłać."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
assert c.get("/", headers=_auth("ala"), follow_redirects=False).status_code == 200
|
||||
|
||||
r = c.post("/wyloguj", headers=_auth("ala"), follow_redirects=False)
|
||||
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
|
||||
# Ciasteczko skasowane: przeglądarka dostaje puste z natychmiastowym kresem.
|
||||
from app import session
|
||||
|
||||
ustawione = r.headers.get("set-cookie", "")
|
||||
assert session.COOKIE in ustawione
|
||||
assert "Max-Age=0" in ustawione or 'expires=Thu, 01 Jan 1970' in ustawione.lower()
|
||||
|
||||
|
||||
def test_logging_out_is_a_post_not_a_link(env, monkeypatch):
|
||||
"""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."""
|
||||
c = _client(monkeypatch)
|
||||
assert c.get("/wyloguj", headers=_auth("szef"),
|
||||
follow_redirects=False).status_code == 405
|
||||
|
||||
|
||||
def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monkeypatch):
|
||||
"""Bez pokazania, KTO jest zalogowany, przycisk wyjścia jest w połowie
|
||||
bezużyteczny — na wspólnym komputerze nie wiadomo, kogo się wylogowuje."""
|
||||
store.create("ala", "x", ["chart", "significators"])
|
||||
c = _client(monkeypatch)
|
||||
for kto in ("szef", "ala"):
|
||||
html = c.get("/", headers=_auth(kto)).text
|
||||
assert 'action="/wyloguj"' in html, f"{kto}: brak wyjścia"
|
||||
assert kto in html, f"{kto}: nie widać, kto jest zalogowany"
|
||||
|
||||
|
||||
def test_no_logout_button_when_authentication_is_off(env, monkeypatch):
|
||||
"""Bez logowania nie ma z czego wychodzić, a przycisk sugerowałby ochronę,
|
||||
której nie ma."""
|
||||
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
c = _client(monkeypatch)
|
||||
assert 'action="/wyloguj"' not in c.get("/").text
|
||||
|
||||
|
||||
def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch):
|
||||
"""Konto bez ani jednego ekranu też ma prawo wyjść."""
|
||||
store.create("bez", "x", [])
|
||||
c = _client(monkeypatch)
|
||||
assert c.post("/wyloguj", headers=_auth("bez"),
|
||||
follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_the_login_screen_cannot_be_turned_into_a_redirector(env, monkeypatch):
|
||||
"""`?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie do
|
||||
odsyłania ludzi gdzie indziej — droga do wyłudzenia hasła, bo odsyłacz
|
||||
prowadzi z zaufanego adresu."""
|
||||
c = _client(monkeypatch)
|
||||
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x", "\\\\obcy.pl"):
|
||||
r = c.post("/logowanie", follow_redirects=False,
|
||||
data={"login": "szef", "haslo": "tajne-szefa", "dokad": zly})
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
|
||||
|
||||
|
||||
def test_changing_the_password_kills_open_sessions(env, monkeypatch):
|
||||
"""Odebranie dostępu przez zmianę hasła MUSI ubić otwarte sesje — inaczej
|
||||
odebranie dostępu nie odbiera dostępu aż do wygaśnięcia."""
|
||||
store.create("ala", "stare", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
ciasteczko = _auth("ala")
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||
|
||||
store.update("ala", password="nowe")
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_the_administrator_can_close_all_sessions_of_an_account(env, monkeypatch):
|
||||
"""„Wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił
|
||||
zalogowany komputer."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
ciasteczko = _auth("ala")
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||
|
||||
c.post("/accounts/sesje", headers=_auth("szef"), follow_redirects=False,
|
||||
data={"login": "ala"})
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||
# ...a hasło dalej działa
|
||||
assert security.verify("ala", "x") is not None
|
||||
|
||||
|
||||
def test_the_administrator_gets_in_even_when_the_accounts_file_is_broken(env, monkeypatch):
|
||||
"""Jedyna sytuacja, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie
|
||||
stanu administratora w tym samym pliku dawałoby zakleszczenie."""
|
||||
import pathlib as _p
|
||||
|
||||
_p.Path(store.store_path()).write_text("{to nie jest json", encoding="utf-8")
|
||||
c = _client(monkeypatch)
|
||||
assert c.get("/accounts", headers=_auth("szef")).status_code == 200
|
||||
assert security.verify("szef", "tajne-szefa") is not None
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Trzy poziomy dostępu do plików baz (DAN-27 × PRE-27).
|
||||
|
||||
user („files") — widzi listę i decyduje, z czego program korzysta,
|
||||
data_input (+„files_input")— dokłada wgrywanie i ARCHIWIZACJĘ,
|
||||
administrator — kasowanie, przywracanie i REGUŁY WALIDACJI.
|
||||
|
||||
Osobno pilnujemy własności negatywnej: poza administratorem NIKT nie ma skąd
|
||||
wiedzieć, że walidacja w ogóle istnieje. To łatwo zepsuć dobrą intencją —
|
||||
„pokażmy człowiekowi, czemu plik nie przeszedł" — więc testy są wprost o tym.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import accounts as store
|
||||
from app import features
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||
monkeypatch.setenv("APP_USER", "szef")
|
||||
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||
monkeypatch.delenv("APP_USERS", 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
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _auth(user, password=""):
|
||||
"""Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
|
||||
|
||||
Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma
|
||||
nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne
|
||||
i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań —
|
||||
sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie."""
|
||||
from app import security, session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
REGISTRY = [
|
||||
{"name": "glowna.xlsx", "path": "glowna.xlsx", "size_mb": 1.0, "modified": "2026-01-01",
|
||||
"status": "active", "in_use": True, "archived_at": "", "uploaded_at": "",
|
||||
"uploaded_by": "", "sha256": "abc"},
|
||||
{"name": "stara.xlsx", "path": "stara.xlsx", "size_mb": 2.0, "modified": "2025-01-01",
|
||||
"status": "archived", "in_use": False, "archived_at": "2026-02-02T10:00:00+00:00",
|
||||
"uploaded_at": "", "uploaded_by": "", "sha256": "def"},
|
||||
]
|
||||
HELD = {"name": "podejrzana.xlsx", "path": "podejrzana.xlsx", "size_mb": 0.1,
|
||||
"modified": "2026-03-03", "status": "quarantine", "in_use": False,
|
||||
"archived_at": "", "uploaded_at": "2026-03-03T09:00:00+00:00",
|
||||
"uploaded_by": "dane", "sha256": "ghi",
|
||||
"rejected_for": ["brak wymaganych kolumn: znaczenie"]}
|
||||
RULES = {"extensions": [".xlsx"], "max_size_mb": 50, "min_rows": 1,
|
||||
"required_columns": ["id", "znaczenie"], "reject_duplicate_content": True}
|
||||
|
||||
|
||||
def _client(monkeypatch, calls=None):
|
||||
"""Klient z zastubowaną warstwą logiczną. `calls` zbiera to, co poszło w dół."""
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app, logic
|
||||
|
||||
calls = calls if calls is not None else []
|
||||
|
||||
def files_list(for_admin=False):
|
||||
rows = list(REGISTRY) + ([HELD] if for_admin else [])
|
||||
return {"files": rows, "rules": RULES if for_admin else {}, "root": "/x"}
|
||||
|
||||
monkeypatch.setattr(logic, "files_list", files_list)
|
||||
for name in ("files_status", "files_upload", "files_delete", "files_rules"):
|
||||
monkeypatch.setattr(logic, name,
|
||||
lambda *a, _n=name, **kw: (calls.append((_n, a, kw)),
|
||||
{"accepted": True, "name": "x.xlsx"})[1])
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
# ── poziom „user" ────────────────────────────────────────────────────────
|
||||
|
||||
def test_user_sees_the_list_and_can_switch_bases_on_and_off(env, monkeypatch):
|
||||
store.create("ula", "x", ["files"])
|
||||
c, calls = _client(monkeypatch)
|
||||
html = c.get("/files", headers=_auth("ula", "x")).text
|
||||
assert "glowna.xlsx" in html and "stara.xlsx" in html
|
||||
|
||||
r = c.post("/files/use", headers=_auth("ula", "x"), follow_redirects=False,
|
||||
data={"path": "glowna.xlsx", "use": "0"})
|
||||
assert r.status_code == 303
|
||||
assert calls and calls[0][0] == "files_status"
|
||||
|
||||
|
||||
def test_user_gets_no_upload_no_archive_no_delete(env, monkeypatch):
|
||||
store.create("ula", "x", ["files"])
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get("/files", headers=_auth("ula", "x")).text
|
||||
for absent in ("/files/upload", "/files/archive", "/files/delete",
|
||||
"/files/restore", "/files/rules"):
|
||||
assert absent not in html, f"strona zdradza {absent}"
|
||||
for path in ("/files/upload", "/files/archive", "/files/delete",
|
||||
"/files/restore", "/files/rules"):
|
||||
r = c.post(path, headers=_auth("ula", "x"), data={"path": "glowna.xlsx"})
|
||||
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||||
|
||||
|
||||
# ── poziom „data_input" ──────────────────────────────────────────────────
|
||||
|
||||
def test_data_input_can_upload_and_archive(env, monkeypatch):
|
||||
store.create("dane", "x", ["files", "files_input"])
|
||||
c, calls = _client(monkeypatch)
|
||||
html = c.get("/files", headers=_auth("dane", "x")).text
|
||||
assert "/files/upload" in html and "/files/archive" in html
|
||||
|
||||
r = c.post("/files/upload", headers=_auth("dane", "x"), follow_redirects=False,
|
||||
files={"upload": ("nowa.xlsx", b"zawartosc", "application/vnd.ms-excel")})
|
||||
assert r.status_code == 303 and calls[-1][0] == "files_upload"
|
||||
|
||||
r = c.post("/files/archive", headers=_auth("dane", "x"), follow_redirects=False,
|
||||
data={"path": "glowna.xlsx"})
|
||||
assert r.status_code == 303
|
||||
assert calls[-1][1][1] == "archived", "archiwizacja ma ustawiać stan `archived`"
|
||||
|
||||
|
||||
def test_data_input_cannot_delete_restore_or_set_rules(env, monkeypatch):
|
||||
"""Wymóg wprost: osoba wgrywająca dane może CO NAJWYŻEJ zarchiwizować."""
|
||||
store.create("dane", "x", ["files", "files_input"])
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get("/files", headers=_auth("dane", "x")).text
|
||||
for absent in ("/files/delete", "/files/restore", "/files/rules"):
|
||||
assert absent not in html, f"strona zdradza {absent}"
|
||||
for path in ("/files/delete", "/files/restore", "/files/rules"):
|
||||
assert c.post(path, headers=_auth("dane", "x"),
|
||||
data={"path": "glowna.xlsx"}).status_code == 404
|
||||
|
||||
|
||||
# ── własność negatywna: walidacja jest tajemnicą administratora ─────────
|
||||
|
||||
@pytest.mark.parametrize("perms", [["files"], ["files", "files_input"]])
|
||||
def test_nobody_below_admin_learns_that_validation_exists(env, monkeypatch, perms):
|
||||
store.create("ktos", "x", perms)
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get("/files", headers=_auth("ktos", "x")).text
|
||||
assert "podejrzana.xlsx" not in html, "plik wstrzymany nie ma prawa się pokazać"
|
||||
for leak in ("walidacj", "Walidacj", "reguł", "Reguł", "znaczenie",
|
||||
"brak wymaganych kolumn", "rozszerzeni", "Maksymalny rozmiar"):
|
||||
assert leak not in html, f"strona zdradza mechanizm: „{leak}”"
|
||||
|
||||
|
||||
def test_refusal_to_activate_gives_no_reason_below_admin(env, monkeypatch):
|
||||
"""Powód odmowy zdradzałby regułę. Komunikat ma być bez treści."""
|
||||
import httpx
|
||||
|
||||
from app.main import app, logic
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
store.create("ula", "x", ["files"])
|
||||
|
||||
def boom(*a, **kw):
|
||||
raise httpx.HTTPStatusError(
|
||||
"422", request=httpx.Request("POST", "http://x"),
|
||||
response=httpx.Response(422, json={"detail": "brak wymaganych kolumn: znaczenie"}))
|
||||
|
||||
monkeypatch.setattr(logic, "files_list", lambda for_admin=False: {"files": [], "rules": {}})
|
||||
monkeypatch.setattr(logic, "files_status", boom)
|
||||
r = TestClient(app).post("/files/use", headers=_auth("ula", "x"),
|
||||
follow_redirects=False, data={"path": "x.xlsx", "use": "1"})
|
||||
assert r.status_code == 303
|
||||
assert "znaczenie" not in r.headers["location"]
|
||||
assert "kolumn" not in r.headers["location"]
|
||||
|
||||
|
||||
# ── administrator ────────────────────────────────────────────────────────
|
||||
|
||||
def test_admin_sees_held_files_with_the_reason_and_the_rules(env, monkeypatch):
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get("/files", headers=_auth("szef", "tajne-szefa")).text
|
||||
assert "podejrzana.xlsx" in html
|
||||
assert "brak wymaganych kolumn: znaczenie" in html
|
||||
assert "Reguły przyjmowania baz" in html
|
||||
for op in ("/files/delete", "/files/restore", "/files/rules",
|
||||
"/files/upload", "/files/archive"):
|
||||
assert op in html, f"administratorowi brakuje {op}"
|
||||
|
||||
|
||||
def test_admin_can_delete_restore_and_change_the_rules(env, monkeypatch):
|
||||
c, calls = _client(monkeypatch)
|
||||
admin = _auth("szef", "tajne-szefa")
|
||||
c.post("/files/delete", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
|
||||
c.post("/files/restore", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
|
||||
c.post("/files/rules", headers=admin, follow_redirects=False,
|
||||
data={"extensions": ".xlsx, .xlsm", "max_size_mb": "10", "min_rows": "5",
|
||||
"required_columns": "id, znaczenie", "reject_duplicate_content": "1"})
|
||||
names = [c[0] for c in calls]
|
||||
assert names == ["files_delete", "files_status", "files_rules"]
|
||||
sent = calls[-1][1][0]
|
||||
assert sent["extensions"] == [".xlsx", ".xlsm"]
|
||||
assert sent["required_columns"] == ["id", "znaczenie"]
|
||||
assert sent["reject_duplicate_content"] is True
|
||||
|
||||
|
||||
def test_archived_file_cannot_be_switched_on_from_the_page(env, monkeypatch):
|
||||
"""Archiwum jest ZAMROŻONE: przełącznik przy nim w ogóle się nie pojawia,
|
||||
a przywrócić może wyłącznie administrator."""
|
||||
store.create("dane", "x", ["files", "files_input"])
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get("/files", headers=_auth("dane", "x")).text
|
||||
row = html[html.index("stara.xlsx") - 700:html.index("stara.xlsx")]
|
||||
assert "/files/use" not in row, "zarchiwizowana baza ma przełącznik użycia"
|
||||
|
||||
|
||||
def test_files_tab_is_hidden_without_the_permission(env, monkeypatch):
|
||||
store.create("bez", "x", ["chart"])
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get("/", headers=_auth("bez", "x")).text
|
||||
assert 'href="/files"' not in html and "Pliki" not in html
|
||||
assert c.get("/files", headers=_auth("bez", "x")).status_code == 404
|
||||
|
||||
|
||||
def test_route_map_covers_every_file_route():
|
||||
for route in ("/files", "/files/use", "/files/upload", "/files/archive",
|
||||
"/files/restore", "/files/delete", "/files/rules"):
|
||||
method = "GET" if route == "/files" else "POST"
|
||||
assert (method, route) in features.ROUTES, route
|
||||
@@ -66,8 +66,12 @@ def test_key_is_read_lazily():
|
||||
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
|
||||
|
||||
def test_pdf_route_uses_print_theme():
|
||||
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów."""
|
||||
assert 'chartwheel.render(chart, theme="print")' in MAIN
|
||||
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów.
|
||||
|
||||
Sprawdzamy, że trasa PDF-a w ogóle podaje motyw druku — bez wiązania się
|
||||
z dokładnym kształtem wywołania (dochodzą do niego kolejne argumenty).
|
||||
Pełny sprawdzian, że KAŻDY rysunek dostaje 'print', jest w test_compile.py."""
|
||||
assert 'theme="print"' in MAIN
|
||||
|
||||
|
||||
def test_print_theme_has_no_css_variables():
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Logowanie do aplikacji i limit żądań (LOG-32).
|
||||
"""Logowanie do aplikacji i limit żądań (LOG-32/LOG-34).
|
||||
|
||||
To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci
|
||||
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
|
||||
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
|
||||
"""
|
||||
import base64
|
||||
|
||||
Od LOG-34 bramą jest SESJA, nie HTTP Basic. Zmiana nie jest kosmetyczna: Basic
|
||||
nie miał wylogowania, bo przeglądarka dosyłała hasło sama przy każdym żądaniu.
|
||||
Teraz to serwer decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
@@ -16,9 +18,11 @@ from app import security
|
||||
USER, PASSWORD = "astrololo", "haslo-testowe"
|
||||
|
||||
|
||||
def _basic(user: str, password: str) -> dict[str, str]:
|
||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {raw}"}
|
||||
def _sesja(user: str = USER) -> dict[str, str]:
|
||||
"""Nagłówek z ważną sesją — odpowiednik bycia zalogowanym."""
|
||||
from app import session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
def _app() -> FastAPI:
|
||||
@@ -47,48 +51,93 @@ def _reset_rate_limit():
|
||||
def guarded(monkeypatch):
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
|
||||
return TestClient(_app())
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
|
||||
return TestClient(_app(), follow_redirects=False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ logowanie
|
||||
|
||||
def test_blocks_anonymous_access(guarded):
|
||||
r = guarded.get("/significators")
|
||||
assert r.status_code == 401
|
||||
assert r.status_code == 303
|
||||
assert "treść z bazy" not in r.text
|
||||
|
||||
|
||||
def test_challenges_with_basic_realm(guarded):
|
||||
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
|
||||
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
|
||||
def test_sends_people_to_the_login_screen_not_a_system_dialog(guarded):
|
||||
"""Basic odpowiadał 401 z WWW-Authenticate, co otwierało okienko systemowe:
|
||||
nie dało się go opisać, ostylować ani z niego wyjść inaczej niż anulowaniem.
|
||||
Ekran logowania jest zwykłą stroną, więc może wyjaśnić, co się stało."""
|
||||
r = guarded.get("/significators")
|
||||
assert r.headers["location"].startswith("/logowanie")
|
||||
assert "WWW-Authenticate" not in r.headers, "okienko systemowe wróciło"
|
||||
|
||||
|
||||
def test_rejects_wrong_password(guarded):
|
||||
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
|
||||
def test_remembers_where_someone_was_heading(guarded):
|
||||
"""Po zalogowaniu ma wrócić tam, gdzie szedł — nie na stronę główną."""
|
||||
assert "dokad=/significators" in guarded.get("/significators").headers["location"]
|
||||
|
||||
|
||||
def test_rejects_wrong_user(guarded):
|
||||
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
|
||||
def test_no_session_no_content(guarded):
|
||||
"""Popsute albo podrobione ciasteczko jest tym samym, co jego brak."""
|
||||
from app import session
|
||||
|
||||
# Same znaki ASCII — nagłówek Cookie nie przenosi innych.
|
||||
for zle in ("", "smiec", "a.b", "...", session.issue(USER, "zly-odcisk")):
|
||||
r = guarded.get("/significators", headers={"Cookie": f"{session.COOKIE}={zle}"})
|
||||
assert r.status_code == 303, f"wpuszczono z ciasteczkiem: {zle!r}"
|
||||
|
||||
|
||||
def test_rejects_malformed_header(guarded):
|
||||
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
|
||||
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
|
||||
|
||||
|
||||
def test_allows_correct_credentials(guarded):
|
||||
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
|
||||
def test_allows_a_valid_session(guarded):
|
||||
r = guarded.get("/significators", headers=_sesja())
|
||||
assert r.status_code == 200
|
||||
assert "treść z bazy" in r.text
|
||||
|
||||
|
||||
def test_credentials_are_checked_by_verify(monkeypatch):
|
||||
"""Samo sprawdzanie hasła — bez HTTP, bo to czysta funkcja."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
assert security.verify(USER, PASSWORD) is not None
|
||||
assert security.verify(USER, "zle") is None
|
||||
assert security.verify("obcy", PASSWORD) is None
|
||||
|
||||
|
||||
def test_a_session_dies_when_the_password_changes(monkeypatch):
|
||||
"""Odebranie dostępu przez zmianę hasła MUSI ubić otwarte sesje — inaczej
|
||||
odebranie dostępu nie odbiera dostępu aż do wygaśnięcia sesji."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
client = TestClient(_app(), follow_redirects=False)
|
||||
ciasteczko = _sesja()
|
||||
assert client.get("/significators", headers=ciasteczko).status_code == 200
|
||||
|
||||
monkeypatch.setenv("APP_PASSWORD", "zupełnie-nowe-hasło")
|
||||
assert client.get("/significators", headers=ciasteczko).status_code == 303
|
||||
|
||||
|
||||
def test_health_stays_public(guarded):
|
||||
assert guarded.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_no_key_no_service(monkeypatch):
|
||||
"""Fail-closed: usługa z kontami, ale bez klucza podpisu, nie umiałaby
|
||||
odróżnić ważnej sesji od podrobionej — więc nie wstaje."""
|
||||
from app import session
|
||||
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||
with pytest.raises(session.SessionError):
|
||||
_app()
|
||||
|
||||
|
||||
def test_open_when_password_unset(monkeypatch):
|
||||
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
|
||||
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie).
|
||||
Klucz sesji nie jest wtedy potrzebny — nie ma czego podpisywać."""
|
||||
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||
assert not security.auth_enabled()
|
||||
assert TestClient(_app()).get("/significators").status_code == 200
|
||||
|
||||
@@ -99,8 +148,9 @@ def test_rate_limit_blocks_flood(monkeypatch):
|
||||
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
client = TestClient(_app())
|
||||
auth = _basic(USER, PASSWORD)
|
||||
auth = _sesja()
|
||||
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
|
||||
assert codes[:5] == [200] * 5
|
||||
assert 429 in codes[5:]
|
||||
@@ -109,8 +159,9 @@ def test_rate_limit_blocks_flood(monkeypatch):
|
||||
def test_rate_limited_response_has_retry_after(monkeypatch):
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
client = TestClient(_app())
|
||||
auth = _basic(USER, PASSWORD)
|
||||
auth = _sesja()
|
||||
client.get("/significators", headers=auth)
|
||||
r = client.get("/significators", headers=auth)
|
||||
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
|
||||
@@ -121,7 +172,8 @@ def test_rate_limit_precedes_auth(monkeypatch):
|
||||
i sondowanie API jest darmowe."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
||||
client = TestClient(_app())
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
client = TestClient(_app(), follow_redirects=False)
|
||||
codes = [client.get("/significators").status_code for _ in range(6)]
|
||||
assert 429 in codes
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Sesje logowania — rdzeń podpisywania (LOG-34).
|
||||
|
||||
Testy dzielą się na dwie grupy i ta druga jest ważniejsza: „czy działa" oraz
|
||||
„czy da się oszukać". Kod, który tylko przechodzi tę pierwszą, wpuszcza każdego,
|
||||
kto spróbuje czegoś innego niż zamierzony przebieg.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app import session
|
||||
|
||||
KLUCZ = "x" * 64
|
||||
ODCISK = session.fingerprint("scrypt$sól$hash")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def klucz(monkeypatch):
|
||||
monkeypatch.setenv("SESSION_SECRET", KLUCZ)
|
||||
monkeypatch.delenv("SESSION_MAX_AGE", raising=False)
|
||||
monkeypatch.delenv("SESSION_IDLE", raising=False)
|
||||
|
||||
|
||||
# ── klucz: fail-closed ───────────────────────────────────────────────────
|
||||
|
||||
def test_no_key_means_no_sessions_not_a_random_one(monkeypatch):
|
||||
"""Losowy klucz przy starcie wylogowywałby wszystkich przy każdym restarcie
|
||||
poda — wygląda jak awaria i uczy ludzi ignorować ekran logowania."""
|
||||
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||
with pytest.raises(session.SessionError):
|
||||
session.secret()
|
||||
assert session.available() is False
|
||||
|
||||
|
||||
def test_a_short_key_is_refused_with_a_reason(monkeypatch):
|
||||
monkeypatch.setenv("SESSION_SECRET", "krótki")
|
||||
with pytest.raises(session.SessionError) as e:
|
||||
session.secret()
|
||||
assert "32" in str(e.value)
|
||||
|
||||
|
||||
# ── zwykły przebieg ──────────────────────────────────────────────────────
|
||||
|
||||
def test_issue_and_read_round_trip(klucz):
|
||||
dane = session.read(session.issue("ala", ODCISK, 3))
|
||||
assert dane["login"] == "ala" and dane["odcisk"] == ODCISK and dane["wersja"] == 3
|
||||
|
||||
|
||||
def test_fingerprint_never_carries_the_password(klucz):
|
||||
"""Odcisk jedzie do przeglądarki, więc nie może dać się cofnąć do hasła."""
|
||||
odcisk = session.fingerprint("tajne-hasło-użytkownika")
|
||||
assert "tajne" not in odcisk and "hasło" not in odcisk
|
||||
assert len(odcisk) == 16
|
||||
assert session.fingerprint("a") != session.fingerprint("b")
|
||||
|
||||
|
||||
# ── próby oszukania ──────────────────────────────────────────────────────
|
||||
|
||||
def test_a_tampered_payload_is_refused(klucz):
|
||||
token = session.issue("ala", ODCISK)
|
||||
surowy, _, podpis = token.rpartition(".")
|
||||
dane = json.loads(session._unb64(surowy))
|
||||
dane["u"] = "szef" # podmiana konta
|
||||
podrobiony = session._b64(json.dumps(dane, separators=(",", ":"),
|
||||
sort_keys=True).encode()) + "." + podpis
|
||||
assert session.read(podrobiony) is None
|
||||
|
||||
|
||||
def test_a_tampered_signature_is_refused(klucz):
|
||||
token = session.issue("ala", ODCISK)
|
||||
surowy, _, podpis = token.rpartition(".")
|
||||
assert session.read(f"{surowy}.{podpis[:-2]}xx") is None
|
||||
|
||||
|
||||
def test_a_token_signed_with_another_key_is_refused(klucz, monkeypatch):
|
||||
token = session.issue("ala", ODCISK)
|
||||
monkeypatch.setenv("SESSION_SECRET", "y" * 64)
|
||||
assert session.read(token) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("smiec", ["", None, "bez-kropki", ".", "a.b",
|
||||
"!!!.???", "a" * 500])
|
||||
def test_garbage_never_raises_it_just_refuses(klucz, smiec):
|
||||
"""Wejście przychodzi z przeglądarki, więc wyjątek tutaj to 500 na każdym
|
||||
żądaniu z popsutym ciasteczkiem — czyli aplikacja nie do odratowania
|
||||
inaczej niż przez wyczyszczenie przeglądarki."""
|
||||
assert session.read(smiec) is None
|
||||
|
||||
|
||||
def test_a_token_from_the_future_is_refused(klucz):
|
||||
"""Przestawiony zegar albo podrobienie — w obu razach nie ma powodu ufać."""
|
||||
przyszly = session.issue("ala", ODCISK, now=time.time() + 3600)
|
||||
assert session.read(przyszly) is None
|
||||
|
||||
|
||||
def test_reading_without_a_key_refuses_instead_of_exploding(klucz, monkeypatch):
|
||||
token = session.issue("ala", ODCISK)
|
||||
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||
assert session.read(token) is None
|
||||
|
||||
|
||||
# ── wygasanie ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_a_session_dies_of_old_age(klucz, monkeypatch):
|
||||
monkeypatch.setenv("SESSION_MAX_AGE", "100")
|
||||
token = session.issue("ala", ODCISK, now=time.time() - 200)
|
||||
assert session.read(token) is None
|
||||
|
||||
|
||||
def test_a_session_dies_of_inactivity(klucz, monkeypatch):
|
||||
"""Twardy kres jeszcze daleko, ale nikt nie ruszał — koniec."""
|
||||
monkeypatch.setenv("SESSION_MAX_AGE", "100000")
|
||||
monkeypatch.setenv("SESSION_IDLE", "60")
|
||||
stary = session.issue("ala", ODCISK, now=time.time() - 600)
|
||||
assert session.read(stary) is None
|
||||
|
||||
|
||||
def test_activity_extends_the_session_but_not_past_the_hard_limit(klucz, monkeypatch):
|
||||
monkeypatch.setenv("SESSION_MAX_AGE", "1000")
|
||||
monkeypatch.setenv("SESSION_IDLE", "100")
|
||||
teraz = time.time()
|
||||
token = session.issue("ala", ODCISK, now=teraz - 900)
|
||||
odswiezony = session.refresh(session.read(token, now=teraz - 900), now=teraz - 10)
|
||||
assert session.read(odswiezony, now=teraz) is not None, "ruch nie przedłużył sesji"
|
||||
# ...ale twardego kresu nie przeskoczy
|
||||
assert session.read(odswiezony, now=teraz + 200) is None
|
||||
|
||||
|
||||
def test_refresh_stays_quiet_until_it_is_worth_it(klucz):
|
||||
"""Bez progu każde żądanie odsyłałoby Set-Cookie — także po arkusze stylów."""
|
||||
dane = session.read(session.issue("ala", ODCISK))
|
||||
assert session.refresh(dane) is None
|
||||
assert session.refresh(dane, now=time.time() + session.REFRESH_AFTER + 1) is not None
|
||||
|
||||
|
||||
# ── ustawienia ciasteczka ────────────────────────────────────────────────
|
||||
|
||||
def test_the_cookie_cannot_be_read_by_scripts_or_sent_cross_site(klucz):
|
||||
p = session.cookie_params()
|
||||
assert p["httponly"] is True, "jeden wstrzyknięty skrypt wyniósłby sesję"
|
||||
assert p["samesite"] == "strict", "obca strona mogłaby działać w imieniu zalogowanego"
|
||||
assert p["secure"] is True
|
||||
@@ -9,7 +9,16 @@ CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_tab_is_in_the_menu():
|
||||
assert 'href="/settings"' in BASE and "Ustawienia" in BASE
|
||||
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
|
||||
|
||||
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
|
||||
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
|
||||
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
|
||||
from app import features
|
||||
|
||||
tab = features.BY_KEY["settings"]
|
||||
assert tab in features.SCREENS
|
||||
assert tab.href and tab.label
|
||||
|
||||
|
||||
def test_route_and_client_exist():
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Zapora słownikowa: czego konto NIE MOŻE zobaczyć w żadnej swojej odpowiedzi.
|
||||
|
||||
Poprzednie testy pilnowały pojedynczych miejsc — tego przycisku, tamtego bloku.
|
||||
Wyciek prawie nigdy nie siedzi w miejscu, o którym się pamięta: katalog modeli
|
||||
jechał blokiem JSON, nazwy ekranów komentarzem w arkuszu stylów, mechanizm
|
||||
kwarantanny zdaniem po polsku w komentarzu CSS, a nazwa gałęzi rozwojowej
|
||||
w komunikacie o błędzie. Wspólne mają jedno — pewne SŁOWA nie mają prawa paść.
|
||||
|
||||
Więc test nie sprawdza miejsc, tylko przechodzi WSZYSTKO, co dane konto może
|
||||
pobrać (strony i zasoby), i szuka słów zakazanych. Nowy ekran, nowy skrypt,
|
||||
nowy komunikat wchodzą pod tę samą zaporę bez dopisywania czegokolwiek.
|
||||
|
||||
Dwie z trzech list biorą się wprost z katalogu funkcji, więc nie rozjadą się
|
||||
z programem: dopisanie funkcji automatycznie dopisuje jej nazwę i jej adres do
|
||||
tego, czego konto bez niej nie ma prawa zobaczyć.
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app import accounts as store
|
||||
from app import features
|
||||
|
||||
# ── persona ──────────────────────────────────────────────────────────────
|
||||
# Osoba zatrudniona wyłącznie do wgrywania plików. Ma widzieć swój ekran i nic
|
||||
# poza nim — ani co program robi, ani co będzie robił, ani że ktoś widzi więcej.
|
||||
WGRYWACZ = ["files", "files_input"]
|
||||
|
||||
# ── słowa zakazane, których NIE DA SIĘ wyprowadzić z katalogu ────────────
|
||||
# Katalog zna nazwy funkcji, ale nie zna dziedziny programu ani słownictwa jego
|
||||
# mechanizmów. Te trzeba wypisać, i każde ma tu swój powód.
|
||||
DZIEDZINA = [
|
||||
# (c) po co są wgrywane pliki
|
||||
"astrolog", "horoskop", "kosmogram", "zodiak", "efemeryd", "sygnifikator",
|
||||
"interpretacyjn", "aspektarian", "antyscj", "deklinacj",
|
||||
# „baza" mówi, że to nie są zwykłe pliki, tylko zbiory czegoś. Na ekranie
|
||||
# wgrywającego wszystko nazywa się plikiem; w częściach administracyjnych
|
||||
# słowo zostaje, bo tam jest właściwe (i tam ten test nie zagląda).
|
||||
"baz",
|
||||
# (b) że tekst pisze model językowy i u kogo
|
||||
"LLM", "anthropic", "openai", "gpt", "claude", "prompt", "token",
|
||||
# (e)/(f) mechanizmy i to, że istnieje ktoś, kto widzi więcej
|
||||
"administrator", "uprawnieni", "walidacj", "kwarantann", "wstrzyman",
|
||||
"zatwierdzi", "przypisan", "zakłada", "zakładał",
|
||||
# „konto" w obu przypadkach: pierwsza wersja listy miała samo „konto",
|
||||
# więc przepuszczała zdanie „Nie masz konta" — literówka, która kosztowałaby
|
||||
# cały sens testu, gdyby nie wyszła przy oglądaniu strony.
|
||||
"konto", "konta", "kontem",
|
||||
# (d) że produkt jest w budowie i ma ponumerowany backlog
|
||||
"roadmap", "przyszł", "planowan", "w budowie", "widok testowy",
|
||||
# topologia: gdzie pukać, żeby ominąć logowanie
|
||||
"warstwa logiczna", "warstwie logicznej", "openapi", "swagger",
|
||||
# architektura: „to jedna z warstw" mówi, że warstw jest więcej
|
||||
"layer", "presentation", "prezentacj",
|
||||
"localhost", "http://logic", "8001", "8002", "8003",
|
||||
]
|
||||
WZORCE = [
|
||||
(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania (ujawnia backlog)"),
|
||||
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
|
||||
(r"(?i)\bmodel(u|em|e|i|ach|owi)?\b", "generowanie tekstu przez model"),
|
||||
(r"(?i)\bAI\b", "generowanie tekstu przez model"),
|
||||
]
|
||||
|
||||
|
||||
def _zakazane_dla(perms: set[str]) -> list[tuple[str, str]]:
|
||||
"""(igła, powód) — wyprowadzone z katalogu plus lista dziedzinowa."""
|
||||
out = [(s, "słownik dziedziny/mechanizmu") for s in DZIEDZINA]
|
||||
for f in features.ALL:
|
||||
if f.key in perms:
|
||||
continue
|
||||
out.append((f.label, f"nazwa funkcji, której to konto nie ma ({f.key})"))
|
||||
if f.href and f.href != "/":
|
||||
out.append((f.href, f"adres ekranu, którego to konto nie ma ({f.key})"))
|
||||
# Nazwa zasobu też jest wyciekiem: `<script src=".../wheelzoom.js">` w źródle
|
||||
# mówi, że istnieje coś do powiększania, nawet jeśli sam plik zwróci 404.
|
||||
# Wykaz bierze się z mapy zasobów, więc nowy skrypt obejmuje sam z siebie.
|
||||
for nazwa, need in features.STATIC.items():
|
||||
if not features.can(frozenset(perms), need):
|
||||
out.append((nazwa, f"nazwa zasobu, którego to konto nie może pobrać"))
|
||||
return out
|
||||
|
||||
|
||||
# Ekran logowania MUSI mówić o koncie — inaczej nie da się go opisać. To jedyne
|
||||
# odstępstwo i jest wąskie: dotyczy tej jednej strony i tych dwóch słów.
|
||||
WYJATKI = {"/logowanie": {"konto", "konta", "kontem"}}
|
||||
|
||||
|
||||
def _przeskanuj(tresc: str, perms: set[str], gdzie: str) -> list[str]:
|
||||
wolno = WYJATKI.get(gdzie, set())
|
||||
grzechy = []
|
||||
for igla, powod in _zakazane_dla(perms):
|
||||
if igla in wolno:
|
||||
continue
|
||||
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
|
||||
grzechy.append(f"{gdzie}: „{igla}” — {powod}")
|
||||
for wzor, powod in WZORCE:
|
||||
m = re.search(wzor, tresc)
|
||||
if m:
|
||||
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
||||
return grzechy
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||
monkeypatch.setenv("APP_USER", "szef")
|
||||
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||
|
||||
|
||||
def _klient(monkeypatch):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app, logic
|
||||
|
||||
# Warstwa logiczna niedostępna CELOWO: ekran plików idzie wtedy ścieżką
|
||||
# błędu, a to właśnie komunikaty o błędach wypisywały nazwy tras, nazwę
|
||||
# gałęzi i wewnętrzny adres z portem.
|
||||
import httpx
|
||||
|
||||
def pada(*a, **k):
|
||||
raise httpx.ConnectError("[Errno 111] connect to http://logic:8001/files failed")
|
||||
|
||||
for nazwa in ("files_list", "report", "timeline", "prompt", "horoscope", "positions"):
|
||||
if hasattr(logic, nazwa):
|
||||
monkeypatch.setattr(logic, nazwa, pada, raising=False)
|
||||
return TestClient(app, follow_redirects=False)
|
||||
|
||||
|
||||
def _co_moze_pobrac(perms: set[str]) -> list[str]:
|
||||
"""Wszystko, co to konto ma prawo dostać: strony i zasoby."""
|
||||
strony = [p for (m, p), need in features.ROUTES.items()
|
||||
if m == "GET" and features.can(frozenset(perms), need)]
|
||||
zasoby = ["/static/base.css"] + [
|
||||
f"/static/{n}" for n, need in features.STATIC.items()
|
||||
if features.can(frozenset(perms), need)]
|
||||
return sorted(set(strony + zasoby))
|
||||
|
||||
|
||||
def test_the_uploader_never_reads_a_forbidden_word(env, monkeypatch):
|
||||
store.create("wgrywacz", "x", WGRYWACZ)
|
||||
from app import security, session
|
||||
|
||||
c = _klient(monkeypatch)
|
||||
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('wgrywacz')}"}
|
||||
perms = set(WGRYWACZ)
|
||||
|
||||
grzechy, obejrzane = [], 0
|
||||
for adres in _co_moze_pobrac(perms):
|
||||
r = c.get(adres, headers=naglowki)
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
obejrzane += 1
|
||||
grzechy += _przeskanuj(r.text, perms, adres)
|
||||
assert obejrzane >= 3, f"test niczego nie obejrzał ({obejrzane}) — zapora byłaby pozorna"
|
||||
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||
|
||||
|
||||
def test_an_anonymous_visitor_never_reads_a_forbidden_word(env, monkeypatch):
|
||||
"""Przed zalogowaniem widać ekran logowania i jeden arkusz. Nic więcej,
|
||||
i nic w nich nie może nazywać funkcji."""
|
||||
store.create("wgrywacz", "x", WGRYWACZ)
|
||||
c = _klient(monkeypatch)
|
||||
grzechy, obejrzane = [], 0
|
||||
for adres in ("/logowanie", "/health", "/static/base.css"):
|
||||
r = c.get(adres)
|
||||
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
||||
obejrzane += 1
|
||||
grzechy += _przeskanuj(r.text, set(), adres)
|
||||
assert obejrzane == 3
|
||||
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||
|
||||
|
||||
def test_every_other_asset_is_denied_to_the_uploader(env, monkeypatch):
|
||||
"""Zasób spoza jego zestawu ma być NIE DO ODRÓŻNIENIA od nieistniejącego."""
|
||||
store.create("wgrywacz", "x", WGRYWACZ)
|
||||
from app import security, session
|
||||
|
||||
c = _klient(monkeypatch)
|
||||
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('wgrywacz')}"}
|
||||
wolno = set(_co_moze_pobrac(set(WGRYWACZ)))
|
||||
nieistniejacy = c.get("/static/nie-ma-takiego-pliku.js", headers=naglowki)
|
||||
for nazwa in features.STATIC:
|
||||
adres = f"/static/{nazwa}"
|
||||
if adres in wolno:
|
||||
continue
|
||||
r = c.get(adres, headers=naglowki)
|
||||
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
|
||||
assert r.text == nieistniejacy.text, f"{adres}: odpowiedź różni się od „nie ma takiego pliku”"
|
||||
|
||||
|
||||
def test_the_catalogue_drives_the_list_so_it_cannot_go_stale(env):
|
||||
"""Zapora ma się uczyć z katalogu, nie z pamięci tego, kto ją pisał."""
|
||||
zakazane = {i for i, _ in _zakazane_dla(set(WGRYWACZ))}
|
||||
assert "Sygnifikatory" in zakazane and "/interpret" in zakazane
|
||||
# a to, co konto MA, nie może trafić na listę zakazanych
|
||||
assert "Pliki" not in zakazane
|
||||
|
||||
|
||||
def test_the_owner_sees_everything(env, monkeypatch):
|
||||
"""Kontrola pozytywna: dla administratora te słowa MAJĄ się pojawiać.
|
||||
Bez niej zapora przechodziłaby także wtedy, gdyby program był pusty."""
|
||||
from app import security, session
|
||||
|
||||
c = _klient(monkeypatch)
|
||||
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('szef')}"}
|
||||
html = c.get("/accounts", headers=naglowki).text
|
||||
assert "Sygnifikatory" in html and "Generowanie tekstu przez model" in html
|
||||
@@ -20,7 +20,16 @@ def _client():
|
||||
|
||||
|
||||
def test_tab_is_in_the_menu():
|
||||
assert 'href="/synastry"' in BASE and "Synastria" in BASE
|
||||
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
|
||||
|
||||
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
|
||||
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
|
||||
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
|
||||
from app import features
|
||||
|
||||
tab = features.BY_KEY["synastry"]
|
||||
assert tab in features.SCREENS
|
||||
assert tab.href and tab.label
|
||||
|
||||
|
||||
def test_routes_exist():
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Generowanie tekstu przez model znika bez uprawnienia (PRE-27 × LOG-29/30/31).
|
||||
|
||||
Wymaganie jest MOCNIEJSZE niż „przycisk ma być schowany": po niedostępnej funkcji
|
||||
nie może zostać ŚLAD w źródle strony. To rozróżnienie ma znaczenie praktyczne —
|
||||
ukrycie stylem albo atrybutem `hidden` zostawia wszystko w HTML-u, więc podgląd
|
||||
źródła nadal opowiada, co program potrafi.
|
||||
|
||||
Największy wyciek nie był przyciskiem: blok generowania wstrzykiwał w stronę CAŁY
|
||||
katalog modeli jako JSON — nazwy dostawców, nazwy modeli i rozmiary okien
|
||||
kontekstu — i robił to na każdej stronie Interpretacji i Kalendarza, niezależnie
|
||||
od uprawnień konta.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import accounts as store
|
||||
|
||||
# Słowa, które nie mają prawa paść przy koncie bez uprawnienia. Mieszanka
|
||||
# widocznego tekstu, nazw pól formularza, identyfikatorów dla JS i nazw plików —
|
||||
# bo ślad zostawia każde z nich z osobna.
|
||||
SLADY_AI = [
|
||||
"Generuj prompt", "Napisz horoskop", "llmCatalog", "llmProvider", "llmModel",
|
||||
"llm_provider", "llm_model", "prompt_budget", "Budżet promptu", "Dostawca",
|
||||
"anthropic", "openai", "models.js", "progress.js",
|
||||
'value="prompt"', 'value="horoscope"',
|
||||
]
|
||||
|
||||
RAPORT = {"engine": "test", "objects": []}
|
||||
KALENDARZ = {"engine": "test", "days": []}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||
monkeypatch.setenv("APP_USER", "szef")
|
||||
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||
|
||||
|
||||
def _auth(user):
|
||||
from app import security, session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
def _client(monkeypatch):
|
||||
"""Klient ze śledzeniem tego, co poszło do warstwy logicznej."""
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app, logic
|
||||
|
||||
wolania = []
|
||||
monkeypatch.setattr(logic, "report", lambda **kw: (wolania.append("report"), RAPORT)[1])
|
||||
monkeypatch.setattr(logic, "timeline", lambda **kw: (wolania.append("timeline"), KALENDARZ)[1])
|
||||
monkeypatch.setattr(logic, "prompt",
|
||||
lambda **kw: (wolania.append("prompt"), {"prompt": "TREŚĆ"})[1])
|
||||
monkeypatch.setattr(logic, "horoscope",
|
||||
lambda **kw: (wolania.append("horoscope"), {"text": "TREŚĆ"})[1])
|
||||
monkeypatch.setattr(logic, "llm_models", lambda: {"providers": {"openai": ["gpt-x"]}})
|
||||
return TestClient(app, follow_redirects=False), wolania
|
||||
|
||||
|
||||
NATAL = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||
"lat": "50.06", "lon": "19.94"}
|
||||
OKRES = {**NATAL, "from_date": "2026-01-01", "to_date": "2026-02-01"}
|
||||
|
||||
|
||||
# ── brak śladu w źródle strony ──────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("ekran", ["/interpret", "/timeline"])
|
||||
def test_no_trace_of_the_model_features_without_the_permission(env, monkeypatch, ekran):
|
||||
store.create("ala", "x", ["interpret", "timeline"])
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get(ekran, headers=_auth("ala")).text
|
||||
for slad in SLADY_AI:
|
||||
assert slad not in html, f"{ekran} zdradza „{slad}”"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ekran", ["/interpret", "/timeline"])
|
||||
def test_the_same_screens_carry_them_with_the_permission(env, monkeypatch, ekran):
|
||||
"""Kontrola pozytywna. Bez niej test wyżej przechodziłby także wtedy, gdyby
|
||||
generowanie było zepsute dla wszystkich."""
|
||||
store.create("ola", "x", ["interpret", "timeline", "ai"])
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get(ekran, headers=_auth("ola")).text
|
||||
for musi_byc in ("Generuj prompt", "llmCatalog", "llmProvider", "models.js"):
|
||||
assert musi_byc in html, f"{ekran}: brakuje „{musi_byc}” mimo uprawnienia"
|
||||
|
||||
|
||||
def test_the_model_catalogue_never_reaches_a_page_it_should_not(env, monkeypatch):
|
||||
"""Katalog jedzie do strony blokiem JSON. Bez ograniczenia nazwy dostawców,
|
||||
modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony."""
|
||||
store.create("ala", "x", ["interpret"])
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get("/interpret", headers=_auth("ala")).text
|
||||
assert "gpt-x" not in html and "providers" not in html
|
||||
|
||||
|
||||
# ── spreparowane żądanie nie kupuje funkcji ─────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("akcja", ["prompt", "horoscope"])
|
||||
def test_a_crafted_request_cannot_generate_without_the_permission(env, monkeypatch, akcja):
|
||||
"""Ukrycie przycisku chroni przed przypadkiem, nie przed kimś, kto zna nazwę
|
||||
pola. Granicą jest handler."""
|
||||
store.create("ala", "x", ["interpret"])
|
||||
c, wolania = _client(monkeypatch)
|
||||
r = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": akcja})
|
||||
assert r.status_code == 200
|
||||
assert akcja not in wolania, f"wygenerowano „{akcja}” bez uprawnienia"
|
||||
assert wolania == ["report"], f"oczekiwano powrotu do domyślnej akcji, było: {wolania}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("akcja", ["prompt", "horoscope"])
|
||||
def test_the_same_on_the_calendar_screen(env, monkeypatch, akcja):
|
||||
store.create("ala", "x", ["timeline"])
|
||||
c, wolania = _client(monkeypatch)
|
||||
c.post("/timeline", headers=_auth("ala"), data={**OKRES, "action": akcja})
|
||||
assert akcja not in wolania and wolania == ["timeline"]
|
||||
|
||||
|
||||
def test_a_crafted_export_needs_the_export_permission_too(env, monkeypatch):
|
||||
"""Ta sama dziura obejmowała eksport: szablon chował przycisk, handler nie
|
||||
sprawdzał niczego, więc spreparowane żądanie pobierało arkusz."""
|
||||
store.create("ala", "x", ["interpret"])
|
||||
c, _ = _client(monkeypatch)
|
||||
r = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": "export"})
|
||||
assert r.status_code == 200
|
||||
assert "spreadsheetml" not in r.headers.get("content-type", ""), "pobrano arkusz bez uprawnienia"
|
||||
|
||||
|
||||
def test_the_streaming_route_stays_shut(env, monkeypatch):
|
||||
"""Pisanie horoskopu idzie osobną trasą — ona też musi być zamknięta,
|
||||
i to jako NIEISTNIEJĄCA, nie jako odmowa."""
|
||||
store.create("ala", "x", ["interpret"])
|
||||
c, wolania = _client(monkeypatch)
|
||||
r = c.post("/horoscope/stream", headers=_auth("ala"), json={"profile": "natal"})
|
||||
assert r.status_code == 404
|
||||
assert wolania == []
|
||||
|
||||
|
||||
def test_refusal_never_explains_itself(env, monkeypatch):
|
||||
"""Komunikat „brak uprawnień do generowania" sam w sobie mówiłby, że taka
|
||||
funkcja istnieje. Akcja bez uprawnienia ma zachowywać się jak literówka."""
|
||||
store.create("ala", "x", ["interpret"])
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": "prompt"}).text
|
||||
for slowo in ("uprawnie", "brak dostępu", "nie wolno", "zabronione", "AI", "model"):
|
||||
assert slowo not in html, f"odpowiedź tłumaczy odmowę słowem „{slowo}”"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Dymki na kosmogramie (PRE-12/PRE-25).
|
||||
|
||||
Rysunek niesie <title> przy każdym obiekcie i każdej linii aspektu, ale natywne
|
||||
dymki przeglądarki przestały się pokazywać po dodaniu powiększania na pełne okno.
|
||||
Sprawdzone w przeglądarce: elementy z <title> SĄ osiągalne kursorem (19 z 20 trafień
|
||||
w hit-teście), więc nic ich nie zasłaniało — problem był w samym natywnym dymku.
|
||||
|
||||
Test strukturalny, jak przy wheelzoom: bez uruchamiania przeglądarki nie zbadamy
|
||||
zachowania JS-a. Pilnujemy więc tego, co da się zepsuć po cichu i co ma znaczenie:
|
||||
kolejności warstw, zdejmowania i ODWIESZANIA <title> oraz wpięcia skryptu.
|
||||
Zachowanie sprawdzone ręcznie w przeglądarce przy 1280×900, w obu stanach koła.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
|
||||
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||
# pilnuje osobny test pokrycia.
|
||||
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||
for f in sorted((APP / "static").glob("*.css")))
|
||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_script_is_loaded_only_where_there_is_a_wheel():
|
||||
"""Koło jest na Horoskopie i na Skompiluj — i tylko tam ten skrypt ma prawo
|
||||
się pojawić.
|
||||
|
||||
Wcześniej ładowany był z base.html, czyli na KAŻDEJ stronie. Konto, które ma
|
||||
wyłącznie Pliki, pobierało go przy zwykłym wejściu na swój jedyny ekran,
|
||||
a jego treść wymienia z nazwy zakładki, o których takie konto nie ma prawa
|
||||
wiedzieć — łącznie z tą, której jeszcze nie ma."""
|
||||
from pathlib import Path
|
||||
|
||||
szablony = Path(__file__).resolve().parents[1] / "app" / "templates"
|
||||
z_kolem, bez_kola = set(), set()
|
||||
for f in szablony.glob("*.html"):
|
||||
(z_kolem if "wheeltip.js" in f.read_text(encoding="utf-8") else bez_kola).add(f.name)
|
||||
assert z_kolem == {"chart.html", "compile.html"}, f"ładowany też przez: {z_kolem}"
|
||||
assert "files.html" in bez_kola and "logowanie.html" in bez_kola
|
||||
|
||||
|
||||
def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window():
|
||||
"""Warstwy: nakładka powiększonego koła < dymek < okno postępu.
|
||||
|
||||
Dymek MUSI być nad nakładką, inaczej po powiększeniu — czyli dokładnie tam,
|
||||
gdzie zgłoszono problem — byłby niewidoczny. I POD oknem postępu, bo gdy trwa
|
||||
pisanie horoskopu, log operacji ma zostać na wierzchu."""
|
||||
tip = re.search(r"\.wheel-tip\s*\{[^}]*z-index:\s*(\d+)", CSS)
|
||||
zoom = re.search(r"\.wheel-fig\.zoomed\s*\{[^}]*z-index:\s*(\d+)", CSS)
|
||||
progress = re.search(r"\.progress-overlay\s*\{[^}]*z-index:\s*(\d+)", CSS)
|
||||
assert tip and zoom and progress, "brakuje którejś reguły z-index"
|
||||
assert int(zoom.group(1)) < int(tip.group(1)) < int(progress.group(1)), \
|
||||
f"warstwy: nakładka {zoom.group(1)}, dymek {tip.group(1)}, postęp {progress.group(1)}"
|
||||
|
||||
|
||||
def test_tooltip_never_swallows_the_pointer():
|
||||
"""Dymek chodzi za kursorem, więc bez pointer-events:none zasłaniałby sam
|
||||
sobie element, nad którym stoi — i migotał."""
|
||||
rule = re.search(r"\.wheel-tip\s*\{([^}]*)\}", CSS)
|
||||
assert rule and "pointer-events: none" in rule.group(1)
|
||||
|
||||
|
||||
def test_title_is_detached_only_for_the_hover_and_always_put_back():
|
||||
"""<title> zostaje w rysunku — to nazwa dostępna dla czytników ekranu.
|
||||
|
||||
Na czas najechania odpinamy go, żeby nie było dwóch dymków naraz, ale MUSI
|
||||
wrócić. Brak odwieszania okradałby czytniki ekranu po pierwszym najechaniu,
|
||||
i to po cichu — na ekranie nic by nie było widać."""
|
||||
assert "removeChild" in JS, "dymek natywny nie jest tłumiony"
|
||||
assert "appendChild(stash.node)" in JS, "brak odwieszania <title>"
|
||||
for leaving in ("mouseleave", "scroll", "resize"):
|
||||
assert leaving in JS, f"brak sprzątania przy: {leaving}"
|
||||
|
||||
|
||||
def test_tooltip_reads_the_title_that_is_already_in_the_drawing():
|
||||
"""Źródłem tekstu jest <title> z SVG, nie druga kopia opisów w JS —
|
||||
inaczej rozjechałyby się przy pierwszej zmianie treści dymka."""
|
||||
assert ":scope > title" in JS
|
||||
assert "textContent" in JS
|
||||
|
||||
|
||||
def test_pdf_path_is_untouched():
|
||||
"""PDF składa się po stronie serwera, więc żaden dymek go nie dotyczy —
|
||||
<title> zostają w rysunku i nic w chartwheel.py się nie zmienia."""
|
||||
wheel = (APP / "chartwheel.py").read_text(encoding="utf-8")
|
||||
assert "<title>" in wheel
|
||||
assert "wheeltip" not in wheel
|
||||
@@ -9,13 +9,31 @@ import re
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
|
||||
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
||||
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||
# pilnuje osobny test pokrycia.
|
||||
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||
for f in sorted((APP / "static").glob("*.css")))
|
||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_script_is_loaded_globally():
|
||||
"""Ładowane w base.html, bo koło pojawi się też na zakładce Skompiluj."""
|
||||
assert "wheelzoom.js" in BASE
|
||||
def test_script_is_loaded_only_where_there_is_a_wheel():
|
||||
"""Koło jest na Horoskopie i na Skompiluj — i tylko tam ten skrypt ma prawo
|
||||
się pojawić.
|
||||
|
||||
Wcześniej ładowany był z base.html, czyli na KAŻDEJ stronie. Konto, które ma
|
||||
wyłącznie Pliki, pobierało go przy zwykłym wejściu na swój jedyny ekran,
|
||||
a jego treść wymienia z nazwy zakładki, o których takie konto nie ma prawa
|
||||
wiedzieć — łącznie z tą, której jeszcze nie ma."""
|
||||
from pathlib import Path
|
||||
|
||||
szablony = Path(__file__).resolve().parents[1] / "app" / "templates"
|
||||
z_kolem, bez_kola = set(), set()
|
||||
for f in szablony.glob("*.html"):
|
||||
(z_kolem if "wheelzoom.js" in f.read_text(encoding="utf-8") else bez_kola).add(f.name)
|
||||
assert z_kolem == {"chart.html", "compile.html"}, f"ładowany też przez: {z_kolem}"
|
||||
assert "files.html" in bez_kola and "logowanie.html" in bez_kola
|
||||
|
||||
|
||||
def test_toggles_on_click():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -24,7 +24,12 @@ from app.compile import RenderError, to_pdf, toolchain_ready
|
||||
|
||||
log = logging.getLogger("astrololo.render")
|
||||
|
||||
app = FastAPI(title="astrololo · usługa render (PDF)")
|
||||
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||
app = FastAPI(title="astrololo · usługa render (PDF)",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
security.install(app, "render") # token międzywarstwowy (LOG-32)
|
||||
# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa
|
||||
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||
|
||||
+48
-10
@@ -14,8 +14,25 @@ tysiące porównań na całej dziedzinie tak.
|
||||
**Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez
|
||||
`swe_houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha",
|
||||
różnica mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli z czegoś,
|
||||
co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach. Czas gwiazdowy
|
||||
i ε mają własny, osobny test (`asc/mc`).
|
||||
co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach.
|
||||
|
||||
**Czego ten framework NIE sprawdza — i trzeba o tym pamiętać.** Skoro ε podajemy
|
||||
obu stronom, to **samo ε nie jest tu testowane**. Test `asc/mc` sprawdza wyłącznie
|
||||
wzory na Ascendent i MC przy zadanym ε, a nie to, czy ε jest właściwe. Kosztowało
|
||||
to realny błąd: silnik liczył RAMC z GAST (czas gwiazdowy POZORNY, od równonocy
|
||||
PRAWDZIWEJ), ale parował go z ε ŚREDNIM, bez nutacji. Dwa różne układy odniesienia,
|
||||
~3,2″ przesunięcia na cuspach — i komplet zielonych przebiegów wyroczni, bo błąd
|
||||
siedział w danych WEJŚCIOWYCH, nie w testowanej funkcji.
|
||||
|
||||
Wejście ma więc własny sprawdzian, poza tym frameworkiem:
|
||||
`test_sidereal_returns_true_obliquity_not_mean` w `services/logic/tests/`, ze
|
||||
Skyfieldem jako niezależnym autorytetem (bez swissepha, więc działa wszędzie).
|
||||
|
||||
Uwaga pokrewna: porównanie „cały horoskop nasz vs cały horoskop swissepha" zostawia
|
||||
resztę ~3″ nawet po naprawie ε — to UT1 kontra UTC. Skyfield konwertuje UTC na UT1
|
||||
z tablic IERS, swisseph przyjmuje podany JD jako UT1 (w 1984-04-30 różnica wynosiła
|
||||
0,181 s = 2,7″ RAMC). Nasza strona jest tu dokładniejsza. Dokładnie po to porównujemy
|
||||
przy identycznym RAMC.
|
||||
|
||||
**Kryterium, nie procent.** Raportujemy liczbę przypadków **powyżej tolerancji
|
||||
(1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram.
|
||||
@@ -70,13 +87,16 @@ zarówno zestaw brzegowy (2520 porównań), jak i losowy przemiał 20 000 przypa
|
||||
| System | Konstrukcja | Maks. odchylenie |
|
||||
|---|---|---|
|
||||
| whole sign | podział ekliptyki | 0,000000000° |
|
||||
| equal | podział ekliptyki | 0,000000000° |
|
||||
| whole sign (od Barana) | dom I zawsze na 0° Barana | 0,000000000° |
|
||||
| equal | podział ekliptyki od Ascendentu | 0,000000000° |
|
||||
| equal (od MC) | dom X dokładnie na MC | 0,000000000° |
|
||||
| porphyry | podział kwadrantów po ekliptyce | 0,000000000° |
|
||||
| vehlow | equal z Ascendentem w środku domu I | 0,000000000° |
|
||||
| morinus | równik rzutowany wprost na ekliptykę | 0,000000000° |
|
||||
| regiomontanus | podział równika, koła przez punkty N/S horyzontu | 0,000000000° |
|
||||
| campanus | podział wertykału pierwszego | 0,000000000° |
|
||||
| alcabitus | podział łuków dobowych po równiku | 0,000000000° |
|
||||
| topocentric | ascendenty pod biegunami tan(P) = tan(φ)·k/3 | 0,000000000° |
|
||||
|
||||
Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny:
|
||||
|
||||
@@ -97,13 +117,31 @@ Warstwa aplikacyjna łapie tę odmowę i podstawia Porphyry'ego z **głośnym
|
||||
komunikatem**, który idzie na ekran, w prompt do modelu i do PDF-a: po podmianie
|
||||
kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa.
|
||||
|
||||
**Topocentric (Polich–Page) jest zaimplementowany, ale NIE wypuszczony** — nie ma go
|
||||
w `houses.SYSTEMS`. Zgadza się z wyrocznią na całej dziedzinie poza otoczeniem
|
||||
bieguna: przy |φ| ≈ 89,9° i RAMC 90°/270° kolejność domów się odwraca i żadna reguła
|
||||
oparta na łuku kwadrantu nie rozstrzyga wyboru gałęzi. Konstrukcja jest tam z natury
|
||||
źle uwarunkowana — „biegun" `atan(tan(φ)·k/3)` dla φ = 89,9° wynosi już 89,7°.
|
||||
Nie zawężamy dziedziny po to, żeby testy przeszły; system dołączy, gdy będzie
|
||||
poprawny wszędzie.
|
||||
### Topocentric — i czego nauczył
|
||||
|
||||
Topocentric był początkowo **wycofany** z powodu rozjazdu przy |φ| ≈ 89,9°.
|
||||
Ta diagnoza była **błędna**, bo opierała się wyłącznie na zestawie brzegowym,
|
||||
który próbkuje tylko wybrane szerokości. Przemiał losowy pokazał prawdziwą skalę:
|
||||
błędy zaczynały się już od ~70° i sięgały 50% przypadków blisko biegunów —
|
||||
**6,5% całości**, nie dwa przypadki brzegowe.
|
||||
|
||||
Przyczyną nie była jednak konstrukcja, tylko wybór gałęzi. Dwa koła wielkie
|
||||
przecinają się w dwóch punktach antypodycznych i trzeba wskazać właściwy.
|
||||
Kolejno zawiodły: „po której stronie MC", „w łuku kwadrantu", „wschodnia połowa
|
||||
horyzontu" oraz śledzenie ciągłości krokami. Każda z nich rozstrzyga **lokalnie**,
|
||||
a przy dużych szerokościach kolejność domów potrafi się odwrócić — przy φ = −79,55°
|
||||
MC wypada na 306,8°, a dom 11 na 291,4°, czyli domy biegną wstecz. To jest poprawne
|
||||
i wyrocznia zwraca dokładnie to samo.
|
||||
|
||||
Rozwiązaniem było **nie wybierać w ogóle**. Iloczyn wektorowy zenitu z biegunem
|
||||
ekliptyki jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot;
|
||||
dwuznaczność wprowadza dopiero `atan2`. Zostając w wektorach i kotwicząc rodzinę
|
||||
raz — na MC górującym — dostajemy zgodność **co do zera na całej dziedzinie**,
|
||||
bez iteracji i bez zawężania szerokości.
|
||||
|
||||
Morał praktyczny: zestaw brzegowy mówi, **czy** system się psuje; dopiero przemiał
|
||||
losowy mówi **jak bardzo**. Wniosek o skali wyciągnięty z samych brzegów był tu
|
||||
zaniżony o trzy rzędy wielkości.
|
||||
|
||||
Większe jądro efemeryd (de441) — **odłożone, „nice to have"**: rozszerza tylko
|
||||
zakres dat, nie poprawia niczego w obecnym.
|
||||
|
||||
@@ -29,6 +29,13 @@ JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny
|
||||
# Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym.
|
||||
DIURNAL_ARC_SYSTEMS = frozenset({"placidus", "koch"})
|
||||
|
||||
# Systemy, w których NASZA dziedzina jest WĘŻSZA niż wyroczni — świadomie, nie przez
|
||||
# rozjazd. Topocentric: swisseph zwraca powyżej koła podbiegunowego cuspy, które nie
|
||||
# dzielą okręgu (domy nachodzą na siebie), i sam sobie tam przeczy — swe_house_pos
|
||||
# nie zgadza się z jego własnymi cuspami. Wolimy odmówić niż podać liczbę bez
|
||||
# znaczenia, więc harness ma tego NIE zgłaszać jako niezgodności dziedziny.
|
||||
NARROWER_THAN_ORACLE = frozenset({"topocentric"})
|
||||
|
||||
# Jak blisko granicy stawiamy wartości brzegowe (po obu stronach).
|
||||
EPSILON_NUDGE = 0.01
|
||||
|
||||
@@ -65,7 +72,7 @@ def in_domain(system: str, lat: float, jd: float) -> bool:
|
||||
(i same bieguny, gdzie degeneruje się cała geometria)."""
|
||||
if abs(lat) >= 90.0:
|
||||
return False
|
||||
if system in DIURNAL_ARC_SYSTEMS:
|
||||
if system in DIURNAL_ARC_SYSTEMS or system in NARROWER_THAN_ORACLE:
|
||||
return abs(lat) < polar_circle(jd)
|
||||
return True
|
||||
|
||||
|
||||
+66
-6
@@ -16,15 +16,18 @@ do ścieżki produkcyjnej ani do zależności produktu — izolacja z LOG-27 zos
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import swisseph as swe
|
||||
|
||||
from domain import Case, in_domain, obliquity
|
||||
from domain import NARROWER_THAN_ORACLE, Case, in_domain, obliquity
|
||||
|
||||
# Litery systemów w Swiss Ephemeris.
|
||||
SWE_CODE = {
|
||||
"whole_sign": b"W", "equal": b"E", "porphyry": b"O",
|
||||
"whole_sign": b"W", "equal": b"E",
|
||||
"equal_mc": b"D",
|
||||
"whole_sign_aries": b"N", "porphyry": b"O",
|
||||
"placidus": b"P", "koch": b"K", "regiomontanus": b"R", "campanus": b"C",
|
||||
"morinus": b"M", "alcabitus": b"B", "vehlow": b"V", "topocentric": b"T",
|
||||
}
|
||||
@@ -108,12 +111,16 @@ def compare_system(system: str, cases: list[Case]) -> Result:
|
||||
|
||||
# Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że
|
||||
# liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie).
|
||||
if (theirs is None) != (not ours_in_domain):
|
||||
# Węższa dziedzina po naszej stronie jest świadoma (patrz NARROWER_THAN_ORACLE),
|
||||
# więc nie jest rozjazdem. Odwrotność — my liczymy, wyrocznia odmawia — nadal jest.
|
||||
narrower_on_purpose = (system in NARROWER_THAN_ORACLE
|
||||
and theirs is not None and not ours_in_domain)
|
||||
if not narrower_on_purpose and (theirs is None) != (not ours_in_domain):
|
||||
r.domain_mismatch.append(
|
||||
f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, "
|
||||
f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}")
|
||||
continue
|
||||
if theirs is None:
|
||||
if theirs is None or not ours_in_domain:
|
||||
r.skipped_out_of_domain += 1
|
||||
continue
|
||||
|
||||
@@ -153,14 +160,67 @@ def histogram(devs: list[float]) -> str:
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# Ile punktów ekliptyki sprawdzamy na jeden zestaw (RAMC, ε, φ).
|
||||
_PROBES_PER_CASE = 6
|
||||
|
||||
|
||||
def compare_house_assignment(system: str, cases: list[Case]) -> Result:
|
||||
"""Nasze przypisanie obiektu do domu vs swe_house_pos.
|
||||
|
||||
To OSOBNA funkcja od liczenia cuspów i osobny rodzaj błędu: cuspy mogą się
|
||||
zgadzać co do zera, a planeta i tak wylądować w złym domu. Tak właśnie było —
|
||||
przy |φ| powyżej koła podbiegunowego systemy dzielące koła wielkie mają
|
||||
kolejność domów ODWRÓCONĄ, a nasze assign_house szło zawsze do przodu.
|
||||
|
||||
WYROCZNIA MA TU GRANICĘ WIARYGODNOŚCI. Dla topocentrica swe_house_pos
|
||||
przestaje być zgodne z WŁASNYMI cuspami swissepha powyżej koła podbiegunowego
|
||||
(zmierzone: 100% zgodności do 62°, 83,9% przy 66°, ok. 50% przy 72°; dla
|
||||
regiomontanusa w tych samych punktach 100%). Skoro wyrocznia przeczy sama
|
||||
sobie, nie może rozstrzygać — nie porównujemy tam, zamiast dopisywać wyjątek
|
||||
„bo topocentric". Nasze przypisanie jest w tym obszarze sprawdzane inaczej:
|
||||
testem samospójności z cuspami (services/logic/tests/test_houses.py).
|
||||
"""
|
||||
from app.engine.houses import assign_house, cusps_for, polar_circle
|
||||
|
||||
r = Result(system=f"dom:{system}")
|
||||
rng = random.Random(20260806)
|
||||
for c in cases:
|
||||
eps = obliquity(c.jd)
|
||||
if system == "topocentric" and abs(c.lat) >= polar_circle(eps):
|
||||
r.skipped_out_of_domain += 1
|
||||
continue
|
||||
try:
|
||||
ours_cusps = cusps_for(c.ramc, eps, c.lat, system)
|
||||
except Exception:
|
||||
r.skipped_out_of_domain += 1
|
||||
continue
|
||||
for _ in range(_PROBES_PER_CASE):
|
||||
lon = rng.uniform(0.0, 360.0)
|
||||
try:
|
||||
theirs = int(swe.house_pos(c.ramc % 360.0, c.lat, eps,
|
||||
(lon, 0.0), SWE_CODE[system]))
|
||||
except Exception:
|
||||
r.skipped_out_of_domain += 1
|
||||
continue
|
||||
r.checked += 1
|
||||
ours = assign_house(lon, ours_cusps)
|
||||
if ours != theirs:
|
||||
r.over_tolerance += 1
|
||||
r.max_dev = max(r.max_dev, float(abs(ours - theirs)))
|
||||
if len(r.domain_mismatch) < 5:
|
||||
r.domain_mismatch.append(
|
||||
f"{c.key()} lon={lon:.4f} — nasz dom {ours}, wyrocznia {theirs}")
|
||||
return r
|
||||
|
||||
|
||||
def format_report(results: list[Result], seed: int | None = None) -> str:
|
||||
lines = ["", "=" * 78,
|
||||
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
|
||||
f"tolerancja: {TOLERANCE_DEG:.8f}° (1″)" + (f" ziarno: {seed}" if seed is not None else ""),
|
||||
"=" * 78,
|
||||
f"{'SYSTEM':<14}{'SPRAWDZONYCH':>13}{'>TOL':>7}{'POZA DZIEDZ.':>14}{'MAX ODCH.':>14} WYNIK"]
|
||||
f"{'SYSTEM':<18}{'SPRAWDZONYCH':>13}{'>TOL':>7}{'POZA DZIEDZ.':>14}{'MAX ODCH.':>14} WYNIK"]
|
||||
for r in results:
|
||||
lines.append(f"{r.system:<14}{r.checked:>13}{r.over_tolerance:>7}"
|
||||
lines.append(f"{r.system:<18}{r.checked:>13}{r.over_tolerance:>7}"
|
||||
f"{r.skipped_out_of_domain:>14}{r.max_dev:>14.9f} "
|
||||
f"{'OK' if r.passed else 'BŁĄD'}")
|
||||
for r in results:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user