Compare commits

..

1 Commits

Author SHA1 Message Date
gitea 09c3a39fa1 feat(prezentacja): eksport wyników do Excela — tabela robocza (DAN-23/PRE-10)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m32s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 3m39s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
Astrolog chce PRACOWAĆ z dopasowaniami: filtrować, sortować, zaznaczać, usuwać —
najwygodniej w Excelu. Raport z logiki jest zagnieżdżony (obiekt → fasety →
próbki), więc spłaszczamy go do JEDNEJ płaskiej tabeli: wiersz = jedno
dopasowanie z bazy. Kolumny: Obiekt · Faseta · Typ · Token · Sygnifikator ·
Rozwinięcie · Opis/efekt.

- `report_export.py`: `report_to_xlsx(report)` (openpyxl). Auto-filtr + zamrożony
  nagłówek = filtrowanie/sortowanie od razu; „Opis" zawijany. Bez ozdób — materiał
  roboczy. Plik składamy TU, w prezentacji (jak PDF idzie przez render): logika
  liczy, prezentacja formatuje wyjście.
- `/interpret` dostaje akcję `export` → pobranie `.xlsx` (nie strona). Przycisk
  „Pobierz Excel" obok „Szukaj interpretacji".
- Zależność: openpyxl (czysty Python).

Zero swissepha, zero walidacji krzyżowej, zero danych od użytkownika — pierwsza
z iteracji „czysto". Testy: +7 (round-trip pliku: nagłówek, spłaszczenie, auto-
filtr/zamrożenie, pusty-bezpieczny, %, wpięcie w UI). Prezentacja 180.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 00:48:33 +02:00
196 changed files with 1215 additions and 15077 deletions
-33
View File
@@ -1,33 +0,0 @@
# Kontekst builda dla obrazów budowanych z KORZENIA repo (dziś: pomocniczy obraz
# wyroczni w CI — patrz .gitea/workflows/tests.yml). Obrazy usług mają własne
# konteksty (services/<usługa>), więc ten plik ich nie dotyczy.
#
# UWAGA: docker NIE czyta .gitignore. Bez tego pliku do demona poleciałby m.in.
# lokalny wirtualenv (~344 MB) i jądra efemeryd — a runner miał już incydent
# „no space left on device".
# środowiska lokalne
.env/
.venv/
venv/
# cache Pythona i narzędzi
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.ruff_cache/
.mypy_cache/
# dane generowane/pobierane, odtwarzalne
**/.ephemeris/
**/.cache/
# historia i metadane repo
.git/
.gitea/
.claude/
# rzeczy nieużywane w obrazach
docs/
*.md
-11
View File
@@ -32,14 +32,3 @@ jobs:
docker push $IMG:$TAG
docker push $IMG:latest
echo "Zbudowano i wypchnieto: $IMG:$TAG (+ latest)"
# Sprzątanie ZAWSZE, także po nieudanym budowaniu. Ten obraz dźwiga TeX Live
# i to właśnie na nim runner zatkał się kiedyś na „no space left on device" —
# a po nieudanym buildzie zostaje najwięcej śmieci.
- name: Sprzątanie po budowaniu
if: always()
run: |
docker system df || true
docker system prune -af --filter "until=168h" || true
docker builder prune -f --filter "until=168h" || true
docker system df || true
+3 -96
View File
@@ -1,112 +1,19 @@
name: build
# Buduje obrazy usług, które dzielą warstwę logiczną i łącze: data, logic,
# presentation, astrodemo. Render i silnik B mają własne pipeline'y, bo są ciężkie
# i rzadko się zmieniają (build-render.yaml, build-swisseph.yaml).
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Dwa commity, bo porównujemy z poprzednim. Domyślny płytki klon ma
# jeden i `HEAD^` w nim nie istnieje.
fetch-depth: 2
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push (tylko usługi, które się zmieniły)
- name: Build & push (data, logic, presentation)
run: |
set -eu
TAG=${GITHUB_SHA::8}
USLUGI="data logic presentation astrodemo astroklient"
# Usługi z własnym pipeline'em — celowo poza tą pętlą.
OSOBNE="render engine-swisseph"
# Usługa, której nie ma na żadnej z list, nigdy się nie zbuduje i NIKT
# tego nie zauważy: brak obrazu wygląda potem na problem z rejestrem.
# Ten sam mechanizm już nas kosztował przy liście obserwowanych obrazów
# w image-updaterze. Lepiej zatrzymać budowanie i powiedzieć wprost.
for KATALOG in services/*/; do
NAZWA=$(basename "$KATALOG")
case " $USLUGI $OSOBNE " in
*" $NAZWA "*) ;;
*) echo "BŁĄD: usługa '$NAZWA' nie jest na żadnej liście budowania."
echo "Dopisz ją do USLUGI w tym pliku albo daj jej własny workflow"
echo "(jak render i engine-swisseph), zależnie od tego, czy ma"
echo "powstawać z każdego commita produktu."
exit 1 ;;
esac
done
# Kontekstem budowania każdego obrazu jest WYŁĄCZNIE ./services/<nazwa>,
# więc zmiana poza tym katalogiem nie może wpłynąć na jego zawartość.
# Dzięki temu porównanie ścieżek jest dokładne, a nie przybliżone.
if POPRZEDNI=$(git rev-parse --verify HEAD^ 2>/dev/null); then
ZMIENIONE=$(git diff --name-only "$POPRZEDNI" HEAD)
else
# Pierwszy commit albo przepisana historia — nie ma z czym porównać,
# więc budujemy wszystko. Lepiej zbudować za dużo niż wypuścić obraz
# ze starym kodem pod nowym tagiem.
echo "Brak poprzedniego commita — buduję komplet."
ZMIENIONE=""
fi
# Zmiana w samym pliku workflow dotyczy wszystkich obrazów naraz.
if [ -z "$ZMIENIONE" ] || echo "$ZMIENIONE" | grep -q '^\.gitea/workflows/build\.yaml$'; then
DO_BUDOWY="$USLUGI"
else
DO_BUDOWY=""
for SVC in $USLUGI; do
if echo "$ZMIENIONE" | grep -q "^services/$SVC/"; then
DO_BUDOWY="$DO_BUDOWY $SVC"
fi
done
# astroklient bierze rdzeń z warstwy prezentacji, więc zmiana TAMTEJ
# też go dotyczy. Bez tego jego obraz zostawałby ze starymi ekranami,
# a różnicy nie byłoby widać aż do zgłoszenia użytkownika.
if echo "$ZMIENIONE" | grep -q "^services/presentation/" \
&& ! echo "$DO_BUDOWY" | grep -q "astroklient"; then
DO_BUDOWY="$DO_BUDOWY astroklient"
fi
fi
if [ -z "$(echo "$DO_BUDOWY" | tr -d ' ')" ]; then
echo "Żadna z usług się nie zmieniła — nie ma czego budować."
echo "Wdrożone tagi zostają na poprzednich wersjach, i tak ma być."
exit 0
fi
echo "Buduję:$DO_BUDOWY (tag $TAG)"
for SVC in $DO_BUDOWY; do
# astroklient buduje się z KORZENIA repozytorium, bo jego Dockerfile
# sięga po rdzeń do services/presentation. Pozostałe mają kontekst
# ograniczony do własnego katalogu — i tak ma zostać, bo to właśnie
# ten kontekst gwarantuje, że nie wciągną niczego spoza siebie.
if [ "$SVC" = "astroklient" ]; then
docker build -f services/astroklient/Dockerfile \
-t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG .
else
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
fi
for SVC in data logic presentation; do
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
done
echo "Tag: $TAG"
# Sprzątanie ZAWSZE, także po nieudanym budowaniu — to właśnie po awarii
# zostaje najwięcej śmieci, a kolejny przebieg zaczyna od mniejszego zapasu
# miejsca niż poprzedni. Tak zatkał się dysk przy budowaniu rendera.
- name: Sprzątanie po budowaniu
if: always()
run: |
docker system df || true
# `until=168h` zostawia tydzień: warstwy bazowe i cache z ostatnich dni
# przeżywają, więc kolejny build nie zaczyna od zera, a stare znikają.
docker system prune -af --filter "until=168h" || true
docker builder prune -f --filter "until=168h" || true
docker system df || true
-59
View File
@@ -1,59 +0,0 @@
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
-151
View File
@@ -71,68 +71,6 @@ jobs:
PYTHONPATH: .
run: pytest tests -q -rs
data-tests:
name: Testy warstwy bazodanowej (ochrona baz)
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/data/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/data/requirements-dev.txt
# Rekordy-pułapki (DAN-26): nietestowany kod ochronny jest gorszy niż jego
# brak, bo daje złudzenie zabezpieczenia. Ta warstwa dotąd nie miała testów.
- name: Testy (pytest)
working-directory: services/data
env:
PYTHONPATH: .
run: pytest tests -q -rs
astrodemo-tests:
name: Testy astrodemo
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/astrodemo/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/astrodemo/requirements-dev.txt
# Demo rozmawia z warstwą danych PRODUKCJI, więc jego powierzchnia musi być
# pilnowana tak samo jak reszty: testy sprawdzają m.in., że nie przybyła
# żadna trasa poza dwiema funkcjami.
- name: Testy (pytest)
working-directory: services/astrodemo
env:
PYTHONPATH: .
run: pytest tests -q -rs
astroklient-tests:
name: Testy astroklient
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/astroklient/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/astroklient/requirements-dev.txt
# Testy budują drzewo tak samo, jak robi to Dockerfile: rdzeń z warstwy
# prezentacji, minus usun.txt, plus nakładka. Sprawdzają WYNIK złożenia,
# bo tylko on trafia do obrazu.
- name: Testy (pytest)
working-directory: services/astroklient
env:
PYTHONPATH: .
run: pytest tests -q -rs
swisseph-image:
name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest
@@ -180,68 +118,9 @@ 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
# Zgodność naszych domów z wyrocznią (Swiss Ephemeris). Odpalamy WEWNĄTRZ
# obrazu silnika B — tylko tam jest pyswisseph — montując naszą warstwę
# logiczną i framework. Bez klastra i bez HTTP: to czysta funkcja.
# BLOKUJE build: błędny system domów jest CICHY (wykres wygląda dobrze,
# planety siedzą w złych domach), więc lepiej zatrzymać go przed wypuszczeniem
# niż wykryć po fakcie.
- name: Domy — zgodność z wyrocznią (brzegi + wnętrze)
run: |
# Pliki WBUDOWUJEMY w obraz, a nie montujemy przez -v. Powód ten sam,
# dla którego wyżej nie startujemy kontenera w tle: job Gitea Actions sam
# działa w kontenerze, więc `-v $PWD/...` docker rozwiązuje na HOŚCIE,
# gdzie tej ścieżki nie ma. Docker nie zgłasza wtedy błędu — po cichu
# tworzy PUSTY katalog, przez co skrypt „znika". Kontekst builda jest
# strumieniowany do demona, więc działa niezależnie od tego, gdzie on stoi.
docker build -t astrololo/oracle:ci -f - . <<'DOCKERFILE'
FROM astrololo/engine-swisseph:ci
COPY services/logic /logic
COPY tests/oracle /oracle
ENV LOGIC_PATH=/logic
DOCKERFILE
docker run --rm astrololo/oracle:ci python /oracle/run.py --mode build
# Obraz pomocniczy nie jest już potrzebny — a runner miał już incydent
# „no space left on device", więc sprzątamy po sobie od razu.
- name: Usuń obraz pomocniczy wyroczni
if: always()
run: docker rmi -f astrololo/oracle:ci 2>/dev/null || true
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
@@ -261,33 +140,3 @@ jobs:
# silnika swisseph, który nie wchodzi do produktu).
- name: py_compile
run: python -m compileall -q services
# Krok bez `run` i bez `uses` jest dla Gitei krokiem PUSTYM: zadanie mija
# go i kończy się powodzeniem, nie uruchomiwszy niczego. Tak właśnie
# zniknęły na jeden commit testy astrodemo — blok został rozcięty przy
# dopisywaniu sąsiedniego, a jego `run` przywędrował do cudzego zadania,
# które przez to przechodziło z niewłaściwego powodu.
#
# Zielone CI, które nic nie uruchomiło, jest gorsze od czerwonego.
- name: Kontrola kroków w workflow
run: |
python - <<'KONTROLA'
import pathlib, sys, yaml
grzechy = []
for f in sorted(pathlib.Path(".gitea/workflows").glob("*.y*ml")):
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
for nazwa, job in (d.get("jobs") or {}).items():
kroki = job.get("steps") or []
if not kroki:
grzechy.append(f"{f.name}: zadanie '{nazwa}' nie ma kroków")
for k in kroki:
if "run" not in k and "uses" not in k:
grzechy.append(
f"{f.name}: '{nazwa}' → krok "
f"'{k.get('name','(bez nazwy)')}' nie ma ani run, ani uses")
if grzechy:
print("PUSTE KROKI — zadanie przeszłoby nic nie robiąc:")
print("\n".join(" " + g for g in grzechy))
sys.exit(1)
print("Wszystkie kroki mają co uruchomić.")
KONTROLA
-2
View File
@@ -16,8 +16,6 @@ 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
+6 -12
View File
@@ -11,10 +11,8 @@ services:
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
# 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`.
ports:
- "8002:8002"
logic:
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
@@ -27,10 +25,8 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
# 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`.
ports:
- "8001:8001"
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up
@@ -40,10 +36,8 @@ services:
container_name: astrololo-engine-swisseph
restart: unless-stopped
profiles: ["comparison"]
# 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`.
ports:
- "8003:8003"
presentation:
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
+6 -12
View File
@@ -9,10 +9,8 @@ services:
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
# 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`.
ports:
- "8002:8002"
logic:
build: ./services/logic
@@ -23,10 +21,8 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
# 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`.
ports:
- "8001:8001"
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up
@@ -34,10 +30,8 @@ services:
engine-swisseph:
build: ./services/engine-swisseph
profiles: ["comparison"]
# 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`.
ports:
- "8003:8003"
presentation:
build: ./services/presentation
Binary file not shown.
-58
View File
@@ -1,58 +0,0 @@
# Rekordy-pułapki (canary) — instrukcja (DAN-26)
Zabezpieczenie **detekcyjne**: nie zapobiega wyciekowi baz, ale pozwala go
**wykryć** i wskazać **z której kopii** wyciekł. Bazy są kupione i są rdzeniem
produktu — jeśli krążą gdzie indziej, chcemy to udowodnić.
Mechanizm żyje w warstwie danych (`services/data/app/canary.py`) i działa na
wyjściu z `/search`, więc pułapki **nie docierają** ani do użytkownika, ani do
promptu LLM (wymóg LOG-30) — niezależnie od dostawcy (Excel/SQL).
## Jak to działa
1. **Marker** — unikalny ciąg, który nie występuje w realnych danych, wpleciony
w kilka wiarygodnie wyglądających rekordów-pułapek w bazach (np. w polu
znaczącym: `Ve Tau ASTROLOLO-CANARY-7f3a9`).
2. **Odsiewanie** — warstwa danych wykrywa rekord z markerem i usuwa go z wyników,
zanim opuszczą usługę. Interpretacje i prompty są czyste (log `info`).
3. **Tripwire** — jeśli zapytanie **celuje wprost** w marker (ktoś enumeruje bazę,
a nie liczy horoskopu), leci `warning` — sygnał podejrzanego zachowania.
## Konfiguracja (per wdrożenie)
Zmienne środowiskowe usługi `data`:
| Zmienna | Znaczenie |
|---|---|
| `CANARY_MARKERS` | markery oddzielone przecinkami (kilka na wariant) |
| `CANARY_VARIANT` | etykieta wariantu tego wdrożenia (np. `prod-2026`, `partnerX`) |
Bez `CANARY_MARKERS` mechanizm jest **przezroczysty** (zero kosztu). Markery są
sekretem — trzymaj je jak `astrololo-auth` (poza repo GitOps), przez `secretKeyRef`.
## Rejestr wariant → kopia (traitor tracing)
Sedno atrybucji: **każda dystrybuowana kopia baz dostaje inny zestaw pułapek**,
a Ty trzymasz mapę, który wariant trafił dokąd. Gdy zobaczysz pułapkę w cudzej
kopii → sprawdzasz marker w rejestrze → wiesz, skąd wyciekła.
Rejestr trzymaj **poza kodem i repo** (arkusz/menedżer sekretów po stronie ops),
np.:
| Wariant | Markery | Wdrożenie / odbiorca | Data |
|---|---|---|---|
| `prod-2026` | `…7f3a9`, `…b12c` | produkcja czernobog | 2026-08 |
| `partnerX` | `…9de4`, `…0a1b` | kopia dla partnera X | 2026-08 |
## Wstrzyknięcie pułapek do baz (krok właściciela)
To robi właściciel na **realnych** plikach (kod tego nie robi — nie ruszamy
kupionych baz automatycznie): dodać kilka rekordów-pułapek z markerem danego
wariantu, w stylu nieodróżnialnym od prawdziwych wpisów. Kilka, wtopionych —
łatwiej, gdy ktoś zna mechanizm, wyciąć jeden oczywisty niż wszystkie.
## Granice
Canary dowodzi pochodzenia tylko, gdy wyciek **zawiera** treść pułapki (pełna
kopia — tak; parafraza — niekoniecznie). Nie wykrywa retencji *prawdziwej* treści
u dostawcy LLM — od tego jest bramka LOG-32 i wniosek o Zero Data Retention.
-195
View File
@@ -1,195 +0,0 @@
# DAN-25 — zamknięcie dostępu do baz na NFS (TrueNAS SCALE)
Bazy interpretacyjne leżą na `192.168.1.34:/mnt/Tank1/astrololo`. Dziś udział jest
osiągalny z całej sieci, więc **kto ma dostęp do LAN, bierze kompletne bazy
w oryginale — z pominięciem logowania, limitów, audytu i canary**. Żadne
zabezpieczenie w kodzie tego nie zamyka: to najkrótsza droga do wycieku.
Cel: udział `astrololo` widoczny **tylko dla trzech węzłów k3s**, **tylko do
odczytu**, z **root_squash**.
## ⚠️ Zasada nadrzędna: ruszamy WYŁĄCZNIE udział astrololo
Tank1 obsługuje cały homelab — Proxmox (`proxmox-NFS`), conjurera (`conjurer_swap`,
z **zapisem**), media, LXC-e, stację roboczą. **Nie dotykamy globalnych ustawień
usługi NFS ani innych udziałów** — inaczej wywalimy VM-y, bota i bibliotekę mediów.
Każda komenda niżej celuje w jeden konkretny udział.
Druga zasada: **w TrueNAS SCALE nie edytuje się `/etc/exports` ręcznie.** Plik
generuje middleware i nadpisze każdą ręczną zmianę. Wszystko robimy przez `midclt`
(albo GUI: *Shares → Unix (NFS) Shares*).
## Ustalone dane
| Co | Wartość |
|---|---|
| NAS | `192.168.1.34` (TrueNAS SCALE / Community Edition) |
| Udział do zamknięcia | `/mnt/Tank1/astrololo` |
| Węzły k3s (jedyni uprawnieni) | `192.168.1.73` (server), `192.168.1.80` (agent2), `192.168.1.81` (agent1) |
| Kto montuje astrololo | wyłącznie pod `data` w ns `astrololo`, **read-only** |
---
## Faza 0 — rozpoznanie (nic nie zmienia)
```bash
ssh admin@192.168.1.34
```
Wersja systemu (potwierdza, że komendy niżej pasują):
```bash
midclt call system.version
```
Lista udziałów NFS z ich obecnymi ustawieniami — **stąd bierzemy ID udziału astrololo**:
```bash
midclt call sharing.nfs.query | python3 -m json.tool
```
> W wyniku poszukaj wpisu ze ścieżką `/mnt/Tank1/astrololo` i zapamiętaj jego `id`.
> **Sprawdź też, jak nazywają się pola** (`path` vs `paths`, `hosts`, `networks`,
> `ro`, `maproot_user`, `mapall_user`) — middleware zmieniało ich nazwy między
> wersjami SCALE. Dalsze komendy używają nazw z Twojego wyniku.
Kto jest teraz podłączony (żeby nie odciąć czegoś w trakcie pracy):
```bash
ss -tn state established '( sport = :2049 )'
```
## Faza 1 — dowód dziury (zrób PRZED zmianą)
Na **stacji roboczej** (Mac mini, czyli host spoza klastra):
```bash
mkdir -p /tmp/nfs-test && sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
```
```bash
ls -la /tmp/nfs-test | head
```
Jeśli widzisz pliki baz — **to jest dokładnie problem, który zamykamy**. Odmontuj:
```bash
sudo umount /tmp/nfs-test
```
## Faza 2 — kopia obecnej konfiguracji (możliwość cofnięcia)
Na NAS-ie, podstaw `<ID>` z Fazy 0:
```bash
midclt call sharing.nfs.query '[["id","=",<ID>]]' > /root/astrololo-nfs-share.backup.json && cat /root/astrololo-nfs-share.backup.json
```
## Faza 3 — zawężenie udziału
Jedna komenda ustawia wszystkie trzy zabezpieczenia naraz: listę hostów, tylko
odczyt i root_squash. Podstaw `<ID>`:
```bash
midclt call sharing.nfs.update <ID> '{"hosts": ["192.168.1.73", "192.168.1.80", "192.168.1.81"], "ro": true, "maproot_user": null, "maproot_group": null, "mapall_user": null, "mapall_group": null}'
```
Co robi każdy element:
| Ustawienie | Znaczenie |
|---|---|
| `hosts` | eksport **tylko** dla trzech węzłów k3s — reszta LAN przestaje widzieć udział |
| `ro: true` | tylko odczyt; aplikacja i tak montuje read-only, więc niczego nie łamie |
| `maproot_*`, `mapall_*` = `null` | **root_squash**: root z klienta nie jest rootem na udziale |
Zastosuj i sprawdź, że middleware przepisał eksporty:
```bash
midclt call service.reload nfs && exportfs -v | grep -A1 astrololo
```
> Jeśli Twoja wersja nie ma `service.reload`, użyj GUI (*Shares → NFS → zapisz*),
> co wymusi to samo.
## Faza 4 — weryfikacja (wszystkie cztery testy)
**1. Spoza klastra ma NIE działać.** Na Macu:
```bash
sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
```
Oczekiwane: `access denied` / `Operation not permitted`. **Jeśli montuje się dalej —
zmiana nie zadziałała, nie idź dalej.**
**2. Z węzła klastra ma działać.**
```bash
ssh 192.168.1.73 'sudo mount -t nfs -o ro 192.168.1.34:/mnt/Tank1/astrololo /mnt/test && ls /mnt/test | head -3 && sudo umount /mnt/test'
```
**3. Aplikacja żyje.** Pody muszą wstać i realnie czytać bazy:
```bash
kubectl -n astrololo rollout restart deploy/data && kubectl -n astrololo rollout status deploy/data
```
```bash
kubectl -n astrololo exec deploy/data -- ls /app/data_files | head -3
```
**4. Reszta homelabu nietknięta** — conjurer (zapis!) i pozostałe udziały:
```bash
kubectl -n conjurer get pods
```
```bash
midclt call sharing.nfs.query | python3 -c "import sys,json;[print(s.get('path') or s.get('paths'), '| hosts:', s.get('hosts'), '| ro:', s.get('ro')) for s in json.load(sys.stdin)]"
```
Oczekiwane: **tylko** astrololo ma zawężone `hosts` i `ro: true`; reszta bez zmian.
## Faza 5 — wycofanie (gdyby coś padło)
```bash
midclt call sharing.nfs.update <ID> '{"hosts": [], "ro": false}'
```
```bash
midclt call service.reload nfs
```
To przywraca poprzedni stan (pełna kopia w `/root/astrololo-nfs-share.backup.json`).
---
## Pułapki, o których warto wiedzieć
**Hookscript Proxmoxa.** Na Proxmoxie działa `wait-truenas.sh`, który przed startem
VM czeka w pętli na `showmount -e 192.168.1.34`. Zawężamy tylko udział astrololo,
więc `showmount` nadal zwróci pozostałe eksporty i pętla przejdzie. Mimo to sprawdź
po zmianie:
```bash
ssh root@pve2 'showmount -e 192.168.1.34'
```
**Aktualizacja baz przestanie działać przez NFS.** Po `ro: true` nikt nie wgra
nowych plików baz przez ten udział — również Ty. Do wgrywania użyj GUI TrueNAS,
SMB albo SSH bezpośrednio na NAS-ie. To celowe: udział ma być drogą tylko do
czytania przez aplikację.
**`hosts` przyjmuje adresy IP, nie nazwy** — świadomie, żeby dostęp nie zależał od
DNS-u (AdGuard na `.57`). Gdyby doszedł czwarty węzeł k3s, trzeba dopisać jego IP,
inaczej pod `data` na nim nie wstanie.
**To nie jest uwierzytelnianie.** Lista IP zatrzymuje przypadkowy i oportunistyczny
dostęp, ale adres da się podszyć w tej samej sieci. Docelowo (poza zakresem tego
kroku): NFSv4 + Kerberos albo przeniesienie plików na wolumen nieosiągalny poza
klastrem — tak mówi samo wymaganie DAN-25.
## Po wykonaniu
Zaktualizuj status DAN-25 w `docs/astrololo_wymagania.xlsx` na **Zrobione** (albo
**W trakcie**, jeśli zostawiasz Kerberosa jako etap docelowy).
-65
View File
@@ -1,65 +0,0 @@
# Konta imienne i dziennik audytowy (PRE-17)
Zamiast jednego wspólnego hasła: **konta imienne**, bo przy bazach o realnej
wartości handlowej trzeba wiedzieć **kto** sięgał do treści — i móc odciąć jedną
osobę bez zmiany hasła całej reszcie.
## Zakładanie konta
Hasło podajesz interaktywnie (nie trafia do historii powłoki ani do listy procesów);
na wyjściu dostajesz **hash**, nie hasło:
```bash
cd services/presentation && python scripts/make_user.py alicja
```
Wynik wklejasz do `APP_USERS` (wpisy po przecinku):
```
APP_USERS='alicja:scrypt$…,bartek:scrypt$…'
```
Hash liczy `scrypt` ze stdlib — **bez nowych zależności**. Sekret ustawiasz jak
resztę (`kubectl create secret …`, `secretKeyRef`), nigdy w repo GitOps.
## Odebranie dostępu jednej osobie
Usuń jej wpis z `APP_USERS` i zrestartuj `presentation`. **Pozostali nie zmieniają
haseł** — to była główna bolączka wspólnego hasła.
## Uwaga: wspólne hasło przestaje działać
Gdy `APP_USERS` jest ustawione, stare `APP_PASSWORD` **nie działa** (aplikacja
zgłasza to ostrzeżeniem przy starcie). Celowo: działające obok kont wspólne hasło
byłoby tylnym wejściem bez śladu w dzienniku, czyli dokładnie problemem, który to
wymaganie zamyka. Po migracji usuń `APP_PASSWORD` z konfiguracji.
Zgodność wstecz: dopóki `APP_USERS` **nie** jest ustawione, `APP_USER`/`APP_PASSWORD`
działa jak dotąd — aktualizacja nie wywraca istniejącego wdrożenia.
## Dziennik audytowy
Każde żądanie do chronionej ścieżki zostawia wpis na stdout (w k8s zbierany
standardowo):
```
2026-08-03 21:45:44 INFO AUDYT user=alicja ip=10.1.2.3 method=POST path=/interpret status=200 records=428 ms=1530
```
| Pole | Znaczenie |
|---|---|
| `user` | kto (`-` przy nieudanym logowaniu — nie podpowiadamy, które konto istnieje) |
| `path`, `method`, `status` | co robił i z jakim skutkiem |
| `records` | **ile rekordów baz** oddaliśmy (`-` gdy żądanie nie dotyka baz) |
| `ms` | czas obsługi |
`records` jest tu najważniejsze: pojedyncze zapytanie wygląda niewinnie, ale suma
pokazuje **powolne wypompowywanie bazy** przez osobę uprawnioną — czego żadne
uwierzytelnienie nie wykryje. Liczone m.in. dla wyszukiwarki sygnifikatorów,
raportu interpretacji i **eksportu do Excela** (ten wynosi najwięcej naraz).
**W dzienniku nie ma treści** — ani rekordów, ani promptów, ani danych
urodzeniowych. Logi byłyby kolejnym nośnikiem wycieku; do wykrycia nadużycia
wystarczą metadane i liczby.
Poziom sterujesz przez `AUDIT_LEVEL` (domyślnie `INFO`).
-168
View File
@@ -1,168 +0,0 @@
# 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.
-171
View File
@@ -1,171 +0,0 @@
# LOG-33 — sekrety w spoczynku i procedura rotacji
Sekrety (`APP_PASSWORD`/`APP_USERS`, `INTERNAL_TOKEN`, klucze łącz AES, klucze API
do dostawców LLM) trafiają do obiektów Secret w Kubernetesie, gdzie domyślnie są
**tylko zakodowane base64** — jawne dla każdego, kto przeczyta magazyn stanu k3s
albo ma prawo odczytu sekretów w namespace.
Ten dokument opisuje: **macierz rotacji** (co restartować przy zmianie czego),
**procedury rotacji per sekret** i **kroki hartowania**, które wymagają dostępu do
węzła.
---
## Macierz zależności — kto używa którego sekretu
Wyliczona z żywych deploymentów, nie z założeń:
| Sekret / klucz | Usługi, które go czytają | Restart obejmuje |
|---|---|---|
| `astrololo-auth` / **`INTERNAL_TOKEN`** | data, logic, presentation, **render** | **wszystkie cztery, równocześnie** |
| `astrololo-auth` / `APP_PASSWORD`, `APP_USERS` | presentation | tylko presentation |
| `astrololo-link` / `LINK_KEY_LOGIC_DATA` | logic, data | **para**: logic + data |
| `astrololo-link` / `LINK_KEY_PRESENTATION_LOGIC` | presentation, logic | **para**: presentation + logic |
| `astrololo-link` / `LINK_KEY_PRESENTATION_RENDER` | presentation, render | **para**: presentation + render |
| `astrololo-llm` / `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | logic | tylko logic |
> **UWAGA — częsty błąd.** Wcześniejsza wersja tego wymagania mówiła o „wszystkich
> **trzech** usługach" przy `INTERNAL_TOKEN`. To już nieprawda: `render` (PRE-24)
> również go używa. Restart trzech zostawi render ze starym tokenem i **usługa po
> cichu przestanie się dogadywać** — dokładnie ta awaria, przed którą wymaganie
> ostrzega.
Klucze łącz są **parami** — rotacja jednego wymaga restartu tylko dwóch usług, nie
całej czwórki. W trakcie wymiany para chwilowo się nie dogaduje (klucze muszą być
zgodne po obu stronach łącza), dlatego restart obu naraz.
---
## Procedury rotacji
### A. Klucze LLM (najbezpieczniejsze do przećwiczenia)
Dotykają wyłącznie logiki, a awaria jest widoczna od razu i nieszkodliwa
(niedostępna chmura, model lokalny działa dalej). **Zacznij ćwiczenie od tego.**
```bash
read -rs -p "Nowy ANTHROPIC_API_KEY: " NEW; echo
```
```bash
kubectl -n astrololo create secret generic astrololo-llm \
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
--from-literal=ANTHROPIC_API_KEY="$NEW" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
unset NEW && kubectl -n astrololo rollout restart deploy/logic && kubectl -n astrololo rollout status deploy/logic
```
### B. Klucz łącza (para usług)
Przykład dla `LINK_KEY_PRESENTATION_RENDER`. Pozostałe klucze zachowujemy bez zmian,
odczytując je z istniejącego sekretu — inaczej skasowalibyśmy pozostałe łącza.
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
kubectl -n astrololo rollout restart deploy/presentation deploy/render
```
```bash
kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
```
### C. `INTERNAL_TOKEN` (wszystkie cztery naraz)
Najbardziej wrażliwa rotacja: w trakcie usługi z różnymi tokenami **odrzucają się
nawzajem**, więc restart musi objąć całą czwórkę.
```bash
kubectl -n astrololo create secret generic astrololo-auth \
--from-literal=APP_PASSWORD="$(kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data.APP_PASSWORD}' | base64 -d)" \
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
kubectl -n astrololo rollout restart deploy/data deploy/logic deploy/presentation deploy/render
```
```bash
kubectl -n astrololo rollout status deploy/data && kubectl -n astrololo rollout status deploy/logic && kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
```
> Jeśli używasz kont imiennych (PRE-17), zamiast `APP_PASSWORD` zachowaj `APP_USERS`
> — patrz [`konta-i-audyt.md`](konta-i-audyt.md).
### Weryfikacja po KAŻDEJ rotacji
Sam `Running` nie wystarczy — pody wstaną nawet, gdy warstwy się nie dogadują.
Trzeba sprawdzić **realny przelot przez wszystkie łącza**:
```bash
kubectl -n astrololo get pods
```
```bash
curl -s -o /dev/null -w "aplikacja: %{http_code}\n" -u "<login>:<hasło>" https://astrololo.czernobog.pl/
```
Policz horoskop w przeglądarce (dotyka presentation→logic→data) i wygeneruj PDF
(dotyka presentation→render). Dopiero to potwierdza, że wszystkie cztery klucze
i token są spójne.
---
## Hartowanie — kroki wymagające dostępu do węzła
### 1. Szyfrowanie sekretów w spoczynku (najważniejsze)
k3s z jednym serwerem trzyma stan w **SQLite**, nie w etcd, więc „szyfrowanie etcd"
sprowadza się do wbudowanej funkcji k3s. Kroki są w sekcji „Co musisz zrobić sam"
poniżej. Efekt: kopia pliku stanu albo snapshot VM przestaje być wyciekiem haseł
i kluczy API.
**Granica:** klucz szyfrujący leży na tym samym serwerze. Chroni przed kradzieżą
pliku/snapshotu — nie przed kimś, kto ma roota na węźle.
### 2. Ograniczenie tokenów kont serwisowych ✅
Zrobione: `automountServiceAccountToken: false` we wszystkich czterech usługach
(deploy #13). Żadna nie rozmawia z API Kubernetesa — sekrety wstrzykuje kubelet,
nie pod — więc token był zbędny, a stanowił gotowy punkt wyjścia do klastra.
### 3. RBAC
Stan sprawdzony: **zero RoleBindings** w `astrololo`, `cluster-admin` tylko dla
`system:masters` i dwóch kont Helma w `kube-system`. Nie ma rozdanych nadmiarowych
uprawnień do cofania.
Realna ekspozycja to **kubeconfig admina**. Sensowny krok: osobny, ograniczony
kubeconfig do codziennej pracy, a admin tylko wtedy, gdy naprawdę potrzebny.
### 4. Sealed Secrets / SOPS — świadomie ODŁOŻONE
Dziś sekrety tworzone są ręcznie i **nie ma ich w repo GitOps** — czyli zasada
„nie wpisywać sekretów do repozytorium" **jest już spełniona**. Kosztem jest
odtwarzalność: po utracie klastra nikt nie wie, co tam było.
Sealed Secrets pozwoliłoby trzymać je w gicie w postaci zaszyfrowanej, ale to
zmiana filozofii i **nowy pojedynczy punkt awarii**: utrata klucza kontrolera =
utrata wszystkich sekretów. Rekomendacja: dopiero po punkcie 1, i tylko jeśli
zależy Ci na odtwarzalności klastra z gita.
---
## Czego NIE robimy
- **Nie wpisujemy sekretów do logów.** Dziennik audytowy (PRE-17) niesie wyłącznie
metadane i liczby.
- **Nie wpisujemy sekretów do repo GitOps.** Manifesty odwołują się do sekretów
przez `secretKeyRef` i celowo nie zawierają wartości.
- **Nie zostawiamy haseł w historii powłoki** — stąd `read -rs` i odczyt istniejących
wartości przez `kubectl … | base64 -d` zamiast wpisywania ich ponownie.
-14
View File
@@ -1,14 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też katalog
# tests/ — a leży w nim lista słów, które w tej usłudze nie mają prawa paść,
# wypisanych wprost. Obraz tej usługi się KOMUŚ ODDAJE, więc niósłby dokładnie
# to, co ten test ma pilnować. Przy okazji obraz jest mniejszy.
COPY app ./app
EXPOSE 8005
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"]
-91
View File
@@ -1,91 +0,0 @@
# astrodemo — wersja demonstracyjna (PRE-28)
Osobna warstwa prezentacji o **dwóch funkcjach**:
1. **dodanie pliku bazy** — wgranie i włączenie do użytku jedną czynnością,
2. **zapytanie o interpretację urodzeniową** dla podanych danych wejściowych.
I na tym koniec. Nie ma tu kosmogramu, synastrii, kalendarza, promptów do modelu,
eksportu ani kont — nie są ukryte, tylko **nie istnieją w tym obrazie**.
## Dlaczego osobna usługa, a nie konto z ograniczeniami
Mechanizm uprawnień z PRE-27 umiałby to ukryć w pełnej aplikacji. Ale **ukrycie
a nieobecność to dwie różne rzeczy**: tutaj nie ma tras, nie ma szablonów, nie ma
nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy
okazji kodu reszty programu.
## Izolacja: własne dane, pula na konto
```
astrodemo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie)
└── klientA/ ← pula konta
└── klientB/ ← pula konta
```
**Oryginalne bazy interpretacyjne są dla demo nieosiągalne.** Nie chodzi
o uprawnienia: demo ma własną warstwę danych, pracującą na osobnym udziale.
Osobna jest też warstwa logiczna, bo zna ona JEDEN adres warstwy danych — demo
korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.
**Każde konto ma własną pulę** plików: swój podkatalog na tym udziale. Konta nie
widzą swoich baz nawzajem ani w liście, ani w wynikach wyszukiwania. Dlatego demo
można rozdawać szeroko.
Pula bierze się **z loginu zalogowanej osoby** i z niczego innego. Gdyby
przychodziła z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze
bazy — osobny test wysyła `tenant`, `user` i `login` w formularzu i sprawdza, że
nie mają na nią wpływu.
Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział.
**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza
utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`,
`astrololo/README-astrodemo.md`).
## Konfiguracja
| zmienna | znaczenie |
|---|---|
| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) |
| `ASTRODEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików |
| `ASTRODEMO_USER` / `ASTRODEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `ASTRODEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie |
| `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług |
| `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja |
| `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza |
| `REPORT_LIMIT` | górny limit rekordów w jednej odpowiedzi, domyślnie 2000 |
| `TRUST_PROXY` | `true` za Ingressem — inaczej limit liczy wszystkich do jednego wiadra |
Hash hasła zrobisz tym samym skryptem, co dla głównej aplikacji:
```bash
cd services/presentation && python scripts/make_user.py demo
```
## Uruchomienie lokalne
```bash
cd services/astrodemo
pip install -r requirements-dev.txt
ASTRODEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \
uvicorn app.main:app --port 8005
```
Testy:
```bash
cd services/astrodemo && PYTHONPATH=. pytest tests -q
```
## Co pilnują testy
Poza zwykłą poprawnością — dwie własności, które łatwo zepsuć bez zauważenia:
* **powierzchnia**: zbiór tras aplikacji musi być dokładnie taki, jak dwie funkcje
wymagają; klient warstwy logicznej ma tylko te metody, których używa. Trasa,
o której nikt nie pamiętał, jest tu realnym ryzykiem, bo ta usługa rozmawia
z produkcyjną warstwą danych.
* **milczenie o regułach walidacji**: plik odrzucony daje komunikat bez powodu.
Reguły zna wyłącznie administrator w pełnej aplikacji (DAN-27), a demo nie ma
prawa ich zdradzić nawet mimochodem.
View File
@@ -1,79 +0,0 @@
"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28).
Ma tu być wyłącznie to, czego ta usługa naprawdę używa. Metoda, której nie ma,
nie zostanie przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest
coś więcej.
Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym
tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza czytany przy KAŻDYM wywołaniu — pod przeżywa rotację sekretu
bez restartu, a brak klucza przy wymaganym szyfrowaniu ma zatrzymać żądanie
ZANIM cokolwiek wyjdzie jawnym tekstem."""
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
return link_crypto.Link(key) if key else None
class LogicClient:
"""Rozmówca warstwy logicznej ZWIĄZANY Z PULĄ KONTA (PRE-29).
Pulę podaje się przy tworzeniu klienta, a bierze się ją WYŁĄCZNIE z loginu
zalogowanej osoby — nigdy z pola formularza. Gdyby przychodziła z żądania,
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
def __init__(self, tenant: str = "", base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
self.tenant = tenant or ""
def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]:
"""Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach.
Jedyne pytanie, jakie astrodemo zadaje o treść — bez grupowania,
bez grupowania i bez żadnej obróbki treści."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"limit": settings.report_limit, "group": False,
"tenant": self.tenant}
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report",
payload=payload, headers=_auth_headers(), link=_link())
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
return link_crypto.call_json(
client, "POST", f"{self.base_url}/files/upload",
payload={"filename": filename, "content_b64": content_b64,
"by": by, "tenant": self.tenant},
headers=_auth_headers(), link=_link())
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
"""Włączenie wgranego pliku do użytku. W demo wgranie i włączenie to JEDNA
czynność: „dodać do bazy" ma znaczyć, że plik od razu bierze udział
w wyszukiwaniu — inaczej demo wyglądałoby na zepsute."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(
client, "POST", f"{self.base_url}/files/status",
payload={"path": path, "status": status, "by": by, "tenant": self.tenant},
headers=_auth_headers(), link=_link())
def health(self) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r.raise_for_status()
return r.json()
-22
View File
@@ -1,22 +0,0 @@
"""Konfiguracja astrodemo — wersji demonstracyjnej (PRE-28).
Zna WYŁĄCZNIE adres warstwy logicznej. Nie wie nic o bazach, Excelu ani SQL-u.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass
class Settings:
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
# Szukanie interpretacji potrafi zajrzeć do wszystkich baz naraz, więc limit
# czasu jest wyższy niż domyślny dla zwykłego żądania.
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "30")))
# Górny limit rekordów w jednej odpowiedzi. Nie jest to optymalizacja, tylko
# ograniczenie, ile treści baz może wyjść jednym zapytaniem.
report_limit: int = field(default_factory=lambda: int(os.getenv("REPORT_LIMIT", "2000")))
settings = Settings()
-525
View File
@@ -1,525 +0,0 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
try:
return self._by_direction[direction].decrypt(
nonce, sealed, self._aad(direction, path, stamp, seq))
except InvalidTag as exc:
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
# ------------------------------------------------------------ strumień ramek
def seal_stream(self, direction: bytes, path: str, stamp: str,
chunks: Iterable[bytes]) -> Iterator[bytes]:
for seq, chunk in enumerate(chunks):
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
def open_stream(self, direction: bytes, path: str, stamp: str,
raw: bytes) -> Iterator[bytes]:
for seq, frame in enumerate(frames_in(raw)):
yield self.open(direction, path, stamp, seq, frame)
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
return b"".join(self.open_stream(direction, path, stamp, raw))
# ---------------------------------------------------------------- ramkowanie
def frame_out(payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + payload
def frames_in(raw: bytes) -> Iterator[bytes]:
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
offset = 0
while offset < len(raw):
if offset + LENGTH_PREFIX > len(raw):
raise LinkError("urwana ramka (brak nagłówka długości)")
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
offset += LENGTH_PREFIX
if offset + size > len(raw):
raise LinkError("urwana ramka (za mało danych)")
yield raw[offset:offset + size]
offset += size
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
rozjechać się między dwa odczyty.
"""
while True:
if len(buffer) < LENGTH_PREFIX:
return
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
if len(buffer) < LENGTH_PREFIX + size:
return
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
del buffer[:LENGTH_PREFIX + size]
yield frame
# ------------------------------------------------------------- świeżość ruchu
def stamp_now() -> str:
return f"{time.time():.3f}"
def check_stamp(stamp: str) -> None:
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
odtworzyć w dowolnym momencie w przyszłości."""
try:
sent = float(stamp)
except (TypeError, ValueError) as exc:
raise LinkError("brak albo błędny znacznik czasu") from exc
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
# =========================================================== strona serwerowa
class LinkCryptoMiddleware:
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
"""
def __init__(self, app, link: Link | None, layer: str) -> None:
self.app = app
self.link = link
self.layer = layer
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
return await self.app(scope, receive, send)
path = scope["path"]
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
if headers.get(HEADER_ENC.lower()) != VERSION:
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
# chcemy po cichu wrócić do jawnego ruchu.
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
self.layer, path)
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
stamp = headers.get(HEADER_TS.lower(), "")
try:
check_stamp(stamp)
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
except LinkError as exc:
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
scope = dict(scope)
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
def _sealing_send(self, send, path: str):
state: dict = {"stamp": "", "seq": 0}
async def sealing(message):
if message["type"] == "http.response.start":
state["stamp"] = stamp_now()
keep = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in (b"content-length", b"content-type")]
message = dict(message)
message["headers"] = keep + [
(b"content-type", CONTENT_TYPE.encode()),
(HEADER_ENC.lower().encode(), VERSION.encode()),
(HEADER_TS.lower().encode(), state["stamp"].encode()),
]
return await send(message)
if message["type"] == "http.response.body":
chunk = message.get("body", b"")
sealed = b""
if chunk:
sealed = frame_out(self.link.seal(
RESPONSE, path, state["stamp"], state["seq"], chunk))
state["seq"] += 1
return await send({"type": "http.response.body", "body": sealed,
"more_body": message.get("more_body", False)})
return await send(message)
return sealing
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
kept.append((b"content-length", str(length).encode()))
if length:
kept.append((b"content-type", b"application/json"))
return kept
async def _read_body(receive) -> bytes:
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
raise LinkError("rozłączenie w trakcie odbioru żądania")
body += message.get("body", b"")
if not message.get("more_body", False):
return bytes(body)
def _replay(body: bytes, original):
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
"""
delivered = False
async def receive():
nonlocal delivered
if delivered:
return await original()
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
async def _refuse(send, detail: str) -> None:
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": 400, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
]})
await send({"type": "http.response.body", "body": payload})
def install(app, env_name: str, layer: str):
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
link_key = key_from_env(env_name)
if link_key is None and encryption_required():
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
f"warstwa {layer} nie wystartuje bez klucza łącza"
)
if link_key is None:
log.warning(
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
"ruch wewnątrz sieci.", env_name, layer,
)
return None
link = Link(link_key)
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
return link
# ============================================================ strona kliencka
def call(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
się niejasnym błędem odszyfrowania.
"""
import json as _json
import httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
# sygnifikatory, o które pytamy bazę.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
)
response = client.request(method, url, json=payload, headers=request_headers)
response.raise_for_status()
return response.content
path = httpx.URL(url).path
stamp = stamp_now()
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
"Content-Type": CONTENT_TYPE})
response = client.request(method, url, content=body, headers=request_headers)
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
log.error("łącze %s odmówiło: %s", path, response.text[:200])
response.raise_for_status()
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
reply_stamp = response.headers.get(HEADER_TS, "")
check_stamp(reply_stamp)
return link.open_all(RESPONSE, path, reply_stamp, response.content)
def call_json(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None):
import json as _json
return _json.loads(call(client, method, url, payload=payload,
headers=headers, link=link))
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
link: Link | None) -> Iterator[str]:
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
dopiero na znakach nowej linii.
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
działał bez zmian.
"""
import json as _json
import httpx as _httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
)
with client.stream("POST", url, json=payload, headers=request_headers) as response:
response.raise_for_status()
for text_line in response.iter_lines():
if text_line:
yield text_line
return
path = _httpx.URL(url).path
stamp = stamp_now()
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
with client.stream("POST", url, content=body, headers=request_headers) as response:
response.raise_for_status()
buffer = bytearray()
for plain in open_response_stream(response, link):
buffer += plain
while True:
nl = buffer.find(b"\n")
if nl < 0:
break
text_line = bytes(buffer[:nl])
del buffer[:nl + 1]
if text_line:
yield text_line.decode("utf-8")
if buffer:
yield bytes(buffer).decode("utf-8")
-255
View File
@@ -1,255 +0,0 @@
"""astrodemo — wersja DEMONSTRACYJNA (PRE-28).
DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ:
1. dodanie pliku bazy (wgranie + włączenie do użytku),
2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych.
DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Ukrycie a nieobecność to
dwie różne rzeczy. Tutaj niczego poza tymi dwiema funkcjami NIE MA W OBRAZIE: nie
ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Obraz
tej usługi można komuś oddać i nie oddaje się przy tym niczego ponad to, co widać
na ekranie — łącznie z komentarzami w tym pliku, bo one też jadą razem z obrazem.
PULE SĄ ROZDZIELONE (PRE-29). Każde konto ma własny, izolowany zbiór plików,
niewidoczny dla pozostałych kont; pula bierze się WYŁĄCZNIE z loginu zalogowanej
osoby, nigdy z pola formularza. Konta są osobne (ASTRODEMO_USERS) — żeby dało się
odciąć tę usługę jedną zmienną, bez
ruszania kont głównej aplikacji.
"""
from __future__ import annotations
import base64
import logging
import re
from functools import lru_cache
from pathlib import Path
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app import link_crypto, security, session
from app.clients.logic_client import LogicClient
# Automatyczna dokumentacja WYŁĄCZONA. /docs wypisałoby komplet tras, a ta usługa
# ma nie zdradzać nawet własnej powierzchni.
app = FastAPI(title="astrodemo", docs_url=None, redoc_url=None, openapi_url=None)
security.install(app)
# ── zasoby (PRE-27) ────────────────────────────────────────────────────────
# Katalog NIE jest montowany. Montowanie serwuje wszystko, co w nim leży, każdemu
# i bez śladu w dzienniku — a komentarz w arkuszu stylów mówił wprost, że istnieje
# więcej, niż widać na ekranie. Tu jest jawna lista: arkusz logowania jest
# publiczny, bo
# potrzebuje go strona logowania, a cokolwiek innego jest nieznane i nie istnieje.
ZASOBY_PUBLICZNE = frozenset({"styles.css"})
_KATALOG_ZASOBOW = Path("app/static")
def _bez_komentarzy(tresc: str) -> str:
"""Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt.
W repozytorium zostaje — do odpowiedzi nie ma po co trafiać."""
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
@lru_cache(maxsize=None)
def _zasob(nazwa: str) -> bytes | None:
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
if nazwa not in ZASOBY_PUBLICZNE:
return None
sciezka = (_KATALOG_ZASOBOW / nazwa).resolve()
# Wyjście poza katalog: `..` w ścieżce albo dowiązanie na zewnątrz.
if _KATALOG_ZASOBOW.resolve() not in sciezka.parents or not sciezka.is_file():
return None
if sciezka.suffix in (".css", ".js"):
return _bez_komentarzy(sciezka.read_text(encoding="utf-8")).encode("utf-8")
return sciezka.read_bytes()
@app.get("/static/{nazwa:path}")
def zasob(nazwa: str):
tresc = _zasob(nazwa)
if tresc is None:
raise HTTPException(status_code=404)
typ = "text/css; charset=utf-8" if nazwa.endswith(".css") else "application/octet-stream"
return Response(tresc, media_type=typ,
headers={"Cache-Control": "public, max-age=3600"})
templates = Jinja2Templates(directory="app/templates")
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _blad).
log = logging.getLogger("astrodemo")
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
templates.env.globals["auth_on"] = security.auth_enabled
def _logic(request: Request) -> LogicClient:
"""Klient związany z pulą ZALOGOWANEGO konta.
Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się
zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state,
które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
return LogicClient(tenant=getattr(request.state, "user", "") or "")
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
# tutaj wprost, bo astrodemo nie importuje tamtej warstwy; literówka objawiłaby
# się dopiero na żywo, więc pilnuje jej test.
ACTIVE = "active"
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Data + godzina lokalna + offset → chwila w UTC.
Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma
dwie funkcje, a strefy to trzecia."""
try:
local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M")
except ValueError as e:
raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e
moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset)))
return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M")
# Jedno zdanie dla wszystkich awarii i CELOWO żadnego szczegółu. Treść wyjątku
# niosła nazwę usługi, jej port i nazwy jej tras — czyli mapę tego, co stoi za tym
# ekranem, podaną komuś, kto ma widzieć wyłącznie dwa formularze.
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
def _blad(e: Exception) -> str:
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran."""
log.warning("operacja nieudana: %s: %s", type(e).__name__, e)
return _AWARIA
def _ile_rekordow(report: dict) -> int:
return sum(len(s.get("samples") or []) for p in (report.get("positions") or [])
for s in (p.get("significators") or []))
@app.get("/", response_class=HTMLResponse)
def formularz(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "index.html",
{"result": None, "form": {}, "error": error, "done": done})
@app.post("/", response_class=HTMLResponse)
def interpretacja(request: Request, date: str = Form(...), time: str = Form(...),
tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)):
"""Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść."""
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = _logic(request).report(when_utc_iso=iso_utc, lat=lat, lon=lon)
# Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17).
security.audit_records(request, _ile_rekordow(ctx["result"]))
except ValueError as e:
ctx["error"] = str(e)
except (httpx.HTTPError, link_crypto.LinkError) as e:
ctx["error"] = _blad(e)
return templates.TemplateResponse(request, "index.html", ctx)
@app.post("/plik")
async def dodaj_plik(request: Request, upload: UploadFile = File(...)):
"""Dodanie pliku bazy: wgranie ORAZ włączenie do użytku.
Jedna czynność, nie dwie: „dodać plik" ma tu znaczyć, że od razu bierze udział
w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i wyglądałoby to
na zepsute.
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
zapisany i czeka."""
raw = await upload.read()
if not raw:
return RedirectResponse("/?error=Pusty+plik.", status_code=303)
who = getattr(request.state, "user", "") or "demo"
try:
out = _logic(request).files_upload(upload.filename or "baza.xlsx",
base64.b64encode(raw).decode("ascii"), by=who)
except (httpx.HTTPError, link_crypto.LinkError) as e:
return _wroc(error=_blad(e))
nazwa = out.get("name") or "plik"
if not out.get("accepted"):
# Bez słowa o regułach — te zna wyłącznie administrator (DAN-27).
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
f"do użycia w tej postaci.")
try:
_logic(request).files_status(out["path"], ACTIVE, by=who)
except (httpx.HTTPError, link_crypto.LinkError) as e:
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}")
return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.")
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
wgrać pliku drugi raz."""
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303)
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
# Bez własnego wolumenu nie ma gdzie trzymać licznika pokolenia sesji, więc
# „wyrzuć ze wszystkich urządzeń" robi się zmianą hasła w ASTRODEMO_USERS.
# Wylogowanie działa natychmiast, bo polega na skasowaniu ciasteczka.
def _bezpieczne_dokad(dokad: str) -> str:
"""Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl`
zamieniłby ekran logowania w narzędzie do wyłudzania haseł."""
dokad = (dokad or "").strip()
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
return "/"
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
@app.get("/logowanie", response_class=HTMLResponse)
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
if security.auth_enabled() and security.principal(request):
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
return templates.TemplateResponse(request, "logowanie.html",
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
@app.post("/logowanie")
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
dokad: str = Form("/")):
cel = _bezpieczne_dokad(dokad)
kto = security.verify(login.strip(), haslo)
if kto is None:
security.audit_login(request, login.strip(), udane=False)
return templates.TemplateResponse(
request, "logowanie.html",
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
status_code=401)
security.audit_login(request, kto, udane=True)
odpowiedz = RedirectResponse(cel, status_code=303)
odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto),
max_age=session.max_age(),
**session.cookie_params(secure=security.cookies_secure()))
return odpowiedz
@app.post("/wyloguj")
def wyloguj(request: Request):
"""POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie,
żeby wylogować kogoś bez jego wiedzy."""
security.audit_logout(request)
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
odpowiedz.delete_cookie(session.COOKIE,
**session.cookie_params(secure=security.cookies_secure()))
return odpowiedz
@app.get("/health")
def health() -> dict:
# Sama „ok". Nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa;
# kubernetes i compose czytają wyłącznie kod odpowiedzi.
return {"status": "ok"}
-266
View File
@@ -1,266 +0,0 @@
"""Logowanie do astrodemo (PRE-28).
OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej
warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy
interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną
środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek.
Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont
i bez uprawnień. Ta usługa ma dwie funkcje i obie są dla tego samego człowieka —
model uprawnień z PRE-27 nie miałby tu czego dzielić.
Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem
co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import logging
import os
import time
from collections import deque
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astrodemo.security")
audit_log = logging.getLogger("astrodemo.audit")
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$"
# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być
# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji.
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/styles.css"})
# Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy
# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani
# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony
# z nazwy, bo potrzebuje go ekran logowania.
PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {}
MAX_TRACKED_CLIENTS = 4096
def accounts() -> dict[str, str]:
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_USERS.
WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29).
Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem
swoje wgrania.
Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten
sam skrypt make_user.py, co w głównej aplikacji.
Zgodność wstecz: pojedyncze ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS
jest puste."""
out: dict[str, str] = {}
for entry in os.getenv("ASTRODEMO_USERS", "").split(","):
entry = entry.strip()
if not entry:
continue
user, sep, secret = entry.partition(":")
if sep and user.strip() and secret.strip():
out[user.strip()] = secret.strip()
if out:
return out
single = os.getenv("ASTRODEMO_PASSWORD", "")
return {os.getenv("ASTRODEMO_USER", "demo"): single} if single else {}
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "60"))
def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
def auth_enabled() -> bool:
return bool(accounts())
def _secret_matches(spec: str, password: str) -> bool:
if spec.startswith(_HASH_PREFIX):
try:
_, salt_hex, hash_hex = spec.split("$", 2)
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
except ValueError:
return False
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return hmac.compare_digest(actual, expected)
# BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach
# spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy.
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
def verify(login: str, password: str) -> str | None:
"""Sprawdza login i hasło; zwraca login albo None.
Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
konto istnieje, a przy demo rozdawanym szeroko to realna informacja."""
spec = accounts().get(login)
if spec is None:
return None
return login if _secret_matches(spec, password) else None
def issue_session(login: str) -> str:
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie
konta z ASTRODEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny
sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu,
bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów
byłby pomieszaniem dwóch zupełnie różnych rzeczy."""
from app import session
return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0)
def principal(request: Request) -> str | None:
"""Ciasteczko → login zalogowanej osoby albo None."""
from app import session
dane = session.read(request.cookies.get(session.COOKIE))
if dane is None:
return None
spec = accounts().get(dane["login"])
if spec is None or session.fingerprint(spec) != dane["odcisk"]:
return None
return dane["login"]
def client_ip(request: Request) -> str:
"""Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy —
inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit."""
peer = request.client.host if request.client else "?"
if not trust_proxy():
return peer
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
last = forwarded.rsplit(",", 1)[-1].strip()
if last:
return last
return request.headers.get("x-real-ip", "").strip() or peer
def _rate_limited(client: str) -> bool:
cap = rate_limit_per_min()
if cap <= 0:
return False
now = time.monotonic()
window = _hits.get(client)
if window is None:
if len(_hits) >= MAX_TRACKED_CLIENTS:
_hits.clear()
window = _hits[client] = deque()
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= cap:
return True
window.append(now)
return False
def _setup_audit_logging() -> None:
"""Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje
naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był
pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17)."""
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
if not audit_log.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
audit_log.addHandler(handler)
audit_log.propagate = False
def audit_records(request: Request, count: int) -> None:
try:
request.state.audit_records = int(count)
except (TypeError, ValueError):
pass
def cookies_secure() -> bool:
"""Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http —
inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa"
bez żadnego komunikatu."""
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
def audit_login(request: Request, login: str, udane: bool) -> None:
"""Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza
nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak,
hasło NIGDY."""
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
"logowanie-ok" if udane else "logowanie-odmowa",
login or "-", client_ip(request))
def audit_logout(request: Request) -> None:
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
getattr(request.state, "user", "") or "-", client_ip(request))
def install(app) -> None:
from app import session
_setup_audit_logging()
# Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od
# podrobionych. Lepiej, żeby usługa nie wstała.
if auth_enabled() and not session.available():
raise session.SessionError(
"Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
"podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32")
if not auth_enabled():
log.warning(
"UWAGA: brak kont (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo stoi OTWARTY. "
"Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym "
"zbiorze baz, widząc nawzajem swoje wgrania."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES):
return await call_next(request)
started = time.monotonic()
if _rate_limited(client_ip(request)):
return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"})
user, odswiez = "", None
if auth_enabled():
user = principal(request) or ""
if not user:
from urllib.parse import quote
cel = request.url.path
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
from app import session as _s
dane = _s.read(request.cookies.get(_s.COOKIE))
odswiez = _s.refresh(dane) if dane else None
request.state.user = user
response = await call_next(request)
if odswiez:
from app import session as _s
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
**_s.cookie_params(secure=cookies_secure()))
records = getattr(request.state, "audit_records", None)
audit_log.info(
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
user or "-", client_ip(request), request.method, request.url.path,
response.status_code, "-" if records is None else records,
(time.monotonic() - started) * 1000)
return response
-190
View File
@@ -1,190 +0,0 @@
"""Sesje logowania — podpisane ciasteczko (LOG-34).
Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi
obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka.
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
w środku, i może tę decyzję cofnąć.
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
o tym — i też nie wymaga magazynu.
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
nie poda licznika, poziomy 1 i 2 nadal działają.
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
dostępu nie odbierałoby dostępu.
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
w PRE-27 i AES w PRE-16.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
# z jednej, logując się do drugiej.
COOKIE = "astrodemo_sesja"
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
REFRESH_AFTER = 300
class SessionError(RuntimeError):
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, "") or default)
except ValueError:
return default
def max_age() -> int:
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
def idle_timeout() -> int:
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
def secret() -> bytes:
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
raw = os.getenv("SESSION_SECRET", "").strip()
if not raw:
raise SessionError(
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
"Wygeneruj: openssl rand -hex 32")
if len(raw) < 32:
raise SessionError(
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
return raw.encode("utf-8")
def available() -> bool:
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
try:
secret()
except SessionError:
return False
return True
def fingerprint(secret_spec: str) -> str:
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
unieważniły jego sesje same z siebie."""
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
def _sign(payload: bytes) -> bytes:
return hmac.new(secret(), payload, hashlib.sha256).digest()
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
"""Nowe ciasteczko sesji dla konta."""
teraz = int(now if now is not None else time.time())
payload = json.dumps(
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def read(token: str | None, *, now: float | None = None) -> dict | None:
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
if not token or "." not in token:
return None
surowy, _, podpis = token.rpartition(".")
try:
payload = _unb64(surowy)
oczekiwany = _sign(payload)
podany = _unb64(podpis)
except (ValueError, SessionError):
return None
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
if not hmac.compare_digest(oczekiwany, podany):
return None
try:
dane = json.loads(payload.decode("utf-8"))
login, odcisk = str(dane["u"]), str(dane["f"])
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
except (ValueError, KeyError, TypeError):
return None
teraz = int(now if now is not None else time.time())
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
# w obu wypadkach nie ma powodu mu ufać.
if wyd > teraz + 60:
return None
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
return None
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
def refresh(dane: dict, *, now: float | None = None) -> str | None:
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
Set-Cookie — także po arkusze stylów i obrazki."""
teraz = int(now if now is not None else time.time())
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
return None
payload = json.dumps(
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
"wyd": dane["wydano"], "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def cookie_params(secure: bool = True) -> dict:
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
-63
View File
@@ -1,63 +0,0 @@
/* 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); }
@@ -1,30 +0,0 @@
<!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>
@@ -1,87 +0,0 @@
{% 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 %}
@@ -1,36 +0,0 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrodemo · logowanie</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="login-main">
<header class="topbar"><h1>astrodemo</h1></header>
{% if request.query_params.get('wylogowano') %}
<p class="ok">Wylogowano.</p>
{% endif %}
{% if blad %}<p class="alert">{{ blad }}</p>{% endif %}
<form method="post" action="/logowanie" class="card login-card">
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
<input type="hidden" name="dokad" value="{{ dokad }}">
<label>Login
<input type="text" name="login" value="{{ login or '' }}" required
autocomplete="username" autofocus></label>
<label>Hasło
<input type="password" name="haslo" required autocomplete="current-password"></label>
<div class="actions"><button type="submit">Zaloguj</button></div>
</form>
<p class="muted small login-note">
Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała
Ci dostęp — kont nie da się odzyskać samodzielnie.
</p>
</main>
</body>
</html>
-2
View File
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-11
View File
@@ -1,11 +0,0 @@
# CELOWO KRÓTSZA niż w warstwie prezentacji. Astroklient ma dwie funkcje, więc nie
# potrzebuje ani Excela (brak eksportu), ani stref czasowych z lokalizacji (offset
# podaje się wprost), ani niczego pod kosmogram — a każda zbędna zależność w obrazie
# demo to kolejna rzecz do pilnowania i kolejna droga do środka.
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20
# Szyfrowanie łącza do warstwy logicznej (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
-301
View File
@@ -1,301 +0,0 @@
"""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}"
@@ -1,192 +0,0 @@
"""Zapora słownikowa astrodemo: czego użytkownik demo nie może zobaczyć.
astrodemo stoi najniżej w drabinie trzech produktów (astrodemo → astroklient →
astrololo) i jest dawane najszerzej. Ma dwie funkcje i ma wyglądać, jakby to
było wszystko, co program potrafi.
Test nie sprawdza pojedynczych miejsc, tylko przechodzi WSZYSTKO, co da się
pobrać, i szuka słów, które nie mają prawa paść. Sprawdza też DRZEWO PLIKÓW
usługi — bo „nie ma śladu w kodzie" znaczy, że `grep` po obrazie ma nic nie
znaleźć, a nie tylko że odpowiedź jest czysta.
Powód, dla którego to powstało: komentarz w pierwszej linii styles.css mówił
„Nie kopiujemy stylów pełnej aplikacji", a arkusz pobierał anonim.
"""
import pathlib
import re
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
# (a) że istnieje wersja obszerniejsza i jak się nazywa
WIEKSZY_BRAT = ["pełna aplikacja", "pełnej aplikacji", "pełna wersja", "pełnej wersji",
"astrololo", "astroklient", "wersji podstawowej", "upgrade", "rozszerzon"]
# (b) że tekst pisze model językowy
# Bez samego „token": w tej usłudze znaczy token sesji i token międzywarstwowy,
# a nie jednostkę rozliczeniową modelu. Zamiast tego zwroty jednoznaczne.
MODEL = ["LLM", "anthropic", "openai", "gpt", "claude", "prompt",
"generowanie tekstu", "sztuczn", "okno kontekstu", "model językow"]
# (c) funkcje, których astrodemo nie ma
INNE_FUNKCJE = ["kosmogram", "aspektarian", "synastri", "kalendarz", "predykcj",
"skompiluj", "system domów", "systemy domów", "deklinacj", "antyscj"]
# (d) że jest ktoś, kto widzi więcej, i że produkt jest w budowie
MECHANIZMY = ["administrator", "uprawnieni", "walidacj", "kwarantann", "roadmap",
"w budowie", "widok testowy", "warstwa logiczna", "layer"]
# Dwa poziomy, bo dwie różne drogi wycieku.
#
# WSZĘDZIE — także w kodzie serwera, którego przeglądarka nigdy nie zobaczy.
# Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie, przeczyta też
# komentarze i docstringi. Zdanie „w pełnej aplikacji to działa inaczej" mówi mu
# wprost, że istnieje pełna aplikacja — a to jest dokładnie to, czego ma nie
# wiedzieć. Komentarz ma tłumaczyć TĘ usługę, bez powoływania się na rodzeństwo.
WSZEDZIE = WIEKSZY_BRAT + MODEL + INNE_FUNKCJE
# TYLKO DO PRZEGLĄDARKI — słownictwo inżynierskie opisujące własne działanie tej
# usługi. W komentarzu serwera jest na miejscu; w odpowiedzi HTTP nie ma czego
# szukać, bo mówi o mechanizmach, o których użytkownik nie ma wiedzieć.
DO_PRZEGLADARKI = WSZEDZIE + MECHANIZMY
ZAKAZANE = DO_PRZEGLADARKI
WZORCE = [(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania"),
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
(r"(?i)\bAI\b", "model językowy")]
def _grzechy(tresc: str, gdzie: str, slownik: list[str] | None = None) -> list[str]:
out = []
for igla in (slownik if slownik is not None else ZAKAZANE):
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
out.append(f"{gdzie}: „{igla}")
for wzor, powod in (WZORCE if slownik is None else WZORCE[-1:]):
m = re.search(wzor, tresc)
if m:
out.append(f"{gdzie}: „{m.group(0)}” — {powod}")
return out
@pytest.fixture()
def klient(monkeypatch):
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo")
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
monkeypatch.setenv("COOKIES_INSECURE", "1")
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
from starlette.testclient import TestClient
from app.main import app
return TestClient(app, follow_redirects=False)
def test_nothing_forbidden_reaches_an_anonymous_visitor(klient):
grzechy, obejrzane = [], 0
for adres in ("/logowanie", "/health", "/static/styles.css"):
r = klient.get(adres)
assert r.status_code == 200, f"{adres}: {r.status_code}"
obejrzane += 1
grzechy += _grzechy(r.text, adres)
assert obejrzane == 3
assert not grzechy, "\n" + "\n".join(grzechy)
def test_nothing_forbidden_reaches_a_logged_in_visitor(klient, monkeypatch):
from app import security, session
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
# Warstwa logiczna niedostępna CELOWO: ekran idzie wtedy ścieżką błędu,
# a to komunikaty o awarii wypisywały nazwy warstw i tras.
import httpx
from app.main import LogicClient
monkeypatch.setattr(LogicClient, "report",
lambda *a, **k: (_ for _ in ()).throw(httpx.ConnectError("nie da rady")))
grzechy = []
for adres in ("/", "/static/styles.css"):
r = klient.get(adres, headers=naglowki)
assert r.status_code == 200, f"{adres}: {r.status_code}"
grzechy += _grzechy(r.text, adres)
r = klient.post("/", headers=naglowki,
data={"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94"})
grzechy += _grzechy(r.text, "POST /")
assert not grzechy, "\n" + "\n".join(grzechy)
# Stałe protokołu łącza (nagłówki, typ treści, etykieta HKDF) niosą nazwę rodziny
# produktów, ale są WSPÓLNE z warstwą logiczną: zmiana wymaga jednoczesnej zmiany
# we wszystkich usługach i rotacji, więc to osobna decyzja. Nie idą do przeglądarki.
BEZ_KONTROLI = {"app/link_crypto.py"}
def test_the_source_tree_itself_carries_no_trace():
"""„Bez śladu w kodzie" znaczy: grep po obrazie ma nic nie znaleźć.
Odpowiedź może być czysta, a plik i tak nieść komentarz — dokładnie tak
wyciekał arkusz stylów, zanim komentarze zaczęły być zdejmowane."""
# Identyfikatory wymagań (PRE-28, LOG-34) zostają: to notatki inżynierskie
# o tej usłudze, nie informacja o istnieniu czegoś większego.
grzechy = []
for f in APP.rglob("*"):
wzgledna = str(f.relative_to(APP.parent))
if (not f.is_file() or "__pycache__" in str(f) or f.suffix == ".pyc"
or wzgledna in BEZ_KONTROLI):
continue
try:
grzechy += _grzechy(f.read_text(encoding="utf-8"),
str(f.relative_to(APP.parent)), WSZEDZIE)
except UnicodeDecodeError:
continue
assert not grzechy, "\n" + "\n".join(grzechy)
def test_an_unknown_asset_is_indistinguishable_from_one_that_never_existed(klient):
from app import security, session
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
nieistniejacy = klient.get("/static/nie-ma-takiego.js", headers=naglowki)
for adres in ("/static/main.py", "/static/../app/config.py", "/static/vendor/cokolwiek.js"):
r = klient.get(adres, headers=naglowki)
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
assert r.text == nieistniejacy.text
def test_the_protocol_constants_are_the_one_known_exception():
"""Świadomie zostawiona dziura — opisana, żeby nie była przypadkiem.
Stałe łącza międzywarstwowego (`X-Astrololo-Token`, `X-Astrololo-Enc`, typ
treści, etykieta HKDF) niosą nazwę rodziny produktów. Są WSPÓLNE z warstwą
logiczną, więc zmiana wymaga jednoczesnej podmiany we wszystkich usługach
i rotacji sekretów — to osobna decyzja, nie efekt uboczny tej zmiany.
Warunek, pod jakim to zostaje: te stałe nie mają prawa dotrzeć do
przeglądarki. Idą wyłącznie w nagłówkach żądań DO warstwy logicznej.
Ten test pilnuje właśnie tego warunku, a nie samego istnienia stałych.
"""
rdzen = "astrololo"
protokol = [f for f in APP.rglob("*.py")
if f.is_file() and rdzen in f.read_text(encoding="utf-8").lower()]
assert protokol, "stałe zniknęły — jeśli je przemianowano, ten wyjątek jest zbędny"
for f in protokol:
tresc = f.read_text(encoding="utf-8").lower()
for linia in tresc.splitlines():
if rdzen not in linia:
continue
# Każde wystąpienie musi być stałą protokołu albo nazwą kanału logów —
# nigdy tekstem, który mógłby trafić do szablonu.
assert any(z in linia for z in ("x-astrololo", "vnd.astrololo",
"astrololo/link/", 'getlogger("astrololo')), \
f"{f.name}: „{linia.strip()[:70]}” to nie jest stała protokołu"
def test_only_the_application_code_ships_in_the_image():
"""Obraz nie może wieźć tego testu.
Ironia, którą łatwo przeoczyć: plik pilnujący, żeby pewne słowa nie padły,
sam je wypisuje — więc gdy trafi do obrazu, staje się tym wyciekiem, przed
którym broni. `COPY . .` wnosiło cały katalog usługi, razem z tests/.
"""
# Same instrukcje, bez komentarzy: komentarz obok tej linii CYTUJE dawną
# postać, więc szukanie po całym pliku zgłaszałoby własne wyjaśnienie.
instrukcje = [l for l in (APP.parent / "Dockerfile").read_text(encoding="utf-8").splitlines()
if l.strip() and not l.lstrip().startswith("#")]
assert not any(l.strip() == "COPY . ." for l in instrukcje), "obraz wnosi wszystko, w tym testy"
assert any(l.strip() == "COPY app ./app" for l in instrukcje)
-28
View File
@@ -1,28 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY services/astroklient/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Rdzeń wspólny z pełną aplikacją: ekrany astrologiczne, szablony, zasoby.
# Jedno źródło, dwa produkty — inaczej te same 2500 linii szablonów żyłyby
# w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu.
COPY services/presentation/app ./app
# …minus wszystko, czego ten produkt nie ma. Lista jest DANYMI, a nie tekstem
# w tym pliku: czyta ją także test, który pilnuje, żeby zgadzała się ze
# złożeniem w main.py. Rozjazd między nimi znaczyłby albo martwy kod w obrazie,
# albo brakujący plik i błąd dopiero przy uruchomieniu.
COPY services/astroklient/usun.txt .
RUN while read -r SCIEZKA; do \
[ -n "$SCIEZKA" ] || continue; \
test -e "app/$SCIEZKA" || { echo "usun.txt wskazuje nieistniejące: $SCIEZKA"; exit 1; }; \
rm -rf "app/$SCIEZKA"; \
done < usun.txt && rm usun.txt
# Nakładka: własne logowanie, własna pula plików, własny ekran wgrywania,
# własne złożenie. Nadpisuje pliki o tych samych nazwach.
COPY services/astroklient/app ./app
EXPOSE 8006
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8006"]
@@ -1,174 +0,0 @@
"""Klient warstwy logicznej — z PULĄ KONTA (PRE-29).
Każde konto ma własny, niewidoczny dla pozostałych zbiór plików. Pula jedzie
w każdym żądaniu w dół i bierze się WYŁĄCZNIE z kontekstu ustawianego przy
wejściu, nigdy z pola formularza: gdyby przychodziła z żądania, wystarczyłoby
podstawić cudzy login, żeby czytać cudze pliki.
Kontekst zamiast argumentu, bo ekrany są wspólne z pełną aplikacją i wołają
klienta jako obiekt modułowy. Przekazywanie puli przez wszystkie wywołania
znaczyłoby, że pominięcie jednego z nich cicho otwiera dostęp do całego udziału —
a takiego pominięcia nie widać.
"""
from __future__ import annotations
import contextvars
import os
from typing import Any
import httpx
# Login zalogowanego konta na czas obsługi żądania. Ustawia go bramka wejścia.
KONTO: contextvars.ContextVar[str] = contextvars.ContextVar("konto", default="")
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
return link_crypto.Link(key) if key else None
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
# Pula konta dokładana do KAŻDEGO żądania w dół. Bierze się z kontekstu
# ustawianego przy wejściu, nigdy z pola formularza — gdyby przychodziła
# z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze pliki.
# Trasy, których pula nie dotyczy, po prostu ignorują nadmiarowe pole.
payload = {**payload, "tenant": KONTO.get("")}
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
with httpx.Client(timeout=timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(),
link=_link())
# ── transport dla modułów opcjonalnych ─────────────────────────────────
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
return self._post(sciezka, payload, timeout)
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
headers=_auth_headers(), link=_link())
def strumien(self, sciezka: str, payload: dict[str, Any]):
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}{sciezka}",
payload=payload, headers=_auth_headers(), link=_link())
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
return self._post("/api/query", payload, settings.http_timeout)
def positions(
self,
when_utc_iso: str,
lat: float,
lon: float,
objects: list[str] | None = None,
house_system: str = "whole_sign",
stations: bool = False,
zodiac: str = "tropical",
tables: bool = False,
house_systems: list[str] | None = None,
aspect_orb: float = 8.0,
aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
"when_utc": when_utc_iso,
"lat": lat,
"lon": lon,
"objects": objects,
"house_system": house_system,
"house_systems": house_systems,
"aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
"stations": stations,
"zodiac": zodiac,
"tables": tables,
}
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
return self._post("/chart/positions", payload, timeout)
def synastry(
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
) -> dict[str, Any]:
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
payload = {
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
}
return self._post("/chart/synastry", payload, settings.http_timeout)
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
def bases(self) -> dict[str, Any]:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/upload",
{"filename": filename, "content_b64": content_b64, "by": by})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
payload = {**payload, "tenant": KONTO.get("")}
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
def timeline(
self, when_utc_iso: str, lat: float, lon: float,
from_date: str, to_date: str, interpret: bool = True,
) -> dict[str, Any]:
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
payload = {
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "interpret": interpret,
}
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
@@ -1,75 +0,0 @@
"""Wgrywanie plików: jedna czynność, nie dwie.
„Dodać plik" ma tu znaczyć, że od razu bierze udział w wyszukiwaniu — inaczej po
wgraniu nic by się nie zmieniło i wyglądałoby to na zepsute. Nie ma listy plików
ani ich stanów: ta usługa ma jeden poziom dostępu, więc nie ma komu ich
pokazywać ani po co.
"""
from __future__ import annotations
import base64
import httpx
from fastapi import File, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import app, features, logic, templates, _logic_error
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
# tutaj wprost, bo ta usługa tamtej warstwy nie importuje; literówka objawiłaby
# się dopiero na żywo, więc pilnuje jej test.
W_UZYCIU = "active"
@app.get("/pliki", response_class=HTMLResponse)
def wgrywanie_form(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "wgrywanie.html",
{"error": error, "done": done})
@app.post("/pliki/wgraj")
async def wgraj(request: Request, upload: UploadFile = File(...)):
"""Wgranie ORAZ włączenie do użytku.
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
zapisany i czeka. Komunikat nie mówi, CZYM się nie nadaje: różnica w treści
byłaby wyrocznią, z której da się odczytać, czego program oczekuje.
"""
raw = await upload.read()
if not raw:
return _wroc(error="Pusty plik.")
kto = getattr(request.state, "user", "") or "?"
try:
out = logic.files_upload(upload.filename or "dane.xlsx",
base64.b64encode(raw).decode("ascii"), by=kto)
except httpx.HTTPError as e:
return _wroc(error=_logic_error(e))
nazwa = out.get("name") or "plik"
if not out.get("accepted"):
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
f"do użycia w tej postaci.")
try:
logic.files_status(out["path"], W_UZYCIU, by=kto)
except httpx.HTTPError as e:
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć.")
return _wroc(done=f"Dodano „{nazwa}”. Plik jest już używany przy wyszukiwaniu.")
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
wgrać pliku drugi raz."""
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/pliki{'?' + q if q else ''}", status_code=303)
features.zarejestruj(
ekran=Feature("files", "Pliki", "Dodawanie plików używanych przy wyszukiwaniu.",
"Ekrany", "/pliki", kolejnosc=70),
trasy={("GET", "/pliki"): "files", ("POST", "/pliki/wgraj"): "files"},
zasoby={"s-wgrywanie.css": "files"},
)
-34
View File
@@ -1,34 +0,0 @@
"""Astroklient — ZŁOŻENIE produktu.
Pełne funkcje astrologiczne, bez generowania tekstu i bez administracji. Ten plik
nie zawiera żadnej trasy: odpowiada wyłącznie na pytanie, z czego składa się ten
produkt. Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją.
Poza tą listą nie ma nic. Nie chodzi o funkcje wyłączone: plików, których tu nie
widać, nie ma w obrazie (patrz usun.txt), więc nie istnieje uprawnienie, którym
dałoby się cokolwiek odsłonić — ani nazwa, po której dałoby się zgadnąć, czego
szukać.
"""
from __future__ import annotations
from app import rozszerzenia
from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error, app,
logic, static_url, templates)
from app.ekrany import ( # noqa: F401 - import rejestruje trasy
horoskop,
interpretacje,
kalendarz,
logowanie,
lokalizacja,
sygnifikatory,
synastria,
wgrywanie,
)
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
# Most modułów opcjonalnych zostaje CELOWO. W tym obrazie nie ma katalogu
# `app/moduly/`, więc odkrywa zero modułów i wszystkie odpowiedzi są puste —
# aplikacja zachowuje się tak, jakby takiego pojęcia nigdy nie było.
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
buduj_utc=_build_utc, blad=_logic_error)
-8
View File
@@ -1,8 +0,0 @@
"""Nazwa produktu — nakładka astroklienta.
Nadpisuje wersję z rdzenia. Test słownikowy pilnuje, żeby po nadpisaniu nazwa
tamtego produktu nie została nigdzie w szablonach, zasobach ani w odpowiedziach.
"""
from __future__ import annotations
NAZWA = "astroklient"
-384
View File
@@ -1,384 +0,0 @@
"""Logowanie i bramka uprawnień astroklienta.
Konta pochodzą WYŁĄCZNIE z konfiguracji środowiska (`ASTROKLIENT_USERS`), a każde
dostaje ten sam zestaw: wszystko, co ta usługa umie. Konta rozdziela się po to,
żeby każde miało własną, niewidoczną dla pozostałych pulę plików — nie po to, żeby
dawać im różne poziomy dostępu. Poziom jest jeden i nie ma go z czym porównywać.
Reszta jest wspólna z pozostałymi usługami i celowo nietknięta: hasła jako scrypt,
sesja w podpisanym ciasteczku, limit żądań, dziennik audytowy, oraz odmowa jako
404 zamiast 403 — bo 403 samo w sobie mówi, że coś tam jest.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import logging
import os
import secrets
import time
from collections import deque
from dataclasses import dataclass
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astrololo.security")
MAX_TRACKED_CLIENTS_DEFAULT = 4096
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
# ── konta ─────────────────────────────────────────────────────────────────
# Wyłącznie z konfiguracji środowiska. Konta rozdziela się po to, żeby każde
# miało własną pulę plików — nie po to, żeby różniły się tym, co widzą.
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$"
def hash_password(password: str, salt: bytes | None = None) -> str:
"""Hasło → wpis do ASTROKLIENT_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
salt = salt or secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
def accounts() -> dict[str, str]:
"""Konta z `ASTROKLIENT_USERS` — `login:sekret`, po przecinku.
Sekret to hash scrypt (`scrypt$sól$hash`), ten sam format co w pozostałych
usługach — jedna implementacja, więc nie ma czego rozjechać."""
out: dict[str, str] = {}
for entry in os.getenv("ASTROKLIENT_USERS", "").split(","):
entry = entry.strip()
if not entry:
continue
user, sep, secret = entry.partition(":")
if sep and user.strip() and secret.strip():
out[user.strip()] = secret.strip()
return out
def _secret_matches(spec: str, password: str) -> bool:
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
if spec.startswith(_HASH_PREFIX):
try:
_, salt_hex, hash_hex = spec.split("$", 2)
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
except ValueError:
return False
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return hmac.compare_digest(actual, expected)
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
def cookies_secure() -> bool:
"""Czy ciasteczko sesji ma być wyłącznie dla https.
Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej
przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa"
bez żadnego komunikatu, co jest wyjątkowo mylącą awarią."""
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego
# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
# jedynym publicznym zasobem — i dlatego nie wolno w nim trzymać niczego, co
# nazywa funkcję (patrz features.STATIC).
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/base.css"})
# Pusto CELOWO. Dopóki był tu "/static/", cały katalog omijał bramkę: każdy plik
# JS i arkusz stylów pobierał ktokolwiek, także niezalogowany, pod zgadywalnym
# adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie baz.
# Ruch do zasobów nie trafiał przy tym ani do dziennika, ani pod limit żądań,
# więc wyciek był dla właściciela niewidoczny.
PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(accounts())
def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
@dataclass(frozen=True)
class Principal:
"""Kto się zalogował i co wolno mu zobaczyć."""
login: str
permissions: frozenset[str]
is_admin: bool
def _uprawnienia() -> frozenset[str]:
"""Każde konto dostaje wszystko, co ta usługa umie — liczone z KATALOGU.
Katalog składa się ze zgłoszeń ekranów obecnych w obrazie, więc ten zbiór
opisuje dokładnie ten produkt, a nie listę wpisaną kiedyś ręcznie. Dopisanie
ekranu nie wymaga pamiętania o niczym; usunięcie go zabiera uprawnienie samo.
Nie ma tu uprawnienia administracyjnego i nie ma go skąd wziąć: `features.ADMIN`
celowo nie należy do GRANTABLE."""
from app import features
return frozenset(features.GRANTABLE)
def _sekret(login: str) -> str | None:
return accounts().get(login)
def verify(login: str, password: str) -> Principal | None:
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
konta istnieją, a to informacja warta czegoś dla zgadującego."""
spec = _sekret(login)
if spec is None or not _secret_matches(spec, password):
return None
return Principal(login, _uprawnienia(), False)
def issue_session(login: str) -> str:
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
i bez żadnego magazynu sesji."""
from app import session
# Wersja sesji zawsze 0: nie ma trwałego magazynu, więc nie ma gdzie trzymać
# licznika pokolenia. Wylogowanie i zmiana hasła i tak działają — pierwsze
# przez skasowanie ciasteczka, drugie przez odcisk, który przestaje pasować.
return session.issue(login, session.fingerprint(_sekret(login) or ""), 0)
def principal(request: Request) -> Principal | None:
"""Ciasteczko → kto to jest. None = nie wpuszczać."""
from app import session
dane = session.read(request.cookies.get(session.COOKIE))
if dane is None:
return None
spec = _sekret(dane["login"])
if spec is None:
return None
# Zmiana hasła w konfiguracji ubija sesje tego konta — to jest tutaj jedyny
# sposób na „wyloguj mnie ze wszystkich urządzeń", bo bez trwałego magazynu
# nie ma gdzie trzymać licznika pokolenia sesji.
if session.fingerprint(spec) != dane["odcisk"]:
return None
return Principal(dane["login"], _uprawnienia(), False)
def client_ip(request: Request) -> str:
"""Adres, po którym rozliczamy limit żądań.
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
wyklika limit, odcięłaby pozostałe.
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
klient, więc nie znaczą nic.
"""
peer = request.client.host if request.client else "?"
if not trust_proxy():
return peer
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
last = forwarded.rsplit(",", 1)[-1].strip()
if last:
return last
return request.headers.get("x-real-ip", "").strip() or peer
def _rate_limited(client: str) -> bool:
cap = rate_limit_per_min()
if cap <= 0:
return False
now = time.monotonic()
window = _hits.get(client)
if window is None:
if len(_hits) >= MAX_TRACKED_CLIENTS:
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
window = _hits[client] = deque()
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= cap:
return True
window.append(now)
return False
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
audit_log = logging.getLogger("astrololo.audit")
def _setup_audit_logging() -> None:
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń.
"""
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
if not audit_log.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
audit_log.addHandler(handler)
audit_log.propagate = False
def audit_records(request: Request, count: int) -> None:
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził",
a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
try:
request.state.audit_records = int(count)
except (TypeError, ValueError):
pass
def audit_login(request: Request, login: str, udane: bool) -> None:
"""Zdarzenie logowania w dzienniku (PRE-17).
Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale
seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy
LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku."""
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
"logowanie-ok" if udane else "logowanie-odmowa",
login or "-", client_ip(request))
def audit_logout(request: Request) -> None:
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
getattr(request.state, "user", "") or "-", client_ip(request))
def _audit(request: Request, user: str, status: int, ms: float) -> None:
records = getattr(request.state, "audit_records", None)
audit_log.info(
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
user or "-", client_ip(request), request.method, request.url.path,
status, "-" if records is None else records, ms,
)
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza publicznymi."""
from app import session
_setup_audit_logging()
# FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić
# ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała —
# i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka.
if auth_enabled() and not session.available():
raise session.SessionError(
"Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
"podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. "
"Wygeneruj: openssl rand -hex 32")
if not auth_enabled():
log.warning(
"UWAGA: brak kont (ASTROKLIENT_USERS) — usługa jest OTWARTA dla "
"każdego, kto ma do niej dostęp sieciowy, wraz z treścią plików."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if _is_public(request.url.path):
return await call_next(request)
started = time.monotonic()
if _rate_limited(client_ip(request)):
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
return JSONResponse(
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"},
)
from app import features
user, who, odswiez = "", None, None
if auth_enabled():
who = principal(request)
if who is None:
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
# Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce.
from urllib.parse import quote
cel = request.url.path
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
user = who.login
# Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy
# znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy
# każdym obrazku i arkuszu stylów.
from app import session as _s
dane = _s.read(request.cookies.get(_s.COOKIE))
odswiez = _s.refresh(dane) if dane else None
else:
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
who = Principal("", _uprawnienia(), False)
request.state.user = user
request.state.principal = who
# Pula plików na czas obsługi tego żądania. Ustawiana TU, w jednym
# miejscu, zaraz po ustaleniu kto to jest — klient czyta ją z kontekstu,
# więc żadne wywołanie w dół nie może o niej zapomnieć.
from app.clients.logic_client import KONTO
KONTO.set(user)
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
need = features.required(request.method, request.url.path)
if not features.can(who.permissions, need):
_audit(request, user, 404, (time.monotonic() - started) * 1000)
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
response = await call_next(request)
if odswiez:
from app import session as _s
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
**_s.cookie_params(secure=cookies_secure()))
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
return response
-186
View File
@@ -1,186 +0,0 @@
"""Sesje logowania — podpisane ciasteczko (LOG-34).
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
w środku, i może tę decyzję cofnąć.
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
o tym — i też nie wymaga magazynu.
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
nie poda licznika, poziomy 1 i 2 nadal działają.
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
dostępu nie odbierałoby dostępu.
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
w PRE-27 i AES w PRE-16.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
# z jednej, logując się do drugiej.
COOKIE = "astroklient_sesja"
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
REFRESH_AFTER = 300
class SessionError(RuntimeError):
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, "") or default)
except ValueError:
return default
def max_age() -> int:
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
def idle_timeout() -> int:
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
def secret() -> bytes:
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
raw = os.getenv("SESSION_SECRET", "").strip()
if not raw:
raise SessionError(
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
"Wygeneruj: openssl rand -hex 32")
if len(raw) < 32:
raise SessionError(
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
return raw.encode("utf-8")
def available() -> bool:
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
try:
secret()
except SessionError:
return False
return True
def fingerprint(secret_spec: str) -> str:
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
unieważniły jego sesje same z siebie."""
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
def _sign(payload: bytes) -> bytes:
return hmac.new(secret(), payload, hashlib.sha256).digest()
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
"""Nowe ciasteczko sesji dla konta."""
teraz = int(now if now is not None else time.time())
payload = json.dumps(
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def read(token: str | None, *, now: float | None = None) -> dict | None:
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
if not token or "." not in token:
return None
surowy, _, podpis = token.rpartition(".")
try:
payload = _unb64(surowy)
oczekiwany = _sign(payload)
podany = _unb64(podpis)
except (ValueError, SessionError):
return None
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
if not hmac.compare_digest(oczekiwany, podany):
return None
try:
dane = json.loads(payload.decode("utf-8"))
login, odcisk = str(dane["u"]), str(dane["f"])
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
except (ValueError, KeyError, TypeError):
return None
teraz = int(now if now is not None else time.time())
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
# w obu wypadkach nie ma powodu mu ufać.
if wyd > teraz + 60:
return None
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
return None
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
def refresh(dane: dict, *, now: float | None = None) -> str | None:
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
Set-Cookie — także po arkusze stylów i obrazki."""
teraz = int(now if now is not None else time.time())
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
return None
payload = json.dumps(
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
"wyd": dane["wydano"], "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def cookie_params(secure: bool = True) -> dict:
"""Parametry ciasteczka wspólne dla ustawiania i kasowania.
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
@@ -1,4 +0,0 @@
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 1rem 1.1rem; margin-top: .4rem; }
@@ -1,23 +0,0 @@
{% extends "base.html" %}
{% set nav_active = "files" %}
{% block title %}Pliki{% endblock %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-wgrywanie.css') }}">
{% endblock %}
{% block content %}
{# Opis czysto operacyjny: co robi wgranie, i nic o tym, do CZEGO te dane służą
ani co się z nimi dzieje dalej. #}
<p class="muted">Wgrany plik jest od razu używany przy wyszukiwaniu.</p>
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Wgraj plik</div>
<form method="post" action="/pliki/wgraj" enctype="multipart/form-data" class="card">
<div class="grid">
<label>Plik <input type="file" name="upload" required></label>
</div>
<div class="actions"><button type="submit">Wgraj</button></div>
</form>
{% endblock %}
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-14
View File
@@ -1,14 +0,0 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
# bez systemowej bazy stref.
tzfpy>=0.15
tzdata>=2024.1
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
openpyxl>=3.1
-94
View File
@@ -1,94 +0,0 @@
"""Drzewo astroklienta budowane tak samo, jak buduje je Dockerfile.
Ta usługa nie ma własnego kompletu plików: bierze rdzeń z warstwy prezentacji,
usuwa z niego to, czego ten produkt nie ma (usun.txt), i nakłada własne. Testy
muszą patrzeć na WYNIK tego złożenia, a nie na katalog `app/` w repozytorium —
inaczej sprawdzałyby garść plików, a nie produkt.
"""
from __future__ import annotations
import json
import pathlib
import re
import shutil
import subprocess
import sys
import pytest
USLUGA = pathlib.Path(__file__).resolve().parents[1]
RDZEN = USLUGA.parents[0] / "presentation" / "app"
def zloz(cel: pathlib.Path) -> pathlib.Path:
"""Rdzeń minus usun.txt plus nakładka — dokładnie jak w Dockerfile."""
app = cel / "app"
shutil.copytree(RDZEN, app, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for linia in (USLUGA / "usun.txt").read_text(encoding="utf-8").splitlines():
sciezka = linia.strip()
if not sciezka:
continue
do_usuniecia = app / sciezka
assert do_usuniecia.exists(), f"usun.txt wskazuje nieistniejące: {sciezka}"
shutil.rmtree(do_usuniecia) if do_usuniecia.is_dir() else do_usuniecia.unlink()
shutil.copytree(USLUGA / "app", app, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
return cel
@pytest.fixture(scope="session")
def drzewo(tmp_path_factory) -> pathlib.Path:
return zloz(tmp_path_factory.mktemp("astroklient"))
SONDA = '''
import json, os, hashlib, secrets, re
sol = secrets.token_bytes(16)
dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32)
os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()),
SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0")
from starlette.testclient import TestClient
from app.main import app
from app import security, session, features
from app.clients import logic_client
c = TestClient(app, follow_redirects=False)
H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"}
strona = c.get("/", headers=H).text
zebrane = []
logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1]
c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20",
"tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"})
print("WYNIK:" + json.dumps({
"ekrany": [f.label for f in features.SCREENS],
"uprawnienia": sorted(security._uprawnienia()),
"dziala": {a: c.get(a, headers=H).status_code
for a in ("/", "/interpret", "/timeline", "/synastry",
"/significators", "/pliki")},
"nie_ma": {a: c.get(a, headers=H).status_code
for a in ("/compile", "/settings", "/accounts", "/files",
"/horoscope/stream", "/compile/pdf")},
"nawigacja": re.findall(r"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'),
"pula": (zebrane[0] or {}).get("tenant"),
"nazwa_w_naglowku": (re.search(r"<h1>(.*?)</h1>", strona) or [None, "?"])[1],
"nazwa_w_tytule": (re.search(r"<title>(.*?) ·", strona) or [None, "?"])[1],
"rdzen_w_tresci": "astrololo" in strona.lower(),
"dokumentacja": {a: c.get(a, headers=H).status_code
for a in ("/docs", "/openapi.json", "/redoc")},
"logowanie_dziala": bool(security.verify("klient", "tajne")),
"zle_haslo_odmowa": security.verify("klient", "inne") is None,
}))
'''
@pytest.fixture(scope="module")
def wynik(drzewo):
"""Aplikacja uruchomiona w OSOBNYM PROCESIE.
Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez
modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie
pozytywny."""
r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True,
text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"})
linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None)
assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}"
return json.loads(linia[len("WYNIK:"):])
@@ -1,137 +0,0 @@
"""Po czym astroklienta NIE MA — ani w odpowiedzi, ani w kodzie w obrazie.
Ta usługa dzieli kod z pełną aplikacją, więc granica między produktami nie jest
oczywista z samego patrzenia: przebiega przez listę importów w `main.py` i przez
`usun.txt`. Ten test sprawdza jej SKUTEK na złożonym drzewie — bo tylko skutek
trafia do obrazu, który komuś się oddaje.
„Bez śladu" znaczy tu również bez zaprzeczeń. Zdanie „nie ma tu generowania
tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak
przycisk. Pierwsza wersja tego testu wyłapała trzy moje własne docstringi
wyliczające nieobecne funkcje.
"""
from __future__ import annotations
import pathlib
import re
import pytest
# Funkcje, których ten produkt nie ma — ani ich słownictwo.
GENEROWANIE = ["prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
"anthropic", "openai", "gpt", "claude", "horoscope",
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js"]
NIEOBECNE_EKRANY = ["Skompiluj", "Ustawienia", "Konta i uprawnien", "Eksport plików"]
MECHANIZMY = ["administrator", "kwarantann", "walidacj", "reguły przyjmowania"]
SLOWA = GENEROWANIE + NIEOBECNE_EKRANY + MECHANIZMY
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
def _pliki(drzewo: pathlib.Path):
for f in sorted((drzewo / "app").rglob("*")):
if f.is_file() and f.suffix != ".pyc" and "__pycache__" not in str(f):
yield f
def test_the_shipped_tree_carries_no_trace(drzewo):
grzechy = []
for f in _pliki(drzewo):
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
gdzie = f.relative_to(drzewo)
for slowo in SLOWA:
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
grzechy.append(f"{gdzie}: „{slowo}")
for wzor, powod in WZORCE:
m = re.search(wzor, tresc)
if m:
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
assert not grzechy, (
"\nW drzewie astroklienta padły słowa o funkcjach, których nie ma. "
"`grep` po kontenerze ma nic nie znaleźć:\n " + "\n ".join(grzechy))
def test_the_check_would_notice_if_something_slipped_in(drzewo, tmp_path):
"""Kontrola negatywna: zapora ma coś łapać.
Bez niej test wyżej przechodziłby także wtedy, gdyby lista słów była pusta
albo gdyby przeszukiwanie trafiało w niewłaściwy katalog."""
podrzucony = drzewo / "app" / "podrzucony.py"
podrzucony.write_text("# generowanie tekstu przez model\n", encoding="utf-8")
try:
with pytest.raises(AssertionError):
test_the_shipped_tree_carries_no_trace(drzewo)
finally:
podrzucony.unlink()
def test_the_removal_list_matches_what_is_actually_composed(drzewo):
"""usun.txt i main.py muszą mówić to samo.
Rozjazd między nimi znaczy albo martwy kod w obrazie (moduł usunięty
z importów, ale nie z listy), albo błąd dopiero przy uruchomieniu (plik
usunięty, a wciąż importowany). Jedno i drugie wychodzi za późno.
"""
main = (drzewo / "app" / "main.py").read_text(encoding="utf-8")
zaimportowane = set(re.findall(r"^\s{4}(\w+),$", main, re.M))
obecne = {f.stem for f in (drzewo / "app" / "ekrany").glob("*.py")
if f.stem != "__init__"}
assert zaimportowane == obecne, (
f"ekrany w drzewie: {sorted(obecne)}\n"
f"ekrany importowane: {sorted(zaimportowane)}")
def test_no_optional_module_survived(drzewo):
"""Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero."""
assert not (drzewo / "app" / "moduly").exists()
# ── nazwa produktu, z którego ten produkt powstał ─────────────────────────
# Osobno od listy wyżej, bo granica przebiega gdzie indziej: „astrololo" wolno
# występować w identyfikatorach WEWNĘTRZNYCH (nazwy loggerów, materiał do
# wyprowadzania kluczy, nagłówki między warstwami), a nie wolno w niczym, co
# dociera do przeglądarki. Zakaz na wszystko byłby albo obejściem przez zmianę
# nazw kluczy kryptograficznych, albo martwą regułą wyłączaną przy pierwszej
# okazji.
NAZWA_RDZENIA = "astrololo"
WIDOCZNE_DLA_KLIENTA = ("templates", "static")
def test_the_parent_product_is_not_named_in_anything_the_browser_receives(drzewo):
"""Szablony i zasoby są WSPÓLNE z pełną aplikacją, więc jej nazwa jechała
z nimi do tego obrazu — i astroklient przedstawiał się jako astrololo.
To jest najpoważniejszy z możliwych śladów: mówi klientowi wprost, że trzyma
okrojoną wersję czegoś większego, i podaje frazę, po której da się wyszukać,
czego mu nie dano."""
grzechy = []
for katalog in WIDOCZNE_DLA_KLIENTA:
for f in sorted((drzewo / "app" / katalog).rglob("*")):
if not f.is_file() or "__pycache__" in str(f):
continue
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if NAZWA_RDZENIA in tresc.lower():
grzechy.append(str(f.relative_to(drzewo)))
assert not grzechy, (
f"\nNazwa „{NAZWA_RDZENIA}” pada w plikach, które dostaje "
f"przeglądarka:\n " + "\n ".join(grzechy))
def test_the_rendered_pages_carry_this_products_own_name(wynik):
"""Sprawdzane na ODPOWIEDZI, nie na szablonie: liczy się to, co dociera do
człowieka, a nie to, co jest w pliku przed złożeniem."""
assert wynik["nazwa_w_naglowku"] == "astroklient"
assert wynik["nazwa_w_tytule"] == "astroklient"
assert not wynik["rdzen_w_tresci"], (
"nazwa pełnej aplikacji pada w treści strony oddawanej klientowi")
def test_the_api_documentation_is_not_reachable(wynik):
"""Tytuł aplikacji FastAPI wciąż niesie nazwę rdzenia, więc dokumentacja
OpenAPI byłaby wyciekiem tylnymi drzwiami — nawet gdy szablony są czyste."""
assert all(k == 404 for k in wynik["dokumentacja"].values()), wynik["dokumentacja"]
@@ -1,46 +0,0 @@
"""Astroklient jest produktem, a nie pełną aplikacją z wyłączonymi kawałkami.
Sprawdzamy trzy rzeczy, i wszystkie na ZŁOŻONYM drzewie, nie na kodzie w repo:
czego w nim nie ma, że to, co zostało, działa, i że nie zostało po niczym słowo.
"""
from __future__ import annotations
def test_the_build_starts_at_all(wynik):
assert wynik["dziala"]["/"] == 200
def test_every_astrological_screen_works(wynik):
assert all(k == 200 for k in wynik["dziala"].values()), wynik["dziala"]
def test_what_it_lacks_does_not_exist_rather_than_being_refused(wynik):
"""404, nie 403 i nie 500. Odmowa z powodem jest informacją, że coś tam jest."""
assert all(k == 404 for k in wynik["nie_ma"].values()), wynik["nie_ma"]
def test_the_catalogue_describes_this_product(wynik):
assert set(wynik["ekrany"]) == {"Horoskop", "Interpretacje", "Kalendarz",
"Synastria", "Sygnifikatory", "Pliki"}
def test_no_account_can_hold_a_permission_this_product_does_not_have(wynik):
"""Uprawnienia liczą się z KATALOGU, więc opisują ten obraz. Gdyby była tu
lista wpisana ręcznie, zostałaby po usuniętych funkcjach."""
assert "ai" not in wynik["uprawnienia"]
assert "export" not in wynik["uprawnienia"]
assert "admin" not in wynik["uprawnienia"]
assert "chart" in wynik["uprawnienia"] and "significators" in wynik["uprawnienia"]
def test_navigation_has_no_dead_links(wynik):
assert wynik["nawigacja"] == len(wynik["ekrany"])
def test_login_still_tells_right_password_from_wrong(wynik):
assert wynik["logowanie_dziala"] and wynik["zle_haslo_odmowa"]
def test_the_pool_comes_from_the_session_not_from_the_form(wynik):
"""Pula jedzie z zalogowanego konta. Gdyby brała się z żądania, wystarczyłoby
podstawić cudzy login, żeby czytać cudze pliki — sonda właśnie to próbuje."""
assert wynik["pula"] == "klient"
-16
View File
@@ -1,16 +0,0 @@
moduly
ekrany/skompiluj.py
ekrany/ustawienia.py
ekrany/konta.py
ekrany/pliki.py
accounts.py
files_state.py
templates/compile.html
templates/settings.html
templates/accounts.html
templates/files.html
static/s-compile.css
static/s-settings.css
static/s-accounts.css
static/s-files.css
static/compile.js
-66
View File
@@ -1,66 +0,0 @@
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
„archiwum/stara_baza.xlsx".
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
def disabled_entries() -> list[str]:
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
def _relative(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return path.name
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
entries = disabled_entries() if entries is None else entries
if not entries:
return True
p = Path(path)
rel = _relative(p, Path(root))
return not any(e == p.name or e == rel for e in entries)
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
root = Path(root)
entries = disabled_entries()
out: list[dict] = []
for raw in paths:
p = Path(raw)
try:
st = p.stat()
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
except OSError:
size_mb, modified = None, None
out.append({
"name": p.name,
"path": _relative(p, root),
"size_mb": size_mb,
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
if modified else None),
"enabled": is_enabled(p, root, entries),
})
return out
-70
View File
@@ -1,70 +0,0 @@
"""Rekordy-pułapki (canary) — wykrywanie wycieku baz (DAN-26).
Zabezpieczenie DETEKCYJNE, nie prewencyjne. Kilka unikalnych, wiarygodnie
wyglądających rekordów wplecionych w bazy: nie zmieniają interpretacji (są
ODSIEWANE z wyników, więc nie trafiają ani do użytkownika, ani do promptu LLM —
wymóg LOG-30), ale jeśli kiedyś pojawią się w cudzej kopii, są dowodem pochodzenia.
Pułapkę rozpoznajemy po MARKERZE: unikalny ciąg, który nie występuje w realnych
danych (wpleciony np. w pole znaczące). Markery i WARIANT tego wdrożenia biorą się
z konfiguracji (ENV `CANARY_MARKERS`, `CANARY_VARIANT`) — każda kopia może dostać
swój zestaw. Który wariant trafił do którego wdrożenia trzyma rejestr po stronie
ops (osobno, poza kodem — patrz docs/canary-registry.md).
Dwa sygnały:
* ODSIEWANIE — pułapka w wynikach znika, zanim opuści warstwę danych (log info);
* TRIPWIRE — zapytanie CELUJE wprost w marker (ktoś enumeruje bazę, a nie liczy
realny horoskop) → log warning, bo to podejrzane zachowanie.
"""
from __future__ import annotations
import logging
import os
log = logging.getLogger("astrololo.data.canary")
def _load_markers() -> list[str]:
return [m.strip() for m in os.getenv("CANARY_MARKERS", "").split(",") if m.strip()]
MARKERS = _load_markers()
VARIANT = os.getenv("CANARY_VARIANT", "")
def _row_is_canary(row: dict, markers: list[str]) -> bool:
"""Czy KTÓRAKOLWIEK tekstowa wartość wiersza zawiera marker pułapki."""
for v in row.values():
if isinstance(v, str):
for m in markers:
if m in v:
return True
return False
def screen(
rows: list[dict], query_value: str = "",
markers: list[str] | None = None, variant: str | None = None,
) -> tuple[list[dict], dict]:
"""Zwraca (widoczne_wiersze, raport).
Odsiewa pułapki z wyników (nie opuszczą warstwy danych). Gdy zapytanie celuje
wprost w marker — podnosi TRIPWIRE (możliwa enumeracja bazy). Bez skonfigurowanych
markerów: przezroczyste (`active=False`), zero kosztu dla normalnego ruchu.
"""
markers = MARKERS if markers is None else markers
variant = VARIANT if variant is None else variant
if not markers:
return rows, {"active": False, "removed": 0, "tripwire": False}
visible = [r for r in rows if not _row_is_canary(r, markers)]
removed = len(rows) - len(visible)
tripwire = any(m in (query_value or "") for m in markers)
if tripwire:
log.warning(
"CANARY TRIPWIRE: zapytanie celuje wprost w rekord-pułapkę (wariant %s) "
"— możliwa enumeracja bazy", variant or "?")
elif removed:
log.info("Odsiano %d rekord(ów)-pułapek z wyników (wariant %s)", removed, variant or "?")
return visible, {"active": True, "removed": removed, "tripwire": tripwire}
-3
View File
@@ -37,9 +37,6 @@ 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")
)
-379
View File
@@ -1,379 +0,0 @@
"""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)
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika protokołu")
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
+4 -109
View File
@@ -7,16 +7,13 @@ 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, HTTPException
from fastapi import FastAPI
from app import canary, files, link_crypto, security
from app import 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)
@@ -28,12 +25,7 @@ async def lifespan(app: FastAPI):
yield
# 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)
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
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ć.
@@ -42,104 +34,7 @@ link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
@app.post("/search", response_model=SearchResult)
def search(query: SearchQuery) -> SearchResult:
result = provider.search(query)
# Rekordy-pułapki (DAN-26) odsiewamy TU, na wyjściu z warstwy danych — dzięki
# temu nie dotrą ani wyżej, ani do promptu LLM (LOG-30), niezależnie od dostawcy.
visible, report = canary.screen(result.rows, query.value)
if report["active"]:
result.rows = visible
result.total = len(visible)
return result
@app.get("/bases")
def bases() -> dict:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
Same METADANE nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
listy nie jest kolejną drogą do ich wyniesienia."""
items = provider.list_bases()
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)}
return provider.search(query)
@app.get("/health", response_model=HealthInfo)
-5
View File
@@ -25,11 +25,6 @@ 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):
-7
View File
@@ -25,10 +25,3 @@ class DataProvider(ABC):
def warmup(self) -> None:
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
return None
def list_bases(self) -> list[dict]:
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
return []
+1 -42
View File
@@ -91,48 +91,10 @@ 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], 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 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
from app import files
return files.registry(self.s.excel_dir, for_admin=True)
# ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult:
t0 = time.perf_counter()
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień
# oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną.
from app import bases
disabled = ",".join(bases.disabled_entries())
# 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}")
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
if hit is not None:
@@ -143,9 +105,6 @@ class ExcelDataProvider(DataProvider):
if not candidates:
# 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], query.tenant))
candidates = [(p, s) for p, s in candidates if p in allowed]
rows: list[dict] = []
for path, _sheet in candidates:
-2
View File
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-5
View File
@@ -6,11 +6,6 @@ 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
-50
View File
@@ -1,50 +0,0 @@
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
import pytest
from app import bases
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv("DISABLED_BASES", raising=False)
ROOT = "/dane"
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
def test_everything_enabled_by_default():
for b in bases.list_bases(ROOT, PATHS):
assert b["enabled"] is True
def test_disable_by_file_name(monkeypatch):
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
def test_disable_by_relative_path(monkeypatch):
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
def test_entries_are_trimmed_and_multiple(monkeypatch):
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
def test_listing_carries_metadata_not_content(tmp_path):
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
f = tmp_path / "baza.xlsx"
f.write_bytes(b"x" * 2048)
item = bases.list_bases(tmp_path, [str(f)])[0]
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
def test_missing_file_does_not_crash_the_listing():
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
assert item["size_mb"] is None and item["modified"] is None
-61
View File
@@ -1,61 +0,0 @@
"""Rekordy-pułapki (canary) — DAN-26.
Testujemy sam MECHANIZM na syntetycznych pułapkach: prawdziwe markery i injekcja
do baz przychodzą od właściciela produktu. Regresja byłaby CICHA i podwójnie zła:
albo pułapka wycieka do interpretacji/LLM (zdradza się i psuje wynik), albo
przestaje odsiewać i nie wiadomo o tym.
"""
import pathlib
from app import canary
MAIN = (pathlib.Path(__file__).resolve().parents[1] / "app" / "main.py").read_text(encoding="utf-8")
MARK = "ASTROLOLO-CANARY-7f3a9" # unikalny — nie wystąpi w realnych danych
ROWS = [
{"significator": "Ma Ari", "effect": "odważny, impulsywny"},
{"significator": "Ve Tau " + MARK, "effect": "pułapka — nie dotknie interpretacji"},
{"significator": "Su Leo", "effect": "dumny, twórczy"},
]
def test_no_markers_is_transparent():
"""Bez skonfigurowanych markerów — zero ingerencji, zero kosztu."""
out, rep = canary.screen(ROWS, "cokolwiek", markers=[])
assert out == ROWS and rep["active"] is False
def test_canary_row_is_fenced_from_results():
"""Pułapka znika z wyników — nie opuści warstwy danych (a więc i promptu LLM)."""
out, rep = canary.screen(ROWS, "Ve Tau", markers=[MARK])
assert rep["active"] and rep["removed"] == 1
assert all(MARK not in str(r) for r in out) # nigdzie nie ma markera
assert len(out) == 2 and {"significator": "Su Leo", "effect": "dumny, twórczy"} in out
def test_real_results_pass_through_untouched():
out, _ = canary.screen(ROWS, "Ari", markers=[MARK])
assert {"significator": "Ma Ari", "effect": "odważny, impulsywny"} in out
def test_tripwire_when_query_targets_a_marker():
"""Zapytanie CELUJĄCE w marker = ktoś enumeruje bazę, nie liczy horoskopu."""
_, rep = canary.screen(ROWS, "Ve Tau " + MARK, markers=[MARK])
assert rep["tripwire"] is True
def test_normal_query_does_not_trip():
_, rep = canary.screen(ROWS, "Ma Ari", markers=[MARK])
assert rep["tripwire"] is False
def test_marker_matched_in_any_string_field():
rows = [{"significator": "X", "effect": "opis " + MARK, "extra": 5}]
out, rep = canary.screen(rows, "X", markers=[MARK])
assert out == [] and rep["removed"] == 1 # marker w polu 'effect' też łapiemy
def test_endpoint_fences_before_returning():
"""/search odsiewa pułapki na wyjściu z warstwy danych (niezależnie od dostawcy)."""
assert "canary.screen(result.rows, query.value)" in MAIN
assert "result.rows = visible" in MAIN
-214
View File
@@ -1,214 +0,0 @@
"""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 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
@@ -1,189 +0,0 @@
"""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
+2 -68
View File
@@ -15,17 +15,12 @@ from __future__ import annotations
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI
from pydantic import BaseModel
import swisseph as swe
# 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)
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
@@ -74,67 +69,6 @@ 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"}
+2 -46
View File
@@ -28,18 +28,8 @@ def _link() -> link_crypto.Link | None:
class DataClient:
"""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 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:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
self.tenant = tenant or ""
def search(
self,
@@ -49,46 +39,12 @@ class DataClient:
limit: int,
fields: list[str] | None = None,
) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit,
"fields": fields, "tenant": self.tenant}
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
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(),
link=_link())
def bases(self) -> dict[str, Any]:
"""Lista baz na udziale + metaopis (DAN-15) — same metadane, bez treści."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_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.
+4 -52
View File
@@ -15,16 +15,6 @@ MAJOR = {
"trine": 120.0,
"opposition": 180.0,
}
# Aspekty POBOCZNE (opcjonalne, PRE-06). Tylko te trzy — bo mają już glify i barwy
# w warstwie prezentacji (chartwheel) oraz w engine/glyphs.py, więc dokładają się
# bez ruszania czegokolwiek poza silnikiem. Bazy interpretacyjne zwykle ich nie
# opisują (brak tokenu w DB_TOKEN), więc trafiają na kosmogram i do tabeli, ale
# NIE generują faset sygnifikatorów — most po cichu je pomija (significators.py).
MINOR = {
"semisextile": 30.0,
"semisquare": 45.0,
"quincunx": 150.0,
}
DB_TOKEN = {
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
"trine": "[tri", "opposition": "[opp",
@@ -71,54 +61,16 @@ def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: f
return dev_next < dev_now
def _first_aspect(sep: float, allowed: float, checks: dict) -> tuple[str, float] | None:
"""Pierwszy aspekt z `checks`, w którego orbie mieści się separacja `sep`."""
for asp, angle in checks.items():
dev = abs(sep - angle)
if dev <= allowed:
return asp, round(dev, 2)
return None
def find_cross_aspects(
a_positions: list[dict], b_positions: list[dict],
orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS, minor: bool = False,
) -> list[dict]:
"""Aspekty MIĘDZY dwoma horoskopami (synastria, PRE-04): każdy obiekt z A vs
każdy obiekt z B (`obj1` = osoba A, `obj2` = osoba B). Statyczne dwa natale,
brak wspólnego czasu, więc bez applying/separating. RIGID_PAIRS nie dotyczy
(NN osoby A vs SN osoby B to realny aspekt, nie artefakt definicji)."""
checks = {**MAJOR, **MINOR} if minor else MAJOR
out: list[dict] = []
for a in a_positions:
la = a.get("decimal")
if la is None:
continue
for b in b_positions:
lb = b.get("decimal")
if lb is None:
continue
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
m = _first_aspect(separation(float(la), float(lb)), allowed, checks)
if m:
out.append({"obj1": a["name"], "obj2": b["name"],
"aspect": m[0], "orb": m[1], "allowed": round(allowed, 2)})
return out
def find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS,
minor: bool = False,
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów; gdy znane prędkości, każdy aspekt ma applying (bool)
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
Zwraca listę aspektów głównych; gdy znane prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
Pary z RIGID_PAIRS (np. NN/SN) pomijane ich kąt jest definicyjny.
"""
checks = {**MAJOR, **MINOR} if minor else MAJOR
out: list[dict] = []
n = len(positions)
for i in range(n):
@@ -131,7 +83,7 @@ def find_aspects(
continue
sep = separation(float(la), float(lb))
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
for asp, angle in checks.items():
for asp, angle in MAJOR.items():
dev = abs(sep - angle)
if dev <= allowed:
applying = _is_applying(
+12 -57
View File
@@ -35,10 +35,7 @@ def _shift_pos(pdict: dict, off: float) -> None:
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
lots_method: str = "degree", zodiac: str = Z.TROPICAL,
house_systems: list[str] | None = None,
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False) -> dict:
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
from app.engine import glyphs as GL
from app.engine.aspects import find_aspects
from app.engine.houses import mean_obliquity
@@ -52,23 +49,13 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
result["positions"], orb=aspect_orb,
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
# 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.
# ε 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"
eps = mean_obliquity(Z.julian_day(moment.when_utc))
result["obliquity"] = round(eps, 6)
for pdict, obj in zip(result["positions"], positions):
dec = declination(obj.longitude, obj.latitude, eps)
@@ -105,27 +92,9 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
# cusps_detailed, nie cusps_for: Placidus i Koch nie istnieją powyżej koła
# podbiegunowego, a astrolog z Tromsø ma dostać wynik ZE ŚLADEM, czym go
# policzyliśmy. Ten ślad musi dojść aż do raportu i PDF-a.
primary = H.cusps_detailed(ramc, eps, moment.lat, system)
cusp_list = primary.cusps # tropikalne — geometria domów jest niezmiennicza
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
def _cusps_out(cl: list[float]) -> list[dict]:
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
return [
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off)),
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
for i, c in enumerate(cl)
]
result["house_system"] = primary.system # FAKTYCZNIE użyty
result["house_system_requested"] = primary.requested
# Lista ostrzeżeń dla całego horoskopu — prezentacja, raport i PDF czytają
# jedno miejsce, więc żaden z nich nie może „zapomnieć" o fallbacku.
result["house_warnings"] = [primary.notice] if primary.notice else []
result["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc, off),
"MC": _fmt("MC", mc, off),
@@ -134,30 +103,16 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
}
for a in result["angles"].values(): # glif znaku osi (LOG-22)
a["sign_glyph"] = GL.sign_glyph(a["sign"])
result["cusps"] = _cusps_out(cusp_list) # PRYMARNY system — pod kosmogram i wstecz
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off)),
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
for i, c in enumerate(cusp_list)
]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
# Wiele systemów domów NARAZ (PRE-05/LOG-05) — do porównania obok siebie.
# Osie (Asc/MC) są wspólne; różni się tylko PODZIAŁ na domy. Prymarny zostaje
# w `cusps`/`house_system` (kosmogram i wstecz), a `house_systems` niesie pełen
# zestaw; `positions[].houses[system]` mówi, w którym domu obiekt siedzi wg
# danego systemu. Dokładamy tylko gdy poproszono o więcej niż jeden.
requested = [system] + [s for s in (house_systems or []) if s in H.SYSTEMS]
ordered = list(dict.fromkeys(requested)) # prymarny pierwszy, bez duplikatów
if len(ordered) > 1:
result["house_systems"] = []
for s in ordered:
cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s)
block = {"system": s, "used_system": cs.system, "cusps": _cusps_out(cs.cusps)}
if cs.notice:
block["notice"] = cs.notice
if cs.notice not in result["house_warnings"]:
result["house_warnings"].append(cs.notice)
result["house_systems"].append(block)
for pdict, obj in zip(result["positions"], positions):
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cs.cusps)
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth
from app.engine.lots import compute_lots
+8 -518
View File
@@ -8,31 +8,13 @@ silnik dostarcza tylko RAMC i ε.
from __future__ import annotations
import math
from dataclasses import dataclass
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.
VEHLOW = "vehlow"
MORINUS = "morinus"
REGIOMONTANUS = "regiomontanus"
CAMPANUS = "campanus"
ALCABITUS = "alcabitus"
TOPOCENTRIC = "topocentric"
PLACIDUS = "placidus"
KOCH = "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)
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
def mean_obliquity(tt_jd: float) -> float:
@@ -49,27 +31,12 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float:
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
"""Ascendent — punkt ekliptyki wschodzący na horyzoncie.
KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle):
ekliptyka przecina horyzont w DWÓCH punktach wschodzącym (Asc) i zachodzącym
(Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi
wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC
zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był
subtelny planety lądowały w PRZECIWNYCH domach dla całej Skandynawii
północnej (Tromsø, Rovaniemi, Murmańsk).
Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu
(0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec
Swiss Ephemeris zero rozbieżności.
"""
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
asc = norm360(math.degrees(math.atan2(
asc = math.atan2(
math.cos(r),
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
)))
mc = compute_mc(ramc_deg, eps_deg)
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
)
return norm360(math.degrees(asc))
def _trisect(a: float, b: float) -> tuple[float, float]:
@@ -78,29 +45,10 @@ def _trisect(a: float, b: float) -> tuple[float, float]:
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE
# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek
# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu.
_SIGN_SNAP_DEG = 1e-9
def _snap_to_sign_boundary(lon: float) -> float:
"""Przyciąga długość do granicy znaku, gdy jest od niej o włos.
Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10¹¹° w Ascendencie
przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony
na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią:
nasz Asc = 359,999999999976, swissepha = 1e-10 ta sama wartość po dwóch
stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność
zmiennoprzecinkowa."""
nearest = round(lon / 30.0) * 30.0
return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon
def cusps(asc: float, mc: float, system: str) -> list[float]:
"""Zwraca 12 cusps (długości) domów 1..12."""
if system == WHOLE_SIGN:
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0
start = sign_index(asc) * 30.0
return [norm360(start + 30.0 * i) for i in range(12)]
if system == EQUAL:
return [norm360(asc + 30.0 * i) for i in range(12)]
@@ -116,472 +64,14 @@ def cusps(asc: float, mc: float, system: str) -> list[float]:
raise ValueError(f"nieznany system domów: {system}")
def polar_circle(eps_deg: float) -> float:
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
Powyżej niej stopnie ekliptyki bywają okołobiegunowe nie wschodzą ani nie
zachodzą przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
definicję."""
return 90.0 - abs(eps_deg)
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
def _cross(a, b):
return (a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0])
def _dot(a, b):
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _equatorial_to_lon(v, eps_rad: float) -> float:
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
x, y, z = v
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
return norm360(math.degrees(math.atan2(y_ecl, x)))
def _ecliptic_pole(eps_rad: float):
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
def _horizon_north(ramc_rad: float, lat_rad: float):
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
return (-math.sin(lat_rad) * math.cos(ramc_rad),
-math.sin(lat_rad) * math.sin(ramc_rad),
math.cos(lat_rad))
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
# są na wschód od MC (przesunięcie 0180°), domy 5, 6, 8, 9 — na zachód.
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
7: False, 8: False, 10: True, 11: True}
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
"""Cusp = przecięcie ekliptyki z kołem domu.
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
wybieramy ten po właściwej stronie południka.
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
z Asc i MC liczenie ich drogą było błędem, bo leżą dokładnie na granicy
wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
się chwieje i potrafi wybrać przeciwny punkt nieba."""
normal = _cross(north, q) # normalna płaszczyzny koła domu
line = _cross(normal, _ecliptic_pole(eps_rad))
lon = _equatorial_to_lon(line, eps_rad)
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC a za kołem podbiegunowym
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90|φδ|,
więc jest nad horyzontem dokładnie wtedy, gdy |φδ| < 90.
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
nie robią i tak samo zachowuje się wyrocznia."""
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
out = [0.0] * 12
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
for i, value in intermediate.items():
out[i] = value
return out
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
r = math.radians(ra_deg)
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
def _equator_point(ra_deg: float):
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
r = math.radians(ra_deg)
return (math.cos(r), math.sin(r), 0.0)
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir Campanus dzieli
właśnie je."""
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
math.cos(lat_rad) * math.sin(ramc_rad),
math.sin(lat_rad))
a = math.radians(angle_deg)
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
north = _horizon_north(rr, lr)
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
for i, e in _INTERMEDIATE.items()}
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
def _cusps_campanus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
north = _horizon_north(rr, lr)
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
for i, e in _INTERMEDIATE.items()}
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym te dwie
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
er = math.radians(eps)
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
for i in range(12)]
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
er = math.radians(eps)
a = math.radians(asc)
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
ra = [0.0] * 12
ra[9] = ramc # dom 10 = MC
ra[10] = ramc + day / 3.0 # dom 11
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
ra[0] = ra_asc # dom 1 = Asc
ra[1] = ra_asc + night / 3.0 # dom 2
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
for i in range(6): # domy 49 naprzeciw 103
ra[i + 3] = ra[(i + 9) % 12] + 180.0
return [_ra_to_ecliptic_lon(x, er) for x in ra]
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (PolichPage).
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
# PolichPage: 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 611% 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.
# 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]:
"""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"."""
# 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, (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)
out[6], out[9] = norm360(asc + 180.0), culminating
return out
# ── systemy łuku dobowego (Placidus, Koch) ───────────────────────────────
# Różnią się od wszystkich poprzednich tym, że NIE MAJĄ wzoru zamkniętego: cusp
# jest zdefiniowany warunkiem na samego siebie („punkt, który przebył 1/3 swojego
# półłuku"), więc trzeba go znaleźć iteracyjnie. Mają też jako jedyne REALNĄ
# granicę dziedziny — powyżej koła podbiegunowego stopnie ekliptyki bywają
# okołobiegunowe, nie wschodzą ani nie zachodzą, i półłuk po prostu nie istnieje.
class HouseSystemUndefined(ValueError):
"""System domów nie ma definicji dla podanych parametrów (nie: błąd liczenia).
Podnoszone zamiast zwrócenia liczby, bo cicha podmiana systemu jest gorsza
niż błąd: wykres wygląda poprawnie, a planety siedzą w innych domach, niż
astrolog zamawiał. Warstwa aplikacyjna łapie to w cusps_detailed() i robi
JAWNY fallback."""
_ITER_MAX = 100
_ITER_TOL_DEG = 1e-11
def _declination_of_ecliptic_lon(lon_deg: float, eps_rad: float) -> float:
"""Deklinacja punktu LEŻĄCEGO NA EKLIPTYCE o danej długości."""
return math.degrees(math.asin(math.sin(eps_rad) * math.sin(math.radians(lon_deg))))
def _ascensional_difference(dec_deg: float, lat_deg: float) -> float:
"""Różnica wschodnia: o ile półłuk dobowy odbiega od 90°.
sin(AD) = tan(φ)·tan(δ). Gdy |tan(φ)·tan(δ)| 1, punkt jest okołobiegunowy
(nigdy nie wschodzi albo nigdy nie zachodzi) i półłuk nie istnieje."""
v = math.tan(math.radians(lat_deg)) * math.tan(math.radians(dec_deg))
if abs(v) >= 1.0:
raise HouseSystemUndefined(
f"punkt okołobiegunowy (tan φ·tan δ = {v:.6f}): półłuk dobowy nie istnieje")
return math.degrees(math.asin(v))
# Dom → (ułamek półłuku, czy łuk NOCNY). Domy 11 i 12 dzielą łuk dzienny licząc
# od MC; domy 2 i 3 — łuk nocny, licząc WSTECZ od IC.
_PLACIDUS_STEP = {10: (1 / 3, False), 11: (2 / 3, False),
1: (2 / 3, True), 2: (1 / 3, True)}
def _placidus_cusp(ramc: float, eps: float, lat: float,
fraction: float, nocturnal: bool) -> float:
"""Punkt ekliptyki, który przebył `fraction` swojego półłuku.
Warunek jest uwikłany: półłuk zależy od deklinacji, deklinacja od długości,
a długość od położenia więc iterujemy po punkcie stałym. Zbieżność jest
szybka z dala od koła podbiegunowego i psuje się przy nim, dlatego brak
zbieżności traktujemy jako wyjście poza dziedzinę, a nie jako wynik."""
eps_rad = math.radians(eps)
# Start od podziału równomiernego — to Porphyry na równiku, czyli dokładnie
# ten przypadek, w którym Placidus się do niego sprowadza.
ra = ramc + 180.0 - 90.0 * fraction if nocturnal else ramc + 90.0 * fraction
for _ in range(_ITER_MAX):
dec = _declination_of_ecliptic_lon(_ra_to_ecliptic_lon(ra, eps_rad), eps_rad)
ad = _ascensional_difference(dec, lat)
nxt = (ramc + 180.0 - fraction * (90.0 - ad) if nocturnal
else ramc + fraction * (90.0 + ad))
if abs(nxt - ra) < _ITER_TOL_DEG:
return _ra_to_ecliptic_lon(nxt, eps_rad)
ra = nxt
raise HouseSystemUndefined(
f"brak zbieżności po {_ITER_MAX} krokach (φ={lat:.4f}, RAMC={ramc:.4f})")
def _cusps_placidus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Półłuki dobowe i nocne dzielone na trzy — każdy punkt swoim własnym łukiem."""
if abs(lat) >= polar_circle(eps):
raise HouseSystemUndefined(
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
inter = {i: _placidus_cusp(ramc, eps, lat, f, noct)
for i, (f, noct) in _PLACIDUS_STEP.items()}
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
return _with_exact_angles(inter, asc, mc)
# Koch dzieli CZAS, nie łuk na niebie. Kryterium: ile czasu minęło od wschodu
# tego stopnia zodiaku, który stoi na MC. Ten odcinek (półłuk dobowy stopnia MC)
# dzielimy na trzy i dla punktów podziału liczymy ZWYKŁY Ascendent — stąd nazwa
# „system miejsca urodzenia". Zgodne z definicją Astrodienst (astro.com/astrowiki).
#
# W przeciwieństwie do Placidusa NIE wymaga iteracji: półłuk zależy od deklinacji
# stopnia MC, którą znamy wprost. Granicę dziedziny dzieli natomiast z Placidusem —
# gdy stopień MC jest okołobiegunowy, „moment jego wschodu" nie istnieje.
_KOCH_OFFSET = {10: -2 / 3, 11: -1 / 3, 1: 1 / 3, 2: 2 / 3}
def _cusps_koch(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Ascendenty dla chwil trójdzielących drogę stopnia MC od wschodu do górowania."""
if abs(lat) >= polar_circle(eps):
raise HouseSystemUndefined(
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
dec_mc = _declination_of_ecliptic_lon(mc, math.radians(eps))
half_arc = 90.0 + _ascensional_difference(dec_mc, lat)
inter = {i: compute_asc(ramc + f * half_arc, eps, lat)
for i, f in _KOCH_OFFSET.items()}
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
return _with_exact_angles(inter, asc, mc)
def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
"""Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps.
Systemy proste (whole sign / equal / porphyry) potrzebują tylko Asc i MC,
ale systemy egzotyczne dzielą inne koła wielkie i wymagają pełnego zestawu
(RAMC, ε, φ). Ta funkcja jest wspólnym punktem wejścia dla obu rodzajów
i to porównuje z wyrocznią framework testowy (tests/oracle).
"""
asc = compute_asc(ramc, eps, lat)
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)]
if system == MORINUS:
return _cusps_morinus(ramc, eps)
if system == REGIOMONTANUS:
return _cusps_regiomontanus(ramc, eps, lat, asc, mc)
if system == CAMPANUS:
return _cusps_campanus(ramc, eps, lat, asc, mc)
if system == ALCABITUS:
return _cusps_alcabitus(ramc, eps, asc)
if system == TOPOCENTRIC:
return _cusps_topocentric(ramc, eps, lat, asc, mc)
if system == PLACIDUS:
return _cusps_placidus(ramc, eps, lat, asc, mc)
if system == KOCH:
return _cusps_koch(ramc, eps, lat, asc, mc)
raise ValueError(f"nieznany system domów: {system}")
# ── jawny fallback poza dziedziną ────────────────────────────────────────
# Placidus i Koch jako jedyne mają miejsca, w których po prostu NIE ISTNIEJĄ.
# Astrolog z Tromsø ma dostać wynik, ale musi wiedzieć, że dostał inny system —
# cicha podmiana jest gorsza niż brak wyniku, bo jest nie do wykrycia z wykresu.
FALLBACK_SYSTEM = PORPHYRY
@dataclass(frozen=True)
class CuspSet:
"""12 cuspów + uczciwa informacja, czym naprawdę zostały policzone."""
cusps: list[float]
system: str # system FAKTYCZNIE użyty
requested: str # o który poproszono
reason: str | None = None # dlaczego nie dało się użyć żądanego
@property
def is_fallback(self) -> bool:
return self.system != self.requested
@property
def notice(self) -> str | None:
"""Komunikat dla człowieka. Ma trafić na ekran, do raportu i do PDF-a."""
if not self.is_fallback:
return None
return (f"UWAGA: system domów \u201e{self.requested}\u201d nie ma definicji "
f"dla tego miejsca i czasu \u2014 {self.reason}. Domy policzono "
f"systemem \u201e{self.system}\u201d. To NIE jest ten sam podzia\u0142: "
f"pozycje planet s\u0105 poprawne, ale przypisanie ich do dom\u00f3w "
f"pochodzi z innego systemu.")
def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
"""Jak cusps_for, ale zamiast wyjątku poza dziedziną robi JAWNY fallback.
cusps_for zostaje funkcją czystą i nieustępliwą (to porównuje wyrocznia);
ustępstwo wobec rzeczywistości jest tutaj i zawsze zostawia ślad."""
try:
return CuspSet(cusps_for(ramc, eps, lat, system), system, system)
except HouseSystemUndefined as e:
return CuspSet(cusps_for(ramc, eps, lat, FALLBACK_SYSTEM),
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.
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)."""
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
lon = norm360(lon)
forward = _runs_forward(cusp_list)
for i in range(12):
start = cusp_list[i]
end = cusp_list[(i + 1) % 12]
if forward:
span, offset = (end - start) % 360.0, (lon - start) % 360.0
else:
span, offset = (start - end) % 360.0, (start - lon) % 360.0
span = (end - start) % 360.0
offset = (lon - start) % 360.0
if offset < span:
return i + 1
return 12
+4 -20
View File
@@ -117,28 +117,12 @@ 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)
return ramc, true_obliquity(t)
eps = mean_obliquity(t.tt)
return ramc, eps
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 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
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika protokołu")
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
+4 -155
View File
@@ -18,12 +18,7 @@ from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
from app.service import QueryService
# 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)
app = FastAPI(title="astrololo · warstwa logiczna")
service = QueryService()
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
@@ -48,11 +43,7 @@ class PositionsRequest(BaseModel):
lat: float = 0.0
lon: float = 0.0
objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry (PRYMARNY — pod kosmogram)
house_systems: list[str] | None = None # PRE-05: dodatkowe systemy do porównania obok
aspect_orb: float = 8.0 # PRE-06: orb aspektów (konfigurowalny)
aspect_luminary_bonus: float = 2.0 # PRE-06: dodatkowy orb dla Słońca/Księżyca
aspect_minor: bool = False # PRE-06: dołóż aspekty poboczne (30/45/150°)
house_system: str = "whole_sign" # whole_sign | equal | porphyry
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@@ -76,11 +67,7 @@ def chart_positions(req: PositionsRequest) -> dict:
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try:
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac,
house_systems=req.house_systems,
aspect_orb=req.aspect_orb,
aspect_luminary_bonus=req.aspect_luminary_bonus,
aspect_minor=req.aspect_minor)
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if req.stations:
@@ -97,136 +84,6 @@ def chart_positions(req: PositionsRequest) -> dict:
return chart
class PersonMoment(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
class SynastryRequest(BaseModel):
"""Dwie osoby (PRE-04). Aspekty liczone MIĘDZY horoskopami, nie w środku."""
person_a: PersonMoment
person_b: PersonMoment
zodiac: str = "tropical"
aspect_orb: float = 8.0
aspect_luminary_bonus: float = 2.0
aspect_minor: bool = False
@app.get("/bases")
def bases() -> dict:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
Przelot do warstwy danych same METADANE, żadnej treści baz."""
from app.clients.data_client import DataClient
try:
return DataClient().bases()
except httpx.HTTPError as e:
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
osoby A vs planeta osoby B). Bez interpretacji z bazy sama siatka aspektów."""
from app.engine import glyphs as GL
from app.engine.aspects import find_cross_aspects
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
engine = get_engine()
a = build_chart(engine, ChartMoment(when_utc=req.person_a.when_utc,
lat=req.person_a.lat, lon=req.person_a.lon), zodiac=req.zodiac)
b = build_chart(engine, ChartMoment(when_utc=req.person_b.when_utc,
lat=req.person_b.lat, lon=req.person_b.lon), zodiac=req.zodiac)
cross = find_cross_aspects(
a["positions"], b["positions"], orb=req.aspect_orb,
luminary_bonus=req.aspect_luminary_bonus, minor=req.aspect_minor)
for c in cross:
c["glyph"] = GL.aspect_glyph(c["aspect"]) # symbol aspektu (LOG-22)
return {"engine": engine.name,
"person_a": {"positions": a["positions"]},
"person_b": {"positions": b["positions"]},
"aspects": cross}
@app.post("/chart/compare")
def chart_compare(req: PositionsRequest) -> dict:
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
@@ -252,10 +109,6 @@ 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")
@@ -271,7 +124,7 @@ def chart_report(req: ReportRequest) -> dict:
chart = build_chart(engine, moment) # pozycje z domami + aspekty
try:
report = build_report(
chart["positions"], DataClient(tenant=req.tenant),
chart["positions"], DataClient(),
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
per_object_limit=req.limit, group=req.group,
)
@@ -599,10 +452,6 @@ class TimelineRequest(BaseModel):
to_date: str
techniques: list[str] | None = None # profection | solar_return | solar_arc
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
# prezentacji, bo to ona zna konta.
tenant: str = ""
@app.post("/chart/timeline")
-4
View File
@@ -8,10 +8,6 @@ from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
# prezentacji, bo to ona zna konta.
tenant: str = ""
query: str = Field(..., min_length=1, description="Szukana fraza.")
field: str = Field("name", description="Po którym polu szukać.")
-5
View File
@@ -200,11 +200,6 @@ def _chart_section(chart: dict, moment_label: str | None) -> str:
if meta:
lines.append(" · ".join(meta))
# Model interpretuje domy, więc musi wiedzieć, że podział pochodzi z innego
# systemu, niż zamówiono — inaczej napisze „Twój Placidus" o Porphyrym.
for w in chart.get("house_warnings") or []:
lines.append(f"\n{w}")
if chart.get("positions"):
lines.append("\n## Pozycje")
for p in chart["positions"]:
+2 -6
View File
@@ -14,7 +14,6 @@ from app.models import QueryRequest, QueryResponse
class QueryService:
def __init__(self, data_client: DataClient | None = None) -> None:
self._wstrzykniety = data_client is not None
self.data = data_client or DataClient()
def handle(self, req: QueryRequest) -> QueryResponse:
@@ -22,11 +21,8 @@ class QueryService:
value = req.query.strip()
key = req.field.strip().lower()
# 2) zapytanie w dół do warstwy bazodanowej — do PULI KONTA, jeśli podano.
# Klient budowany na żądanie, bo pula wynika z tego, kto pyta, a nie ze
# stanu procesu. Wstrzyknięty klient (testy) ma pierwszeństwo.
dane = self.data if (self._wstrzykniety or not req.tenant) else DataClient(tenant=req.tenant)
raw = dane.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 2) zapytanie w dół do warstwy bazodanowej
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
results = raw.get("rows", [])
+1 -58
View File
@@ -1,62 +1,5 @@
"""Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import RIGID_PAIRS, find_aspects, find_cross_aspects, separation
# ── synastria: aspekty MIĘDZY dwoma horoskopami (PRE-04) ──
def test_cross_aspects_between_two_charts():
a = [{"name": "Sun", "decimal": 10.0}, {"name": "Moon", "decimal": 100.0}]
b = [{"name": "Sun", "decimal": 12.0}, {"name": "Mars", "decimal": 70.0}]
pairs = {(c["obj1"], c["obj2"], c["aspect"]) for c in find_cross_aspects(a, b)}
assert ("Sun", "Sun", "conjunction") in pairs # A.Słońce ↔ B.Słońce (2°)
assert ("Sun", "Mars", "sextile") in pairs # A.Słońce(10) ↔ B.Mars(70) = 60°
def test_cross_aspect_obj1_is_person_a_obj2_person_b():
a = [{"name": "Venus", "decimal": 10.0}]
b = [{"name": "Mars", "decimal": 12.0}]
c = find_cross_aspects(a, b)[0]
assert c["obj1"] == "Venus" and c["obj2"] == "Mars"
def test_cross_aspects_do_not_apply_rigid_pair_filter():
"""NN osoby A vs SN osoby B to REALNY aspekt między ludźmi — nie wycinamy go
jak pary sztywnej w jednym horoskopie."""
a = [{"name": "North Node", "decimal": 10.0}]
b = [{"name": "South Node", "decimal": 12.0}]
assert find_cross_aspects(a, b) # niepuste
def test_cross_aspects_no_applying_flag():
"""Dwa natale — brak wspólnego czasu, więc bez applying/separating."""
a = [{"name": "Sun", "decimal": 10.0, "speed": 1.0}]
b = [{"name": "Moon", "decimal": 12.0, "speed": 13.0}]
assert "as" not in find_cross_aspects(a, b)[0]
# ── konfigurowalne aspekty i orby (PRE-06) ──
def test_minor_aspects_only_with_flag():
"""Domyślnie tylko główne; `minor=True` dokłada poboczne (30/45/150°)."""
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 40.0}] # 30° = półsekstyl
assert find_aspects(pos) == [] # główne: brak
assert [a["aspect"] for a in find_aspects(pos, minor=True)] == ["semisextile"]
def test_orb_is_configurable():
"""Węższy orb odsiewa aspekt, który szerszy łapie."""
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 78.0}] # 68° = sextile dev 8°
assert find_aspects(pos, orb=5.0) == []
assert [a["aspect"] for a in find_aspects(pos, orb=10.0)] == ["sextile"]
def test_luminary_bonus_widens_orb_for_lights():
"""Bonus dolicza orb tylko gdy w parze jest Słońce/Księżyc."""
lights = [{"name": "Sun", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}] # dev 9° od sextile
assert find_aspects(lights, orb=8.0, luminary_bonus=0.0) == [] # 9 > 8
assert [a["aspect"] for a in find_aspects(lights, orb=8.0, luminary_bonus=2.0)] == ["sextile"] # 9 < 10
plain = [{"name": "Venus", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}]
assert find_aspects(plain, orb=8.0, luminary_bonus=2.0) == [] # brak światła → bez bonusu
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
def test_separation_wraparound():
-67
View File
@@ -2,9 +2,6 @@
Waliduje względem astro.com dla horoskopu referencyjnego.
"""
import pytest
from app.engine import houses as H
from app.engine.chart import build_chart
@@ -28,67 +25,3 @@ def test_house_systems_available(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system
assert len(chart["cusps"]) == 12
# ── wiele systemów domów naraz (PRE-05/LOG-05) ──
def test_multiple_house_systems_side_by_side(own_engine, reference_moment):
"""Prymarny zostaje w cusps/house_system; `house_systems` niesie pełen zestaw
do porównania, z prymarnym NA POCZĄTKU i bez duplikatów."""
chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["equal", "porphyry", "whole_sign"])
assert chart["house_system"] == "whole_sign" # prymarny bez zmian
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal", "porphyry"]
for h in chart["house_systems"]:
assert len(h["cusps"]) == 12
def test_single_system_has_no_comparison_block(own_engine, reference_moment):
"""Bez dodatkowych systemów nie zaśmiecamy wyniku."""
assert "house_systems" not in build_chart(own_engine, reference_moment, "whole_sign")
def test_object_gets_a_house_in_every_system(own_engine, reference_moment):
"""Sedno porównania: każdy obiekt ma dom w KAŻDYM systemie, a prymarny jest
spójny z polem `.house`."""
chart = build_chart(own_engine, reference_moment, "whole_sign", house_systems=["porphyry"])
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
assert set(sun["houses"]) == {"whole_sign", "porphyry"}
assert sun["houses"]["whole_sign"] == sun["house"]
def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, "equal", house_systems=["porphyry"])
systems = [h["system"] for h in chart["house_systems"]]
assert systems[0] == "equal" and "porphyry" in systems
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
"""Nieznana nazwa systemu jest po prostu pomijana, nie wywala horoskopu.
(Placidus był tu kiedyś przykładem systemu nieobsługiwanego od Etapu 2
jest liczony, więc przykładem musi być nazwa, której naprawdę nie znamy.)"""
chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["nie-ma-takiego-systemu", "equal"])
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
@pytest.mark.parametrize("system", H.SYSTEMS)
def test_every_advertised_system_builds_a_chart(own_engine, reference_moment, system):
"""Każdy system z houses.SYSTEMS musi przejść przez build_chart. Pilnuje,
żeby rozszerzenie SYSTEMS bez przepięcia chart.py nie dało 500 przy wyborze
systemu, który jest na liście, ale nie w użytej funkcji."""
chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system
assert len(chart["cusps"]) == 12
assert all(1 <= p["house"] <= 12 for p in chart["positions"])
def test_multiple_systems_side_by_side_cover_all(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, H.WHOLE_SIGN,
house_systems=list(H.SYSTEMS))
blocks = chart["house_systems"]
assert blocks[0]["system"] == H.WHOLE_SIGN # prymarny zawsze pierwszy
assert {b["system"] for b in blocks} == set(H.SYSTEMS)
for p in chart["positions"]:
assert set(p["houses"]) == set(H.SYSTEMS)
-307
View File
@@ -1,8 +1,4 @@
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
import math
import pytest
from app.engine import houses as H
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
@@ -43,306 +39,3 @@ def test_assign_house_whole_sign():
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90120°)
assert H.assign_house(100.0, cusps) == 1 # w Raku
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
# ── regresje wykryte przez porównanie z wyrocznią (tests/oracle) ──────────
def test_ascendant_branch_beyond_polar_circle():
"""REGRESJA: powyżej koła podbiegunowego `atan2` wybierał NIEWŁAŚCIWY punkt
przecięcia ekliptyki z horyzontem zwracaliśmy Descendent zamiast Ascendenta,
czyli błąd o 180°. Planety lądowały w PRZECIWNYCH domach dla całej północnej
Skandynawii. Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze
w półkolu (0°,180°) na wschód od MC."""
eps = 23.4393
for lat in (67.0, 70.0, 85.0, -70.0):
for ramc in (60.0, 100.0, 200.0, 270.0, 300.0):
asc = H.compute_asc(ramc, eps, lat)
mc = H.compute_mc(ramc, eps)
offset = (asc - mc) % 360.0
assert 0.0 <= offset <= 180.0, (
f"Asc poza półkolem wschodzącym: lat={lat} ramc={ramc} offset={offset:.4f}")
def test_whole_sign_is_deterministic_at_sign_boundary():
"""REGRESJA: Ascendent o włos od granicy znaku (359,999999999976 vs 1e-10 —
ta sama wartość, różne strony) przerzucał cały dom I o 30°. Ten sam horoskop
na innej maszynie dawał inny wynik."""
below = H.cusps(359.999999999976, 270.0, H.WHOLE_SIGN)
above = H.cusps(1e-10, 270.0, H.WHOLE_SIGN)
assert below[0] == above[0] == 0.0, (below[0], above[0])
def test_polar_circle_moves_with_obliquity():
"""Granica dziedziny Placidusa/Kocha NIE jest stałą 66,56° — zależy od ε,
a ε zmienia się z datą. Testy brzegowe muszą liczyć per data."""
assert H.polar_circle(23.4393) == pytest.approx(66.5607, abs=1e-4) # dziś
assert H.polar_circle(23.747) == pytest.approx(66.253, abs=1e-4) # 370 p.n.e.
# ── systemy egzotyczne o zamkniętym wzorze (Etap 1) ──────────────────────
# 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",
"topocentric")
@pytest.mark.parametrize("system", EXOTIC)
def test_exotic_returns_twelve_cusps_in_range(system):
out = H.cusps_for(100.0, 23.4393, 50.0, system)
assert len(out) == 12
assert all(0.0 <= c < 360.0 for c in out)
@pytest.mark.parametrize("system", EXOTIC)
def test_opposite_houses_are_antipodal(system):
"""Domy przeciwległe leżą na tym samym kole wielkim, więc ich cuspy są
dokładnie antypodyczne. Naruszenie tego oznacza błąd w wyborze gałęzi."""
out = H.cusps_for(137.0, 23.4393, 42.0, system)
for i in range(6):
assert abs(((out[i + 6] - out[i]) % 360.0) - 180.0) < 1e-9, f"domy {i+1}/{i+7}"
@pytest.mark.parametrize("system", ("regiomontanus", "campanus", "alcabitus"))
def test_quadrant_systems_anchor_on_ascendant(system):
"""Systemy kwadrantowe zaczynają dom I na Ascendencie."""
ramc, eps, lat = 100.0, 23.4393, 50.0
assert H.cusps_for(ramc, eps, lat, system)[0] == pytest.approx(
H.compute_asc(ramc, eps, lat), abs=1e-9)
def test_vehlow_puts_ascendant_in_the_middle_of_house_one():
ramc, eps, lat = 100.0, 23.4393, 50.0
asc = H.compute_asc(ramc, eps, lat)
assert H.cusps_for(ramc, eps, lat, "vehlow")[0] == pytest.approx(
H.norm360(asc - 15.0), abs=1e-9)
def test_morinus_ignores_latitude():
"""Morinus rzutuje równik wprost na ekliptykę, bez horyzontu — jako jedyny
nie zależy od szerokości geograficznej."""
a = H.cusps_for(100.0, 23.4393, 20.0, "morinus")
b = H.cusps_for(100.0, 23.4393, 65.0, "morinus")
assert a == pytest.approx(b, abs=1e-12)
def test_unknown_system_is_rejected():
with pytest.raises(ValueError):
H.cusps_for(100.0, 23.4393, 50.0, "nie-ma-takiego")
# ── systemy łuku dobowego: Placidus i Koch (Etap 2) ──────────────────────
DIURNAL = ("placidus", "koch")
TROMSO_LAT = 69.65 # za kołem podbiegunowym — tam te systemy nie istnieją
KRAKOW_LAT = 50.06
@pytest.mark.parametrize("system", DIURNAL)
def test_diurnal_systems_refuse_beyond_the_polar_circle(system):
"""Odmowa, nie liczba. Cicha podmiana systemu jest niewykrywalna z wykresu."""
with pytest.raises(H.HouseSystemUndefined):
H.cusps_for(100.0, 23.4393, TROMSO_LAT, system)
@pytest.mark.parametrize("system", DIURNAL)
def test_fallback_says_what_it_did(system):
cs = H.cusps_detailed(100.0, 23.4393, TROMSO_LAT, system)
assert cs.is_fallback and cs.system == H.FALLBACK_SYSTEM and cs.requested == system
assert len(cs.cusps) == 12
assert cs.notice and system in cs.notice and H.FALLBACK_SYSTEM in cs.notice
@pytest.mark.parametrize("system", DIURNAL)
def test_no_fallback_and_no_notice_where_the_system_exists(system):
cs = H.cusps_detailed(100.0, 23.4393, KRAKOW_LAT, system)
assert not cs.is_fallback and cs.notice is None
assert cs.cusps == H.cusps_for(100.0, 23.4393, KRAKOW_LAT, system)
def test_placidus_cusps_satisfy_their_own_definition():
"""Sprawdzian NIEZALEŻNY od wyroczni: czy znaleziony punkt naprawdę przebył
zadany ułamek swojego półłuku. Wyrocznia mówi zgadza się z swissephem",
to mówi zgadza się z definicją Placidusa" — i działa bez swissepha."""
ramc, eps, lat = 212.9986, 23.4393, 66.3144 # tuż pod kołem, najtrudniej
eps_rad = math.radians(eps)
c = H.cusps_for(ramc, eps, lat, "placidus")
for house, (frac, nocturnal) in ((11, (1 / 3, False)), (12, (2 / 3, False)),
(2, (2 / 3, True)), (3, (1 / 3, True))):
lon = c[house - 1]
dec = H._declination_of_ecliptic_lon(lon, eps_rad)
ad = H._ascensional_difference(dec, lat)
ra = math.degrees(math.atan2(math.sin(math.radians(lon)) * math.cos(eps_rad),
math.cos(math.radians(lon))))
want = (ramc + 180.0 - frac * (90.0 - ad) if nocturnal
else ramc + frac * (90.0 + ad))
assert abs((ra - want + 180.0) % 360.0 - 180.0) < 1e-9, f"dom {house}"
def test_koch_reduces_to_equal_spacing_on_the_equator():
"""Na równiku półłuk każdego stopnia to dokładnie 90°, więc trójdzielenie
czasu daje Ascendenty co 30° czasu gwiazdowego dobry test zdrowia wzoru."""
ramc, eps = 100.0, 23.4393
c = H.cusps_for(ramc, eps, 0.0, "koch")
for house, offset in ((11, -60.0), (12, -30.0), (2, 30.0), (3, 60.0)):
assert c[house - 1] == pytest.approx(
H.compute_asc(ramc + offset, eps, 0.0), abs=1e-9), f"dom {house}"
def test_polar_circle_boundary_moves_with_the_date():
"""Granica dziedziny NIE jest stałą 66,56° — zależy od ε, czyli od daty."""
lat = 66.60
assert lat > H.polar_circle(23.4393) # dziś: poza dziedziną
assert lat < H.polar_circle(23.3528) # rok 2270: już w dziedzinie
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 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,223,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
+1 -5
View File
@@ -4,11 +4,7 @@ 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ż testy,
# README i skrypty pomocnicze — a plik testowy nazywa funkcje wprost, więc
# w obrazie zbudowanym bez danego modułu byłby dokładnie tym śladem, którego
# wydzielenie ma się pozbyć. Przy okazji obraz jest mniejszy.
COPY app ./app
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
-213
View File
@@ -1,213 +0,0 @@
"""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)
+27 -43
View File
@@ -114,15 +114,8 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
# 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))
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
phi = math.radians(180.0 - (lon - asc))
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
@@ -213,19 +206,12 @@ def _delta(a: float, b: float) -> float:
return abs(((a - b + 180.0) % 360.0) - 180.0)
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
def render(chart: dict, theme: str = "screen") -> 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)
@@ -237,9 +223,6 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
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
@@ -261,8 +244,8 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
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, ref, R_HUB)
bx1, by1 = _pt(lb, ref, R_HUB)
ax1, ay1 = _pt(la, asc, R_HUB)
bx1, by1 = _pt(lb, asc, 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))
@@ -270,17 +253,17 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
# 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, ref, R_ZOD)
x2, y2 = _pt(deg, ref, r_in)
x1, y1 = _pt(deg, asc, R_ZOD)
x2, y2 = _pt(deg, asc, 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, ref, R_ZOD)
x2, y2 = _pt(b, ref, R_OUT)
x1, y1 = _pt(b, asc, R_ZOD)
x2, y2 = _pt(b, asc, R_OUT)
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
gx, gy = _pt(b + 15.0, ref, R_SIGN)
gx, gy = _pt(b + 15.0, asc, R_SIGN)
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
@@ -288,31 +271,31 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
n = len(cusps)
for i, c in enumerate(cusps):
lon = float(c["decimal"])
x1, y1 = _pt(lon, ref, R_ZOD)
x2, y2 = _pt(lon, ref, R_HUB)
x1, y1 = _pt(lon, asc, R_ZOD)
x2, y2 = _pt(lon, asc, 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, ref, R_HNUM)
hx, hy = _pt(mid, asc, 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, ref, R_ZOD - 9)
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
# osie: AscDsc i MCIC 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, ref, R_ZOD)
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
ax, ay = _pt(lon_a, asc, R_ZOD)
bx, by = _pt(lon_a + 180.0, asc, 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, ref, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, asc, 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"]))
@@ -327,15 +310,15 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
retro = obj.get("direction") == "Rx"
mx1, my1 = _pt(true_lon, ref, R_ZOD)
mx2, my2 = _pt(true_lon, ref, R_MARK)
mx1, my1 = _pt(true_lon, asc, R_ZOD)
mx2, my2 = _pt(true_lon, asc, 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, ref, R_PLANET + 12)
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
gx, gy = _pt(draw_lon, ref, R_PLANET)
gx, gy = _pt(draw_lon, asc, R_PLANET)
tip = obj.get("name") or ""
if obj.get("in_sign"):
tip += f" · {obj['in_sign']}"
@@ -349,7 +332,7 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
# 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, ref, R_DEG)
dx, dy = _pt(draw_lon, asc, 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"]))
@@ -363,10 +346,10 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
if not glyph or lot.get("longitude") is None:
continue
lon = float(lot["longitude"])
mx1, my1 = _pt(lon, ref, R_ZOD)
mx2, my2 = _pt(lon, ref, R_MARK)
mx1, my1 = _pt(lon, asc, R_ZOD)
mx2, my2 = _pt(lon, asc, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
lx, ly = _pt(lon, ref, R_LOT)
lx, ly = _pt(lon, asc, R_LOT)
tip = lot.get("name") or ""
if lot.get("in_sign"):
tip += f" · {lot['in_sign']}"
@@ -384,6 +367,7 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
# ── 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.
@@ -40,29 +40,6 @@ class LogicClient:
payload=payload, headers=_auth_headers(),
link=_link())
# ── transport dla modułów opcjonalnych ─────────────────────────────────
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
return self._post(sciezka, payload, timeout)
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
headers=_auth_headers(), link=_link())
def strumien(self, sciezka: str, payload: dict[str, Any]):
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}{sciezka}",
payload=payload, headers=_auth_headers(), link=_link())
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
return self._post("/api/query", payload, settings.http_timeout)
@@ -77,10 +54,6 @@ class LogicClient:
stations: bool = False,
zodiac: str = "tropical",
tables: bool = False,
house_systems: list[str] | None = None,
aspect_orb: float = 8.0,
aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
@@ -89,10 +62,6 @@ class LogicClient:
"lon": lon,
"objects": objects,
"house_system": house_system,
"house_systems": house_systems,
"aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
"stations": stations,
"zodiac": zodiac,
"tables": tables,
@@ -101,18 +70,6 @@ class LogicClient:
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
return self._post("/chart/positions", payload, timeout)
def synastry(
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
) -> dict[str, Any]:
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
payload = {
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
}
return self._post("/chart/synastry", payload, settings.http_timeout)
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
@@ -120,39 +77,67 @@ class LogicClient:
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
def bases(self) -> dict[str, Any]:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
def prompt(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
provider: str | None = None, model: str | None = None,
) -> dict[str, Any]:
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
`provider` jest potrzebny dla budżetu maksymalny kontekst modelu": limit
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
"model": model,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
def horoscope(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", provider: str | None = None, model: str | None = None,
from_date: str | None = None, to_date: str | None = None,
) -> dict[str, Any]:
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
"""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget,
}
if provider:
payload["provider"] = provider
if model:
payload["model"] = model
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
def horoscope_stream(self, payload: dict[str, Any]):
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
więc cisza na łączu nie zostanie wzięta za zerwanie.
Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
"""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}/chart/horoscope/stream",
payload=payload, headers=_auth_headers(), link=_link())
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
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 timeline(
self, when_utc_iso: str, lat: float, lon: float,
from_date: str, to_date: str, interpret: bool = True,
@@ -1,10 +0,0 @@
"""Ekrany aplikacji — jeden moduł na ekran.
Ekran to trasy plus szablon. Import modułu rejestruje jego trasy (dekoratory
`@app.get`/`@app.post` wykonują się przy imporcie), więc o tym, CO aplikacja
umie, decyduje lista importów w `main.py` a nie warunki rozsiane po kodzie.
Dzięki temu produkt buduje się przez ZŁOŻENIE: inny zestaw importów i inny
zestaw plików w obrazie to inny program, a nie ten sam program z wyłączonymi
kawałkami. Ekranu, którego moduł nie wszedł do obrazu, nie ma jak włączyć.
"""
@@ -1,118 +0,0 @@
"""Horoskop: pozycje, osie, domy, aspekty i kosmogram (strona główna)."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
chartwheel_mod,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_perms,
_limit_options,
_landing,
_build_utc,
_logic_error,
)
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
if "chart" not in _perms(request):
target = _landing(request)
if target:
return RedirectResponse(target, status_code=303)
# Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
# mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
# czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
# a twoje konto zostało okrojone.
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/", response_class=HTMLResponse)
def chart_compute(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables,
"wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=form["aspect_minor"],
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
)
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
orientation=form["wheel_orientation"])
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
if "extra_charts" in _perms(request):
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "chart.html", ctx)
# ── zgłoszenie do katalogu ─────────────────────────────────────────────────
# Ekran zgłasza siebie, swoje trasy i swoje zasoby. Wszystko o tym ekranie jest
# w tym pliku — więc obraz bez tego pliku nie wie o nim NIC.
features.zarejestruj(
ekran=Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.",
"Ekrany", "/", kolejnosc=10),
trasy={("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart"},
zasoby={"s-chart.css": "chart"},
)
features.zarejestruj(rozszerzenie=Feature(
"houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia", kolejnosc=10))
features.zarejestruj(rozszerzenie=Feature(
"extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia", kolejnosc=20))
features.zarejestruj(rozszerzenie=Feature(
"advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia", kolejnosc=30))
@@ -1,81 +0,0 @@
"""Interpretacje: wynik obliczeń szukany w bazach."""
from __future__ import annotations
from fastapi import Depends, Form, Request
from fastapi.responses import HTMLResponse, Response
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
security,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_build_utc,
_dozwolona_akcja,
_report_records,
_logic_error,
)
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request):
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/interpret", response_class=HTMLResponse)
def interpret_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
group: bool = Form(False),
action: str = Form("report"),
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, **dodatki}
action = _dozwolona_akcja(request, action, "report")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
**rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
# Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
# więc ekran robi to, co robi normalnie. Moduł może oddać słownik do
# kontekstu albo gotową odpowiedź — na przykład plik do pobrania.
wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
lat=lat, lon=lon, dodatki=dodatki,
request=request, group=group)
if isinstance(wynik, Response):
return wynik
if wynik is not None:
ctx["wynik_dodatku"] = wynik
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
security.audit_records(request, _report_records(ctx["result"]))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "interpret.html", ctx)
features.zarejestruj(
ekran=Feature("interpret", "Interpretacje", "Interpretacja natalna z baz.",
"Ekrany", "/interpret", kolejnosc=20),
trasy={("GET", "/interpret"): "interpret", ("POST", "/interpret"): "interpret"},
zasoby={"s-interpret.css": "interpret"},
)
@@ -1,78 +0,0 @@
"""Kalendarz: oś czasu z technik wraz z interpretacjami."""
from __future__ import annotations
from fastapi import Depends, Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_build_utc,
_dozwolona_akcja,
_logic_error,
)
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
@app.get("/timeline", response_class=HTMLResponse)
def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/timeline", response_class=HTMLResponse)
def timeline_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
from_date: str = Form(...),
to_date: str = Form(...),
action: str = Form("timeline"),
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date,
**dodatki}
action = _dozwolona_akcja(request, action, "timeline")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
**rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
wynik = rozszerzenia.obsluz(action, profil="period", iso_utc=iso_utc,
lat=lat, lon=lon, dodatki=dodatki,
od=from_date, do=to_date)
if wynik is not None:
ctx["wynik_dodatku"] = wynik
else:
ctx["result"] = logic.timeline(
when_utc_iso=iso_utc, lat=lat, lon=lon,
from_date=from_date, to_date=to_date, interpret=True,
)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "timeline.html", ctx)
features.zarejestruj(
ekran=Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.",
"Ekrany", "/timeline", kolejnosc=30),
trasy={("GET", "/timeline"): "timeline", ("POST", "/timeline"): "timeline"},
zasoby={"s-timeline.css": "timeline"},
)
-105
View File
@@ -1,105 +0,0 @@
"""Konta i uprawnienia (PRE-27) — ekran wyłącznie administracyjny."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app import accounts as accounts_store
from app.features import Feature
from app.podstawa import (app, templates, security, features)
# ---------------- Konta i uprawnienia (PRE-27) ----------------
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
# jest nie tak.
try:
users = accounts_store.all_users()
except accounts_store.AccountsUnavailable as e:
users, error = {}, str(e)
return {
"users": users,
"catalog": features.ALL,
"screens": features.SCREENS,
# Rejestr zawiera już wszystko, co ten obraz umie — łącznie z tym, co
# wniosły moduły opcjonalne. Administrator ma nadawać uprawnienia do
# funkcji, które REALNIE są, a nie do listy wpisanej kiedyś na sztywno.
"extras": features.EXTRAS,
"admin_login": security.app_user(),
"store_path": accounts_store.store_path(),
"error": error, "done": done,
}
@app.get("/accounts", response_class=HTMLResponse)
def accounts_view(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "accounts.html",
_accounts_context(request, error, done))
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
powtórzyć zakładania ani kasowania konta."""
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
@app.post("/accounts/create")
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.create(login, password, granted, note)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
@app.post("/accounts/update")
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.update(login, granted=granted, password=password, note=note)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
@app.post("/accounts/sesje")
def accounts_sesje(request: Request, login: str = Form(...)):
"""Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu.
Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek
wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany
komputer i nie chce wpisywać nowego hasła wszędzie."""
try:
accounts_store.bump_session(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.")
@app.post("/accounts/delete")
def accounts_delete(request: Request, login: str = Form(...)):
try:
accounts_store.delete(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
# Ekran wyłącznie administracyjny — nie ma pozycji w katalogu funkcji, bo ADMIN
# nie jest uprawnieniem do nadania (patrz features.GRANTABLE).
features.zarejestruj(
nawigacja=("accounts", "/accounts", "Konta", features.ADMIN),
trasy={("GET", "/accounts"): features.ADMIN,
("POST", "/accounts/create"): features.ADMIN,
("POST", "/accounts/update"): features.ADMIN,
("POST", "/accounts/delete"): features.ADMIN,
("POST", "/accounts/sesje"): features.ADMIN},
zasoby={"s-accounts.css": features.ADMIN},
)
@@ -1,79 +0,0 @@
"""Logowanie i wylogowanie (LOG-34)."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import (features, app, templates, security, session)
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
def _bezpieczne_dokad(dokad: str) -> str:
"""Adres powrotu po zalogowaniu, przepuszczony przez sito.
Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
do przekierowywania ludzi gdzie indziej klasyczna droga do wyłudzenia hasła,
bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
zacząć adres obcego serwera."""
dokad = (dokad or "").strip()
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
return "/"
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
@app.get("/logowanie", response_class=HTMLResponse)
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
# Zalogowanego nie ma po co pytać o hasło jeszcze raz.
if security.auth_enabled() and security.principal(request) is not None:
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
return templates.TemplateResponse(request, "logowanie.html",
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
@app.post("/logowanie")
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
dokad: str = Form("/")):
cel = _bezpieczne_dokad(dokad)
who = security.verify(login.strip(), haslo)
if who is None:
# JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby,
# które konta istnieją — a to informacja warta czegoś dla zgadującego.
security.audit_login(request, login.strip(), udane=False)
return templates.TemplateResponse(
request, "logowanie.html",
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
status_code=401)
security.audit_login(request, who.login, udane=True)
odpowiedz = RedirectResponse(cel, status_code=303)
odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login),
max_age=session.max_age(),
**session.cookie_params(secure=security.cookies_secure()))
return odpowiedz
@app.post("/wyloguj")
def wyloguj(request: Request):
"""Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe
i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać.
POST, nie GET: pod adresem GET wystarczyłby obrazek na obcej stronie, żeby
wylogować kogoś bez jego wiedzy. Uciążliwość, nie włamanie ale darmowa
do uniknięcia."""
security.audit_logout(request)
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
odpowiedz.delete_cookie(session.COOKIE, **session.cookie_params(
secure=security.cookies_secure()))
return odpowiedz
# Wyjście musi być dostępne dla każdego zalogowanego, niezależnie od tego, co mu
# przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
features.zarejestruj(
trasy={("GET", "/logowanie"): None, ("POST", "/logowanie"): None,
("POST", "/wyloguj"): None},
)
@@ -1,58 +0,0 @@
"""Wyszukiwarka miejsca i strefa czasowa (proxy OSM/Nominatim)."""
from __future__ import annotations
from datetime import timezone
from fastapi import HTTPException, Query
import httpx
from app.features import Feature
from app.podstawa import (features, app, geocode)
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
try:
return {"results": geocode.search(q)}
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/reverse")
def geocode_reverse(lat: float, lon: float):
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
try:
return geocode.reverse(lat, lon)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/timezone")
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
gdy strefy nie da się ustalić front zostawia wtedy ręczny offset."""
from app import timezone as tzmod
res = tzmod.resolve(lat, lon, date, time)
if not res:
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
return res
# Wyszukiwarka miejsca siedzi na kilku ekranach naraz, więc jej zasoby otwiera
# KTÓREKOLWIEK z nich — wymaganie wszystkich byłoby bez sensu.
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_KOLO = frozenset({"chart", "compile"})
features.zarejestruj(
trasy={("GET", "/geocode"): None, ("GET", "/reverse"): None,
("GET", "/timezone"): None},
zasoby={"geo.js": _LOKALIZACJA, "now.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
"formsync.js": _FORMULARZE,
"wheelzoom.js": _KOLO, "wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"})},
)
-170
View File
@@ -1,170 +0,0 @@
"""Pliki baz: wybór, wgrywanie, archiwizacja i reguły przyjmowania (DAN-27)."""
from __future__ import annotations
from fastapi import File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
import base64
import httpx
from app import files_state
from app.features import Feature
from app.podstawa import (app, templates, logic, features, _perms, _logic_error)
# Etykiety stanów pliku dla szablonu. Rejestrowane TUTAJ, a nie w podstawie:
# należą do tego ekranu, a produkt, który go nie ma, nie ma po co ich znać.
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
# ---------------- Pliki baz (DAN-27) ----------------
# Trzy poziomy dostępu, opisane w features.ROUTES:
# „files" — widzi listę i decyduje, z czego program korzysta,
# „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony
# ze znacznikiem czasu, znika tylko z użytku),
# administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
#
# Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE
# DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby
# takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać
# reguły — a te ma znać wyłącznie administrator.
def _files_context(request: Request, error: str = "", done: str = "") -> dict:
is_admin = features.ADMIN in _perms(request)
try:
data = logic.files_list(for_admin=is_admin)
except httpx.HTTPError as e:
return {"files": [], "rules": {}, "is_admin": is_admin,
"error": _logic_error(e), "done": ""}
return {"files": data.get("files") or [], "rules": data.get("rules") or {},
"is_admin": is_admin, "error": error, "done": done}
@app.get("/files", response_class=HTMLResponse)
def files_view(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "files.html",
_files_context(request, error, done))
def _files_redirect(error: str = "", done: str = "") -> RedirectResponse:
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303)
def _who(request: Request) -> str:
return getattr(request.state, "user", "") or "-"
@app.post("/files/use")
def files_use(request: Request, path: str = Form(...), use: str = Form("")):
"""Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji
w warstwie danych odmowa wraca BEZ POWODU, bo powód zdradzałby reguły."""
want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \
else files_state.READY
try:
logic.files_status(path, want, by=_who(request))
except httpx.HTTPStatusError as e:
detail = _http_detail(e)
if features.ADMIN not in _perms(request):
detail = "Tego pliku nie da się teraz włączyć do użytku."
return _files_redirect(error=detail)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano." if want == files_state.READY
else "Plik włączony do użytku.")
@app.post("/files/upload")
async def files_upload(request: Request, upload: UploadFile = File(...)):
"""Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji —
nie tracimy niczego, co ktoś wgrał."""
raw = await upload.read()
if not raw:
return _files_redirect(error="Pusty plik.")
try:
out = logic.files_upload(upload.filename or "plik.xlsx",
base64.b64encode(raw).decode("ascii"), by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
# DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
# komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
# odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
# Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
# musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
# i że plik przechodzi przez jakąś bramkę.
return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
@app.post("/files/archive")
def files_archive(request: Request, path: str = Form(...)):
"""Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko
z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane."""
try:
logic.files_status(path, files_state.ARCHIVED, by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.")
@app.post("/files/restore")
def files_restore(request: Request, path: str = Form(...)):
try:
logic.files_status(path, files_state.READY, by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Przywrócono „{path}” z archiwum.")
@app.post("/files/delete")
def files_delete(request: Request, path: str = Form(...)):
try:
logic.files_delete(path)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.")
@app.post("/files/rules")
def files_rules(request: Request, extensions: str = Form(".xlsx"),
max_size_mb: float = Form(50), min_rows: int = Form(1),
required_columns: str = Form(""),
reject_duplicate_content: str = Form("")):
rules = {
"extensions": [e.strip() for e in extensions.split(",") if e.strip()],
"max_size_mb": max_size_mb,
"min_rows": min_rows,
"required_columns": [c.strip() for c in required_columns.split(",") if c.strip()],
"reject_duplicate_content": reject_duplicate_content.strip().lower()
in {"1", "true", "on", "tak"},
}
try:
logic.files_rules(rules)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano reguły walidacji.")
def _http_detail(e: httpx.HTTPStatusError) -> str:
try:
return str(e.response.json().get("detail") or e)
except Exception: # noqa: BLE001
return str(e)
features.zarejestruj(
ekran=Feature("files", "Pliki", "Wybór plików, z których korzysta program.",
"Ekrany", "/files", kolejnosc=70),
# Kasowanie, przywracanie i REGUŁY PRZYJMOWANIA to wyłącznie administrator —
# o istnieniu reguł nikt poza nim nie ma skąd wiedzieć (DAN-27).
trasy={("GET", "/files"): "files", ("POST", "/files/use"): "files",
("POST", "/files/upload"): "files_input",
("POST", "/files/archive"): "files_input",
("POST", "/files/restore"): features.ADMIN,
("POST", "/files/delete"): features.ADMIN,
("POST", "/files/rules"): features.ADMIN},
zasoby={"s-files.css": "files"},
)
features.zarejestruj(rozszerzenie=Feature(
"files_input", "Wgrywanie i archiwizacja plików",
"Dodawanie nowych plików i wycofywanie ich z użytku (plik zostaje, zamrożony, "
"ze znacznikiem czasu). Kasować może wyłącznie administrator.",
"Rozszerzenia", kolejnosc=40))
@@ -1,96 +0,0 @@
"""Skompiluj: zbiorczy raport z policzonych części (PRE-23), wraz z PDF-em."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
chartwheel_mod,
DEFAULT_LOCATION_LABEL,
default_form,
_perms,
_limit_options,
_build_utc,
_logic_error,
)
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
@app.get("/compile", response_class=HTMLResponse)
def compile_form(request: Request):
return templates.TemplateResponse(
request, "compile.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/compile", response_class=HTMLResponse)
def compile_build(
request: Request,
person: str = Form(""),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
zodiac: str = Form("tropical"),
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
):
"""Składa raport: horoskop liczymy TU NA NOWO, a części z przeglądarki (interpretacja
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
Horoskop liczymy ponownie (czysta funkcja wejścia tanio powtórzyć, bez
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
wybrano przy horoskopie (stacje, tabele, porównanie domów) inaczej
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
zakładkami przez formsync.
"""
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables,
"wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=form["aspect_minor"],
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
)
from app import chartwheel
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
orientation=form["wheel_orientation"])
if "extra_charts" in _perms(request):
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "compile.html", ctx)
features.zarejestruj(
ekran=Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.",
"Ekrany", "/compile", kolejnosc=60),
trasy={("GET", "/compile"): "compile", ("POST", "/compile"): "compile"},
zasoby={"s-compile.css": "compile", "compile.js": "compile"},
)
@@ -1,41 +0,0 @@
"""Sygnifikatory: wyszukiwarka po bazach."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, security, _logic_error)
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
@app.get("/significators", response_class=HTMLResponse)
def significators_form(request: Request):
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
@app.post("/significators", response_class=HTMLResponse)
def significators_search(
request: Request,
query: str = Form(...),
field: str = Form("name"),
exact: bool = Form(False),
limit: int = Form(25),
):
form = {"query": query, "field": field, "exact": exact, "limit": limit}
ctx: dict = {"form": form, "result": None, "error": None}
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
features.zarejestruj(
ekran=Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach.",
"Ekrany", "/significators", kolejnosc=50),
trasy={("GET", "/significators"): "significators",
("POST", "/significators"): "significators"},
zasoby={"s-significators.css": "significators"},
)
@@ -1,58 +0,0 @@
"""Synastria: porównanie dwóch horoskopów."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, _build_utc, _logic_error)
# ---------------- Synastria (technika relacyjna) ----------------
@app.get("/synastry", response_class=HTMLResponse)
def synastry_form(request: Request):
# domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce)
form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0,
"aspect_orb": 8, "aspect_luminary_bonus": 2}
return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form})
@app.post("/synastry", response_class=HTMLResponse)
def synastry_run(
request: Request,
a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...),
a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0),
b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...),
b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0),
zodiac: str = Form("tropical"),
aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
):
"""Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami."""
form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset,
"a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date,
"b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon,
"zodiac": zodiac, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor}
ctx: dict = {"form": form, "result": None, "error": None}
try:
iso_a, _ = _build_utc(a_date, a_time, a_tz_offset)
iso_b, _ = _build_utc(b_date, b_time, b_tz_offset)
ctx["result"] = logic.synastry(
{"when_utc": iso_a, "lat": a_lat, "lon": a_lon},
{"when_utc": iso_b, "lat": b_lat, "lon": b_lon},
zodiac=zodiac, aspect_orb=aspect_orb,
aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor,
)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "synastry.html", ctx)
features.zarejestruj(
ekran=Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.",
"Ekrany", "/synastry", kolejnosc=40),
trasy={("GET", "/synastry"): "synastry", ("POST", "/synastry"): "synastry"},
zasoby={"s-synastry.css": "synastry"},
)
@@ -1,32 +0,0 @@
"""Ustawienia: podgląd baz widocznych na udziale (DAN-15/PRE-09)."""
from __future__ import annotations
from fastapi import Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, _logic_error)
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
@app.get("/settings", response_class=HTMLResponse)
def settings_view(request: Request):
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
Same metadane (nazwa, rozmiar, data) bez treści baz. Przełączniki
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
ctx: dict = {"result": None, "error": None}
try:
ctx["result"] = logic.bases()
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "settings.html", ctx)
features.zarejestruj(
ekran=Feature("settings", "Ustawienia", "Podgląd plików widocznych na udziale.",
"Ekrany", "/settings", kolejnosc=80),
trasy={("GET", "/settings"): "settings"},
zasoby={"s-settings.css": "settings"},
)
-157
View File
@@ -1,157 +0,0 @@
"""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
from app import rozszerzenia
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
kolejnosc: int = 0 # pozycja w nawigacji; nie zależy od kolejności importów
# ── rejestr: katalog składa się z tego, co zgłoszą ekrany ────────────────
# Dawniej wszystkie funkcje były wypisane TUTAJ. To znaczyło, że obraz produktu,
# który części z nich nie ma, i tak niósł ich nazwy — czyli spis funkcji, których
# nie ma jak włączyć. Teraz ekran zgłasza siebie sam, przy imporcie swojego
# modułu, więc katalog opisuje dokładnie ten produkt, który się zbudowało.
#
# `kolejnosc` jest jawna, bo o zawartości decyduje lista importów w `main.py`,
# a kolejność importów nie może rządzić kolejnością zakładek w nawigacji.
SCREENS: list[Feature] = []
# Pozycje nawigacji dla uprawnień NIENADAWALNYCH — tych, których nie ma w
# GRANTABLE, więc nie da się ich przyznać z żadnego ekranu. Osobno od SCREENS
# i wraz z wymaganym uprawnieniem, żeby szablon nie musiał go wypowiadać: wpisane
# w szablonie na sztywno zostawiało nazwę ekranu i martwy odsyłacz w produkcie,
# który tego ekranu nie ma.
NAWIGACJA_SPECJALNA: list[tuple[str, str, str, str]] = [] # (klucz, adres, etykieta, uprawnienie)
EXTRAS: list[Feature] = []
ALL: tuple[Feature, ...] = ()
BY_KEY: dict[str, Feature] = {}
GRANTABLE: frozenset[str] = frozenset()
ROUTES: dict[tuple[str, str], str | None] = {}
STATIC: dict[str, frozenset[str] | str | None] = {}
def _przelicz() -> None:
global ALL, BY_KEY, GRANTABLE
SCREENS.sort(key=lambda f: f.kolejnosc)
EXTRAS.sort(key=lambda f: f.kolejnosc)
ALL = tuple(SCREENS) + tuple(EXTRAS)
BY_KEY = {f.key: f for f in ALL}
GRANTABLE = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None = None,
trasy: dict[tuple[str, str], str | None] | None = None,
zasoby: dict | None = None,
nawigacja: tuple[str, str, str, str] | None = None) -> None:
"""Zgłoszenie funkcji, jej tras i jej zasobów.
Idempotentne po kluczu: `export` zgłaszają dwa ekrany (raport do PDF-a
i wyniki do arkusza), a wpis ma powstać raz."""
for pozycja, zbior in ((ekran, SCREENS), (rozszerzenie, EXTRAS)):
if pozycja and pozycja.key not in {f.key for f in zbior}:
zbior.append(pozycja)
if nawigacja and nawigacja[0] not in {p[0] for p in NAWIGACJA_SPECJALNA}:
NAWIGACJA_SPECJALNA.append(nawigacja)
ROUTES.update(trasy or {})
STATIC.update(zasoby or {})
_przelicz()
# Funkcje wnoszone przez moduły opcjonalne — zgłaszane tak samo jak ekrany,
# tylko przez neutralny most, żeby katalog nie musiał ich wymieniać z nazwy.
for _krotka in rozszerzenia.funkcje():
zarejestruj(rozszerzenie=Feature(*_krotka))
zarejestruj(trasy=rozszerzenia.trasy(), zasoby=rozszerzenia.zasoby())
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
# ── 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: `compile.js`
# zdradza składanie raportu, a `wheelzoom.js` — istnienie kosmogramu. Dlatego każdy
# zasób przechodzi przez tę samą bramkę co ekrany.
#
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
# 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"})
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 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 kolejności zadeklarowanej, nie w tej,
w której akurat wykonały się importy."""
return [f for f in SCREENS if f.key in perms]
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)
-17
View File
@@ -1,17 +0,0 @@
"""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",
}
@@ -1,39 +0,0 @@
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
Nazwy były zaszyte w czterech szablonach naraz (selektor na Horoskop", selektor
w raporcie zbiorczym, checkboxy porównania, nagłówki tabeli). Przy trzech systemach
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu wystarczy
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
Kolejność jest celowa: najpierw dwa najpowszechniejsze (Placidus i Whole Sign),
potem reszta. `LIMITED` oznacza systemy, które powyżej koła podbiegunowego nie
istnieją i podlegają jawnemu fallbackowi UI ma o tym uprzedzać ZANIM ktoś
policzy horoskop dla Tromsø, a nie dopiero w ostrzeżeniu przy wyniku.
"""
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 (od Asc)"),
("equal_mc", "Equal (od MC)"),
("porphyry", "Porphyry"),
("regiomontanus", "Regiomontanus"),
("campanus", "Campanus"),
("alcabitus", "Alcabitus"),
("topocentric", "Topocentric"),
("morinus", "Morinus"),
("vehlow", "Vehlow"),
]
# Systemy oparte na łuku dobowym — jedyne z realną granicą dziedziny.
LIMITED = frozenset({"placidus", "koch"})
HOUSE_SYSTEM_NAMES: dict[str, str] = dict(HOUSE_SYSTEMS)
def label(system: str) -> str:
"""Nazwa do pokazania; nieznany identyfikator zwracamy bez zmian, nie gubimy go."""
return HOUSE_SYSTEM_NAMES.get(system, system)
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika protokołu")
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
+440 -32
View File
@@ -1,39 +1,447 @@
"""Astrololo — ZŁOŻENIE aplikacji z ekranów i modułów opcjonalnych.
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
Ten plik nie zawiera już żadnej trasy. Odpowiada wyłącznie na pytanie z czego
składa się ten produkt": które ekrany wchodzą i co jest podpięte dodatkowo.
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych tylko UI.
Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją produktu.
Inny produkt to inna lista i inny zestaw plików w obrazie nie ten sam program
z wyłączonymi kawałkami.
Strona główna /" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
/significators".
"""
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
from app import rozszerzenia
from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error,
_report_records, app, logic, security, static_url,
templates)
from app.ekrany import ( # noqa: F401 - import rejestruje trasy
horoskop,
interpretacje,
kalendarz,
konta,
logowanie,
lokalizacja,
pliki,
skompiluj,
sygnifikatory,
synastria,
ustawienia,
)
import json
from datetime import datetime, timedelta, timezone
# Nazwy przekazywane dalej dla wygody wołających (testy, uvicorn).
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
# ── moduły opcjonalne ──────────────────────────────────────────────────────
# Na końcu, bo moduł dostaje gotowe zależności, a te muszą już istnieć. Bez
# modułu w obrazie to wywołanie nie robi nic i aplikacja nie ma pojęcia,
# że czegoś brakuje.
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
buduj_utc=_build_utc, blad=_logic_error,
security=security, licz_rekordy=_report_records)
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")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny offset.
"""
local = datetime.fromisoformat(f"{date}T{time}")
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
label = utc.strftime("%Y-%m-%d %H:%M UTC")
return utc.isoformat(), label
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."""
try:
return logic.llm_models()
except httpx.HTTPError:
return {"providers": {}, "defaults": {}}
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}"
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
)
@app.post("/", response_class=HTMLResponse)
def chart_compute(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"zodiac": zodiac, "tables": tables}
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, stations=stations, zodiac=zodiac, tables=tables,
)
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)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "chart.html", ctx)
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
@app.get("/compile", response_class=HTMLResponse)
def compile_form(request: Request):
return templates.TemplateResponse(
request, "compile.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/compile", response_class=HTMLResponse)
def compile_build(
request: Request,
person: str = Form(""),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
zodiac: str = Form("tropical"),
):
"""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).
Dlaczego horoskop liczymy ponownie, zamiast go zapamiętywać: to czysta funkcja
danych wejściowych tanio powtórzyć, a odpada trzymanie w przeglądarce
dużego wyniku, który mógłby się rozjechać z aktualnym formularzem.
"""
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "zodiac": zodiac}
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, zodiac=zodiac,
)
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)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "compile.html", ctx)
@app.post("/compile/pdf")
def compile_pdf(payload: dict):
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje)
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
zawierał dokładnie to, co widać na stronie.
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVGPDF nie zna
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
"""
from fastapi.responses import Response
from app.clients.render_client import RenderClient
data = payload.get("data") or {}
try:
iso_utc, label = _build_utc(
str(data.get("date") or ""), str(data.get("time") or ""),
float(data.get("tz_offset") or 0.0),
)
except (ValueError, TypeError) as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
figures: list[dict] = []
try:
chart = logic.positions(
when_utc_iso=iso_utc,
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
zodiac=str(data.get("zodiac") or "tropical"),
)
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_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
):
if svg:
figures.append({"svg": svg, "caption": caption})
except httpx.HTTPError as e:
# Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
data = {**data, "wheel_error": _logic_error(e)}
report = {
"person": payload.get("person") or "",
"data": {**data, "moment_utc": label},
"figures": figures,
"natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [],
}
try:
pdf = RenderClient().pdf(report)
except httpx.HTTPError as e:
return JSONResponse(
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
status_code=502,
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
@app.get("/significators", response_class=HTMLResponse)
def significators_form(request: Request):
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
@app.post("/significators", response_class=HTMLResponse)
def significators_search(
request: Request,
query: str = Form(...),
field: str = Form("name"),
exact: bool = Form(False),
limit: int = Form(25),
):
form = {"query": query, "field": field, "exact": exact, "limit": limit}
ctx: dict = {"form": form, "result": None, "error": None}
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request):
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
)
@app.post("/interpret", response_class=HTMLResponse)
def interpret_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
group: bool = Form(False),
action: str = Form("report"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
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}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "export":
# Tabela robocza do Excela (DAN-23/PRE-10) — pobranie pliku, nie strona.
from fastapi.responses import Response
from app.report_export import report_to_xlsx
report = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
return Response(
content=report_to_xlsx(report),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
)
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
provider=llm_provider, model=llm_model,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, provider=llm_provider, model=llm_model,
)
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "interpret.html", ctx)
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
@app.get("/timeline", response_class=HTMLResponse)
def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
)
@app.post("/timeline", response_class=HTMLResponse)
def timeline_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
from_date: str = Form(...),
to_date: str = Form(...),
action: str = Form("timeline"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
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}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, from_date=from_date, to_date=to_date,
provider=llm_provider, model=llm_model,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, provider=llm_provider, model=llm_model,
from_date=from_date, to_date=to_date,
)
else:
ctx["result"] = logic.timeline(
when_utc_iso=iso_utc, lat=lat, lon=lon,
from_date=from_date, to_date=to_date, interpret=True,
)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "timeline.html", ctx)
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
@app.post("/horoscope/stream")
def horoscope_stream(
profile: str = Form("natal"),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
from_date: str = Form(""),
to_date: str = Form(""),
):
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
przeładowanie strony jedno źródło prawdy dla wyglądu wyniku.
"""
from fastapi.responses import StreamingResponse
try:
iso_utc, _ = _build_utc(date, time, tz_offset)
except ValueError as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
payload: dict = {
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
}
if profile == "period" and from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
def relay():
try:
for raw in logic.horoscope_stream(payload):
try:
event = json.loads(raw)
except ValueError:
continue
if event.get("type") == "result":
html = templates.get_template("_prompt_result.html").render(
prompt_result=event.get("result") or {}
)
event["html"] = html
yield json.dumps(event, ensure_ascii=False) + "\n"
except httpx.HTTPError as e:
yield json.dumps({"type": "error", "message": _logic_error(e)},
ensure_ascii=False) + "\n"
return StreamingResponse(relay(), media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
try:
return {"results": geocode.search(q)}
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/reverse")
def geocode_reverse(lat: float, lon: float):
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
try:
return geocode.reverse(lat, lon)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
@@ -1,11 +0,0 @@
"""Moduły opcjonalne — każdy podkatalog to jedna odłączalna część produktu.
O tym, co produkt umie, decyduje ZAWARTOŚĆ tego katalogu w obrazie. Most
(`app.rozszerzenia`) odkrywa moduły, przechodząc po podkatalogach, i nie zna
żadnej nazwy gdyby znał, nazwa nieobecnego modułu i tak jechałaby do obrazu,
w którym go nie ma.
Moduł może wnieść: pozycję do katalogu funkcji, trasy, zasoby, akcje formularza,
pola formularza, wpisy do kontekstu szablonu, własne szablony i własne statyki.
Wszystko jest opcjonalne most pyta o to, czego moduł nie ma, i dostaje pusto.
"""
@@ -1,23 +0,0 @@
"""Generowanie tekstu przez model językowy — moduł CAŁKOWICIE ODŁĄCZALNY.
DLACZEGO OSOBNY KATALOG, A NIE `{% if %}` W SZABLONACH. Warunek w szablonie
wystarcza, żeby funkcji nie było WIDAĆ, ale nie żeby jej NIE BYŁO. Plik szablonu
i tak leży w obrazie i i tak zawiera słowa opisujące funkcję `grep` po
kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie.
Astrololo zawiera ten katalog. Astroklient nie, i to jest jedyna różnica
między nimi po stronie funkcji astrologicznych. Dlatego granica MUSI być
katalogiem: co jest w środku, znika razem z nim, a co jest na zewnątrz, zostaje.
ZASADA DLA KOGOŚ, KTO TU DOPISUJE. Poza tym katalogiem nie wolno napisać ani
słowa o modelu, prompcie czy dostawcy nawet w komentarzu, nawet zaprzeczając.
Szablony wspólne mają neutralne gniazda (`_dodatki_*`), a plik wstawiany w
gniazdo sam sprawdza uprawnienie. Pilnuje tego test `test_ai_tylko_w_module.py`.
"""
from __future__ import annotations
from app.moduly.dodatki.katalog import AKCJE, FUNKCJA, TRASY, ZASOBY, katalog_dla, obsluz, pola
from app.moduly.dodatki.trasy import zamontuj
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "ZASOBY", "katalog_dla", "obsluz", "pola",
"zamontuj"]

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