Compare commits
50 Commits
09c3a39fa1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 338ec24e90 | |||
| 54b40857d2 | |||
| 10970c579f | |||
| cf24c4d473 | |||
| fd79513ce2 | |||
| 62d6f9d4d5 | |||
| 10ade55525 | |||
| 2e9d3706ec | |||
| c73ba6a964 | |||
| 16cf25468f | |||
| caf4fd80d1 | |||
| e7b6d5013b | |||
| 320a0ab24e | |||
| 1d0ff2f72e | |||
| f0d07ee8c3 | |||
| 71bb3b9c0a | |||
| ee3c515d59 | |||
| ac8a0e9fc6 | |||
| b838cf4723 | |||
| e2b50b284f | |||
| 003deb9404 | |||
| 6466ab89a9 | |||
| a833965909 | |||
| baf4e0e38a | |||
| 8b6ecc727d | |||
| 21a00b0000 | |||
| 4d1daab35a | |||
| aec3f84331 | |||
| 7266f671a4 | |||
| 548d9301f3 | |||
| 1be57a47d8 | |||
| 40f5e459e0 | |||
| 86a0f16f9e | |||
| e3114f3e7c | |||
| bc80745a94 | |||
| 6e7cfcbbd7 | |||
| 70c83cfc0c | |||
| 4c1e7f8808 | |||
| d3d9b365fe | |||
| dd32f7e82f | |||
| 78af6d4755 | |||
| 7b435d4263 | |||
| a9f2a038fa | |||
| b36b3bee19 | |||
| a0d1135db1 | |||
| 81e09aa6df | |||
| 8ebce816cd | |||
| 52b7c20c2a | |||
| 9b1e4dbb20 | |||
| 4bdfb673cc |
@@ -0,0 +1,33 @@
|
|||||||
|
# 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
|
||||||
@@ -32,3 +32,14 @@ jobs:
|
|||||||
docker push $IMG:$TAG
|
docker push $IMG:$TAG
|
||||||
docker push $IMG:latest
|
docker push $IMG:latest
|
||||||
echo "Zbudowano i wypchnieto: $IMG:$TAG (+ 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
|
||||||
|
|||||||
@@ -1,19 +1,112 @@
|
|||||||
name: build
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Login
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
- name: Build & push (data, logic, presentation)
|
|
||||||
|
- name: Build & push (tylko usługi, które się zmieniły)
|
||||||
run: |
|
run: |
|
||||||
|
set -eu
|
||||||
TAG=${GITHUB_SHA::8}
|
TAG=${GITHUB_SHA::8}
|
||||||
for SVC in data logic presentation; do
|
USLUGI="data logic presentation astrodemo astroklient"
|
||||||
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
# Usługi z własnym pipeline'em — celowo poza tą pętlą.
|
||||||
|
OSOBNE="render engine-swisseph"
|
||||||
|
|
||||||
|
# Usługa, której nie ma na żadnej z list, nigdy się nie zbuduje i NIKT
|
||||||
|
# tego nie zauważy: brak obrazu wygląda potem na problem z rejestrem.
|
||||||
|
# Ten sam mechanizm już nas kosztował przy liście obserwowanych obrazów
|
||||||
|
# w image-updaterze. Lepiej zatrzymać budowanie i powiedzieć wprost.
|
||||||
|
for KATALOG in services/*/; do
|
||||||
|
NAZWA=$(basename "$KATALOG")
|
||||||
|
case " $USLUGI $OSOBNE " in
|
||||||
|
*" $NAZWA "*) ;;
|
||||||
|
*) echo "BŁĄD: usługa '$NAZWA' nie jest na żadnej liście budowania."
|
||||||
|
echo "Dopisz ją do USLUGI w tym pliku albo daj jej własny workflow"
|
||||||
|
echo "(jak render i engine-swisseph), zależnie od tego, czy ma"
|
||||||
|
echo "powstawać z każdego commita produktu."
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Kontekstem budowania każdego obrazu jest WYŁĄCZNIE ./services/<nazwa>,
|
||||||
|
# więc zmiana poza tym katalogiem nie może wpłynąć na jego zawartość.
|
||||||
|
# Dzięki temu porównanie ścieżek jest dokładne, a nie przybliżone.
|
||||||
|
if POPRZEDNI=$(git rev-parse --verify HEAD^ 2>/dev/null); then
|
||||||
|
ZMIENIONE=$(git diff --name-only "$POPRZEDNI" HEAD)
|
||||||
|
else
|
||||||
|
# Pierwszy commit albo przepisana historia — nie ma z czym porównać,
|
||||||
|
# więc budujemy wszystko. Lepiej zbudować za dużo niż wypuścić obraz
|
||||||
|
# ze starym kodem pod nowym tagiem.
|
||||||
|
echo "Brak poprzedniego commita — buduję komplet."
|
||||||
|
ZMIENIONE=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Zmiana w samym pliku workflow dotyczy wszystkich obrazów naraz.
|
||||||
|
if [ -z "$ZMIENIONE" ] || echo "$ZMIENIONE" | grep -q '^\.gitea/workflows/build\.yaml$'; then
|
||||||
|
DO_BUDOWY="$USLUGI"
|
||||||
|
else
|
||||||
|
DO_BUDOWY=""
|
||||||
|
for SVC in $USLUGI; do
|
||||||
|
if echo "$ZMIENIONE" | grep -q "^services/$SVC/"; then
|
||||||
|
DO_BUDOWY="$DO_BUDOWY $SVC"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# astroklient bierze rdzeń z warstwy prezentacji, więc zmiana TAMTEJ
|
||||||
|
# też go dotyczy. Bez tego jego obraz zostawałby ze starymi ekranami,
|
||||||
|
# a różnicy nie byłoby widać aż do zgłoszenia użytkownika.
|
||||||
|
if echo "$ZMIENIONE" | grep -q "^services/presentation/" \
|
||||||
|
&& ! echo "$DO_BUDOWY" | grep -q "astroklient"; then
|
||||||
|
DO_BUDOWY="$DO_BUDOWY astroklient"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(echo "$DO_BUDOWY" | tr -d ' ')" ]; then
|
||||||
|
echo "Żadna z usług się nie zmieniła — nie ma czego budować."
|
||||||
|
echo "Wdrożone tagi zostają na poprzednich wersjach, i tak ma być."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Buduję:$DO_BUDOWY (tag $TAG)"
|
||||||
|
for SVC in $DO_BUDOWY; do
|
||||||
|
# astroklient buduje się z KORZENIA repozytorium, bo jego Dockerfile
|
||||||
|
# sięga po rdzeń do services/presentation. Pozostałe mają kontekst
|
||||||
|
# ograniczony do własnego katalogu — i tak ma zostać, bo to właśnie
|
||||||
|
# ten kontekst gwarantuje, że nie wciągną niczego spoza siebie.
|
||||||
|
if [ "$SVC" = "astroklient" ]; then
|
||||||
|
docker build -f services/astroklient/Dockerfile \
|
||||||
|
-t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG .
|
||||||
|
else
|
||||||
|
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||||
|
fi
|
||||||
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||||
done
|
done
|
||||||
echo "Tag: $TAG"
|
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
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Wyrocznia domów — nocny przemiał
|
||||||
|
|
||||||
|
# DLACZEGO TU, A NIE JAKO JOB W KLASTRZE
|
||||||
|
# Pierwotny plan zakładał CronJob w k3s, bo „duży przemiał jest kosztowny".
|
||||||
|
# Pomiar tego nie potwierdził: 500 000 przypadków × 13 systemów = 70 mln porównań
|
||||||
|
# w 64 sekundy, skalowanie liniowe. Osobny obraz w rejestrze (który już raz zapchał
|
||||||
|
# dysk hosta), manifest, CronJob i kopia harnessu poza repo byłyby infrastrukturą
|
||||||
|
# do problemu, którego nie ma — a każda kopia harnessu poza repo to ryzyko cichego
|
||||||
|
# rozjazdu z kodem, który ma testować.
|
||||||
|
#
|
||||||
|
# Zestaw brzegowy blokuje KAŻDY build (patrz tests.yml). Tutaj chodzi o co innego:
|
||||||
|
# duża losowa próbka z INNYM ZIARNEM co noc, żeby z czasem przeczesać dziedzinę
|
||||||
|
# gęściej, niż zrobi to pojedynczy przebieg.
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 2 * * *' # 02:17 — poza godzinami budowania
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
count:
|
||||||
|
description: 'Liczba przypadków'
|
||||||
|
default: '500000'
|
||||||
|
seed:
|
||||||
|
description: 'Ziarno (puste = z daty)'
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sweep:
|
||||||
|
name: Przemiał losowy (13 systemów)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Obraz wyroczni (silnik B + nasza logika + harness)
|
||||||
|
run: |
|
||||||
|
docker build -t astrololo/engine-swisseph:sweep services/engine-swisseph
|
||||||
|
# Pliki WBUDOWANE, nie montowane: job Gitea Actions sam działa w kontenerze,
|
||||||
|
# więc `-v $PWD/...` rozwiązałoby się na HOŚCIE (docker cicho tworzy pusty
|
||||||
|
# katalog i skrypt „znika"). Kontekst builda jest strumieniowany do demona.
|
||||||
|
docker build -t astrololo/oracle:sweep -f - . <<'DOCKERFILE'
|
||||||
|
FROM astrololo/engine-swisseph:sweep
|
||||||
|
COPY services/logic /logic
|
||||||
|
COPY tests/oracle /oracle
|
||||||
|
ENV LOGIC_PATH=/logic
|
||||||
|
DOCKERFILE
|
||||||
|
|
||||||
|
- name: Przemiał
|
||||||
|
run: |
|
||||||
|
COUNT="${{ inputs.count }}"; COUNT="${COUNT:-500000}"
|
||||||
|
SEED="${{ inputs.seed }}"; SEED="${SEED:-$(date -u +%Y%m%d)}"
|
||||||
|
echo "count=$COUNT seed=$SEED"
|
||||||
|
# Kod wyjścia 1 przy przekroczeniu tolerancji ALBO niezgodności dziedziny,
|
||||||
|
# więc job czerwieni się sam — bez parsowania tekstu raportu.
|
||||||
|
docker run --rm astrololo/oracle:sweep \
|
||||||
|
python /oracle/run.py --mode sweep --count "$COUNT" --seed "$SEED"
|
||||||
|
|
||||||
|
# Runner miał już incydent „no space left on device" — sprzątamy zawsze.
|
||||||
|
- name: Usuń obrazy pomocnicze
|
||||||
|
if: always()
|
||||||
|
run: docker rmi -f astrololo/oracle:sweep astrololo/engine-swisseph:sweep 2>/dev/null || true
|
||||||
@@ -71,6 +71,68 @@ jobs:
|
|||||||
PYTHONPATH: .
|
PYTHONPATH: .
|
||||||
run: pytest tests -q -rs
|
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:
|
swisseph-image:
|
||||||
name: Build obrazu silnika B (swisseph)
|
name: Build obrazu silnika B (swisseph)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -118,9 +180,68 @@ jobs:
|
|||||||
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
||||||
|
|
||||||
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
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")
|
print("SMOKE OK")
|
||||||
PY
|
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
|
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
||||||
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
# 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.
|
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
||||||
@@ -140,3 +261,33 @@ jobs:
|
|||||||
# silnika swisseph, który nie wchodzi do produktu).
|
# silnika swisseph, który nie wchodzi do produktu).
|
||||||
- name: py_compile
|
- name: py_compile
|
||||||
run: python -m compileall -q services
|
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
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ services/logic/.ephemeris/
|
|||||||
|
|
||||||
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
||||||
services/data/data_files/*.xlsx
|
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
|
!services/data/data_files/.gitkeep
|
||||||
|
|
||||||
# Narzędzia
|
# Narzędzia
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./services/data/data_files:/app/data_files
|
- ./services/data/data_files:/app/data_files
|
||||||
- data_cache:/app/.cache
|
- data_cache:/app/.cache
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8002:8002"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
logic:
|
logic:
|
||||||
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
||||||
@@ -25,8 +27,10 @@ services:
|
|||||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8001:8001"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
# docker compose --profile comparison up
|
# docker compose --profile comparison up
|
||||||
@@ -36,8 +40,10 @@ services:
|
|||||||
container_name: astrololo-engine-swisseph
|
container_name: astrololo-engine-swisseph
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["comparison"]
|
profiles: ["comparison"]
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8003:8003"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
presentation:
|
presentation:
|
||||||
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
||||||
|
|||||||
+12
-6
@@ -9,8 +9,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./services/data/data_files:/app/data_files
|
- ./services/data/data_files:/app/data_files
|
||||||
- data_cache:/app/.cache
|
- data_cache:/app/.cache
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8002:8002"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
logic:
|
logic:
|
||||||
build: ./services/logic
|
build: ./services/logic
|
||||||
@@ -21,8 +23,10 @@ services:
|
|||||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8001:8001"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
# docker compose --profile comparison up
|
# docker compose --profile comparison up
|
||||||
@@ -30,8 +34,10 @@ services:
|
|||||||
engine-swisseph:
|
engine-swisseph:
|
||||||
build: ./services/engine-swisseph
|
build: ./services/engine-swisseph
|
||||||
profiles: ["comparison"]
|
profiles: ["comparison"]
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8003:8003"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
presentation:
|
presentation:
|
||||||
build: ./services/presentation
|
build: ./services/presentation
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,58 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 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`).
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Konta i uprawnienia (PRE-27)
|
||||||
|
|
||||||
|
Rozszerzenie kont imiennych z [PRE-17](konta-i-audyt.md): konta zakłada się
|
||||||
|
**z aplikacji**, a każde dostaje własny zestaw funkcji.
|
||||||
|
|
||||||
|
## Dwie zasady, z których wynika reszta
|
||||||
|
|
||||||
|
**1. Konto ograniczone widzi program KOMPLETNY — tylko mniejszy.**
|
||||||
|
Nic nie może zdradzać, że istnieje coś więcej. Żadnych wyszarzonych zakładek,
|
||||||
|
żadnego „brak uprawnień", żadnego 403 — bo **403 samo w sobie jest informacją**,
|
||||||
|
że pod tym adresem coś jest. Ścieżka bez uprawnienia odpowiada **404**, tak samo
|
||||||
|
jak adres, którego nie ma.
|
||||||
|
|
||||||
|
Z tej zasady wynikło też wyłączenie `/docs`, `/redoc` i `/openapi.json`.
|
||||||
|
Automatyczna dokumentacja FastAPI wypisuje komplet tras — czyli spis wszystkich
|
||||||
|
funkcji programu. Ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog.
|
||||||
|
(Znalezione testem, nie przeglądem kodu.)
|
||||||
|
|
||||||
|
**2. Konto administracyjne pochodzi WYŁĄCZNIE ze środowiska.**
|
||||||
|
`APP_USER` / `APP_PASSWORD` (albo `APP_USERS`) — jak dotąd. To konto ma wszystkie
|
||||||
|
uprawnienia i jako jedyne zarządza pozostałymi. **Nie leży w pliku kont**, więc
|
||||||
|
nie da się go skasować ani ograniczyć z ekranu — nawet przez pomyłkę, nawet
|
||||||
|
spreparowanym żądaniem. Konto założone w pliku o tym samym loginie **nie
|
||||||
|
przesłoni** administracyjnego (kolejność sprawdzania jest odwrotna).
|
||||||
|
|
||||||
|
## Podział funkcji
|
||||||
|
|
||||||
|
**Ekrany** — zakładki widoczne w nawigacji:
|
||||||
|
|
||||||
|
| klucz | zakładka |
|
||||||
|
|---|---|
|
||||||
|
| `chart` | Horoskop |
|
||||||
|
| `interpret` | Interpretacje |
|
||||||
|
| `timeline` | Kalendarz |
|
||||||
|
| `synastry` | Synastria |
|
||||||
|
| `significators` | Sygnifikatory |
|
||||||
|
| `compile` | Skompiluj |
|
||||||
|
| `settings` | Ustawienia |
|
||||||
|
|
||||||
|
**Rozszerzenia** — poziomy złożoności wewnątrz ekranów:
|
||||||
|
|
||||||
|
| klucz | co daje |
|
||||||
|
|---|---|
|
||||||
|
| `houses_compare` | wybór systemu domów, zestawienie kilku obok siebie, obrót koła |
|
||||||
|
| `extra_charts` | aspektarian, wykres deklinacji, oś antyscji |
|
||||||
|
| `advanced_calc` | stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne |
|
||||||
|
| `ai` | horoskopy pisane przez model językowy (**każde użycie kosztuje**) |
|
||||||
|
| `export` | pobieranie PDF i Excela |
|
||||||
|
|
||||||
|
Konto bez `houses_compare` dostaje horoskop w Whole Sign i **nie widzi**, że
|
||||||
|
systemów jest trzynaście. Konto bez `ai` nie zobaczy przycisku generowania ani
|
||||||
|
nie wywoła go z pominięciem interfejsu.
|
||||||
|
|
||||||
|
## Gdzie leży granica
|
||||||
|
|
||||||
|
W handlerze, nie w szablonie. Ukrycie pola w formularzu chroni przed przypadkiem,
|
||||||
|
ale nie przed kimś, kto zna nazwy pól — dlatego `_limit_options()` ścina opcje
|
||||||
|
**po stronie serwera**, a rysunki dodatkowe bez uprawnienia w ogóle nie powstają
|
||||||
|
(nie ma ich nawet w źródle strony).
|
||||||
|
|
||||||
|
Mapa `trasa → uprawnienie` jest **jedna**, w `app/features.py`. Rozproszenie jej
|
||||||
|
po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
|
||||||
|
jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wymaga
|
||||||
|
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
|
||||||
|
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
|
||||||
|
|
||||||
|
### Gdy jedna trasa robi kilka rzeczy
|
||||||
|
|
||||||
|
Mapa tras nie wystarcza tam, gdzie jedna trasa obsługuje kilka funkcji naraz.
|
||||||
|
`POST /interpret` liczy horoskop, ale to samo pole `action` prosi o wygenerowanie
|
||||||
|
promptu, napisanie horoskopu przez model albo eksport arkusza. Konto, które ma
|
||||||
|
mieć Interpretacje bez generowania, musi dostać tę trasę — więc granica przebiega
|
||||||
|
wewnątrz niej, po akcjach: `_AKCJE_POD_UPRAWNIENIEM` przypisuje akcji uprawnienie,
|
||||||
|
a `_dozwolona_akcja()` sprowadza żądanie bez uprawnienia do akcji domyślnej ekranu.
|
||||||
|
|
||||||
|
Sprowadza — nie odrzuca. Komunikat „brak uprawnień do generowania" sam w sobie
|
||||||
|
mówiłby, że taka funkcja istnieje, czyli łamałby zasadę drugą po to, żeby
|
||||||
|
wyegzekwować pierwszą. Akcja bez uprawnienia ma wyglądać na literówkę w formularzu.
|
||||||
|
|
||||||
|
### Ślad to nie tylko przycisk
|
||||||
|
|
||||||
|
Wymaganie brzmi „nie może być śladu", i to jest mocniejsze niż schowanie kontrolki.
|
||||||
|
Największym wyciekiem po stronie generowania nie był przycisk, tylko **katalog
|
||||||
|
modeli** — nazwy dostawców, nazwy modeli i rozmiary okien kontekstu — wstrzykiwany
|
||||||
|
w stronę blokiem JSON na każdym ekranie z generowaniem, niezależnie od uprawnień.
|
||||||
|
Dlatego `_llm_catalog_for()` oddaje pusty katalog kontu bez uprawnienia, a szablony
|
||||||
|
trzymają pod bramką także znaczniki (`natalNote`, `reportNatal`), pliki skryptów
|
||||||
|
(`models.js`, `progress.js`, `natal.js`, `predictions.js`) i **zdania opisujące
|
||||||
|
funkcję** — podtytuł ekranu Skompiluj wymieniał interpretację od AI z nazwy.
|
||||||
|
|
||||||
|
Testu na to nie da się napisać przez „sprawdź, czy przycisku nie ma": trzeba
|
||||||
|
sprawdzić, że w źródle strony nie ma żadnego z tych śladów, i mieć kontrolę
|
||||||
|
pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy,
|
||||||
|
gdy generowanie jest zepsute dla wszystkich.
|
||||||
|
|
||||||
|
## Pełna paranoja: ukrywanie jest nadrzędne
|
||||||
|
|
||||||
|
Właściciel produktu postawił to wyżej niż wygodę i wyżej niż czytelność
|
||||||
|
komunikatów: *„nie chcę, żeby osoba wrzucająca bazy wiedziała, po co to robi
|
||||||
|
i jak będzie w przyszłości działał program, bo to rozgada"*. Persona nazywa się
|
||||||
|
**wgrywacz** — konto z uprawnieniami `files` + `files_input` i niczym więcej.
|
||||||
|
|
||||||
|
Nie ma dowiedzieć się: jakie inne funkcje istnieją, że teksty pisze model
|
||||||
|
językowy i u jakiego dostawcy, do czego służą wgrywane pliki, co jest planowane,
|
||||||
|
że istnieje walidacja plików, ani że istnieje konto, które widzi więcej.
|
||||||
|
|
||||||
|
### Wyciek prawie nigdy nie siedzi tam, gdzie się go szuka
|
||||||
|
|
||||||
|
Audyt sześciu kanałów potwierdził 26 wycieków. Ani jeden nie był przyciskiem.
|
||||||
|
|
||||||
|
| Kanał | Co wyciekało |
|
||||||
|
|---|---|
|
||||||
|
| `/static/**` poza bramką | komplet skryptów i arkuszy dla **niezalogowanego** |
|
||||||
|
| komentarze w CSS/JS | pełne zdania po polsku o funkcjach, o kwarantannie i o tym, że administrator widzi więcej |
|
||||||
|
| `styles.css` jako jeden plik | nazwy selektorów = spis funkcji programu |
|
||||||
|
| `base.html` | skrypty kosmogramu na **każdej** stronie, łącznie z „przyszłą zakładką" |
|
||||||
|
| komunikaty błędu | nazwa trasy, nazwa podsystemu, nazwa gałęzi rozwojowej, wewnętrzny `host:port` |
|
||||||
|
| komunikat po wgraniu | słowo „administrator" — i **dwie różne treści**, czyli wyrocznia do odgadywania reguł walidacji |
|
||||||
|
| `/openapi.json` warstw wewnętrznych | katalog wszystkich funkcji, bez logowania |
|
||||||
|
|
||||||
|
### Trzy zasady, które z tego wynikają
|
||||||
|
|
||||||
|
**Zasób jest częścią funkcji.** Skrypt i arkusz przechodzą przez tę samą bramkę
|
||||||
|
co ekran (`features.STATIC`). Nazwa pliku jest zgadywalna, więc plik publiczny
|
||||||
|
opowiada o funkcji równie dokładnie jak przycisk. Publiczny został jeden
|
||||||
|
`base.css` — bo potrzebuje go ekran logowania — i dlatego nie wolno w nim
|
||||||
|
umieścić niczego, co nazywa funkcję.
|
||||||
|
|
||||||
|
**Komentarz nie jedzie na drut.** `_asset_body()` usuwa komentarze przy
|
||||||
|
serwowaniu. Zostają w repozytorium, gdzie są potrzebne. Ta jedna zmiana zamyka
|
||||||
|
cztery z sześciu kanałów naraz.
|
||||||
|
|
||||||
|
**Różnica jest informacją.** Dwa różne komunikaty po wgraniu pliku były
|
||||||
|
wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać odpowiedź. Teraz
|
||||||
|
komunikat jest jeden, niezależnie od wyniku. Z tego samego powodu odmowa to
|
||||||
|
404 identyczne z „nie ma takiej trasy", a akcja bez uprawnienia cofa się do
|
||||||
|
domyślnej zamiast tłumaczyć, czego brakuje.
|
||||||
|
|
||||||
|
### Zapora słownikowa
|
||||||
|
|
||||||
|
Łatanie punkt po punkcie przegrywa z następną zmianą. Dlatego
|
||||||
|
`test_slownik_zakazany.py` nie sprawdza miejsc, tylko przechodzi **wszystko**,
|
||||||
|
co dane konto może pobrać, i szuka słów, które nie mają prawa paść.
|
||||||
|
|
||||||
|
Dwie z trzech list biorą się wprost z katalogu funkcji — nazwa funkcji, adres
|
||||||
|
jej ekranu i nazwy jej zasobów — więc dopisanie funkcji automatycznie dopisuje
|
||||||
|
je do tego, czego konto bez niej nie może zobaczyć. Trzecia lista, słownictwo
|
||||||
|
dziedziny i mechanizmów, jest pisana ręcznie, bo katalog jej nie zna.
|
||||||
|
|
||||||
|
Test ma kontrolę pozytywną: dla administratora te same słowa **muszą** się
|
||||||
|
pojawiać. Bez niej przechodziłby także wtedy, gdyby program był pusty.
|
||||||
|
|
||||||
|
## Gdzie leżą konta
|
||||||
|
|
||||||
|
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
|
||||||
|
na NFS — **własny podkatalog prezentacji**, nie katalog z bazami: zamontowanie
|
||||||
|
tutaj całego udziału obeszłoby bokiem zamknięcie dostępu z DAN-25.
|
||||||
|
|
||||||
|
Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co `APP_USERS` — jedna
|
||||||
|
implementacja, więc nie ma czego rozjechać. Zapis jest **atomowy** (plik
|
||||||
|
tymczasowy + `os.replace` w tym samym katalogu): przerwanie zapisu nie obetnie
|
||||||
|
pliku, czyli nie skasuje wszystkich kont naraz.
|
||||||
|
|
||||||
|
## Czego ten mechanizm NIE robi
|
||||||
|
|
||||||
|
Nie zastępuje ochrony baz na poziomie sieci ani NFS (DAN-25). Ktoś z dostępem do
|
||||||
|
udziału albo do warstwy danych nadal je odczyta — uprawnienia w aplikacji
|
||||||
|
ograniczają to, co widać **przez aplikację**, i tyle.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też katalog
|
||||||
|
# tests/ — a leży w nim lista słów, które w tej usłudze nie mają prawa paść,
|
||||||
|
# wypisanych wprost. Obraz tej usługi się KOMUŚ ODDAJE, więc niósłby dokładnie
|
||||||
|
# to, co ten test ma pilnować. Przy okazji obraz jest mniejszy.
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8005
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# astrodemo — wersja demonstracyjna (PRE-28)
|
||||||
|
|
||||||
|
Osobna warstwa prezentacji o **dwóch funkcjach**:
|
||||||
|
|
||||||
|
1. **dodanie pliku bazy** — wgranie i włączenie do użytku jedną czynnością,
|
||||||
|
2. **zapytanie o interpretację urodzeniową** dla podanych danych wejściowych.
|
||||||
|
|
||||||
|
I na tym koniec. Nie ma tu kosmogramu, synastrii, kalendarza, promptów do modelu,
|
||||||
|
eksportu ani kont — nie są ukryte, tylko **nie istnieją w tym obrazie**.
|
||||||
|
|
||||||
|
## Dlaczego osobna usługa, a nie konto z ograniczeniami
|
||||||
|
|
||||||
|
Mechanizm uprawnień z PRE-27 umiałby to ukryć w pełnej aplikacji. Ale **ukrycie
|
||||||
|
a nieobecność to dwie różne rzeczy**: tutaj nie ma tras, nie ma szablonów, nie ma
|
||||||
|
nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy
|
||||||
|
okazji kodu reszty programu.
|
||||||
|
|
||||||
|
## Izolacja: własne dane, pula na konto
|
||||||
|
|
||||||
|
```
|
||||||
|
astrodemo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie)
|
||||||
|
└── klientA/ ← pula konta
|
||||||
|
└── klientB/ ← pula konta
|
||||||
|
```
|
||||||
|
|
||||||
|
**Oryginalne bazy interpretacyjne są dla demo nieosiągalne.** Nie chodzi
|
||||||
|
o uprawnienia: demo ma własną warstwę danych, pracującą na osobnym udziale.
|
||||||
|
Osobna jest też warstwa logiczna, bo zna ona JEDEN adres warstwy danych — demo
|
||||||
|
korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.
|
||||||
|
|
||||||
|
**Każde konto ma własną pulę** plików: swój podkatalog na tym udziale. Konta nie
|
||||||
|
widzą swoich baz nawzajem ani w liście, ani w wynikach wyszukiwania. Dlatego demo
|
||||||
|
można rozdawać szeroko.
|
||||||
|
|
||||||
|
Pula bierze się **z loginu zalogowanej osoby** i z niczego innego. Gdyby
|
||||||
|
przychodziła z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze
|
||||||
|
bazy — osobny test wysyła `tenant`, `user` i `login` w formularzu i sprawdza, że
|
||||||
|
nie mają na nią wpływu.
|
||||||
|
|
||||||
|
Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
|
||||||
|
w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział.
|
||||||
|
|
||||||
|
**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza
|
||||||
|
utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`,
|
||||||
|
`astrololo/README-astrodemo.md`).
|
||||||
|
|
||||||
|
## Konfiguracja
|
||||||
|
|
||||||
|
| zmienna | znaczenie |
|
||||||
|
|---|---|
|
||||||
|
| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) |
|
||||||
|
| `ASTRODEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików |
|
||||||
|
| `ASTRODEMO_USER` / `ASTRODEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `ASTRODEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie |
|
||||||
|
| `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług |
|
||||||
|
| `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja |
|
||||||
|
| `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza |
|
||||||
|
| `REPORT_LIMIT` | górny limit rekordów w jednej odpowiedzi, domyślnie 2000 |
|
||||||
|
| `TRUST_PROXY` | `true` za Ingressem — inaczej limit liczy wszystkich do jednego wiadra |
|
||||||
|
|
||||||
|
Hash hasła zrobisz tym samym skryptem, co dla głównej aplikacji:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/presentation && python scripts/make_user.py demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uruchomienie lokalne
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/astrodemo
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
ASTRODEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \
|
||||||
|
uvicorn app.main:app --port 8005
|
||||||
|
```
|
||||||
|
|
||||||
|
Testy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/astrodemo && PYTHONPATH=. pytest tests -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Co pilnują testy
|
||||||
|
|
||||||
|
Poza zwykłą poprawnością — dwie własności, które łatwo zepsuć bez zauważenia:
|
||||||
|
|
||||||
|
* **powierzchnia**: zbiór tras aplikacji musi być dokładnie taki, jak dwie funkcje
|
||||||
|
wymagają; klient warstwy logicznej ma tylko te metody, których używa. Trasa,
|
||||||
|
o której nikt nie pamiętał, jest tu realnym ryzykiem, bo ta usługa rozmawia
|
||||||
|
z produkcyjną warstwą danych.
|
||||||
|
* **milczenie o regułach walidacji**: plik odrzucony daje komunikat bez powodu.
|
||||||
|
Reguły zna wyłącznie administrator w pełnej aplikacji (DAN-27), a demo nie ma
|
||||||
|
prawa ich zdradzić nawet mimochodem.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28).
|
||||||
|
|
||||||
|
Ma tu być wyłącznie to, czego ta usługa naprawdę używa. Metoda, której nie ma,
|
||||||
|
nie zostanie przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest
|
||||||
|
coś więcej.
|
||||||
|
|
||||||
|
Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym
|
||||||
|
tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> dict[str, str]:
|
||||||
|
token = os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
return {"X-Astrololo-Token": token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza czytany przy KAŻDYM wywołaniu — pod przeżywa rotację sekretu
|
||||||
|
bez restartu, a brak klucza przy wymaganym szyfrowaniu ma zatrzymać żądanie
|
||||||
|
ZANIM cokolwiek wyjdzie jawnym tekstem."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
|
class LogicClient:
|
||||||
|
"""Rozmówca warstwy logicznej ZWIĄZANY Z PULĄ KONTA (PRE-29).
|
||||||
|
|
||||||
|
Pulę podaje się przy tworzeniu klienta, a bierze się ją WYŁĄCZNIE z loginu
|
||||||
|
zalogowanej osoby — nigdy z pola formularza. Gdyby przychodziła z żądania,
|
||||||
|
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
|
||||||
|
|
||||||
|
def __init__(self, tenant: str = "", base_url: str | None = None) -> None:
|
||||||
|
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||||
|
self.tenant = tenant or ""
|
||||||
|
|
||||||
|
def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]:
|
||||||
|
"""Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach.
|
||||||
|
|
||||||
|
Jedyne pytanie, jakie astrodemo zadaje o treść — bez grupowania,
|
||||||
|
bez grupowania i bez żadnej obróbki treści."""
|
||||||
|
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||||
|
"limit": settings.report_limit, "group": False,
|
||||||
|
"tenant": self.tenant}
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report",
|
||||||
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||||
|
return link_crypto.call_json(
|
||||||
|
client, "POST", f"{self.base_url}/files/upload",
|
||||||
|
payload={"filename": filename, "content_b64": content_b64,
|
||||||
|
"by": by, "tenant": self.tenant},
|
||||||
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
||||||
|
"""Włączenie wgranego pliku do użytku. W demo wgranie i włączenie to JEDNA
|
||||||
|
czynność: „dodać do bazy" ma znaczyć, że plik od razu bierze udział
|
||||||
|
w wyszukiwaniu — inaczej demo wyglądałoby na zepsute."""
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(
|
||||||
|
client, "POST", f"{self.base_url}/files/status",
|
||||||
|
payload={"path": path, "status": status, "by": by, "tenant": self.tenant},
|
||||||
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Konfiguracja astrodemo — wersji demonstracyjnej (PRE-28).
|
||||||
|
|
||||||
|
Zna WYŁĄCZNIE adres warstwy logicznej. Nie wie nic o bazach, Excelu ani SQL-u.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Settings:
|
||||||
|
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
|
||||||
|
# Szukanie interpretacji potrafi zajrzeć do wszystkich baz naraz, więc limit
|
||||||
|
# czasu jest wyższy niż domyślny dla zwykłego żądania.
|
||||||
|
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "30")))
|
||||||
|
# Górny limit rekordów w jednej odpowiedzi. Nie jest to optymalizacja, tylko
|
||||||
|
# ograniczenie, ile treści baz może wyjść jednym zapytaniem.
|
||||||
|
report_limit: int = field(default_factory=lambda: int(os.getenv("REPORT_LIMIT", "2000")))
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||||
|
|
||||||
|
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||||
|
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||||
|
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||||
|
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||||
|
zrzut z węzła), miał je w całości.
|
||||||
|
|
||||||
|
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||||
|
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||||
|
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||||
|
podatne na modyfikację".
|
||||||
|
|
||||||
|
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||||
|
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||||
|
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||||
|
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||||
|
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||||
|
|
||||||
|
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||||
|
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||||
|
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||||
|
|
||||||
|
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||||
|
|
||||||
|
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||||
|
|
||||||
|
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||||
|
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||||
|
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||||
|
|
||||||
|
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||||
|
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||||
|
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||||
|
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.link")
|
||||||
|
|
||||||
|
MAGIC = b"AL1"
|
||||||
|
VERSION = "v1"
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
KEY_BYTES = 32 # AES-256
|
||||||
|
LENGTH_PREFIX = 4
|
||||||
|
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||||
|
MAX_SKEW_SECONDS = 300.0
|
||||||
|
|
||||||
|
HEADER_ENC = "X-Astrololo-Enc"
|
||||||
|
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||||
|
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||||
|
|
||||||
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||||
|
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||||
|
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||||
|
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||||
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|
||||||
|
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||||
|
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(Exception):
|
||||||
|
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def parse_key(raw: str) -> bytes:
|
||||||
|
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise LinkError("pusty klucz łącza")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(text, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
raise LinkError(
|
||||||
|
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||||
|
f"`openssl rand -hex 32`"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_env(env_name: str) -> bytes | None:
|
||||||
|
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||||
|
raw = os.getenv(env_name, "")
|
||||||
|
return parse_key(raw) if raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_required() -> bool:
|
||||||
|
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||||
|
|
||||||
|
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||||
|
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||||
|
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||||
|
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||||
|
"""
|
||||||
|
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||||
|
info=b"astrololo/link/" + direction,
|
||||||
|
).derive(link_key)
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||||
|
|
||||||
|
def __init__(self, link_key: bytes) -> None:
|
||||||
|
self._by_direction = {
|
||||||
|
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||||
|
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- pojedyncza ramka
|
||||||
|
|
||||||
|
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||||
|
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||||
|
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||||
|
|
||||||
|
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
plaintext: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
sealed = self._by_direction[direction].encrypt(
|
||||||
|
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||||
|
return MAGIC + nonce + sealed
|
||||||
|
|
||||||
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
frame: bytes) -> bytes:
|
||||||
|
if not frame.startswith(MAGIC):
|
||||||
|
raise LinkError("ramka bez znacznika protokołu")
|
||||||
|
body = frame[len(MAGIC):]
|
||||||
|
if len(body) <= NONCE_BYTES:
|
||||||
|
raise LinkError("ramka za krótka")
|
||||||
|
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
||||||
|
try:
|
||||||
|
return self._by_direction[direction].decrypt(
|
||||||
|
nonce, sealed, self._aad(direction, path, stamp, seq))
|
||||||
|
except InvalidTag as exc:
|
||||||
|
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
||||||
|
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
||||||
|
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
||||||
|
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ strumień ramek
|
||||||
|
|
||||||
|
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
||||||
|
for seq, chunk in enumerate(chunks):
|
||||||
|
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
||||||
|
|
||||||
|
def open_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
raw: bytes) -> Iterator[bytes]:
|
||||||
|
for seq, frame in enumerate(frames_in(raw)):
|
||||||
|
yield self.open(direction, path, stamp, seq, frame)
|
||||||
|
|
||||||
|
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
||||||
|
return b"".join(self.open_stream(direction, path, stamp, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ramkowanie
|
||||||
|
|
||||||
|
def frame_out(payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frames_in(raw: bytes) -> Iterator[bytes]:
|
||||||
|
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
||||||
|
offset = 0
|
||||||
|
while offset < len(raw):
|
||||||
|
if offset + LENGTH_PREFIX > len(raw):
|
||||||
|
raise LinkError("urwana ramka (brak nagłówka długości)")
|
||||||
|
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
offset += LENGTH_PREFIX
|
||||||
|
if offset + size > len(raw):
|
||||||
|
raise LinkError("urwana ramka (za mało danych)")
|
||||||
|
yield raw[offset:offset + size]
|
||||||
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
|
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
||||||
|
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
||||||
|
|
||||||
|
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
||||||
|
rozjechać się między dwa odczyty.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if len(buffer) < LENGTH_PREFIX:
|
||||||
|
return
|
||||||
|
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
if len(buffer) < LENGTH_PREFIX + size:
|
||||||
|
return
|
||||||
|
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
||||||
|
del buffer[:LENGTH_PREFIX + size]
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- świeżość ruchu
|
||||||
|
|
||||||
|
def stamp_now() -> str:
|
||||||
|
return f"{time.time():.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_stamp(stamp: str) -> None:
|
||||||
|
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
||||||
|
odtworzyć w dowolnym momencie w przyszłości."""
|
||||||
|
try:
|
||||||
|
sent = float(stamp)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise LinkError("brak albo błędny znacznik czasu") from exc
|
||||||
|
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
||||||
|
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================== strona serwerowa
|
||||||
|
|
||||||
|
class LinkCryptoMiddleware:
|
||||||
|
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
||||||
|
|
||||||
|
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
||||||
|
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
||||||
|
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, link: Link | None, layer: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.link = link
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
path = scope["path"]
|
||||||
|
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||||
|
|
||||||
|
if headers.get(HEADER_ENC.lower()) != VERSION:
|
||||||
|
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
||||||
|
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
||||||
|
# chcemy po cichu wrócić do jawnego ruchu.
|
||||||
|
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
||||||
|
self.layer, path)
|
||||||
|
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
||||||
|
|
||||||
|
stamp = headers.get(HEADER_TS.lower(), "")
|
||||||
|
try:
|
||||||
|
check_stamp(stamp)
|
||||||
|
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
||||||
|
except LinkError as exc:
|
||||||
|
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
||||||
|
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
||||||
|
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
||||||
|
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
||||||
|
|
||||||
|
def _sealing_send(self, send, path: str):
|
||||||
|
state: dict = {"stamp": "", "seq": 0}
|
||||||
|
|
||||||
|
async def sealing(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
state["stamp"] = stamp_now()
|
||||||
|
keep = [(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
message = dict(message)
|
||||||
|
message["headers"] = keep + [
|
||||||
|
(b"content-type", CONTENT_TYPE.encode()),
|
||||||
|
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
||||||
|
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
||||||
|
]
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
chunk = message.get("body", b"")
|
||||||
|
sealed = b""
|
||||||
|
if chunk:
|
||||||
|
sealed = frame_out(self.link.seal(
|
||||||
|
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
||||||
|
state["seq"] += 1
|
||||||
|
return await send({"type": "http.response.body", "body": sealed,
|
||||||
|
"more_body": message.get("more_body", False)})
|
||||||
|
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
return sealing
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
||||||
|
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
||||||
|
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
||||||
|
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
kept.append((b"content-length", str(length).encode()))
|
||||||
|
if length:
|
||||||
|
kept.append((b"content-type", b"application/json"))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay(body: bytes, original):
|
||||||
|
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
||||||
|
|
||||||
|
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
||||||
|
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
||||||
|
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
||||||
|
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
||||||
|
"""
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return await original()
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(send, detail: str) -> None:
|
||||||
|
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
||||||
|
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
||||||
|
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
||||||
|
await send({"type": "http.response.start", "status": 400, "headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(payload)).encode()),
|
||||||
|
]})
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, env_name: str, layer: str):
|
||||||
|
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
||||||
|
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
||||||
|
link_key = key_from_env(env_name)
|
||||||
|
if link_key is None and encryption_required():
|
||||||
|
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
||||||
|
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
||||||
|
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
||||||
|
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
||||||
|
)
|
||||||
|
if link_key is None:
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
||||||
|
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
||||||
|
"ruch wewnątrz sieci.", env_name, layer,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
link = Link(link_key)
|
||||||
|
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
||||||
|
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ strona kliencka
|
||||||
|
|
||||||
|
def call(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
||||||
|
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
||||||
|
|
||||||
|
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
||||||
|
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
||||||
|
się niejasnym błędem odszyfrowania.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
||||||
|
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
||||||
|
# sygnifikatory, o które pytamy bazę.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
||||||
|
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
response = client.request(method, url, json=payload, headers=request_headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
||||||
|
"Content-Type": CONTENT_TYPE})
|
||||||
|
|
||||||
|
response = client.request(method, url, content=body, headers=request_headers)
|
||||||
|
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
||||||
|
reply_stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(reply_stamp)
|
||||||
|
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return _json.loads(call(client, method, url, payload=payload,
|
||||||
|
headers=headers, link=link))
|
||||||
|
|
||||||
|
|
||||||
|
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||||
|
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
||||||
|
|
||||||
|
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
||||||
|
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
||||||
|
"""
|
||||||
|
if link is None:
|
||||||
|
yield from response.iter_bytes()
|
||||||
|
return
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
||||||
|
stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(stamp)
|
||||||
|
path = response.request.url.path
|
||||||
|
buffer = bytearray()
|
||||||
|
seq = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
buffer += chunk
|
||||||
|
for frame in unframe_incremental(buffer):
|
||||||
|
yield link.open(RESPONSE, path, stamp, seq, frame)
|
||||||
|
seq += 1
|
||||||
|
if buffer:
|
||||||
|
raise LinkError("strumień urwał się w połowie ramki")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||||
|
link: Link | None) -> Iterator[str]:
|
||||||
|
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||||
|
|
||||||
|
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||||
|
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||||
|
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||||
|
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||||
|
dopiero na znakach nowej linii.
|
||||||
|
|
||||||
|
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||||
|
działał bez zmian.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||||
|
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||||
|
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||||
|
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for text_line in response.iter_lines():
|
||||||
|
if text_line:
|
||||||
|
yield text_line
|
||||||
|
return
|
||||||
|
|
||||||
|
path = _httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||||
|
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
buffer = bytearray()
|
||||||
|
for plain in open_response_stream(response, link):
|
||||||
|
buffer += plain
|
||||||
|
while True:
|
||||||
|
nl = buffer.find(b"\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
text_line = bytes(buffer[:nl])
|
||||||
|
del buffer[:nl + 1]
|
||||||
|
if text_line:
|
||||||
|
yield text_line.decode("utf-8")
|
||||||
|
if buffer:
|
||||||
|
yield bytes(buffer).decode("utf-8")
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""astrodemo — wersja DEMONSTRACYJNA (PRE-28).
|
||||||
|
|
||||||
|
DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ:
|
||||||
|
1. dodanie pliku bazy (wgranie + włączenie do użytku),
|
||||||
|
2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych.
|
||||||
|
|
||||||
|
DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Ukrycie a nieobecność to
|
||||||
|
dwie różne rzeczy. Tutaj niczego poza tymi dwiema funkcjami NIE MA W OBRAZIE: nie
|
||||||
|
ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Obraz
|
||||||
|
tej usługi można komuś oddać i nie oddaje się przy tym niczego ponad to, co widać
|
||||||
|
na ekranie — łącznie z komentarzami w tym pliku, bo one też jadą razem z obrazem.
|
||||||
|
|
||||||
|
PULE SĄ ROZDZIELONE (PRE-29). Każde konto ma własny, izolowany zbiór plików,
|
||||||
|
niewidoczny dla pozostałych kont; pula bierze się WYŁĄCZNIE z loginu zalogowanej
|
||||||
|
osoby, nigdy z pola formularza. Konta są osobne (ASTRODEMO_USERS) — żeby dało się
|
||||||
|
odciąć tę usługę jedną zmienną, bez
|
||||||
|
ruszania kont głównej aplikacji.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app import link_crypto, security, session
|
||||||
|
from app.clients.logic_client import LogicClient
|
||||||
|
|
||||||
|
# Automatyczna dokumentacja WYŁĄCZONA. /docs wypisałoby komplet tras, a ta usługa
|
||||||
|
# ma nie zdradzać nawet własnej powierzchni.
|
||||||
|
app = FastAPI(title="astrodemo", docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
security.install(app)
|
||||||
|
|
||||||
|
# ── zasoby (PRE-27) ────────────────────────────────────────────────────────
|
||||||
|
# Katalog NIE jest montowany. Montowanie serwuje wszystko, co w nim leży, każdemu
|
||||||
|
# i bez śladu w dzienniku — a komentarz w arkuszu stylów mówił wprost, że istnieje
|
||||||
|
# więcej, niż widać na ekranie. Tu jest jawna lista: arkusz logowania jest
|
||||||
|
# publiczny, bo
|
||||||
|
# potrzebuje go strona logowania, a cokolwiek innego jest nieznane i nie istnieje.
|
||||||
|
ZASOBY_PUBLICZNE = frozenset({"styles.css"})
|
||||||
|
_KATALOG_ZASOBOW = Path("app/static")
|
||||||
|
|
||||||
|
|
||||||
|
def _bez_komentarzy(tresc: str) -> str:
|
||||||
|
"""Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt.
|
||||||
|
W repozytorium zostaje — do odpowiedzi nie ma po co trafiać."""
|
||||||
|
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def _zasob(nazwa: str) -> bytes | None:
|
||||||
|
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
|
||||||
|
if nazwa not in ZASOBY_PUBLICZNE:
|
||||||
|
return None
|
||||||
|
sciezka = (_KATALOG_ZASOBOW / nazwa).resolve()
|
||||||
|
# Wyjście poza katalog: `..` w ścieżce albo dowiązanie na zewnątrz.
|
||||||
|
if _KATALOG_ZASOBOW.resolve() not in sciezka.parents or not sciezka.is_file():
|
||||||
|
return None
|
||||||
|
if sciezka.suffix in (".css", ".js"):
|
||||||
|
return _bez_komentarzy(sciezka.read_text(encoding="utf-8")).encode("utf-8")
|
||||||
|
return sciezka.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/static/{nazwa:path}")
|
||||||
|
def zasob(nazwa: str):
|
||||||
|
tresc = _zasob(nazwa)
|
||||||
|
if tresc is None:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
typ = "text/css; charset=utf-8" if nazwa.endswith(".css") else "application/octet-stream"
|
||||||
|
return Response(tresc, media_type=typ,
|
||||||
|
headers={"Cache-Control": "public, max-age=3600"})
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _blad).
|
||||||
|
log = logging.getLogger("astrodemo")
|
||||||
|
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
|
||||||
|
templates.env.globals["auth_on"] = security.auth_enabled
|
||||||
|
|
||||||
|
def _logic(request: Request) -> LogicClient:
|
||||||
|
"""Klient związany z pulą ZALOGOWANEGO konta.
|
||||||
|
|
||||||
|
Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się
|
||||||
|
zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state,
|
||||||
|
które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy
|
||||||
|
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
|
||||||
|
return LogicClient(tenant=getattr(request.state, "user", "") or "")
|
||||||
|
|
||||||
|
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
|
||||||
|
# tutaj wprost, bo astrodemo nie importuje tamtej warstwy; literówka objawiłaby
|
||||||
|
# się dopiero na żywo, więc pilnuje jej test.
|
||||||
|
ACTIVE = "active"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||||
|
"""Data + godzina lokalna + offset → chwila w UTC.
|
||||||
|
|
||||||
|
Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma
|
||||||
|
dwie funkcje, a strefy to trzecia."""
|
||||||
|
try:
|
||||||
|
local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M")
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e
|
||||||
|
moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset)))
|
||||||
|
return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
# Jedno zdanie dla wszystkich awarii i CELOWO żadnego szczegółu. Treść wyjątku
|
||||||
|
# niosła nazwę usługi, jej port i nazwy jej tras — czyli mapę tego, co stoi za tym
|
||||||
|
# ekranem, podaną komuś, kto ma widzieć wyłącznie dwa formularze.
|
||||||
|
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
|
||||||
|
|
||||||
|
|
||||||
|
def _blad(e: Exception) -> str:
|
||||||
|
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran."""
|
||||||
|
log.warning("operacja nieudana: %s: %s", type(e).__name__, e)
|
||||||
|
return _AWARIA
|
||||||
|
|
||||||
|
|
||||||
|
def _ile_rekordow(report: dict) -> int:
|
||||||
|
return sum(len(s.get("samples") or []) for p in (report.get("positions") or [])
|
||||||
|
for s in (p.get("significators") or []))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def formularz(request: Request, error: str = "", done: str = ""):
|
||||||
|
return templates.TemplateResponse(request, "index.html",
|
||||||
|
{"result": None, "form": {}, "error": error, "done": done})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/", response_class=HTMLResponse)
|
||||||
|
def interpretacja(request: Request, date: str = Form(...), time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)):
|
||||||
|
"""Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść."""
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
ctx["result"] = _logic(request).report(when_utc_iso=iso_utc, lat=lat, lon=lon)
|
||||||
|
# Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17).
|
||||||
|
security.audit_records(request, _ile_rekordow(ctx["result"]))
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = str(e)
|
||||||
|
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
||||||
|
ctx["error"] = _blad(e)
|
||||||
|
return templates.TemplateResponse(request, "index.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/plik")
|
||||||
|
async def dodaj_plik(request: Request, upload: UploadFile = File(...)):
|
||||||
|
"""Dodanie pliku bazy: wgranie ORAZ włączenie do użytku.
|
||||||
|
|
||||||
|
Jedna czynność, nie dwie: „dodać plik" ma tu znaczyć, że od razu bierze udział
|
||||||
|
w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i wyglądałoby to
|
||||||
|
na zepsute.
|
||||||
|
|
||||||
|
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
|
||||||
|
zapisany i czeka."""
|
||||||
|
raw = await upload.read()
|
||||||
|
if not raw:
|
||||||
|
return RedirectResponse("/?error=Pusty+plik.", status_code=303)
|
||||||
|
|
||||||
|
who = getattr(request.state, "user", "") or "demo"
|
||||||
|
try:
|
||||||
|
out = _logic(request).files_upload(upload.filename or "baza.xlsx",
|
||||||
|
base64.b64encode(raw).decode("ascii"), by=who)
|
||||||
|
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
||||||
|
return _wroc(error=_blad(e))
|
||||||
|
|
||||||
|
nazwa = out.get("name") or "plik"
|
||||||
|
if not out.get("accepted"):
|
||||||
|
# Bez słowa o regułach — te zna wyłącznie administrator (DAN-27).
|
||||||
|
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
|
||||||
|
f"do użycia w tej postaci.")
|
||||||
|
try:
|
||||||
|
_logic(request).files_status(out["path"], ACTIVE, by=who)
|
||||||
|
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
||||||
|
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}")
|
||||||
|
return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.")
|
||||||
|
|
||||||
|
|
||||||
|
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
|
||||||
|
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||||||
|
wgrać pliku drugi raz."""
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||||
|
return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
|
||||||
|
# Bez własnego wolumenu nie ma gdzie trzymać licznika pokolenia sesji, więc
|
||||||
|
# „wyrzuć ze wszystkich urządzeń" robi się zmianą hasła w ASTRODEMO_USERS.
|
||||||
|
# Wylogowanie działa natychmiast, bo polega na skasowaniu ciasteczka.
|
||||||
|
|
||||||
|
def _bezpieczne_dokad(dokad: str) -> str:
|
||||||
|
"""Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl`
|
||||||
|
zamieniłby ekran logowania w narzędzie do wyłudzania haseł."""
|
||||||
|
dokad = (dokad or "").strip()
|
||||||
|
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
|
||||||
|
return "/"
|
||||||
|
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logowanie", response_class=HTMLResponse)
|
||||||
|
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
|
||||||
|
if security.auth_enabled() and security.principal(request):
|
||||||
|
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
|
||||||
|
return templates.TemplateResponse(request, "logowanie.html",
|
||||||
|
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logowanie")
|
||||||
|
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
|
||||||
|
dokad: str = Form("/")):
|
||||||
|
cel = _bezpieczne_dokad(dokad)
|
||||||
|
kto = security.verify(login.strip(), haslo)
|
||||||
|
if kto is None:
|
||||||
|
security.audit_login(request, login.strip(), udane=False)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "logowanie.html",
|
||||||
|
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
|
||||||
|
status_code=401)
|
||||||
|
security.audit_login(request, kto, udane=True)
|
||||||
|
odpowiedz = RedirectResponse(cel, status_code=303)
|
||||||
|
odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto),
|
||||||
|
max_age=session.max_age(),
|
||||||
|
**session.cookie_params(secure=security.cookies_secure()))
|
||||||
|
return odpowiedz
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/wyloguj")
|
||||||
|
def wyloguj(request: Request):
|
||||||
|
"""POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie,
|
||||||
|
żeby wylogować kogoś bez jego wiedzy."""
|
||||||
|
security.audit_logout(request)
|
||||||
|
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
|
||||||
|
odpowiedz.delete_cookie(session.COOKIE,
|
||||||
|
**session.cookie_params(secure=security.cookies_secure()))
|
||||||
|
return odpowiedz
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
# Sama „ok". Nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa;
|
||||||
|
# kubernetes i compose czytają wyłącznie kod odpowiedzi.
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Logowanie do astrodemo (PRE-28).
|
||||||
|
|
||||||
|
OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej
|
||||||
|
warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy
|
||||||
|
interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną
|
||||||
|
środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek.
|
||||||
|
|
||||||
|
Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont
|
||||||
|
i bez uprawnień. Ta usługa ma dwie funkcje i obie są dla tego samego człowieka —
|
||||||
|
model uprawnień z PRE-27 nie miałby tu czego dzielić.
|
||||||
|
|
||||||
|
Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem
|
||||||
|
co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
log = logging.getLogger("astrodemo.security")
|
||||||
|
audit_log = logging.getLogger("astrodemo.audit")
|
||||||
|
|
||||||
|
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
||||||
|
_HASH_PREFIX = "scrypt$"
|
||||||
|
|
||||||
|
# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być
|
||||||
|
# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/styles.css"})
|
||||||
|
# Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy
|
||||||
|
# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani
|
||||||
|
# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony
|
||||||
|
# z nazwy, bo potrzebuje go ekran logowania.
|
||||||
|
PUBLIC_PREFIXES: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_hits: dict[str, deque[float]] = {}
|
||||||
|
MAX_TRACKED_CLIENTS = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def accounts() -> dict[str, str]:
|
||||||
|
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_USERS.
|
||||||
|
|
||||||
|
WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29).
|
||||||
|
Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem
|
||||||
|
swoje wgrania.
|
||||||
|
|
||||||
|
Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten
|
||||||
|
sam skrypt make_user.py, co w głównej aplikacji.
|
||||||
|
|
||||||
|
Zgodność wstecz: pojedyncze ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS
|
||||||
|
jest puste."""
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for entry in os.getenv("ASTRODEMO_USERS", "").split(","):
|
||||||
|
entry = entry.strip()
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
user, sep, secret = entry.partition(":")
|
||||||
|
if sep and user.strip() and secret.strip():
|
||||||
|
out[user.strip()] = secret.strip()
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
single = os.getenv("ASTRODEMO_PASSWORD", "")
|
||||||
|
return {os.getenv("ASTRODEMO_USER", "demo"): single} if single else {}
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit_per_min() -> int:
|
||||||
|
return int(os.getenv("RATE_LIMIT_PER_MIN", "60"))
|
||||||
|
|
||||||
|
|
||||||
|
def trust_proxy() -> bool:
|
||||||
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def auth_enabled() -> bool:
|
||||||
|
return bool(accounts())
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_matches(spec: str, password: str) -> bool:
|
||||||
|
if spec.startswith(_HASH_PREFIX):
|
||||||
|
try:
|
||||||
|
_, salt_hex, hash_hex = spec.split("$", 2)
|
||||||
|
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
||||||
|
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
||||||
|
return hmac.compare_digest(actual, expected)
|
||||||
|
# BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach
|
||||||
|
# spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy.
|
||||||
|
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def verify(login: str, password: str) -> str | None:
|
||||||
|
"""Sprawdza login i hasło; zwraca login albo None.
|
||||||
|
|
||||||
|
Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
|
||||||
|
konto istnieje, a przy demo rozdawanym szeroko to realna informacja."""
|
||||||
|
spec = accounts().get(login)
|
||||||
|
if spec is None:
|
||||||
|
return None
|
||||||
|
return login if _secret_matches(spec, password) else None
|
||||||
|
|
||||||
|
|
||||||
|
def issue_session(login: str) -> str:
|
||||||
|
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
||||||
|
|
||||||
|
W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie
|
||||||
|
konta z ASTRODEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny
|
||||||
|
sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu,
|
||||||
|
bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów
|
||||||
|
byłby pomieszaniem dwóch zupełnie różnych rzeczy."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(request: Request) -> str | None:
|
||||||
|
"""Ciasteczko → login zalogowanej osoby albo None."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
dane = session.read(request.cookies.get(session.COOKIE))
|
||||||
|
if dane is None:
|
||||||
|
return None
|
||||||
|
spec = accounts().get(dane["login"])
|
||||||
|
if spec is None or session.fingerprint(spec) != dane["odcisk"]:
|
||||||
|
return None
|
||||||
|
return dane["login"]
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy —
|
||||||
|
inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit."""
|
||||||
|
peer = request.client.host if request.client else "?"
|
||||||
|
if not trust_proxy():
|
||||||
|
return peer
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded:
|
||||||
|
last = forwarded.rsplit(",", 1)[-1].strip()
|
||||||
|
if last:
|
||||||
|
return last
|
||||||
|
return request.headers.get("x-real-ip", "").strip() or peer
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limited(client: str) -> bool:
|
||||||
|
cap = rate_limit_per_min()
|
||||||
|
if cap <= 0:
|
||||||
|
return False
|
||||||
|
now = time.monotonic()
|
||||||
|
window = _hits.get(client)
|
||||||
|
if window is None:
|
||||||
|
if len(_hits) >= MAX_TRACKED_CLIENTS:
|
||||||
|
_hits.clear()
|
||||||
|
window = _hits[client] = deque()
|
||||||
|
while window and now - window[0] > 60.0:
|
||||||
|
window.popleft()
|
||||||
|
if len(window) >= cap:
|
||||||
|
return True
|
||||||
|
window.append(now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_audit_logging() -> None:
|
||||||
|
"""Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje
|
||||||
|
naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był
|
||||||
|
pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17)."""
|
||||||
|
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
|
||||||
|
if not audit_log.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
audit_log.addHandler(handler)
|
||||||
|
audit_log.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
def audit_records(request: Request, count: int) -> None:
|
||||||
|
try:
|
||||||
|
request.state.audit_records = int(count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cookies_secure() -> bool:
|
||||||
|
"""Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http —
|
||||||
|
inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa"
|
||||||
|
bez żadnego komunikatu."""
|
||||||
|
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def audit_login(request: Request, login: str, udane: bool) -> None:
|
||||||
|
"""Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza
|
||||||
|
nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak,
|
||||||
|
hasło NIGDY."""
|
||||||
|
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
|
||||||
|
"logowanie-ok" if udane else "logowanie-odmowa",
|
||||||
|
login or "-", client_ip(request))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_logout(request: Request) -> None:
|
||||||
|
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
|
||||||
|
getattr(request.state, "user", "") or "-", client_ip(request))
|
||||||
|
|
||||||
|
|
||||||
|
def install(app) -> None:
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
_setup_audit_logging()
|
||||||
|
|
||||||
|
# Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od
|
||||||
|
# podrobionych. Lepiej, żeby usługa nie wstała.
|
||||||
|
if auth_enabled() and not session.available():
|
||||||
|
raise session.SessionError(
|
||||||
|
"Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
||||||
|
"podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32")
|
||||||
|
|
||||||
|
if not auth_enabled():
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: brak kont (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo stoi OTWARTY. "
|
||||||
|
"Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym "
|
||||||
|
"zbiorze baz, widząc nawzajem swoje wgrania."
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _guard(request: Request, call_next):
|
||||||
|
if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
if _rate_limited(client_ip(request)):
|
||||||
|
return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
||||||
|
status_code=429, headers={"Retry-After": "60"})
|
||||||
|
|
||||||
|
user, odswiez = "", None
|
||||||
|
if auth_enabled():
|
||||||
|
user = principal(request) or ""
|
||||||
|
if not user:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
cel = request.url.path
|
||||||
|
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
|
||||||
|
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
|
||||||
|
from app import session as _s
|
||||||
|
|
||||||
|
dane = _s.read(request.cookies.get(_s.COOKIE))
|
||||||
|
odswiez = _s.refresh(dane) if dane else None
|
||||||
|
request.state.user = user
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
if odswiez:
|
||||||
|
from app import session as _s
|
||||||
|
|
||||||
|
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
|
||||||
|
**_s.cookie_params(secure=cookies_secure()))
|
||||||
|
records = getattr(request.state, "audit_records", None)
|
||||||
|
audit_log.info(
|
||||||
|
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
||||||
|
user or "-", client_ip(request), request.method, request.url.path,
|
||||||
|
response.status_code, "-" if records is None else records,
|
||||||
|
(time.monotonic() - started) * 1000)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Sesje logowania — podpisane ciasteczko (LOG-34).
|
||||||
|
|
||||||
|
Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi
|
||||||
|
obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka.
|
||||||
|
|
||||||
|
|
||||||
|
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
|
||||||
|
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
|
||||||
|
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
|
||||||
|
w środku, i może tę decyzję cofnąć.
|
||||||
|
|
||||||
|
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
|
||||||
|
|
||||||
|
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
|
||||||
|
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
|
||||||
|
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
|
||||||
|
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
|
||||||
|
o tym — i też nie wymaga magazynu.
|
||||||
|
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
|
||||||
|
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
|
||||||
|
nie poda licznika, poziomy 1 i 2 nadal działają.
|
||||||
|
|
||||||
|
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
|
||||||
|
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
|
||||||
|
dostępu nie odbierałoby dostępu.
|
||||||
|
|
||||||
|
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
|
||||||
|
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
|
||||||
|
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
|
||||||
|
|
||||||
|
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
|
||||||
|
w PRE-27 i AES w PRE-16.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
|
||||||
|
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
|
||||||
|
# z jednej, logując się do drugiej.
|
||||||
|
COOKIE = "astrodemo_sesja"
|
||||||
|
|
||||||
|
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
|
||||||
|
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
|
||||||
|
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
|
||||||
|
|
||||||
|
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
|
||||||
|
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
|
||||||
|
REFRESH_AFTER = 300
|
||||||
|
|
||||||
|
|
||||||
|
class SessionError(RuntimeError):
|
||||||
|
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.getenv(name, "") or default)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def max_age() -> int:
|
||||||
|
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
|
||||||
|
|
||||||
|
|
||||||
|
def idle_timeout() -> int:
|
||||||
|
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
|
||||||
|
|
||||||
|
|
||||||
|
def secret() -> bytes:
|
||||||
|
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
|
||||||
|
|
||||||
|
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
|
||||||
|
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
|
||||||
|
raw = os.getenv("SESSION_SECRET", "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise SessionError(
|
||||||
|
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
|
||||||
|
"Wygeneruj: openssl rand -hex 32")
|
||||||
|
if len(raw) < 32:
|
||||||
|
raise SessionError(
|
||||||
|
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
|
||||||
|
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
|
||||||
|
return raw.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> bool:
|
||||||
|
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
|
||||||
|
try:
|
||||||
|
secret()
|
||||||
|
except SessionError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(secret_spec: str) -> str:
|
||||||
|
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
|
||||||
|
|
||||||
|
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
|
||||||
|
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
|
||||||
|
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
|
||||||
|
unieważniły jego sesje same z siebie."""
|
||||||
|
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(payload: bytes) -> bytes:
|
||||||
|
return hmac.new(secret(), payload, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(raw: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(text: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
|
||||||
|
"""Nowe ciasteczko sesji dla konta."""
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
payload = json.dumps(
|
||||||
|
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
|
||||||
|
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
||||||
|
|
||||||
|
|
||||||
|
def read(token: str | None, *, now: float | None = None) -> dict | None:
|
||||||
|
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
|
||||||
|
|
||||||
|
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
|
||||||
|
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
|
||||||
|
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
surowy, _, podpis = token.rpartition(".")
|
||||||
|
try:
|
||||||
|
payload = _unb64(surowy)
|
||||||
|
oczekiwany = _sign(payload)
|
||||||
|
podany = _unb64(podpis)
|
||||||
|
except (ValueError, SessionError):
|
||||||
|
return None
|
||||||
|
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
|
||||||
|
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
|
||||||
|
if not hmac.compare_digest(oczekiwany, podany):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dane = json.loads(payload.decode("utf-8"))
|
||||||
|
login, odcisk = str(dane["u"]), str(dane["f"])
|
||||||
|
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
|
||||||
|
# w obu wypadkach nie ma powodu mu ufać.
|
||||||
|
if wyd > teraz + 60:
|
||||||
|
return None
|
||||||
|
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
|
||||||
|
return None
|
||||||
|
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh(dane: dict, *, now: float | None = None) -> str | None:
|
||||||
|
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
|
||||||
|
|
||||||
|
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
|
||||||
|
Set-Cookie — także po arkusze stylów i obrazki."""
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
|
||||||
|
return None
|
||||||
|
payload = json.dumps(
|
||||||
|
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
|
||||||
|
"wyd": dane["wydano"], "akt": teraz},
|
||||||
|
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_params(secure: bool = True) -> dict:
|
||||||
|
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
|
||||||
|
|
||||||
|
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
|
||||||
|
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
|
||||||
|
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
|
||||||
|
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/* Arkusz CELOWO minimalny: dwa formularze i tabela wyników. Każda klasa ponad to
|
||||||
|
niosłaby nazwę funkcji, której tu nie ma. */
|
||||||
|
:root {
|
||||||
|
--bg: #11121b; --panel: #191b26; --line: #2b2e3d;
|
||||||
|
--fg: #e8e9f0; --muted: #9aa0b4; --accent: #8ab4ff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--fg);
|
||||||
|
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
||||||
|
.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line);
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
||||||
|
.kto { display: flex; align-items: center; gap: .5rem; }
|
||||||
|
.whoami { color: var(--muted); font-size: .85rem; }
|
||||||
|
.wyloguj-form { display: inline; margin: 0; }
|
||||||
|
button.wyloguj {
|
||||||
|
background: none; border: 1px solid var(--line); color: var(--muted);
|
||||||
|
font: inherit; font-weight: 400; padding: .35rem .8rem; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button.wyloguj:hover { color: var(--fg); }
|
||||||
|
|
||||||
|
/* Ekran logowania — wąski, bez niczego poza formularzem. */
|
||||||
|
.login-card label { display: block; margin-top: .75rem; }
|
||||||
|
.login-card input { width: 100%; }
|
||||||
|
h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; }
|
||||||
|
h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||||
|
h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-top: 1.25rem; padding: 1rem 1.25rem;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
}
|
||||||
|
.row { display: flex; gap: .75rem; flex-wrap: wrap; align-items: flex-end; margin-top: .6rem; }
|
||||||
|
label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 10rem; }
|
||||||
|
input {
|
||||||
|
width: 100%; padding: .45rem .6rem; background: #0e0f18; color: var(--fg);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; font: inherit;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: .5rem 1.25rem; background: var(--accent); color: #10121a;
|
||||||
|
border: none; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: .3rem; }
|
||||||
|
td { padding: .35rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; font-size: .92rem; }
|
||||||
|
td.key { color: var(--accent); white-space: nowrap; padding-right: 1rem; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.sig { margin-top: .75rem; }
|
||||||
|
.sig-head { color: var(--muted); font-size: .85rem; }
|
||||||
|
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: .85rem; }
|
||||||
|
.foot { margin-top: 2.5rem; color: var(--muted); font-size: .8rem; }
|
||||||
|
|
||||||
|
.alert, .ok {
|
||||||
|
margin: 1rem 0; padding: .6rem .9rem; border-radius: 8px; font-size: .92rem;
|
||||||
|
border: 1px solid var(--line); border-left-width: 4px;
|
||||||
|
}
|
||||||
|
.alert { border-left-color: #b8860b; background: rgba(184, 134, 11, .1); }
|
||||||
|
.ok { border-left-color: var(--accent); background: rgba(138, 180, 255, .08); }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>astrodemo · {% block title %}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>astrodemo</h1>
|
||||||
|
{# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach,
|
||||||
|
więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #}
|
||||||
|
{% if auth_on() %}
|
||||||
|
<div class="kto">
|
||||||
|
<span class="whoami">{{ current_user(request) }}</span>
|
||||||
|
<form method="post" action="/wyloguj" class="wyloguj-form">
|
||||||
|
<button type="submit" class="wyloguj">Wyloguj</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie,
|
||||||
|
a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
|
||||||
|
</header>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
<footer class="foot">wersja demonstracyjna</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Interpretacja{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
|
||||||
|
{% if done %}<p class="ok">{{ done }}</p>{% endif %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Dodaj bazę</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/plik" enctype="multipart/form-data">
|
||||||
|
<div class="row">
|
||||||
|
<label>Plik <input type="file" name="upload" required></label>
|
||||||
|
<button type="submit">Dodaj</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Zapytaj o interpretację urodzeniową</h2>
|
||||||
|
<form method="post" action="/">
|
||||||
|
<div class="row">
|
||||||
|
<label>Data urodzenia
|
||||||
|
<input type="date" name="date" value="{{ form.date or '' }}" required></label>
|
||||||
|
<label>Godzina (lokalna)
|
||||||
|
<input type="time" name="time" value="{{ form.time or '' }}" required></label>
|
||||||
|
<label>Offset względem GMT (h)
|
||||||
|
<input type="number" name="tz_offset" step="0.5" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}"></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Szerokość (lat, + N)
|
||||||
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}" required></label>
|
||||||
|
<label>Długość (lon, + E)
|
||||||
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}" required></label>
|
||||||
|
<button type="submit">Szukaj interpretacji</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
<p class="meta">
|
||||||
|
Moment: <strong>{{ moment }}</strong>
|
||||||
|
{% if result.engine %}· silnik: {{ result.engine }}{% endif %}
|
||||||
|
{% if result.objects %}· obiektów: {{ result.objects | length }}{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Kształt odpowiedzi: objects[] → facets[] → samples[]. Pytamy bez grupowania,
|
||||||
|
więc `groups` tu nie wystąpi i nie ma czego obsługiwać. #}
|
||||||
|
{% set znaleziono = namespace(ile=0) %}
|
||||||
|
{% for o in result.objects or [] %}
|
||||||
|
{% set trafienia = o.facets | selectattr('samples') | list %}
|
||||||
|
{% if trafienia %}
|
||||||
|
{% set znaleziono.ile = znaleziono.ile + trafienia | length %}
|
||||||
|
<section class="card">
|
||||||
|
<h3>{{ o.object }}
|
||||||
|
<span class="muted">w {{ o.sign }}{% if o.house %}, {{ o.house }}. dom{% endif %}
|
||||||
|
{%- if o.direction == 'Rx' %} · Rx{% endif %}</span>
|
||||||
|
</h3>
|
||||||
|
{% for f in trafienia %}
|
||||||
|
<div class="sig">
|
||||||
|
<div class="sig-head">{{ f.label }} <span class="muted small">— {{ f.count }} dopasowań</span></div>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{% for s in f.samples %}
|
||||||
|
<tr>
|
||||||
|
<td class="key" title="{{ s.significator }}">{{ s.expanded }}</td>
|
||||||
|
<td>{{ s.effect }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if znaleziono.ile == 0 %}
|
||||||
|
<p class="muted">
|
||||||
|
Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę
|
||||||
|
zawierającą pasujące opisy.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>astrodemo · logowanie</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="login-main">
|
||||||
|
<header class="topbar"><h1>astrodemo</h1></header>
|
||||||
|
|
||||||
|
{% if request.query_params.get('wylogowano') %}
|
||||||
|
<p class="ok">Wylogowano.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if blad %}<p class="alert">{{ blad }}</p>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/logowanie" class="card login-card">
|
||||||
|
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
|
||||||
|
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
|
||||||
|
<input type="hidden" name="dokad" value="{{ dokad }}">
|
||||||
|
<label>Login
|
||||||
|
<input type="text" name="login" value="{{ login or '' }}" required
|
||||||
|
autocomplete="username" autofocus></label>
|
||||||
|
<label>Hasło
|
||||||
|
<input type="password" name="haslo" required autocomplete="current-password"></label>
|
||||||
|
<div class="actions"><button type="submit">Zaloguj</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted small login-note">
|
||||||
|
Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała
|
||||||
|
Ci dostęp — kont nie da się odzyskać samodzielnie.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# CELOWO KRÓTSZA niż w warstwie prezentacji. Astroklient ma dwie funkcje, więc nie
|
||||||
|
# potrzebuje ani Excela (brak eksportu), ani stref czasowych z lokalizacji (offset
|
||||||
|
# podaje się wprost), ani niczego pod kosmogram — a każda zbędna zależność w obrazie
|
||||||
|
# demo to kolejna rzecz do pilnowania i kolejna droga do środka.
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
httpx>=0.28
|
||||||
|
jinja2>=3.1
|
||||||
|
python-multipart>=0.0.20
|
||||||
|
# Szyfrowanie łącza do warstwy logicznej (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""astrodemo — wersja demonstracyjna (PRE-28).
|
||||||
|
|
||||||
|
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
|
||||||
|
|
||||||
|
1. Demo ma DWIE funkcje i ani jednej więcej. Sprawdzamy to na trasach aplikacji,
|
||||||
|
nie w szablonie: trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem,
|
||||||
|
bo ta usługa rozmawia z warstwą danych produkcji.
|
||||||
|
|
||||||
|
2. „Dodać plik do bazy" znaczy, że plik ZACZYNA BYĆ UŻYWANY. W pełnej aplikacji
|
||||||
|
wgranie i włączenie to dwie decyzje (DAN-27); tutaj muszą być jedną, inaczej
|
||||||
|
po wgraniu nic się nie zmienia i demo wygląda na zepsute.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB")
|
||||||
|
monkeypatch.delenv("ASTRODEMO_USER", raising=False)
|
||||||
|
monkeypatch.delenv("ASTRODEMO_PASSWORD", raising=False)
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||||
|
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(user="demo", password=""):
|
||||||
|
"""Nagłówek z WAŻNĄ SESJĄ — odpowiednik bycia zalogowanym.
|
||||||
|
|
||||||
|
Po przejściu z Basic na sesje (LOG-34) „zalogowany" znaczy „ma podpisane
|
||||||
|
ciasteczko", nie „ma nagłówek z hasłem". Hasło przyjmowane tylko po to, żeby
|
||||||
|
nie przepisywać wszystkich wywołań — sprawdzanie poświadczeń ma własne testy."""
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||||
|
|
||||||
|
|
||||||
|
REPORT = {
|
||||||
|
"engine": "test",
|
||||||
|
"objects": [{
|
||||||
|
"object": "Sun", "sign": "Taurus", "house": 11, "direction": "D",
|
||||||
|
"planet_token": "Su", "planet_total": 3,
|
||||||
|
"facets": [{"label": "w znaku Taurus", "token": "Su Tau", "count": 1, "score": 5.0,
|
||||||
|
"samples": [{"significator": "Su Tau", "expanded": "Sun in Taurus",
|
||||||
|
"effect": "wytrwały, praktyczny"}]}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch, calls=None, upload=None):
|
||||||
|
"""Podmieniamy METODY KLASY, nie obiekt modułowy: klient jest budowany per
|
||||||
|
żądanie, żeby pula wynikała z zalogowanego konta. Dzięki temu podglądamy
|
||||||
|
też `self.tenant`, czyli to, do której puli faktycznie poszło zapytanie."""
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.clients.logic_client import LogicClient
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
calls = calls if calls is not None else []
|
||||||
|
domyslny = {"path": "nowa.xlsx", "name": "nowa.xlsx", "accepted": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(LogicClient, "report",
|
||||||
|
lambda self, **kw: (calls.append(("report", self.tenant, kw)), REPORT)[1])
|
||||||
|
monkeypatch.setattr(LogicClient, "files_upload",
|
||||||
|
lambda self, *a, **kw: (calls.append(("upload", self.tenant, a)),
|
||||||
|
upload or domyslny)[1])
|
||||||
|
monkeypatch.setattr(LogicClient, "files_status",
|
||||||
|
lambda self, *a, **kw: (calls.append(("status", self.tenant, a)), {})[1])
|
||||||
|
return TestClient(app), calls
|
||||||
|
|
||||||
|
|
||||||
|
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||||
|
"lat": "50.06", "lon": "19.94"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── powierzchnia: dokładnie dwie funkcje ────────────────────────────────
|
||||||
|
|
||||||
|
def test_the_service_exposes_nothing_beyond_its_two_functions():
|
||||||
|
"""Trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem — ta usługa
|
||||||
|
rozmawia z warstwą danych PRODUKCJI."""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set())
|
||||||
|
if not r.path.startswith("/static") and m in {"GET", "POST"}}
|
||||||
|
assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health"),
|
||||||
|
("GET", "/logowanie"), ("POST", "/logowanie"), ("POST", "/wyloguj")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_client_can_only_ask_for_what_the_demo_needs():
|
||||||
|
"""Klient warstwy logicznej jest okrojony CELOWO: metody, której nie ma, nikt
|
||||||
|
przypadkiem nie zawoła i nie podpowie ona, że po drugiej stronie jest więcej."""
|
||||||
|
from app.clients.logic_client import LogicClient
|
||||||
|
|
||||||
|
publiczne = {n for n in dir(LogicClient) if not n.startswith("_")}
|
||||||
|
assert publiczne == {"report", "files_upload", "files_status", "health"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_docs_are_off():
|
||||||
|
"""/docs wypisałoby komplet tras — demo ma nie zdradzać własnej powierzchni."""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
assert app.docs_url is None and app.openapi_url is None and app.redoc_url is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── logowanie ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_without_credentials_nothing_is_served(env, monkeypatch):
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")):
|
||||||
|
r = getattr(c, method)(path, follow_redirects=False)
|
||||||
|
assert r.status_code == 303, path
|
||||||
|
assert "/logowanie" in r.headers["location"], path
|
||||||
|
assert c.get("/health").status_code == 200, "sonda musi działać bez hasła"
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_account_is_separate_from_the_main_application(env, monkeypatch):
|
||||||
|
"""Konto astrodemo MUSI być własne: demo pracuje na tej samej warstwie
|
||||||
|
danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł
|
||||||
|
w głównej aplikacji."""
|
||||||
|
monkeypatch.setenv("APP_USER", "astrololo")
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji")
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
assert security.verify("astrololo", "haslo-glownej-aplikacji") is None
|
||||||
|
assert security.verify("demo", "tajne-demo") == "demo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
|
||||||
|
monkeypatch.setenv("ASTRODEMO_USERS", "demo:zażółć-gęślą")
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── funkcja 1: dodanie pliku ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch):
|
||||||
|
"""Sedno wymagania: jedna czynność, nie dwie."""
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
||||||
|
files={"upload": ("baza.xlsx", b"zawartosc", "application/vnd.ms-excel")})
|
||||||
|
assert r.status_code == 303
|
||||||
|
rodzaje = [k[0] for k in calls]
|
||||||
|
assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}"
|
||||||
|
assert calls[1][2][1] == "active", "plik nie został włączony do użytku"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch):
|
||||||
|
"""Walidacja zostaje, ale jej REGUŁY zna wyłącznie administrator (DAN-27) —
|
||||||
|
demo nie ma prawa ich zdradzić, nawet mimochodem."""
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
|
c, calls = _client(monkeypatch, upload={"path": "zla.xlsx", "name": "zla.xlsx",
|
||||||
|
"accepted": False})
|
||||||
|
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
||||||
|
files={"upload": ("zla.xlsx", b"x", "text/plain")})
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert [k[0] for k in calls] == ["upload"], "odrzucony plik został włączony do użytku"
|
||||||
|
komunikat = unquote_plus(r.headers["location"])
|
||||||
|
for slowo in ("walidacj", "reguł", "kolumn", "rozszerzeni", "rozmiar"):
|
||||||
|
assert slowo not in komunikat, f"komunikat zdradza mechanizm: „{slowo}”"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_file_is_refused_before_anything_is_sent(env, monkeypatch):
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
||||||
|
files={"upload": ("pusty.xlsx", b"", "application/vnd.ms-excel")})
|
||||||
|
assert r.status_code == 303 and calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── funkcja 2: interpretacja urodzeniowa ────────────────────────────────
|
||||||
|
|
||||||
|
def test_asking_for_an_interpretation_returns_the_matches(env, monkeypatch):
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
r = c.post("/", headers=_auth(), data=FORM)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Sun in Taurus" in r.text and "wytrwały, praktyczny" in r.text
|
||||||
|
assert [k[0] for k in calls] == ["report"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch):
|
||||||
|
"""11:20 przy offsecie +2 to 09:20 UTC. Pomyłka tutaj przesuwa CAŁY horoskop,
|
||||||
|
a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost."""
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
c.post("/", headers=_auth(), data=FORM)
|
||||||
|
assert calls[0][2]["when_utc_iso"].startswith("1984-04-30T09:20")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch):
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
r = c.post("/", headers=_auth(), data={**FORM, "date": "30 kwietnia"})
|
||||||
|
assert r.status_code == 200 and "RRRR-MM-DD" in r.text
|
||||||
|
assert calls == [], "poszło zapytanie mimo błędnych danych"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.clients.logic_client import LogicClient
|
||||||
|
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw(
|
||||||
|
httpx.ConnectError("brak połączenia")))
|
||||||
|
r = c.post("/", headers=_auth(), data=FORM)
|
||||||
|
# Komunikat jest CELOWO nieinformacyjny: treść wyjątku niosła nazwę usługi,
|
||||||
|
# jej port i nazwy tras, czyli mapę tego, co stoi za tym ekranem.
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Nie udało się teraz wykonać tej operacji" in r.text
|
||||||
|
assert "Warstwa logiczna" not in r.text and "8001" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch):
|
||||||
|
"""Demo ma wyglądać na KOMPLETNE w swojej postaci — żadnych śladów funkcji,
|
||||||
|
których nie ma (ta sama zasada, co przy uprawnieniach w PRE-27)."""
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get("/", headers=_auth()).text
|
||||||
|
for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia",
|
||||||
|
"Konta", "PDF", "Excel", "prompt", "model"):
|
||||||
|
assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie"
|
||||||
|
|
||||||
|
|
||||||
|
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
|
||||||
|
# Demo jest udostępniane szeroko i różnym osobom, więc pula musi wynikać
|
||||||
|
# z tego, KTO SIĘ ZALOGOWAŁ — i z niczego innego. Wyciek byłby cichy: klient
|
||||||
|
# zobaczyłby cudze bazy jako własne.
|
||||||
|
|
||||||
|
def test_the_pool_is_taken_from_the_login(env, monkeypatch):
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
c.post("/", headers=_auth("klientA", "hasloA"), data=FORM)
|
||||||
|
c.post("/", headers=_auth("klientB", "hasloB"), data=FORM)
|
||||||
|
assert [k[1] for k in calls] == ["klientA", "klientB"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploads_go_to_the_pool_of_the_account_that_sent_them(env, monkeypatch):
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
c.post("/plik", headers=_auth("klientA", "hasloA"), follow_redirects=False,
|
||||||
|
files={"upload": ("baza.xlsx", b"x", "application/vnd.ms-excel")})
|
||||||
|
assert {k[1] for k in calls} == {"klientA"}, "wgranie trafiło do cudzej puli"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pool_cannot_be_chosen_from_the_form(env, monkeypatch):
|
||||||
|
"""Gdyby pula przychodziła z żądania, wystarczyłoby podstawić cudzy login."""
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
c.post("/", headers=_auth("klientA", "hasloA"),
|
||||||
|
data={**FORM, "tenant": "klientB", "user": "klientB", "login": "klientB"})
|
||||||
|
assert [k[1] for k in calls] == ["klientA"], "pula dała się podmienić z formularza"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_account_has_its_own_pool_by_construction(env, monkeypatch):
|
||||||
|
"""Każdy login = inna pula. Dwa konta nie mogą dzielić zbioru baz."""
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
for login, haslo in (("demo", "tajne-demo"), ("klientA", "hasloA"), ("klientB", "hasloB")):
|
||||||
|
c.post("/", headers=_auth(login, haslo), data=FORM)
|
||||||
|
pule = [k[1] for k in calls]
|
||||||
|
assert len(set(pule)) == len(pule) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_account_gets_nothing(env, monkeypatch):
|
||||||
|
"""Bez ważnej sesji żądanie nie schodzi niżej — nie wystarczy, że skończy
|
||||||
|
się przekierowaniem: nie może po drodze dotknąć warstwy logicznej."""
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
r = c.post("/", data=FORM, follow_redirects=False)
|
||||||
|
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
|
||||||
|
assert calls == [], "poszło zapytanie mimo braku sesji"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_in_and_out_of_the_demo(env, monkeypatch):
|
||||||
|
"""Pełny obieg: demo bywa używane na cudzych komputerach, więc wyjście musi
|
||||||
|
naprawdę kończyć sesję, a nie tylko tak wyglądać."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
r = c.post("/logowanie", follow_redirects=False,
|
||||||
|
data={"login": "klientA", "haslo": "hasloA", "dokad": "/"})
|
||||||
|
assert r.status_code == 303 and session.COOKIE in r.cookies
|
||||||
|
|
||||||
|
wyjscie = c.post("/wyloguj", headers=_auth("klientA"), follow_redirects=False)
|
||||||
|
assert wyjscie.status_code == 303
|
||||||
|
ustawione = wyjscie.headers.get("set-cookie", "")
|
||||||
|
assert session.COOKIE in ustawione and "Max-Age=0" in ustawione
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_an_account_kills_its_open_session(env, monkeypatch):
|
||||||
|
"""Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi
|
||||||
|
się przez ASTRODEMO_USERS — usunięcie albo zmiana hasła ubija sesję odciskiem."""
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
ciasteczko = _auth("klientA")
|
||||||
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||||
|
|
||||||
|
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA
|
||||||
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_demo_login_cannot_be_turned_into_a_redirector(env, monkeypatch):
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x"):
|
||||||
|
r = c.post("/logowanie", follow_redirects=False,
|
||||||
|
data={"login": "demo", "haslo": "tajne-demo", "dokad": zly})
|
||||||
|
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Zapora słownikowa astrodemo: czego użytkownik demo nie może zobaczyć.
|
||||||
|
|
||||||
|
astrodemo stoi najniżej w drabinie trzech produktów (astrodemo → astroklient →
|
||||||
|
astrololo) i jest dawane najszerzej. Ma dwie funkcje i ma wyglądać, jakby to
|
||||||
|
było wszystko, co program potrafi.
|
||||||
|
|
||||||
|
Test nie sprawdza pojedynczych miejsc, tylko przechodzi WSZYSTKO, co da się
|
||||||
|
pobrać, i szuka słów, które nie mają prawa paść. Sprawdza też DRZEWO PLIKÓW
|
||||||
|
usługi — bo „nie ma śladu w kodzie" znaczy, że `grep` po obrazie ma nic nie
|
||||||
|
znaleźć, a nie tylko że odpowiedź jest czysta.
|
||||||
|
|
||||||
|
Powód, dla którego to powstało: komentarz w pierwszej linii styles.css mówił
|
||||||
|
„Nie kopiujemy stylów pełnej aplikacji", a arkusz pobierał anonim.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
|
||||||
|
# (a) że istnieje wersja obszerniejsza i jak się nazywa
|
||||||
|
WIEKSZY_BRAT = ["pełna aplikacja", "pełnej aplikacji", "pełna wersja", "pełnej wersji",
|
||||||
|
"astrololo", "astroklient", "wersji podstawowej", "upgrade", "rozszerzon"]
|
||||||
|
# (b) że tekst pisze model językowy
|
||||||
|
# Bez samego „token": w tej usłudze znaczy token sesji i token międzywarstwowy,
|
||||||
|
# a nie jednostkę rozliczeniową modelu. Zamiast tego zwroty jednoznaczne.
|
||||||
|
MODEL = ["LLM", "anthropic", "openai", "gpt", "claude", "prompt",
|
||||||
|
"generowanie tekstu", "sztuczn", "okno kontekstu", "model językow"]
|
||||||
|
# (c) funkcje, których astrodemo nie ma
|
||||||
|
INNE_FUNKCJE = ["kosmogram", "aspektarian", "synastri", "kalendarz", "predykcj",
|
||||||
|
"skompiluj", "system domów", "systemy domów", "deklinacj", "antyscj"]
|
||||||
|
# (d) że jest ktoś, kto widzi więcej, i że produkt jest w budowie
|
||||||
|
MECHANIZMY = ["administrator", "uprawnieni", "walidacj", "kwarantann", "roadmap",
|
||||||
|
"w budowie", "widok testowy", "warstwa logiczna", "layer"]
|
||||||
|
# Dwa poziomy, bo dwie różne drogi wycieku.
|
||||||
|
#
|
||||||
|
# WSZĘDZIE — także w kodzie serwera, którego przeglądarka nigdy nie zobaczy.
|
||||||
|
# Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie, przeczyta też
|
||||||
|
# komentarze i docstringi. Zdanie „w pełnej aplikacji to działa inaczej" mówi mu
|
||||||
|
# wprost, że istnieje pełna aplikacja — a to jest dokładnie to, czego ma nie
|
||||||
|
# wiedzieć. Komentarz ma tłumaczyć TĘ usługę, bez powoływania się na rodzeństwo.
|
||||||
|
WSZEDZIE = WIEKSZY_BRAT + MODEL + INNE_FUNKCJE
|
||||||
|
# TYLKO DO PRZEGLĄDARKI — słownictwo inżynierskie opisujące własne działanie tej
|
||||||
|
# usługi. W komentarzu serwera jest na miejscu; w odpowiedzi HTTP nie ma czego
|
||||||
|
# szukać, bo mówi o mechanizmach, o których użytkownik nie ma wiedzieć.
|
||||||
|
DO_PRZEGLADARKI = WSZEDZIE + MECHANIZMY
|
||||||
|
ZAKAZANE = DO_PRZEGLADARKI
|
||||||
|
WZORCE = [(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania"),
|
||||||
|
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
|
||||||
|
(r"(?i)\bAI\b", "model językowy")]
|
||||||
|
|
||||||
|
|
||||||
|
def _grzechy(tresc: str, gdzie: str, slownik: list[str] | None = None) -> list[str]:
|
||||||
|
out = []
|
||||||
|
for igla in (slownik if slownik is not None else ZAKAZANE):
|
||||||
|
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
|
||||||
|
out.append(f"{gdzie}: „{igla}”")
|
||||||
|
for wzor, powod in (WZORCE if slownik is None else WZORCE[-1:]):
|
||||||
|
m = re.search(wzor, tresc)
|
||||||
|
if m:
|
||||||
|
out.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def klient(monkeypatch):
|
||||||
|
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo")
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_forbidden_reaches_an_anonymous_visitor(klient):
|
||||||
|
grzechy, obejrzane = [], 0
|
||||||
|
for adres in ("/logowanie", "/health", "/static/styles.css"):
|
||||||
|
r = klient.get(adres)
|
||||||
|
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
||||||
|
obejrzane += 1
|
||||||
|
grzechy += _grzechy(r.text, adres)
|
||||||
|
assert obejrzane == 3
|
||||||
|
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_forbidden_reaches_a_logged_in_visitor(klient, monkeypatch):
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
|
||||||
|
# Warstwa logiczna niedostępna CELOWO: ekran idzie wtedy ścieżką błędu,
|
||||||
|
# a to komunikaty o awarii wypisywały nazwy warstw i tras.
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.main import LogicClient
|
||||||
|
|
||||||
|
monkeypatch.setattr(LogicClient, "report",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(httpx.ConnectError("nie da rady")))
|
||||||
|
grzechy = []
|
||||||
|
for adres in ("/", "/static/styles.css"):
|
||||||
|
r = klient.get(adres, headers=naglowki)
|
||||||
|
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
||||||
|
grzechy += _grzechy(r.text, adres)
|
||||||
|
r = klient.post("/", headers=naglowki,
|
||||||
|
data={"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||||
|
"lat": "50.06", "lon": "19.94"})
|
||||||
|
grzechy += _grzechy(r.text, "POST /")
|
||||||
|
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||||
|
|
||||||
|
|
||||||
|
# Stałe protokołu łącza (nagłówki, typ treści, etykieta HKDF) niosą nazwę rodziny
|
||||||
|
# produktów, ale są WSPÓLNE z warstwą logiczną: zmiana wymaga jednoczesnej zmiany
|
||||||
|
# we wszystkich usługach i rotacji, więc to osobna decyzja. Nie idą do przeglądarki.
|
||||||
|
BEZ_KONTROLI = {"app/link_crypto.py"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_source_tree_itself_carries_no_trace():
|
||||||
|
"""„Bez śladu w kodzie" znaczy: grep po obrazie ma nic nie znaleźć.
|
||||||
|
|
||||||
|
Odpowiedź może być czysta, a plik i tak nieść komentarz — dokładnie tak
|
||||||
|
wyciekał arkusz stylów, zanim komentarze zaczęły być zdejmowane."""
|
||||||
|
# Identyfikatory wymagań (PRE-28, LOG-34) zostają: to notatki inżynierskie
|
||||||
|
# o tej usłudze, nie informacja o istnieniu czegoś większego.
|
||||||
|
grzechy = []
|
||||||
|
for f in APP.rglob("*"):
|
||||||
|
wzgledna = str(f.relative_to(APP.parent))
|
||||||
|
if (not f.is_file() or "__pycache__" in str(f) or f.suffix == ".pyc"
|
||||||
|
or wzgledna in BEZ_KONTROLI):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
grzechy += _grzechy(f.read_text(encoding="utf-8"),
|
||||||
|
str(f.relative_to(APP.parent)), WSZEDZIE)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_asset_is_indistinguishable_from_one_that_never_existed(klient):
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
|
||||||
|
nieistniejacy = klient.get("/static/nie-ma-takiego.js", headers=naglowki)
|
||||||
|
for adres in ("/static/main.py", "/static/../app/config.py", "/static/vendor/cokolwiek.js"):
|
||||||
|
r = klient.get(adres, headers=naglowki)
|
||||||
|
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
|
||||||
|
assert r.text == nieistniejacy.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_protocol_constants_are_the_one_known_exception():
|
||||||
|
"""Świadomie zostawiona dziura — opisana, żeby nie była przypadkiem.
|
||||||
|
|
||||||
|
Stałe łącza międzywarstwowego (`X-Astrololo-Token`, `X-Astrololo-Enc`, typ
|
||||||
|
treści, etykieta HKDF) niosą nazwę rodziny produktów. Są WSPÓLNE z warstwą
|
||||||
|
logiczną, więc zmiana wymaga jednoczesnej podmiany we wszystkich usługach
|
||||||
|
i rotacji sekretów — to osobna decyzja, nie efekt uboczny tej zmiany.
|
||||||
|
|
||||||
|
Warunek, pod jakim to zostaje: te stałe nie mają prawa dotrzeć do
|
||||||
|
przeglądarki. Idą wyłącznie w nagłówkach żądań DO warstwy logicznej.
|
||||||
|
Ten test pilnuje właśnie tego warunku, a nie samego istnienia stałych.
|
||||||
|
"""
|
||||||
|
rdzen = "astrololo"
|
||||||
|
protokol = [f for f in APP.rglob("*.py")
|
||||||
|
if f.is_file() and rdzen in f.read_text(encoding="utf-8").lower()]
|
||||||
|
assert protokol, "stałe zniknęły — jeśli je przemianowano, ten wyjątek jest zbędny"
|
||||||
|
for f in protokol:
|
||||||
|
tresc = f.read_text(encoding="utf-8").lower()
|
||||||
|
for linia in tresc.splitlines():
|
||||||
|
if rdzen not in linia:
|
||||||
|
continue
|
||||||
|
# Każde wystąpienie musi być stałą protokołu albo nazwą kanału logów —
|
||||||
|
# nigdy tekstem, który mógłby trafić do szablonu.
|
||||||
|
assert any(z in linia for z in ("x-astrololo", "vnd.astrololo",
|
||||||
|
"astrololo/link/", 'getlogger("astrololo')), \
|
||||||
|
f"{f.name}: „{linia.strip()[:70]}” to nie jest stała protokołu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_application_code_ships_in_the_image():
|
||||||
|
"""Obraz nie może wieźć tego testu.
|
||||||
|
|
||||||
|
Ironia, którą łatwo przeoczyć: plik pilnujący, żeby pewne słowa nie padły,
|
||||||
|
sam je wypisuje — więc gdy trafi do obrazu, staje się tym wyciekiem, przed
|
||||||
|
którym broni. `COPY . .` wnosiło cały katalog usługi, razem z tests/.
|
||||||
|
"""
|
||||||
|
# Same instrukcje, bez komentarzy: komentarz obok tej linii CYTUJE dawną
|
||||||
|
# postać, więc szukanie po całym pliku zgłaszałoby własne wyjaśnienie.
|
||||||
|
instrukcje = [l for l in (APP.parent / "Dockerfile").read_text(encoding="utf-8").splitlines()
|
||||||
|
if l.strip() and not l.lstrip().startswith("#")]
|
||||||
|
assert not any(l.strip() == "COPY . ." for l in instrukcje), "obraz wnosi wszystko, w tym testy"
|
||||||
|
assert any(l.strip() == "COPY app ./app" for l in instrukcje)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY services/astroklient/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Rdzeń wspólny z pełną aplikacją: ekrany astrologiczne, szablony, zasoby.
|
||||||
|
# Jedno źródło, dwa produkty — inaczej te same 2500 linii szablonów żyłyby
|
||||||
|
# w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu.
|
||||||
|
COPY services/presentation/app ./app
|
||||||
|
|
||||||
|
# …minus wszystko, czego ten produkt nie ma. Lista jest DANYMI, a nie tekstem
|
||||||
|
# w tym pliku: czyta ją także test, który pilnuje, żeby zgadzała się ze
|
||||||
|
# złożeniem w main.py. Rozjazd między nimi znaczyłby albo martwy kod w obrazie,
|
||||||
|
# albo brakujący plik i błąd dopiero przy uruchomieniu.
|
||||||
|
COPY services/astroklient/usun.txt .
|
||||||
|
RUN while read -r SCIEZKA; do \
|
||||||
|
[ -n "$SCIEZKA" ] || continue; \
|
||||||
|
test -e "app/$SCIEZKA" || { echo "usun.txt wskazuje nieistniejące: $SCIEZKA"; exit 1; }; \
|
||||||
|
rm -rf "app/$SCIEZKA"; \
|
||||||
|
done < usun.txt && rm usun.txt
|
||||||
|
|
||||||
|
# Nakładka: własne logowanie, własna pula plików, własny ekran wgrywania,
|
||||||
|
# własne złożenie. Nadpisuje pliki o tych samych nazwach.
|
||||||
|
COPY services/astroklient/app ./app
|
||||||
|
|
||||||
|
EXPOSE 8006
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8006"]
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Klient warstwy logicznej — z PULĄ KONTA (PRE-29).
|
||||||
|
|
||||||
|
Każde konto ma własny, niewidoczny dla pozostałych zbiór plików. Pula jedzie
|
||||||
|
w każdym żądaniu w dół i bierze się WYŁĄCZNIE z kontekstu ustawianego przy
|
||||||
|
wejściu, nigdy z pola formularza: gdyby przychodziła z żądania, wystarczyłoby
|
||||||
|
podstawić cudzy login, żeby czytać cudze pliki.
|
||||||
|
|
||||||
|
Kontekst zamiast argumentu, bo ekrany są wspólne z pełną aplikacją i wołają
|
||||||
|
klienta jako obiekt modułowy. Przekazywanie puli przez wszystkie wywołania
|
||||||
|
znaczyłoby, że pominięcie jednego z nich cicho otwiera dostęp do całego udziału —
|
||||||
|
a takiego pominięcia nie widać.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Login zalogowanego konta na czas obsługi żądania. Ustawia go bramka wejścia.
|
||||||
|
KONTO: contextvars.ContextVar[str] = contextvars.ContextVar("konto", default="")
|
||||||
|
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> dict[str, str]:
|
||||||
|
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
|
||||||
|
token = os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
return {"X-Astrololo-Token": token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
|
||||||
|
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
|
class LogicClient:
|
||||||
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
|
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||||
|
|
||||||
|
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||||
|
# Pula konta dokładana do KAŻDEGO żądania w dół. Bierze się z kontekstu
|
||||||
|
# ustawianego przy wejściu, nigdy z pola formularza — gdyby przychodziła
|
||||||
|
# z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze pliki.
|
||||||
|
# Trasy, których pula nie dotyczy, po prostu ignorują nadmiarowe pole.
|
||||||
|
payload = {**payload, "tenant": KONTO.get("")}
|
||||||
|
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
|
||||||
|
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
|
||||||
|
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||||
|
payload=payload, headers=_auth_headers(),
|
||||||
|
link=_link())
|
||||||
|
|
||||||
|
# ── transport dla modułów opcjonalnych ─────────────────────────────────
|
||||||
|
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
|
||||||
|
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
|
||||||
|
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
|
||||||
|
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
|
||||||
|
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||||
|
return self._post(sciezka, payload, timeout)
|
||||||
|
|
||||||
|
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
|
||||||
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def strumien(self, sciezka: str, payload: dict[str, Any]):
|
||||||
|
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
|
||||||
|
|
||||||
|
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
|
||||||
|
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
|
||||||
|
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
||||||
|
yield from link_crypto.stream_lines(
|
||||||
|
client, f"{self.base_url}{sciezka}",
|
||||||
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||||
|
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||||
|
return self._post("/api/query", payload, settings.http_timeout)
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self,
|
||||||
|
when_utc_iso: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
objects: list[str] | None = None,
|
||||||
|
house_system: str = "whole_sign",
|
||||||
|
stations: bool = False,
|
||||||
|
zodiac: str = "tropical",
|
||||||
|
tables: bool = False,
|
||||||
|
house_systems: list[str] | None = None,
|
||||||
|
aspect_orb: float = 8.0,
|
||||||
|
aspect_luminary_bonus: float = 2.0,
|
||||||
|
aspect_minor: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||||
|
payload = {
|
||||||
|
"when_utc": when_utc_iso,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"objects": objects,
|
||||||
|
"house_system": house_system,
|
||||||
|
"house_systems": house_systems,
|
||||||
|
"aspect_orb": aspect_orb,
|
||||||
|
"aspect_luminary_bonus": aspect_luminary_bonus,
|
||||||
|
"aspect_minor": aspect_minor,
|
||||||
|
"stations": stations,
|
||||||
|
"zodiac": zodiac,
|
||||||
|
"tables": tables,
|
||||||
|
}
|
||||||
|
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
|
||||||
|
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
||||||
|
return self._post("/chart/positions", payload, timeout)
|
||||||
|
|
||||||
|
def synastry(
|
||||||
|
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
|
||||||
|
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
|
||||||
|
payload = {
|
||||||
|
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
|
||||||
|
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
|
||||||
|
"aspect_minor": aspect_minor,
|
||||||
|
}
|
||||||
|
return self._post("/chart/synastry", payload, settings.http_timeout)
|
||||||
|
|
||||||
|
def report(
|
||||||
|
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
||||||
|
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
||||||
|
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
||||||
|
|
||||||
|
def bases(self) -> dict[str, Any]:
|
||||||
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||||
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
|
||||||
|
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
|
||||||
|
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
|
||||||
|
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
|
||||||
|
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
|
||||||
|
|
||||||
|
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
|
||||||
|
|
||||||
|
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/upload",
|
||||||
|
{"filename": filename, "content_b64": content_b64, "by": by})
|
||||||
|
|
||||||
|
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
||||||
|
payload = {**payload, "tenant": KONTO.get("")}
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||||
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def timeline(
|
||||||
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
from_date: str, to_date: str, interpret: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
|
||||||
|
payload = {
|
||||||
|
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||||
|
}
|
||||||
|
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Wgrywanie plików: jedna czynność, nie dwie.
|
||||||
|
|
||||||
|
„Dodać plik" ma tu znaczyć, że od razu bierze udział w wyszukiwaniu — inaczej po
|
||||||
|
wgraniu nic by się nie zmieniło i wyglądałoby to na zepsute. Nie ma listy plików
|
||||||
|
ani ich stanów: ta usługa ma jeden poziom dostępu, więc nie ma komu ich
|
||||||
|
pokazywać ani po co.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import File, Request, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import app, features, logic, templates, _logic_error
|
||||||
|
|
||||||
|
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
|
||||||
|
# tutaj wprost, bo ta usługa tamtej warstwy nie importuje; literówka objawiłaby
|
||||||
|
# się dopiero na żywo, więc pilnuje jej test.
|
||||||
|
W_UZYCIU = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/pliki", response_class=HTMLResponse)
|
||||||
|
def wgrywanie_form(request: Request, error: str = "", done: str = ""):
|
||||||
|
return templates.TemplateResponse(request, "wgrywanie.html",
|
||||||
|
{"error": error, "done": done})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/pliki/wgraj")
|
||||||
|
async def wgraj(request: Request, upload: UploadFile = File(...)):
|
||||||
|
"""Wgranie ORAZ włączenie do użytku.
|
||||||
|
|
||||||
|
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
|
||||||
|
zapisany i czeka. Komunikat nie mówi, CZYM się nie nadaje: różnica w treści
|
||||||
|
byłaby wyrocznią, z której da się odczytać, czego program oczekuje.
|
||||||
|
"""
|
||||||
|
raw = await upload.read()
|
||||||
|
if not raw:
|
||||||
|
return _wroc(error="Pusty plik.")
|
||||||
|
|
||||||
|
kto = getattr(request.state, "user", "") or "?"
|
||||||
|
try:
|
||||||
|
out = logic.files_upload(upload.filename or "dane.xlsx",
|
||||||
|
base64.b64encode(raw).decode("ascii"), by=kto)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _wroc(error=_logic_error(e))
|
||||||
|
|
||||||
|
nazwa = out.get("name") or "plik"
|
||||||
|
if not out.get("accepted"):
|
||||||
|
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
|
||||||
|
f"do użycia w tej postaci.")
|
||||||
|
try:
|
||||||
|
logic.files_status(out["path"], W_UZYCIU, by=kto)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć.")
|
||||||
|
return _wroc(done=f"Dodano „{nazwa}”. Plik jest już używany przy wyszukiwaniu.")
|
||||||
|
|
||||||
|
|
||||||
|
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
|
||||||
|
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||||||
|
wgrać pliku drugi raz."""
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||||
|
return RedirectResponse(f"/pliki{'?' + q if q else ''}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
features.zarejestruj(
|
||||||
|
ekran=Feature("files", "Pliki", "Dodawanie plików używanych przy wyszukiwaniu.",
|
||||||
|
"Ekrany", "/pliki", kolejnosc=70),
|
||||||
|
trasy={("GET", "/pliki"): "files", ("POST", "/pliki/wgraj"): "files"},
|
||||||
|
zasoby={"s-wgrywanie.css": "files"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Astroklient — ZŁOŻENIE produktu.
|
||||||
|
|
||||||
|
Pełne funkcje astrologiczne, bez generowania tekstu i bez administracji. Ten plik
|
||||||
|
nie zawiera żadnej trasy: odpowiada wyłącznie na pytanie, z czego składa się ten
|
||||||
|
produkt. Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją.
|
||||||
|
|
||||||
|
Poza tą listą nie ma nic. Nie chodzi o funkcje wyłączone: plików, których tu nie
|
||||||
|
widać, nie ma w obrazie (patrz usun.txt), więc nie istnieje uprawnienie, którym
|
||||||
|
dałoby się cokolwiek odsłonić — ani nazwa, po której dałoby się zgadnąć, czego
|
||||||
|
szukać.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app import rozszerzenia
|
||||||
|
from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error, app,
|
||||||
|
logic, static_url, templates)
|
||||||
|
from app.ekrany import ( # noqa: F401 - import rejestruje trasy
|
||||||
|
horoskop,
|
||||||
|
interpretacje,
|
||||||
|
kalendarz,
|
||||||
|
logowanie,
|
||||||
|
lokalizacja,
|
||||||
|
sygnifikatory,
|
||||||
|
synastria,
|
||||||
|
wgrywanie,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
|
||||||
|
|
||||||
|
# Most modułów opcjonalnych zostaje CELOWO. W tym obrazie nie ma katalogu
|
||||||
|
# `app/moduly/`, więc odkrywa zero modułów i wszystkie odpowiedzi są puste —
|
||||||
|
# aplikacja zachowuje się tak, jakby takiego pojęcia nigdy nie było.
|
||||||
|
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
|
||||||
|
buduj_utc=_build_utc, blad=_logic_error)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Nazwa produktu — nakładka astroklienta.
|
||||||
|
|
||||||
|
Nadpisuje wersję z rdzenia. Test słownikowy pilnuje, żeby po nadpisaniu nazwa
|
||||||
|
tamtego produktu nie została nigdzie w szablonach, zasobach ani w odpowiedziach.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
NAZWA = "astroklient"
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""Logowanie i bramka uprawnień astroklienta.
|
||||||
|
|
||||||
|
Konta pochodzą WYŁĄCZNIE z konfiguracji środowiska (`ASTROKLIENT_USERS`), a każde
|
||||||
|
dostaje ten sam zestaw: wszystko, co ta usługa umie. Konta rozdziela się po to,
|
||||||
|
żeby każde miało własną, niewidoczną dla pozostałych pulę plików — nie po to, żeby
|
||||||
|
dawać im różne poziomy dostępu. Poziom jest jeden i nie ma go z czym porównywać.
|
||||||
|
|
||||||
|
Reszta jest wspólna z pozostałymi usługami i celowo nietknięta: hasła jako scrypt,
|
||||||
|
sesja w podpisanym ciasteczku, limit żądań, dziennik audytowy, oraz odmowa jako
|
||||||
|
404 zamiast 403 — bo 403 samo w sobie mówi, że coś tam jest.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.security")
|
||||||
|
|
||||||
|
MAX_TRACKED_CLIENTS_DEFAULT = 4096
|
||||||
|
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
|
||||||
|
|
||||||
|
|
||||||
|
# ── konta ─────────────────────────────────────────────────────────────────
|
||||||
|
# Wyłącznie z konfiguracji środowiska. Konta rozdziela się po to, żeby każde
|
||||||
|
# miało własną pulę plików — nie po to, żeby różniły się tym, co widzą.
|
||||||
|
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
|
||||||
|
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
|
||||||
|
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
|
||||||
|
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
||||||
|
_HASH_PREFIX = "scrypt$"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str, salt: bytes | None = None) -> str:
|
||||||
|
"""Hasło → wpis do ASTROKLIENT_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
|
||||||
|
salt = salt or secrets.token_bytes(16)
|
||||||
|
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
||||||
|
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
||||||
|
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def accounts() -> dict[str, str]:
|
||||||
|
"""Konta z `ASTROKLIENT_USERS` — `login:sekret`, po przecinku.
|
||||||
|
|
||||||
|
Sekret to hash scrypt (`scrypt$sól$hash`), ten sam format co w pozostałych
|
||||||
|
usługach — jedna implementacja, więc nie ma czego rozjechać."""
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for entry in os.getenv("ASTROKLIENT_USERS", "").split(","):
|
||||||
|
entry = entry.strip()
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
user, sep, secret = entry.partition(":")
|
||||||
|
if sep and user.strip() and secret.strip():
|
||||||
|
out[user.strip()] = secret.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_matches(spec: str, password: str) -> bool:
|
||||||
|
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
|
||||||
|
if spec.startswith(_HASH_PREFIX):
|
||||||
|
try:
|
||||||
|
_, salt_hex, hash_hex = spec.split("$", 2)
|
||||||
|
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
||||||
|
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
||||||
|
return hmac.compare_digest(actual, expected)
|
||||||
|
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
|
||||||
|
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
|
||||||
|
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
|
||||||
|
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit_per_min() -> int:
|
||||||
|
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
def cookies_secure() -> bool:
|
||||||
|
"""Czy ciasteczko sesji ma być wyłącznie dla https.
|
||||||
|
|
||||||
|
Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej
|
||||||
|
przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa"
|
||||||
|
bez żadnego komunikatu, co jest wyjątkowo mylącą awarią."""
|
||||||
|
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def trust_proxy() -> bool:
|
||||||
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
|
||||||
|
# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego
|
||||||
|
# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym
|
||||||
|
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
|
||||||
|
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
|
||||||
|
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
|
||||||
|
# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
|
||||||
|
# jedynym publicznym zasobem — i dlatego nie wolno w nim trzymać niczego, co
|
||||||
|
# nazywa funkcję (patrz features.STATIC).
|
||||||
|
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/base.css"})
|
||||||
|
# Pusto CELOWO. Dopóki był tu "/static/", cały katalog omijał bramkę: każdy plik
|
||||||
|
# JS i arkusz stylów pobierał ktokolwiek, także niezalogowany, pod zgadywalnym
|
||||||
|
# adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie baz.
|
||||||
|
# Ruch do zasobów nie trafiał przy tym ani do dziennika, ani pod limit żądań,
|
||||||
|
# więc wyciek był dla właściciela niewidoczny.
|
||||||
|
PUBLIC_PREFIXES: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_hits: dict[str, deque[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def auth_enabled() -> bool:
|
||||||
|
return bool(accounts())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_public(path: str) -> bool:
|
||||||
|
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Principal:
|
||||||
|
"""Kto się zalogował i co wolno mu zobaczyć."""
|
||||||
|
|
||||||
|
login: str
|
||||||
|
permissions: frozenset[str]
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _uprawnienia() -> frozenset[str]:
|
||||||
|
"""Każde konto dostaje wszystko, co ta usługa umie — liczone z KATALOGU.
|
||||||
|
|
||||||
|
Katalog składa się ze zgłoszeń ekranów obecnych w obrazie, więc ten zbiór
|
||||||
|
opisuje dokładnie ten produkt, a nie listę wpisaną kiedyś ręcznie. Dopisanie
|
||||||
|
ekranu nie wymaga pamiętania o niczym; usunięcie go zabiera uprawnienie samo.
|
||||||
|
|
||||||
|
Nie ma tu uprawnienia administracyjnego i nie ma go skąd wziąć: `features.ADMIN`
|
||||||
|
celowo nie należy do GRANTABLE."""
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
return frozenset(features.GRANTABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _sekret(login: str) -> str | None:
|
||||||
|
return accounts().get(login)
|
||||||
|
|
||||||
|
|
||||||
|
def verify(login: str, password: str) -> Principal | None:
|
||||||
|
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
|
||||||
|
|
||||||
|
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
|
||||||
|
konta istnieją, a to informacja warta czegoś dla zgadującego."""
|
||||||
|
spec = _sekret(login)
|
||||||
|
if spec is None or not _secret_matches(spec, password):
|
||||||
|
return None
|
||||||
|
return Principal(login, _uprawnienia(), False)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_session(login: str) -> str:
|
||||||
|
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
||||||
|
|
||||||
|
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
|
||||||
|
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
|
||||||
|
i bez żadnego magazynu sesji."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
# Wersja sesji zawsze 0: nie ma trwałego magazynu, więc nie ma gdzie trzymać
|
||||||
|
# licznika pokolenia. Wylogowanie i zmiana hasła i tak działają — pierwsze
|
||||||
|
# przez skasowanie ciasteczka, drugie przez odcisk, który przestaje pasować.
|
||||||
|
return session.issue(login, session.fingerprint(_sekret(login) or ""), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(request: Request) -> Principal | None:
|
||||||
|
"""Ciasteczko → kto to jest. None = nie wpuszczać."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
dane = session.read(request.cookies.get(session.COOKIE))
|
||||||
|
if dane is None:
|
||||||
|
return None
|
||||||
|
spec = _sekret(dane["login"])
|
||||||
|
if spec is None:
|
||||||
|
return None
|
||||||
|
# Zmiana hasła w konfiguracji ubija sesje tego konta — to jest tutaj jedyny
|
||||||
|
# sposób na „wyloguj mnie ze wszystkich urządzeń", bo bez trwałego magazynu
|
||||||
|
# nie ma gdzie trzymać licznika pokolenia sesji.
|
||||||
|
if session.fingerprint(spec) != dane["odcisk"]:
|
||||||
|
return None
|
||||||
|
return Principal(dane["login"], _uprawnienia(), False)
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""Adres, po którym rozliczamy limit żądań.
|
||||||
|
|
||||||
|
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
|
||||||
|
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
|
||||||
|
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
|
||||||
|
wyklika limit, odcięłaby pozostałe.
|
||||||
|
|
||||||
|
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
|
||||||
|
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
|
||||||
|
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
|
||||||
|
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
|
||||||
|
klient, więc nie znaczą nic.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else "?"
|
||||||
|
if not trust_proxy():
|
||||||
|
return peer
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded:
|
||||||
|
last = forwarded.rsplit(",", 1)[-1].strip()
|
||||||
|
if last:
|
||||||
|
return last
|
||||||
|
return request.headers.get("x-real-ip", "").strip() or peer
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limited(client: str) -> bool:
|
||||||
|
cap = rate_limit_per_min()
|
||||||
|
if cap <= 0:
|
||||||
|
return False
|
||||||
|
now = time.monotonic()
|
||||||
|
window = _hits.get(client)
|
||||||
|
if window is None:
|
||||||
|
if len(_hits) >= MAX_TRACKED_CLIENTS:
|
||||||
|
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
|
||||||
|
window = _hits[client] = deque()
|
||||||
|
while window and now - window[0] > 60.0:
|
||||||
|
window.popleft()
|
||||||
|
if len(window) >= cap:
|
||||||
|
return True
|
||||||
|
window.append(now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
|
||||||
|
audit_log = logging.getLogger("astrololo.audit")
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_audit_logging() -> None:
|
||||||
|
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
|
||||||
|
|
||||||
|
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
|
||||||
|
poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone
|
||||||
|
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
|
||||||
|
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
|
||||||
|
są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń.
|
||||||
|
"""
|
||||||
|
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
|
||||||
|
if not audit_log.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
audit_log.addHandler(handler)
|
||||||
|
audit_log.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
def audit_records(request: Request, count: int) -> None:
|
||||||
|
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
|
||||||
|
|
||||||
|
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził",
|
||||||
|
a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
|
||||||
|
try:
|
||||||
|
request.state.audit_records = int(count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def audit_login(request: Request, login: str, udane: bool) -> None:
|
||||||
|
"""Zdarzenie logowania w dzienniku (PRE-17).
|
||||||
|
|
||||||
|
Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale
|
||||||
|
seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy
|
||||||
|
LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku."""
|
||||||
|
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
|
||||||
|
"logowanie-ok" if udane else "logowanie-odmowa",
|
||||||
|
login or "-", client_ip(request))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_logout(request: Request) -> None:
|
||||||
|
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
|
||||||
|
getattr(request.state, "user", "") or "-", client_ip(request))
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(request: Request, user: str, status: int, ms: float) -> None:
|
||||||
|
records = getattr(request.state, "audit_records", None)
|
||||||
|
audit_log.info(
|
||||||
|
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
||||||
|
user or "-", client_ip(request), request.method, request.url.path,
|
||||||
|
status, "-" if records is None else records, ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install(app) -> None:
|
||||||
|
"""Podpina ochronę pod wszystkie ścieżki poza publicznymi."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
_setup_audit_logging()
|
||||||
|
|
||||||
|
# FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić
|
||||||
|
# ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała —
|
||||||
|
# i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka.
|
||||||
|
if auth_enabled() and not session.available():
|
||||||
|
raise session.SessionError(
|
||||||
|
"Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
||||||
|
"podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. "
|
||||||
|
"Wygeneruj: openssl rand -hex 32")
|
||||||
|
|
||||||
|
if not auth_enabled():
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: brak kont (ASTROKLIENT_USERS) — usługa jest OTWARTA dla "
|
||||||
|
"każdego, kto ma do niej dostęp sieciowy, wraz z treścią plików."
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _guard(request: Request, call_next):
|
||||||
|
if _is_public(request.url.path):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
if _rate_limited(client_ip(request)):
|
||||||
|
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
||||||
|
status_code=429, headers={"Retry-After": "60"},
|
||||||
|
)
|
||||||
|
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
user, who, odswiez = "", None, None
|
||||||
|
if auth_enabled():
|
||||||
|
who = principal(request)
|
||||||
|
if who is None:
|
||||||
|
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
|
||||||
|
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
|
||||||
|
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
|
||||||
|
# Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce.
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
cel = request.url.path
|
||||||
|
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
|
||||||
|
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
|
||||||
|
user = who.login
|
||||||
|
# Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy
|
||||||
|
# znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy
|
||||||
|
# każdym obrazku i arkuszu stylów.
|
||||||
|
from app import session as _s
|
||||||
|
|
||||||
|
dane = _s.read(request.cookies.get(_s.COOKIE))
|
||||||
|
odswiez = _s.refresh(dane) if dane else None
|
||||||
|
else:
|
||||||
|
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
|
||||||
|
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
|
||||||
|
who = Principal("", _uprawnienia(), False)
|
||||||
|
|
||||||
|
request.state.user = user
|
||||||
|
request.state.principal = who
|
||||||
|
# Pula plików na czas obsługi tego żądania. Ustawiana TU, w jednym
|
||||||
|
# miejscu, zaraz po ustaleniu kto to jest — klient czyta ją z kontekstu,
|
||||||
|
# więc żadne wywołanie w dół nie może o niej zapomnieć.
|
||||||
|
from app.clients.logic_client import KONTO
|
||||||
|
|
||||||
|
KONTO.set(user)
|
||||||
|
|
||||||
|
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
|
||||||
|
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
|
||||||
|
need = features.required(request.method, request.url.path)
|
||||||
|
if not features.can(who.permissions, need):
|
||||||
|
_audit(request, user, 404, (time.monotonic() - started) * 1000)
|
||||||
|
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||||||
|
response = await call_next(request)
|
||||||
|
if odswiez:
|
||||||
|
from app import session as _s
|
||||||
|
|
||||||
|
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
|
||||||
|
**_s.cookie_params(secure=cookies_secure()))
|
||||||
|
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Sesje logowania — podpisane ciasteczko (LOG-34).
|
||||||
|
|
||||||
|
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
|
||||||
|
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
|
||||||
|
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
|
||||||
|
w środku, i może tę decyzję cofnąć.
|
||||||
|
|
||||||
|
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
|
||||||
|
|
||||||
|
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
|
||||||
|
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
|
||||||
|
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
|
||||||
|
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
|
||||||
|
o tym — i też nie wymaga magazynu.
|
||||||
|
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
|
||||||
|
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
|
||||||
|
nie poda licznika, poziomy 1 i 2 nadal działają.
|
||||||
|
|
||||||
|
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
|
||||||
|
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
|
||||||
|
dostępu nie odbierałoby dostępu.
|
||||||
|
|
||||||
|
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
|
||||||
|
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
|
||||||
|
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
|
||||||
|
|
||||||
|
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
|
||||||
|
w PRE-27 i AES w PRE-16.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
|
||||||
|
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
|
||||||
|
# z jednej, logując się do drugiej.
|
||||||
|
COOKIE = "astroklient_sesja"
|
||||||
|
|
||||||
|
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
|
||||||
|
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
|
||||||
|
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
|
||||||
|
|
||||||
|
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
|
||||||
|
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
|
||||||
|
REFRESH_AFTER = 300
|
||||||
|
|
||||||
|
|
||||||
|
class SessionError(RuntimeError):
|
||||||
|
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.getenv(name, "") or default)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def max_age() -> int:
|
||||||
|
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
|
||||||
|
|
||||||
|
|
||||||
|
def idle_timeout() -> int:
|
||||||
|
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
|
||||||
|
|
||||||
|
|
||||||
|
def secret() -> bytes:
|
||||||
|
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
|
||||||
|
|
||||||
|
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
|
||||||
|
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
|
||||||
|
raw = os.getenv("SESSION_SECRET", "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise SessionError(
|
||||||
|
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
|
||||||
|
"Wygeneruj: openssl rand -hex 32")
|
||||||
|
if len(raw) < 32:
|
||||||
|
raise SessionError(
|
||||||
|
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
|
||||||
|
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
|
||||||
|
return raw.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> bool:
|
||||||
|
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
|
||||||
|
try:
|
||||||
|
secret()
|
||||||
|
except SessionError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(secret_spec: str) -> str:
|
||||||
|
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
|
||||||
|
|
||||||
|
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
|
||||||
|
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
|
||||||
|
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
|
||||||
|
unieważniły jego sesje same z siebie."""
|
||||||
|
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(payload: bytes) -> bytes:
|
||||||
|
return hmac.new(secret(), payload, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(raw: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(text: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
|
||||||
|
"""Nowe ciasteczko sesji dla konta."""
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
payload = json.dumps(
|
||||||
|
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
|
||||||
|
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
||||||
|
|
||||||
|
|
||||||
|
def read(token: str | None, *, now: float | None = None) -> dict | None:
|
||||||
|
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
|
||||||
|
|
||||||
|
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
|
||||||
|
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
|
||||||
|
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
surowy, _, podpis = token.rpartition(".")
|
||||||
|
try:
|
||||||
|
payload = _unb64(surowy)
|
||||||
|
oczekiwany = _sign(payload)
|
||||||
|
podany = _unb64(podpis)
|
||||||
|
except (ValueError, SessionError):
|
||||||
|
return None
|
||||||
|
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
|
||||||
|
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
|
||||||
|
if not hmac.compare_digest(oczekiwany, podany):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dane = json.loads(payload.decode("utf-8"))
|
||||||
|
login, odcisk = str(dane["u"]), str(dane["f"])
|
||||||
|
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
|
||||||
|
# w obu wypadkach nie ma powodu mu ufać.
|
||||||
|
if wyd > teraz + 60:
|
||||||
|
return None
|
||||||
|
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
|
||||||
|
return None
|
||||||
|
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh(dane: dict, *, now: float | None = None) -> str | None:
|
||||||
|
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
|
||||||
|
|
||||||
|
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
|
||||||
|
Set-Cookie — także po arkusze stylów i obrazki."""
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
|
||||||
|
return None
|
||||||
|
payload = json.dumps(
|
||||||
|
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
|
||||||
|
"wyd": dane["wydano"], "akt": teraz},
|
||||||
|
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_params(secure: bool = True) -> dict:
|
||||||
|
"""Parametry ciasteczka wspólne dla ustawiania i kasowania.
|
||||||
|
|
||||||
|
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
|
||||||
|
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
|
||||||
|
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
|
||||||
|
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
padding: 1rem 1.1rem; margin-top: .4rem; }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set nav_active = "files" %}
|
||||||
|
{% block title %}Pliki{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-wgrywanie.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{# Opis czysto operacyjny: co robi wgranie, i nic o tym, do CZEGO te dane służą
|
||||||
|
ani co się z nimi dzieje dalej. #}
|
||||||
|
<p class="muted">Wgrany plik jest od razu używany przy wyszukiwaniu.</p>
|
||||||
|
|
||||||
|
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
|
||||||
|
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||||
|
|
||||||
|
<div class="meta">Wgraj plik</div>
|
||||||
|
<form method="post" action="/pliki/wgraj" enctype="multipart/form-data" class="card">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Plik <input type="file" name="upload" required></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button type="submit">Wgraj</button></div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
httpx>=0.28
|
||||||
|
jinja2>=3.1
|
||||||
|
python-multipart>=0.0.20
|
||||||
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
|
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
|
||||||
|
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
|
||||||
|
# bez systemowej bazy stref.
|
||||||
|
tzfpy>=0.15
|
||||||
|
tzdata>=2024.1
|
||||||
|
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
|
||||||
|
openpyxl>=3.1
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Drzewo astroklienta budowane tak samo, jak buduje je Dockerfile.
|
||||||
|
|
||||||
|
Ta usługa nie ma własnego kompletu plików: bierze rdzeń z warstwy prezentacji,
|
||||||
|
usuwa z niego to, czego ten produkt nie ma (usun.txt), i nakłada własne. Testy
|
||||||
|
muszą patrzeć na WYNIK tego złożenia, a nie na katalog `app/` w repozytorium —
|
||||||
|
inaczej sprawdzałyby garść plików, a nie produkt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
USLUGA = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
RDZEN = USLUGA.parents[0] / "presentation" / "app"
|
||||||
|
|
||||||
|
|
||||||
|
def zloz(cel: pathlib.Path) -> pathlib.Path:
|
||||||
|
"""Rdzeń minus usun.txt plus nakładka — dokładnie jak w Dockerfile."""
|
||||||
|
app = cel / "app"
|
||||||
|
shutil.copytree(RDZEN, app, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||||
|
for linia in (USLUGA / "usun.txt").read_text(encoding="utf-8").splitlines():
|
||||||
|
sciezka = linia.strip()
|
||||||
|
if not sciezka:
|
||||||
|
continue
|
||||||
|
do_usuniecia = app / sciezka
|
||||||
|
assert do_usuniecia.exists(), f"usun.txt wskazuje nieistniejące: {sciezka}"
|
||||||
|
shutil.rmtree(do_usuniecia) if do_usuniecia.is_dir() else do_usuniecia.unlink()
|
||||||
|
shutil.copytree(USLUGA / "app", app, dirs_exist_ok=True,
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||||
|
return cel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def drzewo(tmp_path_factory) -> pathlib.Path:
|
||||||
|
return zloz(tmp_path_factory.mktemp("astroklient"))
|
||||||
|
|
||||||
|
|
||||||
|
SONDA = '''
|
||||||
|
import json, os, hashlib, secrets, re
|
||||||
|
sol = secrets.token_bytes(16)
|
||||||
|
dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32)
|
||||||
|
os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()),
|
||||||
|
SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0")
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
from app import security, session, features
|
||||||
|
from app.clients import logic_client
|
||||||
|
c = TestClient(app, follow_redirects=False)
|
||||||
|
H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"}
|
||||||
|
strona = c.get("/", headers=H).text
|
||||||
|
zebrane = []
|
||||||
|
logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1]
|
||||||
|
c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20",
|
||||||
|
"tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"})
|
||||||
|
print("WYNIK:" + json.dumps({
|
||||||
|
"ekrany": [f.label for f in features.SCREENS],
|
||||||
|
"uprawnienia": sorted(security._uprawnienia()),
|
||||||
|
"dziala": {a: c.get(a, headers=H).status_code
|
||||||
|
for a in ("/", "/interpret", "/timeline", "/synastry",
|
||||||
|
"/significators", "/pliki")},
|
||||||
|
"nie_ma": {a: c.get(a, headers=H).status_code
|
||||||
|
for a in ("/compile", "/settings", "/accounts", "/files",
|
||||||
|
"/horoscope/stream", "/compile/pdf")},
|
||||||
|
"nawigacja": re.findall(r"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'),
|
||||||
|
"pula": (zebrane[0] or {}).get("tenant"),
|
||||||
|
"nazwa_w_naglowku": (re.search(r"<h1>(.*?)</h1>", strona) or [None, "?"])[1],
|
||||||
|
"nazwa_w_tytule": (re.search(r"<title>(.*?) ·", strona) or [None, "?"])[1],
|
||||||
|
"rdzen_w_tresci": "astrololo" in strona.lower(),
|
||||||
|
"dokumentacja": {a: c.get(a, headers=H).status_code
|
||||||
|
for a in ("/docs", "/openapi.json", "/redoc")},
|
||||||
|
"logowanie_dziala": bool(security.verify("klient", "tajne")),
|
||||||
|
"zle_haslo_odmowa": security.verify("klient", "inne") is None,
|
||||||
|
}))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def wynik(drzewo):
|
||||||
|
"""Aplikacja uruchomiona w OSOBNYM PROCESIE.
|
||||||
|
|
||||||
|
Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez
|
||||||
|
modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie
|
||||||
|
pozytywny."""
|
||||||
|
r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True,
|
||||||
|
text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"})
|
||||||
|
linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None)
|
||||||
|
assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}"
|
||||||
|
return json.loads(linia[len("WYNIK:"):])
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Po czym astroklienta NIE MA — ani w odpowiedzi, ani w kodzie w obrazie.
|
||||||
|
|
||||||
|
Ta usługa dzieli kod z pełną aplikacją, więc granica między produktami nie jest
|
||||||
|
oczywista z samego patrzenia: przebiega przez listę importów w `main.py` i przez
|
||||||
|
`usun.txt`. Ten test sprawdza jej SKUTEK na złożonym drzewie — bo tylko skutek
|
||||||
|
trafia do obrazu, który komuś się oddaje.
|
||||||
|
|
||||||
|
„Bez śladu" znaczy tu również bez zaprzeczeń. Zdanie „nie ma tu generowania
|
||||||
|
tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak
|
||||||
|
przycisk. Pierwsza wersja tego testu wyłapała trzy moje własne docstringi
|
||||||
|
wyliczające nieobecne funkcje.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Funkcje, których ten produkt nie ma — ani ich słownictwo.
|
||||||
|
GENEROWANIE = ["prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
|
||||||
|
"anthropic", "openai", "gpt", "claude", "horoscope",
|
||||||
|
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js"]
|
||||||
|
NIEOBECNE_EKRANY = ["Skompiluj", "Ustawienia", "Konta i uprawnien", "Eksport plików"]
|
||||||
|
MECHANIZMY = ["administrator", "kwarantann", "walidacj", "reguły przyjmowania"]
|
||||||
|
SLOWA = GENEROWANIE + NIEOBECNE_EKRANY + MECHANIZMY
|
||||||
|
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
|
||||||
|
|
||||||
|
|
||||||
|
def _pliki(drzewo: pathlib.Path):
|
||||||
|
for f in sorted((drzewo / "app").rglob("*")):
|
||||||
|
if f.is_file() and f.suffix != ".pyc" and "__pycache__" not in str(f):
|
||||||
|
yield f
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_tree_carries_no_trace(drzewo):
|
||||||
|
grzechy = []
|
||||||
|
for f in _pliki(drzewo):
|
||||||
|
try:
|
||||||
|
tresc = f.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
gdzie = f.relative_to(drzewo)
|
||||||
|
for slowo in SLOWA:
|
||||||
|
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
|
||||||
|
grzechy.append(f"{gdzie}: „{slowo}”")
|
||||||
|
for wzor, powod in WZORCE:
|
||||||
|
m = re.search(wzor, tresc)
|
||||||
|
if m:
|
||||||
|
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
||||||
|
assert not grzechy, (
|
||||||
|
"\nW drzewie astroklienta padły słowa o funkcjach, których nie ma. "
|
||||||
|
"`grep` po kontenerze ma nic nie znaleźć:\n " + "\n ".join(grzechy))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_check_would_notice_if_something_slipped_in(drzewo, tmp_path):
|
||||||
|
"""Kontrola negatywna: zapora ma coś łapać.
|
||||||
|
|
||||||
|
Bez niej test wyżej przechodziłby także wtedy, gdyby lista słów była pusta
|
||||||
|
albo gdyby przeszukiwanie trafiało w niewłaściwy katalog."""
|
||||||
|
podrzucony = drzewo / "app" / "podrzucony.py"
|
||||||
|
podrzucony.write_text("# generowanie tekstu przez model\n", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
test_the_shipped_tree_carries_no_trace(drzewo)
|
||||||
|
finally:
|
||||||
|
podrzucony.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_removal_list_matches_what_is_actually_composed(drzewo):
|
||||||
|
"""usun.txt i main.py muszą mówić to samo.
|
||||||
|
|
||||||
|
Rozjazd między nimi znaczy albo martwy kod w obrazie (moduł usunięty
|
||||||
|
z importów, ale nie z listy), albo błąd dopiero przy uruchomieniu (plik
|
||||||
|
usunięty, a wciąż importowany). Jedno i drugie wychodzi za późno.
|
||||||
|
"""
|
||||||
|
main = (drzewo / "app" / "main.py").read_text(encoding="utf-8")
|
||||||
|
zaimportowane = set(re.findall(r"^\s{4}(\w+),$", main, re.M))
|
||||||
|
obecne = {f.stem for f in (drzewo / "app" / "ekrany").glob("*.py")
|
||||||
|
if f.stem != "__init__"}
|
||||||
|
assert zaimportowane == obecne, (
|
||||||
|
f"ekrany w drzewie: {sorted(obecne)}\n"
|
||||||
|
f"ekrany importowane: {sorted(zaimportowane)}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_optional_module_survived(drzewo):
|
||||||
|
"""Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero."""
|
||||||
|
assert not (drzewo / "app" / "moduly").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ── nazwa produktu, z którego ten produkt powstał ─────────────────────────
|
||||||
|
# Osobno od listy wyżej, bo granica przebiega gdzie indziej: „astrololo" wolno
|
||||||
|
# występować w identyfikatorach WEWNĘTRZNYCH (nazwy loggerów, materiał do
|
||||||
|
# wyprowadzania kluczy, nagłówki między warstwami), a nie wolno w niczym, co
|
||||||
|
# dociera do przeglądarki. Zakaz na wszystko byłby albo obejściem przez zmianę
|
||||||
|
# nazw kluczy kryptograficznych, albo martwą regułą wyłączaną przy pierwszej
|
||||||
|
# okazji.
|
||||||
|
NAZWA_RDZENIA = "astrololo"
|
||||||
|
WIDOCZNE_DLA_KLIENTA = ("templates", "static")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_parent_product_is_not_named_in_anything_the_browser_receives(drzewo):
|
||||||
|
"""Szablony i zasoby są WSPÓLNE z pełną aplikacją, więc jej nazwa jechała
|
||||||
|
z nimi do tego obrazu — i astroklient przedstawiał się jako astrololo.
|
||||||
|
|
||||||
|
To jest najpoważniejszy z możliwych śladów: mówi klientowi wprost, że trzyma
|
||||||
|
okrojoną wersję czegoś większego, i podaje frazę, po której da się wyszukać,
|
||||||
|
czego mu nie dano."""
|
||||||
|
grzechy = []
|
||||||
|
for katalog in WIDOCZNE_DLA_KLIENTA:
|
||||||
|
for f in sorted((drzewo / "app" / katalog).rglob("*")):
|
||||||
|
if not f.is_file() or "__pycache__" in str(f):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tresc = f.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
if NAZWA_RDZENIA in tresc.lower():
|
||||||
|
grzechy.append(str(f.relative_to(drzewo)))
|
||||||
|
assert not grzechy, (
|
||||||
|
f"\nNazwa „{NAZWA_RDZENIA}” pada w plikach, które dostaje "
|
||||||
|
f"przeglądarka:\n " + "\n ".join(grzechy))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rendered_pages_carry_this_products_own_name(wynik):
|
||||||
|
"""Sprawdzane na ODPOWIEDZI, nie na szablonie: liczy się to, co dociera do
|
||||||
|
człowieka, a nie to, co jest w pliku przed złożeniem."""
|
||||||
|
assert wynik["nazwa_w_naglowku"] == "astroklient"
|
||||||
|
assert wynik["nazwa_w_tytule"] == "astroklient"
|
||||||
|
assert not wynik["rdzen_w_tresci"], (
|
||||||
|
"nazwa pełnej aplikacji pada w treści strony oddawanej klientowi")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_api_documentation_is_not_reachable(wynik):
|
||||||
|
"""Tytuł aplikacji FastAPI wciąż niesie nazwę rdzenia, więc dokumentacja
|
||||||
|
OpenAPI byłaby wyciekiem tylnymi drzwiami — nawet gdy szablony są czyste."""
|
||||||
|
assert all(k == 404 for k in wynik["dokumentacja"].values()), wynik["dokumentacja"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Astroklient jest produktem, a nie pełną aplikacją z wyłączonymi kawałkami.
|
||||||
|
|
||||||
|
Sprawdzamy trzy rzeczy, i wszystkie na ZŁOŻONYM drzewie, nie na kodzie w repo:
|
||||||
|
czego w nim nie ma, że to, co zostało, działa, i że nie zostało po niczym słowo.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
def test_the_build_starts_at_all(wynik):
|
||||||
|
assert wynik["dziala"]["/"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_astrological_screen_works(wynik):
|
||||||
|
assert all(k == 200 for k in wynik["dziala"].values()), wynik["dziala"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_what_it_lacks_does_not_exist_rather_than_being_refused(wynik):
|
||||||
|
"""404, nie 403 i nie 500. Odmowa z powodem jest informacją, że coś tam jest."""
|
||||||
|
assert all(k == 404 for k in wynik["nie_ma"].values()), wynik["nie_ma"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_catalogue_describes_this_product(wynik):
|
||||||
|
assert set(wynik["ekrany"]) == {"Horoskop", "Interpretacje", "Kalendarz",
|
||||||
|
"Synastria", "Sygnifikatory", "Pliki"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_account_can_hold_a_permission_this_product_does_not_have(wynik):
|
||||||
|
"""Uprawnienia liczą się z KATALOGU, więc opisują ten obraz. Gdyby była tu
|
||||||
|
lista wpisana ręcznie, zostałaby po usuniętych funkcjach."""
|
||||||
|
assert "ai" not in wynik["uprawnienia"]
|
||||||
|
assert "export" not in wynik["uprawnienia"]
|
||||||
|
assert "admin" not in wynik["uprawnienia"]
|
||||||
|
assert "chart" in wynik["uprawnienia"] and "significators" in wynik["uprawnienia"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_has_no_dead_links(wynik):
|
||||||
|
assert wynik["nawigacja"] == len(wynik["ekrany"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_still_tells_right_password_from_wrong(wynik):
|
||||||
|
assert wynik["logowanie_dziala"] and wynik["zle_haslo_odmowa"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pool_comes_from_the_session_not_from_the_form(wynik):
|
||||||
|
"""Pula jedzie z zalogowanego konta. Gdyby brała się z żądania, wystarczyłoby
|
||||||
|
podstawić cudzy login, żeby czytać cudze pliki — sonda właśnie to próbuje."""
|
||||||
|
assert wynik["pula"] == "klient"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
moduly
|
||||||
|
ekrany/skompiluj.py
|
||||||
|
ekrany/ustawienia.py
|
||||||
|
ekrany/konta.py
|
||||||
|
ekrany/pliki.py
|
||||||
|
accounts.py
|
||||||
|
files_state.py
|
||||||
|
templates/compile.html
|
||||||
|
templates/settings.html
|
||||||
|
templates/accounts.html
|
||||||
|
templates/files.html
|
||||||
|
static/s-compile.css
|
||||||
|
static/s-settings.css
|
||||||
|
static/s-accounts.css
|
||||||
|
static/s-files.css
|
||||||
|
static/compile.js
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""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}
|
||||||
@@ -37,6 +37,9 @@ class Settings:
|
|||||||
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
|
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
|
||||||
|
|
||||||
# Warstwa SQL (po migracji)
|
# 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(
|
sql_url: str = field(
|
||||||
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
|
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""Rejestr plików baz: stan użycia, wgrywanie, archiwizacja, walidacja (DAN-27).
|
||||||
|
|
||||||
|
CO SIĘ ZMIENIA WZGLĘDEM DAN-15. Dotąd włączanie i wyłączanie baz szło przez
|
||||||
|
zmienną `DISABLED_BASES` — deklaratywnie, bo warstwa danych nie miała gdzie
|
||||||
|
zapisywać stanu (udział read-only, cache jako emptyDir). Teraz stan jest KLIKANY,
|
||||||
|
więc musi być trwały: udział jest zapisywalny, a stan leży w pliku obok baz.
|
||||||
|
|
||||||
|
STANY PLIKU
|
||||||
|
active — bierze udział w wyszukiwaniu,
|
||||||
|
ready — sprawny, ale świadomie odstawiony; można włączyć jednym kliknięciem,
|
||||||
|
archived — ZAMROŻONY: nie bierze udziału, ma znacznik czasu archiwizacji,
|
||||||
|
sam plik zostaje nietknięty. To jedyna forma „usuwania" dostępna
|
||||||
|
osobie wgrywającej dane,
|
||||||
|
quarantine — wgrany, ale nie przeszedł walidacji. NIE JEST TRACONY; decyzję,
|
||||||
|
czy go skasować, podejmuje wyłącznie administrator.
|
||||||
|
|
||||||
|
DLACZEGO KWARANTANNA JEST NIEWIDOCZNA POZA ADMINISTRATOREM. Zasada z PRE-27 mówi,
|
||||||
|
że konto ograniczone nie ma skąd wiedzieć o mechanizmach, których nie obsługuje.
|
||||||
|
Gdyby plik w kwarantannie był widoczny z powodem odrzucenia, każdy wgrywający
|
||||||
|
poznałby reguły walidacji — a te są narzędziem administratora. Osoba wgrywająca
|
||||||
|
widzi więc plik jako „oczekuje na zatwierdzenie", bez powodu i bez reguł.
|
||||||
|
|
||||||
|
REGUŁY WALIDACJI są danymi, nie kodem: administrator ustawia je z ekranu. Trzymamy
|
||||||
|
je w tym samym pliku stanu, bo stan i reguły zmieniają się razem i muszą przetrwać
|
||||||
|
restart tak samo.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
|
||||||
|
|
||||||
|
# Nazwa puli może przyjść z zewnątrz (login konta w wersji demo), więc przechodzi
|
||||||
|
# przez sito: wyłącznie znaki bezpieczne w nazwie katalogu. Bez tego „../..” albo
|
||||||
|
# ukośnik wyprowadziłyby zapytanie poza udział — czyli wprost do cudzych baz.
|
||||||
|
_TENANT_OK = re.compile(r"[^A-Za-z0-9._-]")
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_root(root: Path | str, tenant: str = "") -> Path:
|
||||||
|
"""Katalog, w którym mieszka pula danego konta.
|
||||||
|
|
||||||
|
Puste `tenant` = cały udział, czyli zachowanie produkcji. Nazwa jest
|
||||||
|
OCZYSZCZANA, a nie sprawdzana i odrzucana: sito zamienia wszystko podejrzane
|
||||||
|
na podkreślenie, więc żadne wejście nie wyprowadzi ścieżki wyżej."""
|
||||||
|
root = Path(root)
|
||||||
|
name = _TENANT_OK.sub("_", (tenant or "").strip()).strip("._")
|
||||||
|
return root / name if name else root
|
||||||
|
USABLE = frozenset({ACTIVE})
|
||||||
|
|
||||||
|
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
|
||||||
|
# Kwarantanna świadomie poza listą — patrz nagłówek modułu.
|
||||||
|
VISIBLE_TO_EVERYONE = frozenset({ACTIVE, READY, ARCHIVED})
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
DEFAULT_RULES: dict = {
|
||||||
|
"extensions": [".xlsx"],
|
||||||
|
"max_size_mb": 50,
|
||||||
|
"min_rows": 1,
|
||||||
|
"required_columns": [], # puste = bez wymagań co do nagłówków
|
||||||
|
"reject_duplicate_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def state_path(root: Path | str) -> Path:
|
||||||
|
"""Plik stanu — obok baz, chyba że wskazano inaczej.
|
||||||
|
|
||||||
|
Sprawdzamy NAPIS ze środowiska, nie Path(napis): Path("") to Path("."),
|
||||||
|
czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna` zawsze
|
||||||
|
wybierało pustą zmienną i zapisywało stan do katalogu bieżącego."""
|
||||||
|
override = os.getenv("FILES_STATE", "").strip()
|
||||||
|
return Path(override) if override else Path(root) / ".files-state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_of(path: Path | str) -> str:
|
||||||
|
"""Skrót treści pliku — tożsamość pliku niezależna od nazwy.
|
||||||
|
|
||||||
|
Przyda się też krokowi drugiemu (lustro w SQL): to po nim poznamy, że plik
|
||||||
|
na dysku rozjechał się z tym, co wczytano do bazy."""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ── stan ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_state(root: Path) -> dict:
|
||||||
|
try:
|
||||||
|
with open(state_path(root), encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
files = data.get("files")
|
||||||
|
rules = data.get("rules")
|
||||||
|
return {
|
||||||
|
"files": files if isinstance(files, dict) else {},
|
||||||
|
"rules": {**DEFAULT_RULES, **(rules if isinstance(rules, dict) else {})},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(root: Path, data: dict) -> None:
|
||||||
|
path = state_path(root)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Atomowo: plik stanu opisuje CAŁY zbiór baz, więc obcięcie go w połowie
|
||||||
|
# zapisu skasowałoby wiedzę o wszystkich naraz.
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
Path(tmp).unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def rules(root: Path | str) -> dict:
|
||||||
|
return _read_state(Path(root))["rules"]
|
||||||
|
|
||||||
|
|
||||||
|
def set_rules(root: Path | str, new: dict) -> dict:
|
||||||
|
root = Path(root)
|
||||||
|
with _lock:
|
||||||
|
data = _read_state(root)
|
||||||
|
merged = {**data["rules"]}
|
||||||
|
for key, value in (new or {}).items():
|
||||||
|
if key in DEFAULT_RULES:
|
||||||
|
merged[key] = value
|
||||||
|
data["rules"] = merged
|
||||||
|
_write_state(root, data)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# ── walidacja ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def validate(path: Path | str, root: Path | str, *, digest: str = "",
|
||||||
|
known_digests: dict[str, str] | None = None) -> list[str]:
|
||||||
|
"""Lista POWODÓW odrzucenia. Pusta lista = plik nadaje się do użytku.
|
||||||
|
|
||||||
|
Zwracamy powody, a nie samo „tak/nie", bo administrator ma zobaczyć, CZEGO
|
||||||
|
plikowi brakuje — inaczej poprawianie bazy byłoby zgadywanką. Poza konto
|
||||||
|
administracyjne ta lista nie wychodzi."""
|
||||||
|
p, rs = Path(path), rules(root)
|
||||||
|
why: list[str] = []
|
||||||
|
|
||||||
|
exts = [str(e).lower() for e in rs.get("extensions") or []]
|
||||||
|
if exts and p.suffix.lower() not in exts:
|
||||||
|
why.append(f"rozszerzenie {p.suffix or '(brak)'} spoza dozwolonych: {', '.join(exts)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
size_mb = p.stat().st_size / (1024 * 1024)
|
||||||
|
except OSError:
|
||||||
|
return why + ["pliku nie da się odczytać"]
|
||||||
|
cap = float(rs.get("max_size_mb") or 0)
|
||||||
|
if cap and size_mb > cap:
|
||||||
|
why.append(f"rozmiar {size_mb:.1f} MB przekracza limit {cap:g} MB")
|
||||||
|
|
||||||
|
if rs.get("reject_duplicate_content") and known_digests:
|
||||||
|
digest = digest or sha256_of(p)
|
||||||
|
twin = next((name for name, d in known_digests.items()
|
||||||
|
if d == digest and name != p.name), None)
|
||||||
|
if twin:
|
||||||
|
why.append(f"treść identyczna z plikiem „{twin}”")
|
||||||
|
|
||||||
|
required = [str(c).strip() for c in (rs.get("required_columns") or []) if str(c).strip()]
|
||||||
|
min_rows = int(rs.get("min_rows") or 0)
|
||||||
|
if required or min_rows:
|
||||||
|
why += _inspect_workbook(p, required, min_rows)
|
||||||
|
return why
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_workbook(path: Path, required: list[str], min_rows: int) -> list[str]:
|
||||||
|
"""Zagląda do arkusza: nagłówki i liczba wierszy.
|
||||||
|
|
||||||
|
read_only + tylko pierwszy arkusz — plik bazy potrafi mieć kilkadziesiąt MB,
|
||||||
|
a wczytanie go w całości przy każdym wgraniu zatkałoby usługę."""
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||||
|
except Exception as e: # noqa: BLE001 — każdy błąd = powód
|
||||||
|
return [f"nie udało się otworzyć arkusza ({type(e).__name__})"]
|
||||||
|
why: list[str] = []
|
||||||
|
try:
|
||||||
|
ws = wb[wb.sheetnames[0]]
|
||||||
|
rows = ws.iter_rows(values_only=True)
|
||||||
|
header = [str(c).strip().lower() for c in (next(rows, ()) or ()) if c is not None]
|
||||||
|
missing = [c for c in required if c.strip().lower() not in header]
|
||||||
|
if missing:
|
||||||
|
why.append(f"brak wymaganych kolumn: {', '.join(missing)}")
|
||||||
|
if min_rows:
|
||||||
|
seen = sum(1 for i, _ in enumerate(rows) if i < min_rows)
|
||||||
|
if seen < min_rows:
|
||||||
|
why.append(f"za mało wierszy danych ({seen} < {min_rows})")
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
return why
|
||||||
|
|
||||||
|
|
||||||
|
# ── rejestr ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _scan(root: Path) -> list[Path]:
|
||||||
|
"""Pliki na udziale, bez śmieci technicznych.
|
||||||
|
|
||||||
|
Pomijamy nie tylko ukryte PLIKI, ale i wszystko, co leży w ukrytym KATALOGU:
|
||||||
|
filtr po samej nazwie pliku wciągał do rejestru zawartość `.cache`, bo pliki
|
||||||
|
w środku nie zaczynają się od kropki. Efekt: cache podawany jako baza, a przy
|
||||||
|
pierwszym uruchomieniu jeszcze przyjmowany jako aktywny."""
|
||||||
|
def ukryta_sciezka(p: Path) -> bool:
|
||||||
|
return any(part.startswith(".") for part in p.relative_to(root).parts[:-1])
|
||||||
|
|
||||||
|
return [p for p in sorted(root.glob("**/*"))
|
||||||
|
if p.is_file() and not p.name.startswith((".", "~$"))
|
||||||
|
and not ukryta_sciezka(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def _adopt_existing(root: Path) -> dict:
|
||||||
|
"""Pierwsze uruchomienie: bazy zastane na udziale są OD RAZU w użyciu.
|
||||||
|
|
||||||
|
Bez tego wdrożenie DAN-27 wyłączyłoby wyszukiwanie. Dotąd bazy działały
|
||||||
|
domyślnie (wyłączało się je jawnie przez DISABLED_BASES); po przejściu na
|
||||||
|
rejestr plik bez wpisu dostaje `ready`, czyli NIE w użyciu — więc pusty stan
|
||||||
|
po wdrożeniu oznaczałby, że program nagle niczego nie znajduje. Ta cicha
|
||||||
|
zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza.
|
||||||
|
|
||||||
|
Rozróżnienie jest celowe: `ready` dotyczy plików WGRANYCH przez ekran (te
|
||||||
|
ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr.
|
||||||
|
|
||||||
|
Zapis stanu może się nie udać (udział read-only) — wtedy trudno, przy każdym
|
||||||
|
uruchomieniu przyjmiemy je na nowo. Zachowanie jest to samo, koszt żaden."""
|
||||||
|
files = {str(p.relative_to(root)): {"status": ACTIVE, "adopted_at": _now()}
|
||||||
|
for p in _scan(root)}
|
||||||
|
data = {"files": files, "rules": {**DEFAULT_RULES}}
|
||||||
|
try:
|
||||||
|
_write_state(root, data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]:
|
||||||
|
"""Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody.
|
||||||
|
|
||||||
|
Filtrowanie siedzi TUTAJ, a nie w szablonie: gdyby pliki w kwarantannie
|
||||||
|
dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd
|
||||||
|
źródła strony, żeby poznać reguły walidacji."""
|
||||||
|
root = Path(root)
|
||||||
|
# Brak PLIKU stanu = pierwsze uruchomienie. Pusty słownik przy istniejącym
|
||||||
|
# pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy.
|
||||||
|
data = _read_state(root) if state_path(root).exists() else _adopt_existing(root)
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in _scan(root):
|
||||||
|
rel = str(p.relative_to(root))
|
||||||
|
row = data["files"].get(rel, {})
|
||||||
|
status = row.get("status") or READY
|
||||||
|
if status == QUARANTINE and not for_admin:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = p.stat()
|
||||||
|
size_mb = round(st.st_size / (1024 * 1024), 2)
|
||||||
|
modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
except OSError:
|
||||||
|
size_mb, modified = None, None
|
||||||
|
entry = {
|
||||||
|
"name": p.name, "path": rel, "size_mb": size_mb, "modified": modified,
|
||||||
|
"status": status, "in_use": status in USABLE,
|
||||||
|
# `enabled` to TA SAMA informacja pod nazwą, której używa reszta
|
||||||
|
# świata: endpoint /bases, warstwa logiczna i ekran „Ustawienia"
|
||||||
|
# (DAN-15/PRE-09). Rejestr wszedł w miejsce starej listy baz, więc
|
||||||
|
# musi mówić jej językiem — inaczej każdy odbiorca dostaje KeyError,
|
||||||
|
# a to była właśnie awaria /bases po wdrożeniu DAN-27.
|
||||||
|
"enabled": status in USABLE,
|
||||||
|
"archived_at": row.get("archived_at") or "",
|
||||||
|
"uploaded_at": row.get("uploaded_at") or "",
|
||||||
|
"uploaded_by": row.get("uploaded_by") or "",
|
||||||
|
"sha256": row.get("sha256") or "",
|
||||||
|
}
|
||||||
|
if for_admin:
|
||||||
|
entry["rejected_for"] = list(row.get("rejected_for") or [])
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def usable_paths(root: Path | str) -> list[str]:
|
||||||
|
"""Ścieżki baz, które FAKTYCZNIE biorą udział w wyszukiwaniu."""
|
||||||
|
root = Path(root)
|
||||||
|
return [str(root / e["path"]) for e in registry(root, for_admin=True) if e["in_use"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(root: Path, rel: str, **fields) -> dict:
|
||||||
|
with _lock:
|
||||||
|
data = _read_state(root)
|
||||||
|
row = {**data["files"].get(rel, {}), **fields}
|
||||||
|
data["files"][rel] = row
|
||||||
|
_write_state(root, data)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(root: Path | str, rel: str, status: str, *, by: str = "") -> dict:
|
||||||
|
"""Zmienia stan pliku. Włączyć do użytku można TYLKO plik, który przeszedł
|
||||||
|
walidację — to jest właśnie ta bramka, o której mowa w wymaganiu."""
|
||||||
|
root = Path(root)
|
||||||
|
target = root / rel
|
||||||
|
if not target.is_file():
|
||||||
|
raise ValueError(f"Nie ma pliku „{rel}”.")
|
||||||
|
if status not in {ACTIVE, READY, ARCHIVED, QUARANTINE}:
|
||||||
|
raise ValueError(f"Nieznany stan: {status}")
|
||||||
|
|
||||||
|
data = _read_state(root)
|
||||||
|
current = (data["files"].get(rel) or {}).get("status") or READY
|
||||||
|
if status == ACTIVE:
|
||||||
|
if current == QUARANTINE:
|
||||||
|
raise ValueError("Plik nie może trafić do użytku.")
|
||||||
|
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"]}
|
||||||
|
why = validate(target, root, known_digests=known)
|
||||||
|
if why:
|
||||||
|
_touch(root, rel, status=QUARANTINE, rejected_for=why, checked_at=_now())
|
||||||
|
raise ValueError("Plik nie może trafić do użytku.")
|
||||||
|
|
||||||
|
fields = {"status": status, "changed_at": _now(), "changed_by": by}
|
||||||
|
if status == ARCHIVED:
|
||||||
|
# Znacznik czasu archiwizacji to wymóg: „zamrożona forma z timestampem".
|
||||||
|
fields["archived_at"] = _now()
|
||||||
|
elif status == ACTIVE:
|
||||||
|
fields["archived_at"] = ""
|
||||||
|
fields["rejected_for"] = []
|
||||||
|
return _touch(root, rel, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def store_upload(root: Path | str, filename: str, content: bytes, *, by: str = "") -> dict:
|
||||||
|
"""Zapisuje wgrany plik i od razu go sprawdza.
|
||||||
|
|
||||||
|
Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś
|
||||||
|
wgrał. Zmienia się tylko to, czy da się go włączyć do użytku."""
|
||||||
|
root = Path(root)
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9._ -]", "_", Path(filename or "").name).strip() or "plik"
|
||||||
|
target = root / safe
|
||||||
|
stem, suffix, n = Path(safe).stem, Path(safe).suffix, 1
|
||||||
|
while target.exists(): # nie nadpisujemy cudzej bazy
|
||||||
|
target = root / f"{stem}-{n}{suffix}"
|
||||||
|
n += 1
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(content)
|
||||||
|
|
||||||
|
rel = str(target.relative_to(root))
|
||||||
|
digest = sha256_of(target)
|
||||||
|
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True)
|
||||||
|
if e["sha256"] and e["path"] != rel}
|
||||||
|
why = validate(target, root, digest=digest, known_digests=known)
|
||||||
|
_touch(root, rel, status=QUARANTINE if why else READY, rejected_for=why,
|
||||||
|
sha256=digest, uploaded_at=_now(), uploaded_by=by, checked_at=_now())
|
||||||
|
return {"path": rel, "name": target.name, "accepted": not why}
|
||||||
|
|
||||||
|
|
||||||
|
def delete(root: Path | str, rel: str) -> None:
|
||||||
|
"""Nieodwracalne skasowanie pliku — wyłącznie dla administratora."""
|
||||||
|
root = Path(root)
|
||||||
|
target = root / rel
|
||||||
|
if not target.is_file():
|
||||||
|
raise ValueError(f"Nie ma pliku „{rel}”.")
|
||||||
|
target.unlink()
|
||||||
|
with _lock:
|
||||||
|
data = _read_state(root)
|
||||||
|
data["files"].pop(rel, None)
|
||||||
|
_write_state(root, data)
|
||||||
@@ -153,7 +153,7 @@ class Link:
|
|||||||
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
frame: bytes) -> bytes:
|
frame: bytes) -> bytes:
|
||||||
if not frame.startswith(MAGIC):
|
if not frame.startswith(MAGIC):
|
||||||
raise LinkError("ramka bez znacznika astrololo")
|
raise LinkError("ramka bez znacznika protokołu")
|
||||||
body = frame[len(MAGIC):]
|
body = frame[len(MAGIC):]
|
||||||
if len(body) <= NONCE_BYTES:
|
if len(body) <= NONCE_BYTES:
|
||||||
raise LinkError("ramka za krótka")
|
raise LinkError("ramka za krótka")
|
||||||
|
|||||||
+109
-4
@@ -7,13 +7,16 @@ ani prezentacji.
|
|||||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
from app import link_crypto, security
|
from app import canary, files, link_crypto, security
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||||
|
from pydantic import BaseModel
|
||||||
from app.providers.factory import build_provider
|
from app.providers.factory import build_provider
|
||||||
|
|
||||||
provider = build_provider(settings)
|
provider = build_provider(settings)
|
||||||
@@ -25,7 +28,12 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||||
|
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||||
|
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||||
|
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||||
|
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan,
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||||
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
||||||
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
@@ -34,7 +42,104 @@ link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
|
|||||||
|
|
||||||
@app.post("/search", response_model=SearchResult)
|
@app.post("/search", response_model=SearchResult)
|
||||||
def search(query: SearchQuery) -> SearchResult:
|
def search(query: SearchQuery) -> SearchResult:
|
||||||
return provider.search(query)
|
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)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthInfo)
|
@app.get("/health", response_model=HealthInfo)
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class SearchQuery(BaseModel):
|
|||||||
fields: list[str] | None = Field(
|
fields: list[str] | None = Field(
|
||||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
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):
|
class SearchResult(BaseModel):
|
||||||
|
|||||||
@@ -25,3 +25,10 @@ class DataProvider(ABC):
|
|||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
||||||
return None
|
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 []
|
||||||
|
|||||||
@@ -91,10 +91,48 @@ class ExcelDataProvider(DataProvider):
|
|||||||
base = Path(self.s.excel_dir)
|
base = Path(self.s.excel_dir)
|
||||||
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
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 ----
|
# ---- publiczne API ----
|
||||||
def search(self, query: SearchQuery) -> SearchResult:
|
def search(self, query: SearchQuery) -> SearchResult:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
|
# 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}")
|
||||||
|
|
||||||
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
@@ -105,6 +143,9 @@ class ExcelDataProvider(DataProvider):
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
||||||
candidates = [(p, "0") for p in self._excel_files()]
|
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] = []
|
rows: list[dict] = []
|
||||||
for path, _sheet in candidates:
|
for path, _sheet in candidates:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -6,6 +6,11 @@ pandas>=2.2
|
|||||||
openpyxl>=3.1
|
openpyxl>=3.1
|
||||||
pyarrow>=18.0
|
pyarrow>=18.0
|
||||||
SQLAlchemy>=2.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
|
pydantic>=2.10
|
||||||
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
cryptography>=44.0
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Rejestr plików baz: stany, walidacja, archiwizacja (DAN-27).
|
||||||
|
|
||||||
|
Testujemy tu RDZEŃ — bez HTTP i bez uprawnień, bo uprawnienia rozstrzyga
|
||||||
|
prezentacja (patrz services/presentation/tests/test_pliki.py). Tutaj chodzi
|
||||||
|
o to, żeby żadna operacja nie gubiła pliku i żeby bramka „do użytku tylko po
|
||||||
|
walidacji" faktycznie trzymała.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import files
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def root(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("FILES_STATE", raising=False)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _xlsx(path, rows=3, header=("id", "opis")):
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(list(header))
|
||||||
|
for i in range(rows):
|
||||||
|
ws.append([i, f"treść {i}"])
|
||||||
|
wb.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ── rejestr i stany ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_a_file_dropped_on_the_share_is_adopted_as_active(root):
|
||||||
|
"""Baza położona na udziale poza aplikacją (np. przez NFS) ma działać —
|
||||||
|
tak było przed DAN-27 i tak ma zostać. Plik WGRANY EKRANEM to inna sprawa:
|
||||||
|
ten wymaga świadomego włączenia (patrz test niżej)."""
|
||||||
|
_xlsx(root / "baza.xlsx")
|
||||||
|
entry = files.registry(root)[0]
|
||||||
|
assert entry["status"] == files.ACTIVE
|
||||||
|
assert entry["in_use"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_active_files_reach_the_search(root):
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
_xlsx(root / "b.xlsx")
|
||||||
|
files.registry(root) # przyjęcie zastanych
|
||||||
|
files.set_status(root, "b.xlsx", files.READY) # świadome odstawienie
|
||||||
|
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"]
|
||||||
|
files.set_status(root, "b.xlsx", files.ACTIVE)
|
||||||
|
assert len(files.usable_paths(root)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_survives_a_restart(root):
|
||||||
|
"""Stan jest KLIKANY, więc musi być trwały — inaczej restart poda po cichu
|
||||||
|
przywracałby bazy wyłączone świadomie."""
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
files.set_status(root, "a.xlsx", files.ACTIVE)
|
||||||
|
assert files.state_path(root).exists()
|
||||||
|
assert files.registry(root)[0]["in_use"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── archiwizacja ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_archiving_freezes_the_file_but_never_removes_it(root):
|
||||||
|
"""To jest najdalej idąca operacja osoby wgrywającej dane: plik ZOSTAJE."""
|
||||||
|
p = _xlsx(root / "stara.xlsx")
|
||||||
|
files.set_status(root, "stara.xlsx", files.ACTIVE)
|
||||||
|
files.set_status(root, "stara.xlsx", files.ARCHIVED, by="dane")
|
||||||
|
entry = files.registry(root)[0]
|
||||||
|
assert p.exists(), "plik zniknął z dysku — archiwizacja ma go zachować"
|
||||||
|
assert entry["status"] == files.ARCHIVED
|
||||||
|
assert entry["in_use"] is False
|
||||||
|
assert entry["archived_at"], "brak znacznika czasu archiwizacji"
|
||||||
|
|
||||||
|
|
||||||
|
def test_archived_file_cannot_slip_back_into_use_by_itself(root):
|
||||||
|
_xlsx(root / "stara.xlsx")
|
||||||
|
files.set_status(root, "stara.xlsx", files.ARCHIVED)
|
||||||
|
assert files.usable_paths(root) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── walidacja: bramka do użytku ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_upload_keeps_a_file_that_fails_validation(root):
|
||||||
|
"""Rzecz najważniejsza: wgranego pliku NIE TRACIMY, choćby nie przeszedł."""
|
||||||
|
files.set_rules(root, {"extensions": [".xlsx"]})
|
||||||
|
out = files.store_upload(root, "notatka.txt", "to nie jest baza".encode("utf-8"), by="dane")
|
||||||
|
assert out["accepted"] is False
|
||||||
|
assert (root / out["path"]).exists(), "plik odrzucony zniknął z dysku"
|
||||||
|
admin_view = files.registry(root, for_admin=True)[0]
|
||||||
|
assert admin_view["status"] == files.QUARANTINE
|
||||||
|
assert admin_view["rejected_for"], "administrator ma widzieć powód"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_held_file_is_invisible_without_admin_rights(root):
|
||||||
|
"""Gdyby plik wstrzymany był widoczny z powodem odrzucenia, każdy wgrywający
|
||||||
|
poznałby reguły walidacji — a te są narzędziem administratora."""
|
||||||
|
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
|
||||||
|
assert files.registry(root, for_admin=False) == []
|
||||||
|
assert len(files.registry(root, for_admin=True)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_held_file_cannot_be_switched_into_use(root):
|
||||||
|
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
|
||||||
|
rel = files.registry(root, for_admin=True)[0]["path"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
files.set_status(root, rel, files.ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_activation_revalidates_and_holds_a_file_that_stopped_qualifying(root):
|
||||||
|
"""Reguły mogą się zmienić PO wgraniu — bramka sprawdza w chwili włączania,
|
||||||
|
a nie tylko przy wgrywaniu."""
|
||||||
|
_xlsx(root / "mala.xlsx", rows=2)
|
||||||
|
files.set_status(root, "mala.xlsx", files.ACTIVE)
|
||||||
|
files.set_rules(root, {"min_rows": 500})
|
||||||
|
files.set_status(root, "mala.xlsx", files.READY)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
files.set_status(root, "mala.xlsx", files.ACTIVE)
|
||||||
|
assert (root / "mala.xlsx").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rule,value,bad", [
|
||||||
|
("extensions", [".xlsx"], "plik.csv"),
|
||||||
|
("max_size_mb", 0.000001, "plik.xlsx"),
|
||||||
|
])
|
||||||
|
def test_rules_reject_what_they_are_meant_to(root, rule, value, bad):
|
||||||
|
files.set_rules(root, {rule: value})
|
||||||
|
out = files.store_upload(root, bad, b"x" * 2048)
|
||||||
|
assert out["accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_columns_are_checked_inside_the_workbook(root):
|
||||||
|
files.set_rules(root, {"required_columns": ["id", "znaczenie"]})
|
||||||
|
_xlsx(root / "tmp.xlsx", header=("id", "opis"))
|
||||||
|
why = files.validate(root / "tmp.xlsx", root)
|
||||||
|
assert why and "znaczenie" in why[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_content_is_rejected_by_hash_not_by_name(root):
|
||||||
|
files.set_rules(root, {"reject_duplicate_content": True})
|
||||||
|
data = _xlsx(root / "wzor.xlsx").read_bytes()
|
||||||
|
first = files.store_upload(root, "pierwsza.xlsx", data)
|
||||||
|
assert first["accepted"] is True
|
||||||
|
second = files.store_upload(root, "inna-nazwa.xlsx", data)
|
||||||
|
assert second["accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_never_overwrites_someone_elses_base(root):
|
||||||
|
files.store_upload(root, "baza.xlsx", _xlsx(root / "w.xlsx").read_bytes())
|
||||||
|
(root / "w.xlsx").unlink()
|
||||||
|
files.set_rules(root, {"reject_duplicate_content": False})
|
||||||
|
out = files.store_upload(root, "baza.xlsx", "inna treść".encode("utf-8"))
|
||||||
|
assert out["name"] != "baza.xlsx"
|
||||||
|
assert (root / "baza.xlsx").exists() and (root / out["path"]).exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ── kasowanie ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_delete_removes_the_file_and_its_entry(root):
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
files.set_status(root, "a.xlsx", files.ACTIVE)
|
||||||
|
files.delete(root, "a.xlsx")
|
||||||
|
assert not (root / "a.xlsx").exists()
|
||||||
|
assert files.registry(root, for_admin=True) == []
|
||||||
|
assert files.usable_paths(root) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── przejście na rejestr nie może wyłączyć wyszukiwania ─────────────────
|
||||||
|
|
||||||
|
def test_bases_already_on_the_share_stay_in_use_after_the_switch(root):
|
||||||
|
"""Dotąd bazy działały domyślnie (wyłączało się je przez DISABLED_BASES).
|
||||||
|
Po przejściu na rejestr pusty stan oznaczałby, że program nagle niczego nie
|
||||||
|
znajduje — cicha zmiana zachowania gorsza od awarii, bo wygląda jak pusta baza."""
|
||||||
|
_xlsx(root / "main_base.xlsx")
|
||||||
|
_xlsx(root / "zodiac_pl.xlsx")
|
||||||
|
assert len(files.usable_paths(root)) == 2, "zastane bazy wypadły z wyszukiwania"
|
||||||
|
assert all(e["in_use"] for e in files.registry(root))
|
||||||
|
|
||||||
|
|
||||||
|
def test_adoption_happens_once_and_respects_later_decisions(root):
|
||||||
|
"""Po przyjęciu stan jest zapisany, więc świadome odstawienie bazy ZOSTAJE —
|
||||||
|
kolejny odczyt nie może jej wskrzesić."""
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
_xlsx(root / "b.xlsx")
|
||||||
|
files.registry(root) # przyjęcie
|
||||||
|
files.set_status(root, "a.xlsx", files.READY) # świadome odstawienie
|
||||||
|
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["b.xlsx"]
|
||||||
|
files.set_status(root, "b.xlsx", files.READY) # odstawiamy wszystko
|
||||||
|
assert files.usable_paths(root) == [], "pusty wybór został wskrzeszony"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_files_still_need_an_explicit_switch_on(root):
|
||||||
|
"""Przyjęcie dotyczy TYLKO baz zastanych. Plik wgrany ekranem ktoś musi
|
||||||
|
świadomie włączyć — inaczej nowa baza wchodziłaby do wyników sama."""
|
||||||
|
_xlsx(root / "zastana.xlsx")
|
||||||
|
files.registry(root)
|
||||||
|
out = files.store_upload(root, "nowa.xlsx", _xlsx(root / "tmp.xlsx").read_bytes())
|
||||||
|
assert out["accepted"] is True
|
||||||
|
names = [pathlib.Path(p).name for p in files.usable_paths(root)]
|
||||||
|
assert "nowa.xlsx" not in names, "wgrana baza weszła do wyników bez decyzji"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adoption_survives_a_read_only_share(root, monkeypatch):
|
||||||
|
"""Na udziale tylko do odczytu stanu nie da się zapisać — zachowanie ma
|
||||||
|
zostać to samo, tylko przyjęcie powtórzy się przy każdym uruchomieniu."""
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise OSError("read-only file system")
|
||||||
|
|
||||||
|
monkeypatch.setattr(files, "_write_state", boom)
|
||||||
|
assert len(files.usable_paths(root)) == 1
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Rejestr plików a RESZTA warstwy danych — punkty styku (DAN-27).
|
||||||
|
|
||||||
|
DLACZEGO OSOBNY PLIK. test_files.py sprawdza sam rejestr w izolacji i przechodził
|
||||||
|
na zielono, podczas gdy na produkcji leżało wyszukiwanie (500) i lista baz (502).
|
||||||
|
Rejestr wszedł w miejsce starego mechanizmu włączania baz, więc groźne jest nie
|
||||||
|
to, co robi w środku, tylko czy MÓWI TYM SAMYM JĘZYKIEM, co jego odbiorcy.
|
||||||
|
|
||||||
|
Oba tamte błędy były jednolinijkowe i oba niewidoczne dla testów jednostkowych:
|
||||||
|
* NameError, bo przepisując `_enabled_files` usunąłem lokalny import `bases`,
|
||||||
|
* KeyError, bo rejestr oddawał `in_use`, a endpoint /bases czytał `enabled`.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def excel_dir(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("EXCEL_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("CACHE_DIR", str(tmp_path / ".cache"))
|
||||||
|
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||||
|
monkeypatch.delenv("FILES_STATE", raising=False)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _xlsx(path, rows=(("Ma Ari", "odważny"), ("Ve Tau", "zgodny"))):
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(["significator", "effect"])
|
||||||
|
for r in rows:
|
||||||
|
ws.append(list(r))
|
||||||
|
wb.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _provider(excel_dir):
|
||||||
|
from app.config import Settings
|
||||||
|
from app.providers.excel_provider import ExcelDataProvider
|
||||||
|
|
||||||
|
return ExcelDataProvider(Settings())
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_does_not_explode_on_the_registry(excel_dir):
|
||||||
|
"""Regresja: `_enabled_files` wołało bases.disabled_entries() bez importu,
|
||||||
|
więc KAŻDE wyszukiwanie kończyło się NameError → 500 z warstwy danych."""
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_xlsx(excel_dir / "baza.xlsx")
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
|
||||||
|
assert out.total >= 1, "zastana baza nie weszła do wyszukiwania"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_still_works_with_disabled_bases_set(excel_dir, monkeypatch):
|
||||||
|
"""DISABLED_BASES zostaje jako awaryjne wyłączenie i ma odsiewać DODATKOWO —
|
||||||
|
to właśnie ta gałąź kodu wywalała się na braku importu."""
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_xlsx(excel_dir / "a.xlsx")
|
||||||
|
_xlsx(excel_dir / "b.xlsx")
|
||||||
|
monkeypatch.setenv("DISABLED_BASES", "b.xlsx")
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
|
||||||
|
assert out.total >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_speaks_the_language_the_bases_endpoint_reads(excel_dir):
|
||||||
|
"""Regresja: endpoint /bases liczy `b["enabled"]`, rejestr oddawał `in_use`.
|
||||||
|
KeyError → 500 z danych → 502 z logiki → „Warstwa logiczna niedostępna"
|
||||||
|
na ekranie Ustawienia."""
|
||||||
|
_xlsx(excel_dir / "baza.xlsx")
|
||||||
|
items = _provider(excel_dir).list_bases()
|
||||||
|
assert items, "lista baz jest pusta"
|
||||||
|
for row in items:
|
||||||
|
for key in ("name", "path", "enabled", "in_use", "size_mb", "modified"):
|
||||||
|
assert key in row, f"brak pola `{key}` — odbiorca dostanie KeyError"
|
||||||
|
assert row["enabled"] == row["in_use"], "dwa pola, jedna prawda"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bases_endpoint_answers_end_to_end(excel_dir):
|
||||||
|
"""Przez TRASĘ, nie przez dostawcę: to ona wywalała się na produkcji."""
|
||||||
|
_xlsx(excel_dir / "baza.xlsx")
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from app import main as data_main
|
||||||
|
|
||||||
|
importlib.reload(data_main)
|
||||||
|
body = data_main.bases()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["enabled"] == 1, "zastana baza powinna być włączona po adopcji"
|
||||||
|
assert body["bases"][0]["name"] == "baza.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_a_base_off_is_visible_in_both_places(excel_dir):
|
||||||
|
"""Odstawienie bazy ma zniknąć i z wyszukiwania, i z licznika na Ustawieniach."""
|
||||||
|
from app import files
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_xlsx(excel_dir / "baza.xlsx")
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1
|
||||||
|
|
||||||
|
files.set_status(excel_dir, "baza.xlsx", files.READY)
|
||||||
|
p2 = _provider(excel_dir)
|
||||||
|
p2.warmup()
|
||||||
|
assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0
|
||||||
|
assert [b["enabled"] for b in p2.list_bases()] == [False]
|
||||||
|
|
||||||
|
|
||||||
|
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
|
||||||
|
# Najgroźniejsza rzecz w tej zmianie. Wyciek między pulami jest CICHY: klient
|
||||||
|
# zobaczyłby cudze opisy jako własny wynik i nikt by się nie zorientował.
|
||||||
|
|
||||||
|
def _pool(root, name, plik, tresc):
|
||||||
|
from app import files
|
||||||
|
|
||||||
|
pula = files.tenant_root(root, name)
|
||||||
|
pula.mkdir(parents=True, exist_ok=True)
|
||||||
|
_xlsx(pula / plik, rows=[(tresc, f"opis {tresc}")])
|
||||||
|
files.registry(pula) # przyjęcie zastanych
|
||||||
|
return pula
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_account_sees_only_its_own_files(excel_dir):
|
||||||
|
from app import files
|
||||||
|
|
||||||
|
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
|
||||||
|
_pool(excel_dir, "klientB", "b.xlsx", "Ve Tau")
|
||||||
|
|
||||||
|
a = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientA"))]
|
||||||
|
b = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientB"))]
|
||||||
|
assert a == ["a.xlsx"] and b == ["b.xlsx"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_never_reaches_another_account_pool(excel_dir):
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
|
||||||
|
_pool(excel_dir, "klientB", "b.xlsx", "Ve Tau")
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
|
||||||
|
swoje = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA"))
|
||||||
|
cudze = p.search(SearchQuery(key="significator", value="Ve Tau", limit=10, tenant="klientA"))
|
||||||
|
assert swoje.total >= 1, "konto nie widzi własnej bazy"
|
||||||
|
assert cudze.total == 0, "konto sięgnęło do puli innego konta"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_query_cache_cannot_serve_one_account_the_answer_of_another(excel_dir):
|
||||||
|
"""Bez puli w kluczu cache wynik policzony dla jednego konta trafiłby do
|
||||||
|
drugiego — cicha wymiana treści baz, niewidoczna w logach."""
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
|
||||||
|
_pool(excel_dir, "klientB", "b.xlsx", "Ma Ari") # TA SAMA szukana wartość
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
|
||||||
|
a = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA"))
|
||||||
|
b = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientB"))
|
||||||
|
assert a.total >= 1 and b.total >= 1
|
||||||
|
assert b.cache != "hit", "odpowiedź dla jednego konta wyszła z cache drugiego"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crafted_pool_name_cannot_escape_the_share(excel_dir):
|
||||||
|
"""Nazwa puli przychodzi z zewnątrz (login), więc „../..” albo ukośnik
|
||||||
|
wyprowadziłyby zapytanie wprost do cudzych baz."""
|
||||||
|
from app import files
|
||||||
|
|
||||||
|
for zlosliwa in ("../..", "../klientB", "/etc", "a/../../b", "..", "."):
|
||||||
|
wynik = files.tenant_root(excel_dir, zlosliwa)
|
||||||
|
assert excel_dir in wynik.parents or wynik == excel_dir, f"ucieczka: {zlosliwa} → {wynik}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_pool_means_the_whole_share_so_production_is_untouched(excel_dir):
|
||||||
|
"""Produkcja nie wie o pulach i ma działać dokładnie jak dotąd."""
|
||||||
|
from app import files
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_xlsx(excel_dir / "produkcyjna.xlsx")
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
assert files.tenant_root(excel_dir, "") == excel_dir
|
||||||
|
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1
|
||||||
@@ -15,12 +15,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import swisseph as swe
|
import swisseph as swe
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
|
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||||
|
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||||
|
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||||
|
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||||
|
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)",
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
||||||
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
||||||
@@ -69,6 +74,67 @@ def positions(req: PositionsRequest) -> dict:
|
|||||||
return {"engine": "swisseph", "positions": rows}
|
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")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
||||||
|
|||||||
@@ -28,8 +28,18 @@ def _link() -> link_crypto.Link | None:
|
|||||||
|
|
||||||
|
|
||||||
class DataClient:
|
class DataClient:
|
||||||
def __init__(self, base_url: str | None = None) -> None:
|
"""Rozmówca warstwy danych, opcjonalnie ZWIĄZANY Z PULĄ KONTA (PRE-29).
|
||||||
|
|
||||||
|
Pula siedzi w INSTANCJI, nie w sygnaturach metod. Gdyby jechała argumentem,
|
||||||
|
trzeba by ją przeprowadzić przez protokół DataSource i przez build_report —
|
||||||
|
czyli przez kod, który o kontach nie ma prawa nic wiedzieć. A przy okazji
|
||||||
|
każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam.
|
||||||
|
|
||||||
|
Puste = cały udział, czyli zachowanie produkcji."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str | None = None, tenant: str = "") -> None:
|
||||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||||
|
self.tenant = tenant or ""
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self,
|
self,
|
||||||
@@ -39,12 +49,46 @@ class DataClient:
|
|||||||
limit: int,
|
limit: int,
|
||||||
fields: list[str] | None = None,
|
fields: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
payload = {"key": key, "value": value, "exact": exact, "limit": limit,
|
||||||
|
"fields": fields, "tenant": self.tenant}
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||||
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
|
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
|
||||||
payload=payload, headers=_auth_headers(),
|
payload=payload, headers=_auth_headers(),
|
||||||
link=_link())
|
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]:
|
def health(self) -> dict[str, Any]:
|
||||||
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||||
# nie mają, a nie przechodzi tędy nic z baz.
|
# nie mają, a nie przechodzi tędy nic z baz.
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ MAJOR = {
|
|||||||
"trine": 120.0,
|
"trine": 120.0,
|
||||||
"opposition": 180.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 = {
|
DB_TOKEN = {
|
||||||
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
||||||
"trine": "[tri", "opposition": "[opp",
|
"trine": "[tri", "opposition": "[opp",
|
||||||
@@ -61,16 +71,54 @@ def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: f
|
|||||||
return dev_next < dev_now
|
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(
|
def find_aspects(
|
||||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS,
|
||||||
|
minor: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
||||||
|
|
||||||
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
|
Zwraca listę aspektów; gdy znane są prędkości, każdy aspekt ma applying (bool)
|
||||||
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
|
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł są
|
||||||
|
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
|
||||||
|
|
||||||
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
||||||
"""
|
"""
|
||||||
|
checks = {**MAJOR, **MINOR} if minor else MAJOR
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
n = len(positions)
|
n = len(positions)
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
@@ -83,7 +131,7 @@ def find_aspects(
|
|||||||
continue
|
continue
|
||||||
sep = separation(float(la), float(lb))
|
sep = separation(float(la), float(lb))
|
||||||
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||||
for asp, angle in MAJOR.items():
|
for asp, angle in checks.items():
|
||||||
dev = abs(sep - angle)
|
dev = abs(sep - angle)
|
||||||
if dev <= allowed:
|
if dev <= allowed:
|
||||||
applying = _is_applying(
|
applying = _is_applying(
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ def _shift_pos(pdict: dict, off: float) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
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:
|
||||||
from app.engine import glyphs as GL
|
from app.engine import glyphs as GL
|
||||||
from app.engine.aspects import find_aspects
|
from app.engine.aspects import find_aspects
|
||||||
from app.engine.houses import mean_obliquity
|
from app.engine.houses import mean_obliquity
|
||||||
@@ -49,13 +52,23 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
positions = engine.positions(moment)
|
positions = engine.positions(moment)
|
||||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
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
|
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||||
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
|
||||||
|
result["positions"], orb=aspect_orb,
|
||||||
|
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
|
||||||
|
|
||||||
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
|
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
|
||||||
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
||||||
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
||||||
# z surowych długości/szerokości silnika.
|
# z surowych długości/szerokości silnika.
|
||||||
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
# ε PRAWDZIWE — to samo, którym liczymy domy, bo pozycje ze Skyfielda są
|
||||||
|
# POZORNE (uwzględniają nutację). Silnik bez sidereal() nutacji nie poda;
|
||||||
|
# wtedy zostaje średnie, z jawnym oznaczeniem w wyniku.
|
||||||
|
if hasattr(engine, "sidereal"):
|
||||||
|
eps = engine.sidereal(moment)[1]
|
||||||
|
result["obliquity_kind"] = "true"
|
||||||
|
else:
|
||||||
|
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||||
|
result["obliquity_kind"] = "mean"
|
||||||
result["obliquity"] = round(eps, 6)
|
result["obliquity"] = round(eps, 6)
|
||||||
for pdict, obj in zip(result["positions"], positions):
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
dec = declination(obj.longitude, obj.latitude, eps)
|
dec = declination(obj.longitude, obj.latitude, eps)
|
||||||
@@ -92,9 +105,27 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||||
mc = H.compute_mc(ramc, eps)
|
mc = H.compute_mc(ramc, eps)
|
||||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||||
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
# 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
|
||||||
|
|
||||||
result["house_system"] = system
|
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["angles"] = {
|
result["angles"] = {
|
||||||
"Asc": _fmt("Asc", asc, off),
|
"Asc": _fmt("Asc", asc, off),
|
||||||
"MC": _fmt("MC", mc, off),
|
"MC": _fmt("MC", mc, off),
|
||||||
@@ -103,16 +134,30 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
}
|
}
|
||||||
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||||
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||||
result["cusps"] = [
|
result["cusps"] = _cusps_out(cusp_list) # PRYMARNY system — pod kosmogram i wstecz
|
||||||
{"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):
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
|
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)
|
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||||
from app.engine.firdaria import is_day_birth
|
from app.engine.firdaria import is_day_birth
|
||||||
from app.engine.lots import compute_lots
|
from app.engine.lots import compute_lots
|
||||||
|
|||||||
@@ -8,13 +8,31 @@ silnik dostarcza tylko RAMC i ε.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||||
|
|
||||||
WHOLE_SIGN = "whole_sign"
|
WHOLE_SIGN = "whole_sign"
|
||||||
EQUAL = "equal"
|
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"
|
PORPHYRY = "porphyry"
|
||||||
SYSTEMS = (WHOLE_SIGN, EQUAL, 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)
|
||||||
|
|
||||||
|
|
||||||
def mean_obliquity(tt_jd: float) -> float:
|
def mean_obliquity(tt_jd: float) -> float:
|
||||||
@@ -31,12 +49,27 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def compute_asc(ramc_deg: float, eps_deg: float, lat_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)
|
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||||
asc = math.atan2(
|
asc = norm360(math.degrees(math.atan2(
|
||||||
math.cos(r),
|
math.cos(r),
|
||||||
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||||
)
|
)))
|
||||||
return norm360(math.degrees(asc))
|
mc = compute_mc(ramc_deg, eps_deg)
|
||||||
|
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
|
||||||
|
|
||||||
|
|
||||||
def _trisect(a: float, b: float) -> tuple[float, float]:
|
def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||||
@@ -45,10 +78,29 @@ def _trisect(a: float, b: float) -> tuple[float, float]:
|
|||||||
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
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]:
|
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||||
"""Zwraca 12 cusps (długości) domów 1..12."""
|
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||||
if system == WHOLE_SIGN:
|
if system == WHOLE_SIGN:
|
||||||
start = sign_index(asc) * 30.0
|
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0
|
||||||
return [norm360(start + 30.0 * i) for i in range(12)]
|
return [norm360(start + 30.0 * i) for i in range(12)]
|
||||||
if system == EQUAL:
|
if system == EQUAL:
|
||||||
return [norm360(asc + 30.0 * i) for i in range(12)]
|
return [norm360(asc + 30.0 * i) for i in range(12)]
|
||||||
@@ -64,14 +116,472 @@ def cusps(asc: float, mc: float, system: str) -> list[float]:
|
|||||||
raise ValueError(f"nieznany system domów: {system}")
|
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 0–180°), 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 tą 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 4–9 naprzeciw 10–3
|
||||||
|
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 (Polich–Page).
|
||||||
|
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
||||||
|
# Polich–Page: dom pośredni to Ascendent policzony pod własnym „biegunem"
|
||||||
|
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
|
||||||
|
# przy k=0 (biegun 0, przesunięcie −90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
|
||||||
|
# Ascendent, a domy 11 i 12 leżą po drodze.
|
||||||
|
#
|
||||||
|
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
|
||||||
|
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
|
||||||
|
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
|
||||||
|
# krokami) myliły się na 6–11% przypadków powyżej ~70°, bo każda z nich rozstrzyga
|
||||||
|
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
|
||||||
|
#
|
||||||
|
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
|
||||||
|
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
|
||||||
|
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
|
||||||
|
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
|
||||||
|
# i bez zawężania szerokości.
|
||||||
|
|
||||||
|
# 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 ją 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 ją 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:
|
def assign_house(lon: float, cusp_list: list[float]) -> int:
|
||||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
|
"""Numer domu (1..12), w którym leży dana długość ekliptyczna.
|
||||||
|
|
||||||
|
Kierunek liczenia bierzemy z samych cuspów. Zaszycie „zawsze do przodu"
|
||||||
|
dawało przy |φ| powyżej koła podbiegunowego złe domy dla regiomontanusa,
|
||||||
|
campanusa i topocentrica — mimo cuspów zgodnych z wyrocznią co do zera.
|
||||||
|
Błąd był CICHY: wykres wyglądał poprawnie, tylko planety siedziały gdzie
|
||||||
|
indziej. Sprawdzane wobec swe_house_pos (tests/oracle)."""
|
||||||
lon = norm360(lon)
|
lon = norm360(lon)
|
||||||
|
forward = _runs_forward(cusp_list)
|
||||||
for i in range(12):
|
for i in range(12):
|
||||||
start = cusp_list[i]
|
start = cusp_list[i]
|
||||||
end = cusp_list[(i + 1) % 12]
|
end = cusp_list[(i + 1) % 12]
|
||||||
span = (end - start) % 360.0
|
if forward:
|
||||||
offset = (lon - start) % 360.0
|
span, offset = (end - start) % 360.0, (lon - start) % 360.0
|
||||||
|
else:
|
||||||
|
span, offset = (start - end) % 360.0, (start - lon) % 360.0
|
||||||
if offset < span:
|
if offset < span:
|
||||||
return i + 1
|
return i + 1
|
||||||
return 12
|
return 12
|
||||||
|
|||||||
@@ -117,12 +117,28 @@ class SkyfieldEngine(EphemerisEngine):
|
|||||||
|
|
||||||
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
|
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)
|
t = self.ts.from_datetime(moment.when_utc)
|
||||||
ramc = norm360(t.gast * 15.0 + moment.lon)
|
ramc = norm360(t.gast * 15.0 + moment.lon)
|
||||||
eps = mean_obliquity(t.tt)
|
return ramc, true_obliquity(t)
|
||||||
return ramc, eps
|
|
||||||
|
|
||||||
def health(self) -> dict:
|
def health(self) -> dict:
|
||||||
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||||
|
|
||||||
|
|
||||||
|
def true_obliquity(t) -> float:
|
||||||
|
"""ε PRAWDZIWE (średnie + nutacja w nachyleniu), w stopniach.
|
||||||
|
|
||||||
|
MUSI być prawdziwe, nie średnie. RAMC liczymy z `t.gast` — czasu gwiazdowego
|
||||||
|
POZORNEGO, mierzonego od równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej
|
||||||
|
samej równonocy ma ε z nutacją; sparowanie GAST z ε średnim miesza dwa układy
|
||||||
|
odniesienia. Kosztowało to ~3,2″ na cuspach domów i było niewidoczne dla
|
||||||
|
frameworka wyroczni, bo ten z założenia podaje to samo ε obu stronom
|
||||||
|
(izoluje samą funkcję domów) — patrz tests/oracle/README.md.
|
||||||
|
|
||||||
|
Nutacja z serii IAU 2000A, czyli z tego samego źródła, którego Skyfield
|
||||||
|
używa do policzenia GAST — dzięki temu oba są spójne z definicji."""
|
||||||
|
from skyfield.nutationlib import iau2000a, mean_obliquity
|
||||||
|
|
||||||
|
mean_arcsec = float(mean_obliquity(t.tdb))
|
||||||
|
d_eps_arcsec = float(iau2000a(t.tt)[1]) * 1e-7 # jednostki 0,1 µas
|
||||||
|
return (mean_arcsec + d_eps_arcsec) / 3600.0
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ class Link:
|
|||||||
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
frame: bytes) -> bytes:
|
frame: bytes) -> bytes:
|
||||||
if not frame.startswith(MAGIC):
|
if not frame.startswith(MAGIC):
|
||||||
raise LinkError("ramka bez znacznika astrololo")
|
raise LinkError("ramka bez znacznika protokołu")
|
||||||
body = frame[len(MAGIC):]
|
body = frame[len(MAGIC):]
|
||||||
if len(body) <= NONCE_BYTES:
|
if len(body) <= NONCE_BYTES:
|
||||||
raise LinkError("ramka za krótka")
|
raise LinkError("ramka za krótka")
|
||||||
|
|||||||
+155
-4
@@ -18,7 +18,12 @@ from app.clients.data_client import DataClient
|
|||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
from app.service import QueryService
|
from app.service import QueryService
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||||
|
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||||
|
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||||
|
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||||
|
app = FastAPI(title="astrololo · warstwa logiczna",
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
service = QueryService()
|
service = QueryService()
|
||||||
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||||
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
||||||
@@ -43,7 +48,11 @@ class PositionsRequest(BaseModel):
|
|||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
lon: float = 0.0
|
lon: float = 0.0
|
||||||
objects: list[str] | None = None
|
objects: list[str] | None = None
|
||||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
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°)
|
||||||
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
||||||
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
|
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
|
||||||
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
||||||
@@ -67,7 +76,11 @@ def chart_positions(req: PositionsRequest) -> dict:
|
|||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
try:
|
try:
|
||||||
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
|
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)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=422, detail=str(e))
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
if req.stations:
|
if req.stations:
|
||||||
@@ -84,6 +97,136 @@ def chart_positions(req: PositionsRequest) -> dict:
|
|||||||
return chart
|
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")
|
@app.post("/chart/compare")
|
||||||
def chart_compare(req: PositionsRequest) -> dict:
|
def chart_compare(req: PositionsRequest) -> dict:
|
||||||
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
||||||
@@ -109,6 +252,10 @@ class ReportRequest(BaseModel):
|
|||||||
lon: float = 0.0
|
lon: float = 0.0
|
||||||
limit: int = 5000
|
limit: int = 5000
|
||||||
group: bool = False # grupowanie identycznych opisów
|
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")
|
@app.post("/chart/report")
|
||||||
@@ -124,7 +271,7 @@ def chart_report(req: ReportRequest) -> dict:
|
|||||||
chart = build_chart(engine, moment) # pozycje z domami + aspekty
|
chart = build_chart(engine, moment) # pozycje z domami + aspekty
|
||||||
try:
|
try:
|
||||||
report = build_report(
|
report = build_report(
|
||||||
chart["positions"], DataClient(),
|
chart["positions"], DataClient(tenant=req.tenant),
|
||||||
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||||
per_object_limit=req.limit, group=req.group,
|
per_object_limit=req.limit, group=req.group,
|
||||||
)
|
)
|
||||||
@@ -452,6 +599,10 @@ class TimelineRequest(BaseModel):
|
|||||||
to_date: str
|
to_date: str
|
||||||
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
||||||
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
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")
|
@app.post("/chart/timeline")
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
class QueryRequest(BaseModel):
|
class QueryRequest(BaseModel):
|
||||||
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
|
"""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.")
|
query: str = Field(..., min_length=1, description="Szukana fraza.")
|
||||||
field: str = Field("name", description="Po którym polu szukać.")
|
field: str = Field("name", description="Po którym polu szukać.")
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ def _chart_section(chart: dict, moment_label: str | None) -> str:
|
|||||||
if meta:
|
if meta:
|
||||||
lines.append(" · ".join(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"):
|
if chart.get("positions"):
|
||||||
lines.append("\n## Pozycje")
|
lines.append("\n## Pozycje")
|
||||||
for p in chart["positions"]:
|
for p in chart["positions"]:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models import QueryRequest, QueryResponse
|
|||||||
|
|
||||||
class QueryService:
|
class QueryService:
|
||||||
def __init__(self, data_client: DataClient | None = None) -> None:
|
def __init__(self, data_client: DataClient | None = None) -> None:
|
||||||
|
self._wstrzykniety = data_client is not None
|
||||||
self.data = data_client or DataClient()
|
self.data = data_client or DataClient()
|
||||||
|
|
||||||
def handle(self, req: QueryRequest) -> QueryResponse:
|
def handle(self, req: QueryRequest) -> QueryResponse:
|
||||||
@@ -21,8 +22,11 @@ class QueryService:
|
|||||||
value = req.query.strip()
|
value = req.query.strip()
|
||||||
key = req.field.strip().lower()
|
key = req.field.strip().lower()
|
||||||
|
|
||||||
# 2) zapytanie w dół do warstwy bazodanowej
|
# 2) zapytanie w dół do warstwy bazodanowej — do PULI KONTA, jeśli podano.
|
||||||
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
|
# Klient budowany na żądanie, bo pula wynika z tego, kto pyta, a nie ze
|
||||||
|
# stanu procesu. Wstrzyknięty klient (testy) ma pierwszeństwo.
|
||||||
|
dane = self.data if (self._wstrzykniety or not req.tenant) else DataClient(tenant=req.tenant)
|
||||||
|
raw = dane.search(key=key, value=value, exact=req.exact, limit=req.limit)
|
||||||
|
|
||||||
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
|
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
|
||||||
results = raw.get("rows", [])
|
results = raw.get("rows", [])
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||||
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
|
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
|
||||||
|
|
||||||
|
|
||||||
def test_separation_wraparound():
|
def test_separation_wraparound():
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Waliduje względem astro.com dla horoskopu referencyjnego.
|
Waliduje względem astro.com dla horoskopu referencyjnego.
|
||||||
"""
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import houses as H
|
||||||
from app.engine.chart import build_chart
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
|
||||||
@@ -25,3 +28,67 @@ def test_house_systems_available(own_engine, reference_moment):
|
|||||||
chart = build_chart(own_engine, reference_moment, system)
|
chart = build_chart(own_engine, reference_moment, system)
|
||||||
assert chart["house_system"] == system
|
assert chart["house_system"] == system
|
||||||
assert len(chart["cusps"]) == 12
|
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)
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.engine import houses as H
|
from app.engine import houses as H
|
||||||
|
|
||||||
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||||
@@ -39,3 +43,306 @@ def test_assign_house_whole_sign():
|
|||||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
||||||
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||||
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
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ą ją 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 są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej
|
||||||
|
koła podbiegunowego przestają dzielić okrąg — cusp VII (= I + 180°) wypada
|
||||||
|
przed cuspem VI i domy nachodzą na siebie. Przypisanie planety do domu traci
|
||||||
|
wtedy sens, więc odmawiamy zamiast zwracać liczbę bez znaczenia.
|
||||||
|
|
||||||
|
Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°",
|
||||||
|
a nie dobrany pod wynik testu — i wypada na kole podbiegunowym."""
|
||||||
|
for lat in (0.0, 50.06, 66.0):
|
||||||
|
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
|
||||||
|
for lat in (69.65, 89.9, -89.9):
|
||||||
|
with pytest.raises(H.HouseSystemUndefined):
|
||||||
|
H.cusps_for(100.0, 23.4393, lat, "topocentric")
|
||||||
|
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
|
||||||
|
assert cs.is_fallback and cs.notice and "topocentric" in cs.notice
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_sign_aries_ignores_the_ascendant():
|
||||||
|
"""Wariant „od Barana": dom I zaczyna się na 0° Barana niezależnie od tego,
|
||||||
|
co wschodzi. To odróżnia go od zwykłego whole sign."""
|
||||||
|
for lat in (0.0, 50.06, -33.9):
|
||||||
|
c = H.cusps_for(100.0, 23.4393, lat, "whole_sign_aries")
|
||||||
|
assert c == [pytest.approx(30.0 * i) for i in range(12)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
|
||||||
|
"""W `equal` MC leży GDZIEŚ w domu X; w `equal_mc` zaczyna go dokładnie."""
|
||||||
|
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||||
|
mc = H.compute_mc(ramc, eps)
|
||||||
|
c = H.cusps_for(ramc, eps, lat, "equal_mc")
|
||||||
|
assert c[9] == pytest.approx(mc, abs=1e-9) # dom X rusza na MC
|
||||||
|
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
|
||||||
|
for i in range(12): # nadal równe 30°
|
||||||
|
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
# ── nachylenie ekliptyki: prawdziwe, nie średnie ─────────────────────────
|
||||||
|
|
||||||
|
def test_sidereal_returns_true_obliquity_not_mean():
|
||||||
|
"""RAMC liczymy z GAST — czasu gwiazdowego POZORNEGO, mierzonego od
|
||||||
|
równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej samej równonocy ma ε
|
||||||
|
Z NUTACJĄ. Sparowanie GAST z ε średnim mieszało dwa układy odniesienia
|
||||||
|
i przesuwało cuspy domów o ~3″.
|
||||||
|
|
||||||
|
Framework wyroczni tego NIE wykryje: z założenia podaje to samo ε obu
|
||||||
|
stronom, żeby izolować samą funkcję domów. Dlatego sprawdzian jest tutaj,
|
||||||
|
ze Skyfieldem jako niezależnym autorytetem."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from skyfield.api import load
|
||||||
|
from skyfield.nutationlib import mean_obliquity
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.skyfield_engine import SkyfieldEngine, true_obliquity
|
||||||
|
|
||||||
|
when = datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc)
|
||||||
|
t = load.timescale().from_datetime(when)
|
||||||
|
_, eps = SkyfieldEngine().sidereal(ChartMoment(when_utc=when, lat=50.06, lon=19.94))
|
||||||
|
|
||||||
|
assert eps == pytest.approx(true_obliquity(t), abs=1e-12)
|
||||||
|
eps_mean = float(mean_obliquity(t.tdb)) / 3600.0
|
||||||
|
assert eps != pytest.approx(eps_mean, abs=1e-9), "ε jest średnie — nutacja zgubiona"
|
||||||
|
# nutacja w nachyleniu nie przekracza ~9,2″; większa różnica to nie nutacja
|
||||||
|
assert abs(eps - eps_mean) * 3600.0 < 9.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_true_obliquity_stays_in_a_sane_band_across_the_date_range():
|
||||||
|
"""ε maleje ~0,013°/wiek. Na całym zakresie programu (370 p.n.e. – 2270 n.e.)
|
||||||
|
musi zostać w widełkach 23,2–23,8° — wyjście oznacza błąd jednostek."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from skyfield.api import load
|
||||||
|
|
||||||
|
from app.engine.skyfield_engine import true_obliquity
|
||||||
|
|
||||||
|
ts = load.timescale()
|
||||||
|
for year in (1600, 1900, 2000, 2100):
|
||||||
|
t = ts.from_datetime(datetime(year, 6, 1, tzinfo=timezone.utc))
|
||||||
|
assert 23.2 < true_obliquity(t) < 23.8, year
|
||||||
|
|
||||||
|
|
||||||
|
# ── przypisanie obiektu do domu ──────────────────────────────────────────
|
||||||
|
|
||||||
|
REVERSED = (108.456, 23.4393, -84.324) # tu domy biegną WSTECZ
|
||||||
|
|
||||||
|
|
||||||
|
def test_cusps_can_run_backwards_at_extreme_latitudes():
|
||||||
|
"""Nie jest to błąd, tylko własność tych systemów — wyrocznia zwraca to samo.
|
||||||
|
Test istnieje po to, żeby nikt tego „nie naprawił"."""
|
||||||
|
ramc, eps, lat = REVERSED
|
||||||
|
c = H.cusps_for(ramc, eps, lat, "regiomontanus")
|
||||||
|
assert c[1] < c[0] and c[2] < c[1], "kolejność miała być odwrócona"
|
||||||
|
assert not H._runs_forward(c)
|
||||||
|
assert H._runs_forward(H.cusps_for(ramc, eps, 50.0, "regiomontanus"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("system", H.SYSTEMS)
|
||||||
|
def test_every_point_lands_in_exactly_one_house(system):
|
||||||
|
"""Cuspy dzielą okrąg bez luk i bez zakładek, więc każdy punkt ma dokładnie
|
||||||
|
jeden dom, a cusp trafia do domu, który zaczyna. Sprawdzian SAMOSPÓJNOŚCI —
|
||||||
|
działa bez swissepha, także tam, gdzie sama wyrocznia przeczy sobie
|
||||||
|
(topocentric powyżej koła podbiegunowego)."""
|
||||||
|
for ramc, eps, lat in (REVERSED, (100.0, 23.4393, 50.06), (210.33, 23.4393, 72.675)):
|
||||||
|
try:
|
||||||
|
cusps = H.cusps_for(ramc, eps, lat, system)
|
||||||
|
except H.HouseSystemUndefined:
|
||||||
|
continue
|
||||||
|
for i, cusp in enumerate(cusps):
|
||||||
|
assert H.assign_house(cusp, cusps) == i + 1, f"{system}: cusp {i+1}"
|
||||||
|
seen = {H.assign_house(lon / 4.0, cusps) for lon in range(0, 1440)}
|
||||||
|
assert seen == set(range(1, 13)), f"{system}: puste albo zdublowane domy {sorted(seen)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_house_follows_the_cusps_not_a_fixed_direction():
|
||||||
|
"""Regresja: zaszyte „zawsze do przodu" dawało przy |φ| za kołem podbiegunowym
|
||||||
|
złe domy dla systemów dzielących koła wielkie — mimo cuspów zgodnych
|
||||||
|
z wyrocznią co do zera. Błąd był cichy: wykres wyglądał poprawnie."""
|
||||||
|
ramc, eps, lat = REVERSED
|
||||||
|
cusps = H.cusps_for(ramc, eps, lat, "regiomontanus")
|
||||||
|
# 1,7822° leży między cuspem 6 (4,458°) a 7 (354,228°) — patrząc WSTECZ
|
||||||
|
assert H.assign_house(1.7822, cusps) == 6
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też testy,
|
||||||
|
# README i skrypty pomocnicze — a plik testowy nazywa funkcje wprost, więc
|
||||||
|
# w obrazie zbudowanym bez danego modułu byłby dokładnie tym śladem, którego
|
||||||
|
# wydzielenie ma się pozbyć. Przy okazji obraz jest mniejszy.
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Magazyn kont zarządzanych z aplikacji (PRE-27).
|
||||||
|
|
||||||
|
KONTO ADMINISTRACYJNE NIE LEŻY TUTAJ. Pochodzi z konfiguracji środowiska
|
||||||
|
(APP_USER/APP_PASSWORD albo APP_USERS) i dlatego NIE DA SIĘ go skasować ani
|
||||||
|
ograniczyć z poziomu ekranu — nawet przez pomyłkę, nawet spreparowanym żądaniem.
|
||||||
|
Ekran zarządzania kontami operuje wyłącznie na tym pliku.
|
||||||
|
|
||||||
|
Format: JSON `{"users": {"login": {"secret": "scrypt$...", "features": [...],
|
||||||
|
"note": "...", "created": "ISO"}}}`. Hasła WYŁĄCZNIE jako hash scrypt — ten sam
|
||||||
|
mechanizm, co dla kont z APP_USERS (security.hash_password), więc nie ma tu
|
||||||
|
drugiej implementacji do rozjechania.
|
||||||
|
|
||||||
|
Zapis jest ATOMOWY (plik tymczasowy + os.replace) i pod blokadą procesu. Plik
|
||||||
|
leży na NFS, dzieli go kilka replik, a zwykły zapis „w miejscu" po przerwaniu
|
||||||
|
zostawiłby plik obcięty — czyli utratę WSZYSTKICH kont naraz. os.replace jest
|
||||||
|
w obrębie jednego katalogu operacją niepodzielną.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsUnavailable(RuntimeError):
|
||||||
|
"""Pliku kont nie da się odczytać albo zapisać — problem z magazynem.
|
||||||
|
|
||||||
|
Osobny wyjątek, bo to NIE JEST błąd danych: konta mogą być w porządku, tylko
|
||||||
|
katalog jest niedostępny (złe prawa na udziale, nieprzemontowany wolumen,
|
||||||
|
brak miejsca). Ekran ma wtedy POWIEDZIEĆ, co i gdzie jest nie tak — gołe 500
|
||||||
|
zostawia administratora z niczym, a to jedyny ekran, z którego może to
|
||||||
|
naprawić."""
|
||||||
|
|
||||||
|
|
||||||
|
def store_path() -> str:
|
||||||
|
"""Ścieżka pliku kont.
|
||||||
|
|
||||||
|
Domyślnie WŁASNY podkatalog prezentacji na NFS, nie katalog z bazami:
|
||||||
|
prezentacja nie ma powodu widzieć baz interpretacyjnych, a zamontowanie ich
|
||||||
|
tutaj obeszłoby bokiem zamknięcie dostępu z DAN-25."""
|
||||||
|
return os.getenv("ACCOUNTS_FILE", "/app/state/accounts.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _read() -> dict:
|
||||||
|
path = store_path()
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Pierwsze uruchomienie: pliku jeszcze nie ma i to jest normalne.
|
||||||
|
return {"users": {}}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Plik jest, ale nieczytelny. NIE nadpisujemy go pustym zbiorem —
|
||||||
|
# to skasowałoby wszystkie konta przy pierwszym zapisie.
|
||||||
|
raise AccountsUnavailable(
|
||||||
|
f"Plik kont {path} jest uszkodzony (niepoprawny JSON). "
|
||||||
|
f"Nic nie zostało zmienione — napraw albo usuń plik ręcznie.") from None
|
||||||
|
except OSError as e:
|
||||||
|
raise AccountsUnavailable(
|
||||||
|
f"Nie mogę odczytać pliku kont {path}: {e.strerror or e}. "
|
||||||
|
f"Najczęstsza przyczyna: prawa do katalogu na udziale albo "
|
||||||
|
f"niezamontowany wolumen.") from e
|
||||||
|
users = data.get("users")
|
||||||
|
return {"users": users if isinstance(users, dict) else {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(data: dict) -> None:
|
||||||
|
path = store_path()
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
raise AccountsUnavailable(
|
||||||
|
f"Nie mogę utworzyć katalogu na plik kont ({os.path.dirname(path)}): "
|
||||||
|
f"{e.strerror or e}.") from e
|
||||||
|
# Plik tymczasowy MUSI powstać w tym samym katalogu — os.replace jest
|
||||||
|
# niepodzielne tylko w obrębie jednego systemu plików.
|
||||||
|
# mkstemp też musi być w klamrze: przy katalogu tylko do odczytu wywala się
|
||||||
|
# ONO pierwsze, jeszcze zanim dojdzie do zapisu i podmiany.
|
||||||
|
try:
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp")
|
||||||
|
except OSError as e:
|
||||||
|
raise AccountsUnavailable(
|
||||||
|
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
|
||||||
|
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
|
||||||
|
f"prawa katalogu. Nic nie zostało zmienione.") from e
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except OSError as e:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise AccountsUnavailable(
|
||||||
|
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
|
||||||
|
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
|
||||||
|
f"prawa katalogu. Nic nie zostało zmienione.") from e
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def all_users() -> dict[str, dict]:
|
||||||
|
"""Konta zarządzane: login → {features, note, created}. BEZ sekretów.
|
||||||
|
|
||||||
|
Sekret nie wychodzi z tego modułu — ekran nigdy go nie potrzebuje, a to, czego
|
||||||
|
nie ma w kontekście szablonu, nie wycieknie do HTML-a przez pomyłkę."""
|
||||||
|
out = {}
|
||||||
|
for login, row in _read()["users"].items():
|
||||||
|
out[login] = {
|
||||||
|
"features": sorted(features.normalise(row.get("features"))),
|
||||||
|
"session_version": int(row.get("session_version") or 0),
|
||||||
|
"note": str(row.get("note") or ""),
|
||||||
|
"created": str(row.get("created") or ""),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def secret_of(login: str) -> str | None:
|
||||||
|
return (_read()["users"].get(login) or {}).get("secret")
|
||||||
|
|
||||||
|
|
||||||
|
def session_version(login: str) -> int:
|
||||||
|
"""Numer pokolenia sesji konta (LOG-34).
|
||||||
|
|
||||||
|
Podbicie unieważnia WSZYSTKIE otwarte sesje tego konta, także na innych
|
||||||
|
urządzeniach — bez zmiany hasła. Zmiana hasła robi to samo niejako przy
|
||||||
|
okazji (zmienia się odcisk w ciasteczku), więc licznik jest potrzebny tylko
|
||||||
|
do „wyrzuć zewsząd, ale hasło zostaw"."""
|
||||||
|
try:
|
||||||
|
return int((_read()["users"].get(login) or {}).get("session_version") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def bump_session(login: str) -> int:
|
||||||
|
"""Unieważnia wszystkie sesje konta. Podnosi ValueError, gdy konta nie ma."""
|
||||||
|
with _lock:
|
||||||
|
data = _read()
|
||||||
|
row = data["users"].get(login)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"Nie ma konta „{login}”.")
|
||||||
|
row["session_version"] = session_version(login) + 1
|
||||||
|
_write(data)
|
||||||
|
return row["session_version"]
|
||||||
|
|
||||||
|
|
||||||
|
def permissions_of(login: str) -> frozenset[str]:
|
||||||
|
return features.normalise((_read()["users"].get(login) or {}).get("features"))
|
||||||
|
|
||||||
|
|
||||||
|
def exists(login: str) -> bool:
|
||||||
|
return login in _read()["users"]
|
||||||
|
|
||||||
|
|
||||||
|
def create(login: str, password: str, granted, note: str = "") -> None:
|
||||||
|
"""Zakłada konto. Podnosi ValueError, gdy login zajęty albo dane puste."""
|
||||||
|
from app.security import hash_password
|
||||||
|
|
||||||
|
login = (login or "").strip()
|
||||||
|
if not login:
|
||||||
|
raise ValueError("Login nie może być pusty.")
|
||||||
|
if not (password or "").strip():
|
||||||
|
raise ValueError("Hasło nie może być puste.")
|
||||||
|
with _lock:
|
||||||
|
data = _read()
|
||||||
|
if login in data["users"]:
|
||||||
|
raise ValueError(f"Konto „{login}” już istnieje.")
|
||||||
|
data["users"][login] = {
|
||||||
|
"secret": hash_password(password),
|
||||||
|
"features": sorted(features.normalise(granted)),
|
||||||
|
"note": (note or "").strip(),
|
||||||
|
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
_write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def update(login: str, granted=None, password: str = "", note: str | None = None) -> None:
|
||||||
|
"""Zmiana uprawnień, hasła albo opisu. Puste hasło = bez zmiany hasła."""
|
||||||
|
from app.security import hash_password
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
data = _read()
|
||||||
|
row = data["users"].get(login)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"Nie ma konta „{login}”.")
|
||||||
|
if granted is not None:
|
||||||
|
row["features"] = sorted(features.normalise(granted))
|
||||||
|
if (password or "").strip():
|
||||||
|
row["secret"] = hash_password(password)
|
||||||
|
if note is not None:
|
||||||
|
row["note"] = note.strip()
|
||||||
|
_write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def delete(login: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
data = _read()
|
||||||
|
if data["users"].pop(login, None) is None:
|
||||||
|
raise ValueError(f"Nie ma konta „{login}”.")
|
||||||
|
_write(data)
|
||||||
@@ -114,8 +114,15 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
|
|||||||
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
||||||
|
|
||||||
|
|
||||||
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
|
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc").
|
||||||
phi = math.radians(180.0 - (lon - asc))
|
ASC_LEFT = "asc"
|
||||||
|
ARIES_LEFT = "aries"
|
||||||
|
ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej"))
|
||||||
|
|
||||||
|
|
||||||
|
def _pt(lon: float, ref: float, r: float) -> tuple[float, float]:
|
||||||
|
"""Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku."""
|
||||||
|
phi = math.radians(180.0 - (lon - ref))
|
||||||
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
||||||
|
|
||||||
|
|
||||||
@@ -206,12 +213,19 @@ def _delta(a: float, b: float) -> float:
|
|||||||
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
||||||
|
|
||||||
|
|
||||||
def render(chart: dict, theme: str = "screen") -> str:
|
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
|
||||||
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
"""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='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
||||||
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
||||||
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
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"])
|
T = _THEMES.get(theme, _THEMES["screen"])
|
||||||
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
||||||
@@ -223,6 +237,9 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
asc = float(angles["Asc"]["decimal"])
|
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] = []
|
parts: list[str] = []
|
||||||
|
|
||||||
# tło i okręgi
|
# tło i okręgi
|
||||||
@@ -244,8 +261,8 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
continue
|
continue
|
||||||
color = T["aspects"].get(asp.get("aspect"), T["muted"])
|
color = T["aspects"].get(asp.get("aspect"), T["muted"])
|
||||||
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
||||||
ax1, ay1 = _pt(la, asc, R_HUB)
|
ax1, ay1 = _pt(la, ref, R_HUB)
|
||||||
bx1, by1 = _pt(lb, asc, R_HUB)
|
bx1, by1 = _pt(lb, ref, R_HUB)
|
||||||
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
|
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}°')
|
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
|
||||||
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
||||||
@@ -253,17 +270,17 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
||||||
for deg in range(0, 360, 5):
|
for deg in range(0, 360, 5):
|
||||||
r_in = R_TICK if deg % 10 else R_TICK - 4
|
r_in = R_TICK if deg % 10 else R_TICK - 4
|
||||||
x1, y1 = _pt(deg, asc, R_ZOD)
|
x1, y1 = _pt(deg, ref, R_ZOD)
|
||||||
x2, y2 = _pt(deg, asc, r_in)
|
x2, y2 = _pt(deg, ref, r_in)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
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
|
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
||||||
for i, sg in enumerate(signs):
|
for i, sg in enumerate(signs):
|
||||||
b = 30.0 * i
|
b = 30.0 * i
|
||||||
x1, y1 = _pt(b, asc, R_ZOD)
|
x1, y1 = _pt(b, ref, R_ZOD)
|
||||||
x2, y2 = _pt(b, asc, R_OUT)
|
x2, y2 = _pt(b, ref, R_OUT)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
||||||
gx, gy = _pt(b + 15.0, asc, R_SIGN)
|
gx, gy = _pt(b + 15.0, ref, R_SIGN)
|
||||||
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
||||||
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
||||||
|
|
||||||
@@ -271,31 +288,31 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
n = len(cusps)
|
n = len(cusps)
|
||||||
for i, c in enumerate(cusps):
|
for i, c in enumerate(cusps):
|
||||||
lon = float(c["decimal"])
|
lon = float(c["decimal"])
|
||||||
x1, y1 = _pt(lon, asc, R_ZOD)
|
x1, y1 = _pt(lon, ref, R_ZOD)
|
||||||
x2, y2 = _pt(lon, asc, R_HUB)
|
x2, y2 = _pt(lon, ref, R_HUB)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
||||||
nxt = float(cusps[(i + 1) % n]["decimal"])
|
nxt = float(cusps[(i + 1) % n]["decimal"])
|
||||||
span = (nxt - lon) % 360.0 or 360.0
|
span = (nxt - lon) % 360.0 or 360.0
|
||||||
mid = lon + span / 2.0
|
mid = lon + span / 2.0
|
||||||
hx, hy = _pt(mid, asc, R_HNUM)
|
hx, hy = _pt(mid, ref, R_HNUM)
|
||||||
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
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
|
# 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;
|
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
|
||||||
# w systemach kwadratowych ta liczba realnie coś mówi.
|
# w systemach kwadratowych ta liczba realnie coś mówi.
|
||||||
deg_in_sign = lon % 30.0
|
deg_in_sign = lon % 30.0
|
||||||
if deg_in_sign >= 0.5:
|
if deg_in_sign >= 0.5:
|
||||||
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
|
cx1, cy1 = _pt(lon, ref, R_ZOD - 9)
|
||||||
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
||||||
|
|
||||||
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
||||||
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
||||||
lon_a = float(angles[a]["decimal"])
|
lon_a = float(angles[a]["decimal"])
|
||||||
ax, ay = _pt(lon_a, asc, R_ZOD)
|
ax, ay = _pt(lon_a, ref, R_ZOD)
|
||||||
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
|
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
|
||||||
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
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
|
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
||||||
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
|
lax, lay = _pt(lon_a, ref, R_AXIS_LABEL)
|
||||||
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
|
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
|
||||||
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
||||||
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
||||||
|
|
||||||
@@ -310,15 +327,15 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
||||||
retro = obj.get("direction") == "Rx"
|
retro = obj.get("direction") == "Rx"
|
||||||
|
|
||||||
mx1, my1 = _pt(true_lon, asc, R_ZOD)
|
mx1, my1 = _pt(true_lon, ref, R_ZOD)
|
||||||
mx2, my2 = _pt(true_lon, asc, R_MARK)
|
mx2, my2 = _pt(true_lon, ref, R_MARK)
|
||||||
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
||||||
|
|
||||||
if _delta(draw_lon, true_lon) > 0.4:
|
if _delta(draw_lon, true_lon) > 0.4:
|
||||||
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
|
lx, ly = _pt(draw_lon, ref, R_PLANET + 12)
|
||||||
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
||||||
|
|
||||||
gx, gy = _pt(draw_lon, asc, R_PLANET)
|
gx, gy = _pt(draw_lon, ref, R_PLANET)
|
||||||
tip = obj.get("name") or ""
|
tip = obj.get("name") or ""
|
||||||
if obj.get("in_sign"):
|
if obj.get("in_sign"):
|
||||||
tip += f" · {obj['in_sign']}"
|
tip += f" · {obj['in_sign']}"
|
||||||
@@ -332,7 +349,7 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
||||||
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
||||||
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
||||||
dx, dy = _pt(draw_lon, asc, R_DEG)
|
dx, dy = _pt(draw_lon, ref, R_DEG)
|
||||||
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
||||||
parts.append(_text(dx, dy, label, size=8,
|
parts.append(_text(dx, dy, label, size=8,
|
||||||
fill=T["retro"] if retro else T["muted"]))
|
fill=T["retro"] if retro else T["muted"]))
|
||||||
@@ -346,10 +363,10 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
if not glyph or lot.get("longitude") is None:
|
if not glyph or lot.get("longitude") is None:
|
||||||
continue
|
continue
|
||||||
lon = float(lot["longitude"])
|
lon = float(lot["longitude"])
|
||||||
mx1, my1 = _pt(lon, asc, R_ZOD)
|
mx1, my1 = _pt(lon, ref, R_ZOD)
|
||||||
mx2, my2 = _pt(lon, asc, R_MARK)
|
mx2, my2 = _pt(lon, ref, R_MARK)
|
||||||
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
||||||
lx, ly = _pt(lon, asc, R_LOT)
|
lx, ly = _pt(lon, ref, R_LOT)
|
||||||
tip = lot.get("name") or ""
|
tip = lot.get("name") or ""
|
||||||
if lot.get("in_sign"):
|
if lot.get("in_sign"):
|
||||||
tip += f" · {lot['in_sign']}"
|
tip += f" · {lot['in_sign']}"
|
||||||
@@ -367,7 +384,6 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
|
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
|
||||||
ASP_CELL = 30.0 # bok komórki siatki aspektów
|
ASP_CELL = 30.0 # bok komórki siatki aspektów
|
||||||
|
|
||||||
|
|
||||||
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
||||||
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
|
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,29 @@ class LogicClient:
|
|||||||
payload=payload, headers=_auth_headers(),
|
payload=payload, headers=_auth_headers(),
|
||||||
link=_link())
|
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]:
|
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||||
return self._post("/api/query", payload, settings.http_timeout)
|
return self._post("/api/query", payload, settings.http_timeout)
|
||||||
@@ -54,6 +77,10 @@ class LogicClient:
|
|||||||
stations: bool = False,
|
stations: bool = False,
|
||||||
zodiac: str = "tropical",
|
zodiac: str = "tropical",
|
||||||
tables: bool = False,
|
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]:
|
) -> dict[str, Any]:
|
||||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||||
payload = {
|
payload = {
|
||||||
@@ -62,6 +89,10 @@ class LogicClient:
|
|||||||
"lon": lon,
|
"lon": lon,
|
||||||
"objects": objects,
|
"objects": objects,
|
||||||
"house_system": house_system,
|
"house_system": house_system,
|
||||||
|
"house_systems": house_systems,
|
||||||
|
"aspect_orb": aspect_orb,
|
||||||
|
"aspect_luminary_bonus": aspect_luminary_bonus,
|
||||||
|
"aspect_minor": aspect_minor,
|
||||||
"stations": stations,
|
"stations": stations,
|
||||||
"zodiac": zodiac,
|
"zodiac": zodiac,
|
||||||
"tables": tables,
|
"tables": tables,
|
||||||
@@ -70,6 +101,18 @@ class LogicClient:
|
|||||||
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
||||||
return self._post("/chart/positions", payload, 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(
|
def report(
|
||||||
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -77,67 +120,39 @@ class LogicClient:
|
|||||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
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))
|
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
||||||
|
|
||||||
def prompt(
|
def bases(self) -> dict[str, Any]:
|
||||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
||||||
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:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||||
headers=_auth_headers(), link=_link())
|
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(
|
def timeline(
|
||||||
self, when_utc_iso: str, lat: float, lon: float,
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
from_date: str, to_date: str, interpret: bool = True,
|
from_date: str, to_date: str, interpret: bool = True,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Ekrany aplikacji — jeden moduł na ekran.
|
||||||
|
|
||||||
|
Ekran to trasy plus szablon. Import modułu rejestruje jego trasy (dekoratory
|
||||||
|
`@app.get`/`@app.post` wykonują się przy imporcie), więc o tym, CO aplikacja
|
||||||
|
umie, decyduje lista importów w `main.py` — a nie warunki rozsiane po kodzie.
|
||||||
|
|
||||||
|
Dzięki temu produkt buduje się przez ZŁOŻENIE: inny zestaw importów i inny
|
||||||
|
zestaw plików w obrazie to inny program, a nie ten sam program z wyłączonymi
|
||||||
|
kawałkami. Ekranu, którego moduł nie wszedł do obrazu, nie ma jak włączyć.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Horoskop: pozycje, osie, domy, aspekty i kosmogram (strona główna)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features,
|
||||||
|
app,
|
||||||
|
templates,
|
||||||
|
logic,
|
||||||
|
chartwheel_mod,
|
||||||
|
DEFAULT_LOCATION_LABEL,
|
||||||
|
default_form,
|
||||||
|
rozszerzenia,
|
||||||
|
_perms,
|
||||||
|
_limit_options,
|
||||||
|
_landing,
|
||||||
|
_build_utc,
|
||||||
|
_logic_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def chart_form(request: Request):
|
||||||
|
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
|
||||||
|
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
|
||||||
|
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
|
||||||
|
if "chart" not in _perms(request):
|
||||||
|
target = _landing(request)
|
||||||
|
if target:
|
||||||
|
return RedirectResponse(target, status_code=303)
|
||||||
|
# Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
|
||||||
|
# mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
|
||||||
|
# czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
|
||||||
|
# a twoje konto zostało okrojone.
|
||||||
|
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "chart.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
**rozszerzenia.kontekst_szablonu(request)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/", response_class=HTMLResponse)
|
||||||
|
def chart_compute(
|
||||||
|
request: Request,
|
||||||
|
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
house_system: str = Form("whole_sign"),
|
||||||
|
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
|
||||||
|
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
|
||||||
|
aspect_luminary_bonus: float = Form(2.0),
|
||||||
|
aspect_minor: bool = Form(False),
|
||||||
|
stations: bool = Form(False),
|
||||||
|
zodiac: str = Form("tropical"),
|
||||||
|
tables: bool = Form(False),
|
||||||
|
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
|
||||||
|
):
|
||||||
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "house_system": house_system,
|
||||||
|
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||||||
|
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||||
|
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||||
|
"wheel_orientation": wheel_orientation}
|
||||||
|
form = _limit_options(request, form)
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
ctx["result"] = logic.positions(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||||
|
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||||
|
aspect_minor=form["aspect_minor"],
|
||||||
|
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||||
|
)
|
||||||
|
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||||
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||||
|
orientation=form["wheel_orientation"])
|
||||||
|
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
|
||||||
|
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
|
||||||
|
if "extra_charts" in _perms(request):
|
||||||
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
|
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||||
|
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||||
|
except (httpx.HTTPError,) as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "chart.html", ctx)
|
||||||
|
|
||||||
|
# ── zgłoszenie do katalogu ─────────────────────────────────────────────────
|
||||||
|
# Ekran zgłasza siebie, swoje trasy i swoje zasoby. Wszystko o tym ekranie jest
|
||||||
|
# w tym pliku — więc obraz bez tego pliku nie wie o nim NIC.
|
||||||
|
features.zarejestruj(
|
||||||
|
ekran=Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.",
|
||||||
|
"Ekrany", "/", kolejnosc=10),
|
||||||
|
trasy={("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
|
||||||
|
("POST", "/"): "chart"},
|
||||||
|
zasoby={"s-chart.css": "chart"},
|
||||||
|
)
|
||||||
|
features.zarejestruj(rozszerzenie=Feature(
|
||||||
|
"houses_compare", "Porównanie systemów domów",
|
||||||
|
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
|
||||||
|
"Rozszerzenia", kolejnosc=10))
|
||||||
|
features.zarejestruj(rozszerzenie=Feature(
|
||||||
|
"extra_charts", "Wykresy dodatkowe",
|
||||||
|
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia", kolejnosc=20))
|
||||||
|
features.zarejestruj(rozszerzenie=Feature(
|
||||||
|
"advanced_calc", "Obliczenia zaawansowane",
|
||||||
|
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
|
||||||
|
"Rozszerzenia", kolejnosc=30))
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Interpretacje: wynik obliczeń szukany w bazach."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features,
|
||||||
|
app,
|
||||||
|
templates,
|
||||||
|
logic,
|
||||||
|
security,
|
||||||
|
DEFAULT_LOCATION_LABEL,
|
||||||
|
default_form,
|
||||||
|
rozszerzenia,
|
||||||
|
_build_utc,
|
||||||
|
_dozwolona_akcja,
|
||||||
|
_report_records,
|
||||||
|
_logic_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||||||
|
@app.get("/interpret", response_class=HTMLResponse)
|
||||||
|
def interpret_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "interpret.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
**rozszerzenia.kontekst_szablonu(request)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/interpret", response_class=HTMLResponse)
|
||||||
|
def interpret_run(
|
||||||
|
request: Request,
|
||||||
|
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
group: bool = Form(False),
|
||||||
|
action: str = Form("report"),
|
||||||
|
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
|
||||||
|
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
|
||||||
|
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
|
||||||
|
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
|
||||||
|
):
|
||||||
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "group": group, **dodatki}
|
||||||
|
action = _dozwolona_akcja(request, action, "report")
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
|
**rozszerzenia.kontekst_szablonu(request)}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
# Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
|
||||||
|
# więc ekran robi to, co robi normalnie. Moduł może oddać słownik do
|
||||||
|
# kontekstu albo gotową odpowiedź — na przykład plik do pobrania.
|
||||||
|
wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
|
||||||
|
lat=lat, lon=lon, dodatki=dodatki,
|
||||||
|
request=request, group=group)
|
||||||
|
if isinstance(wynik, Response):
|
||||||
|
return wynik
|
||||||
|
if wynik is not None:
|
||||||
|
ctx["wynik_dodatku"] = wynik
|
||||||
|
else:
|
||||||
|
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||||
|
security.audit_records(request, _report_records(ctx["result"]))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "interpret.html", ctx)
|
||||||
|
|
||||||
|
features.zarejestruj(
|
||||||
|
ekran=Feature("interpret", "Interpretacje", "Interpretacja natalna z baz.",
|
||||||
|
"Ekrany", "/interpret", kolejnosc=20),
|
||||||
|
trasy={("GET", "/interpret"): "interpret", ("POST", "/interpret"): "interpret"},
|
||||||
|
zasoby={"s-interpret.css": "interpret"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Kalendarz: oś czasu z technik wraz z interpretacjami."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features,
|
||||||
|
app,
|
||||||
|
templates,
|
||||||
|
logic,
|
||||||
|
DEFAULT_LOCATION_LABEL,
|
||||||
|
default_form,
|
||||||
|
rozszerzenia,
|
||||||
|
_build_utc,
|
||||||
|
_dozwolona_akcja,
|
||||||
|
_logic_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
|
||||||
|
@app.get("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "timeline.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
**rozszerzenia.kontekst_szablonu(request)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_run(
|
||||||
|
request: Request,
|
||||||
|
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
from_date: str = Form(...),
|
||||||
|
to_date: str = Form(...),
|
||||||
|
action: str = Form("timeline"),
|
||||||
|
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
|
||||||
|
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
|
||||||
|
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
|
||||||
|
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
|
||||||
|
):
|
||||||
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date,
|
||||||
|
**dodatki}
|
||||||
|
action = _dozwolona_akcja(request, action, "timeline")
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
|
**rozszerzenia.kontekst_szablonu(request)}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
wynik = rozszerzenia.obsluz(action, profil="period", iso_utc=iso_utc,
|
||||||
|
lat=lat, lon=lon, dodatki=dodatki,
|
||||||
|
od=from_date, do=to_date)
|
||||||
|
if wynik is not None:
|
||||||
|
ctx["wynik_dodatku"] = wynik
|
||||||
|
else:
|
||||||
|
ctx["result"] = logic.timeline(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
from_date=from_date, to_date=to_date, interpret=True,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||||||
|
|
||||||
|
features.zarejestruj(
|
||||||
|
ekran=Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.",
|
||||||
|
"Ekrany", "/timeline", kolejnosc=30),
|
||||||
|
trasy={("GET", "/timeline"): "timeline", ("POST", "/timeline"): "timeline"},
|
||||||
|
zasoby={"s-timeline.css": "timeline"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Konta i uprawnienia (PRE-27) — ekran wyłącznie administracyjny."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app import accounts as accounts_store
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (app, templates, security, features)
|
||||||
|
|
||||||
|
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
||||||
|
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
||||||
|
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
||||||
|
|
||||||
|
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
|
||||||
|
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
|
||||||
|
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
|
||||||
|
# jest nie tak.
|
||||||
|
try:
|
||||||
|
users = accounts_store.all_users()
|
||||||
|
except accounts_store.AccountsUnavailable as e:
|
||||||
|
users, error = {}, str(e)
|
||||||
|
return {
|
||||||
|
"users": users,
|
||||||
|
"catalog": features.ALL,
|
||||||
|
"screens": features.SCREENS,
|
||||||
|
# Rejestr zawiera już wszystko, co ten obraz umie — łącznie z tym, co
|
||||||
|
# wniosły moduły opcjonalne. Administrator ma nadawać uprawnienia do
|
||||||
|
# funkcji, które REALNIE są, a nie do listy wpisanej kiedyś na sztywno.
|
||||||
|
"extras": features.EXTRAS,
|
||||||
|
"admin_login": security.app_user(),
|
||||||
|
"store_path": accounts_store.store_path(),
|
||||||
|
"error": error, "done": done,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/accounts", response_class=HTMLResponse)
|
||||||
|
def accounts_view(request: Request, error: str = "", done: str = ""):
|
||||||
|
return templates.TemplateResponse(request, "accounts.html",
|
||||||
|
_accounts_context(request, error, done))
|
||||||
|
|
||||||
|
|
||||||
|
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||||||
|
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||||||
|
powtórzyć zakładania ani kasowania konta."""
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||||
|
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/create")
|
||||||
|
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
|
||||||
|
note: str = Form(""), granted: list[str] = Form([])):
|
||||||
|
try:
|
||||||
|
accounts_store.create(login, password, granted, note)
|
||||||
|
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||||||
|
return _accounts_redirect(error=str(e))
|
||||||
|
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/update")
|
||||||
|
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
|
||||||
|
note: str = Form(""), granted: list[str] = Form([])):
|
||||||
|
try:
|
||||||
|
accounts_store.update(login, granted=granted, password=password, note=note)
|
||||||
|
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||||||
|
return _accounts_redirect(error=str(e))
|
||||||
|
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
|
||||||
|
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/sesje")
|
||||||
|
def accounts_sesje(request: Request, login: str = Form(...)):
|
||||||
|
"""Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu.
|
||||||
|
|
||||||
|
Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek
|
||||||
|
„wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany
|
||||||
|
komputer i nie chce wpisywać nowego hasła wszędzie."""
|
||||||
|
try:
|
||||||
|
accounts_store.bump_session(login)
|
||||||
|
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||||||
|
return _accounts_redirect(error=str(e))
|
||||||
|
return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/delete")
|
||||||
|
def accounts_delete(request: Request, login: str = Form(...)):
|
||||||
|
try:
|
||||||
|
accounts_store.delete(login)
|
||||||
|
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||||||
|
return _accounts_redirect(error=str(e))
|
||||||
|
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
|
||||||
|
|
||||||
|
# Ekran wyłącznie administracyjny — nie ma pozycji w katalogu funkcji, bo ADMIN
|
||||||
|
# nie jest uprawnieniem do nadania (patrz features.GRANTABLE).
|
||||||
|
features.zarejestruj(
|
||||||
|
nawigacja=("accounts", "/accounts", "Konta", features.ADMIN),
|
||||||
|
trasy={("GET", "/accounts"): features.ADMIN,
|
||||||
|
("POST", "/accounts/create"): features.ADMIN,
|
||||||
|
("POST", "/accounts/update"): features.ADMIN,
|
||||||
|
("POST", "/accounts/delete"): features.ADMIN,
|
||||||
|
("POST", "/accounts/sesje"): features.ADMIN},
|
||||||
|
zasoby={"s-accounts.css": features.ADMIN},
|
||||||
|
)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Logowanie i wylogowanie (LOG-34)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features, app, templates, security, session)
|
||||||
|
|
||||||
|
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
|
||||||
|
# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
|
||||||
|
# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
|
||||||
|
# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
|
||||||
|
|
||||||
|
def _bezpieczne_dokad(dokad: str) -> str:
|
||||||
|
"""Adres powrotu po zalogowaniu, przepuszczony przez sito.
|
||||||
|
|
||||||
|
Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
|
||||||
|
do przekierowywania ludzi gdzie indziej — klasyczna droga do wyłudzenia hasła,
|
||||||
|
bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
|
||||||
|
wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
|
||||||
|
zacząć adres obcego serwera."""
|
||||||
|
dokad = (dokad or "").strip()
|
||||||
|
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
|
||||||
|
return "/"
|
||||||
|
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logowanie", response_class=HTMLResponse)
|
||||||
|
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
|
||||||
|
# Zalogowanego nie ma po co pytać o hasło jeszcze raz.
|
||||||
|
if security.auth_enabled() and security.principal(request) is not None:
|
||||||
|
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
|
||||||
|
return templates.TemplateResponse(request, "logowanie.html",
|
||||||
|
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logowanie")
|
||||||
|
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
|
||||||
|
dokad: str = Form("/")):
|
||||||
|
cel = _bezpieczne_dokad(dokad)
|
||||||
|
who = security.verify(login.strip(), haslo)
|
||||||
|
if who is None:
|
||||||
|
# JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby,
|
||||||
|
# które konta istnieją — a to informacja warta czegoś dla zgadującego.
|
||||||
|
security.audit_login(request, login.strip(), udane=False)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "logowanie.html",
|
||||||
|
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
|
||||||
|
status_code=401)
|
||||||
|
|
||||||
|
security.audit_login(request, who.login, udane=True)
|
||||||
|
odpowiedz = RedirectResponse(cel, status_code=303)
|
||||||
|
odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login),
|
||||||
|
max_age=session.max_age(),
|
||||||
|
**session.cookie_params(secure=security.cookies_secure()))
|
||||||
|
return odpowiedz
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/wyloguj")
|
||||||
|
def wyloguj(request: Request):
|
||||||
|
"""Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe
|
||||||
|
i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać.
|
||||||
|
|
||||||
|
POST, nie GET: pod adresem GET wystarczyłby obrazek na obcej stronie, żeby
|
||||||
|
wylogować kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa
|
||||||
|
do uniknięcia."""
|
||||||
|
security.audit_logout(request)
|
||||||
|
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
|
||||||
|
odpowiedz.delete_cookie(session.COOKIE, **session.cookie_params(
|
||||||
|
secure=security.cookies_secure()))
|
||||||
|
return odpowiedz
|
||||||
|
|
||||||
|
# Wyjście musi być dostępne dla każdego zalogowanego, niezależnie od tego, co mu
|
||||||
|
# przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
|
||||||
|
features.zarejestruj(
|
||||||
|
trasy={("GET", "/logowanie"): None, ("POST", "/logowanie"): None,
|
||||||
|
("POST", "/wyloguj"): None},
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Wyszukiwarka miejsca i strefa czasowa (proxy OSM/Nominatim)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timezone
|
||||||
|
from fastapi import HTTPException, Query
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features, app, geocode)
|
||||||
|
|
||||||
|
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||||
|
@app.get("/geocode")
|
||||||
|
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||||||
|
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
|
||||||
|
try:
|
||||||
|
return {"results": geocode.search(q)}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reverse")
|
||||||
|
def geocode_reverse(lat: float, lon: float):
|
||||||
|
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
|
||||||
|
try:
|
||||||
|
return geocode.reverse(lat, lon)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/timezone")
|
||||||
|
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
|
||||||
|
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
|
||||||
|
|
||||||
|
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
|
||||||
|
gdy strefy nie da się ustalić — front zostawia wtedy ręczny offset."""
|
||||||
|
from app import timezone as tzmod
|
||||||
|
|
||||||
|
res = tzmod.resolve(lat, lon, date, time)
|
||||||
|
if not res:
|
||||||
|
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
|
||||||
|
return res
|
||||||
|
|
||||||
|
# Wyszukiwarka miejsca siedzi na kilku ekranach naraz, więc jej zasoby otwiera
|
||||||
|
# KTÓREKOLWIEK z nich — wymaganie wszystkich byłoby bez sensu.
|
||||||
|
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
|
||||||
|
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
|
||||||
|
_KOLO = frozenset({"chart", "compile"})
|
||||||
|
|
||||||
|
features.zarejestruj(
|
||||||
|
trasy={("GET", "/geocode"): None, ("GET", "/reverse"): None,
|
||||||
|
("GET", "/timezone"): None},
|
||||||
|
zasoby={"geo.js": _LOKALIZACJA, "now.js": _LOKALIZACJA,
|
||||||
|
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
|
||||||
|
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
|
||||||
|
"formsync.js": _FORMULARZE,
|
||||||
|
"wheelzoom.js": _KOLO, "wheeltip.js": _KOLO,
|
||||||
|
"copy.js": frozenset({"interpret", "timeline"})},
|
||||||
|
)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Pliki baz: wybór, wgrywanie, archiwizacja i reguły przyjmowania (DAN-27)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import File, Form, Request, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
import base64
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app import files_state
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (app, templates, logic, features, _perms, _logic_error)
|
||||||
|
|
||||||
|
# Etykiety stanów pliku dla szablonu. Rejestrowane TUTAJ, a nie w podstawie:
|
||||||
|
# należą do tego ekranu, a produkt, który go nie ma, nie ma po co ich znać.
|
||||||
|
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Pliki baz (DAN-27) ----------------
|
||||||
|
# Trzy poziomy dostępu, opisane w features.ROUTES:
|
||||||
|
# „files" — widzi listę i decyduje, z czego program korzysta,
|
||||||
|
# „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony
|
||||||
|
# ze znacznikiem czasu, znika tylko z użytku),
|
||||||
|
# administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
|
||||||
|
#
|
||||||
|
# Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE
|
||||||
|
# DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby
|
||||||
|
# takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać
|
||||||
|
# reguły — a te ma znać wyłącznie administrator.
|
||||||
|
|
||||||
|
def _files_context(request: Request, error: str = "", done: str = "") -> dict:
|
||||||
|
is_admin = features.ADMIN in _perms(request)
|
||||||
|
try:
|
||||||
|
data = logic.files_list(for_admin=is_admin)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"files": [], "rules": {}, "is_admin": is_admin,
|
||||||
|
"error": _logic_error(e), "done": ""}
|
||||||
|
return {"files": data.get("files") or [], "rules": data.get("rules") or {},
|
||||||
|
"is_admin": is_admin, "error": error, "done": done}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/files", response_class=HTMLResponse)
|
||||||
|
def files_view(request: Request, error: str = "", done: str = ""):
|
||||||
|
return templates.TemplateResponse(request, "files.html",
|
||||||
|
_files_context(request, error, done))
|
||||||
|
|
||||||
|
|
||||||
|
def _files_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||||
|
return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
def _who(request: Request) -> str:
|
||||||
|
return getattr(request.state, "user", "") or "-"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/use")
|
||||||
|
def files_use(request: Request, path: str = Form(...), use: str = Form("")):
|
||||||
|
"""Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji
|
||||||
|
w warstwie danych — odmowa wraca BEZ POWODU, bo powód zdradzałby reguły."""
|
||||||
|
want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \
|
||||||
|
else files_state.READY
|
||||||
|
try:
|
||||||
|
logic.files_status(path, want, by=_who(request))
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
detail = _http_detail(e)
|
||||||
|
if features.ADMIN not in _perms(request):
|
||||||
|
detail = "Tego pliku nie da się teraz włączyć do użytku."
|
||||||
|
return _files_redirect(error=detail)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done="Zapisano." if want == files_state.READY
|
||||||
|
else "Plik włączony do użytku.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/upload")
|
||||||
|
async def files_upload(request: Request, upload: UploadFile = File(...)):
|
||||||
|
"""Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji —
|
||||||
|
nie tracimy niczego, co ktoś wgrał."""
|
||||||
|
raw = await upload.read()
|
||||||
|
if not raw:
|
||||||
|
return _files_redirect(error="Pusty plik.")
|
||||||
|
try:
|
||||||
|
out = logic.files_upload(upload.filename or "plik.xlsx",
|
||||||
|
base64.b64encode(raw).decode("ascii"), by=_who(request))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
# DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
|
||||||
|
# komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
|
||||||
|
# odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
|
||||||
|
# Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
|
||||||
|
# musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
|
||||||
|
# i że plik przechodzi przez jakąś bramkę.
|
||||||
|
return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/archive")
|
||||||
|
def files_archive(request: Request, path: str = Form(...)):
|
||||||
|
"""Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko
|
||||||
|
z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane."""
|
||||||
|
try:
|
||||||
|
logic.files_status(path, files_state.ARCHIVED, by=_who(request))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/restore")
|
||||||
|
def files_restore(request: Request, path: str = Form(...)):
|
||||||
|
try:
|
||||||
|
logic.files_status(path, files_state.READY, by=_who(request))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done=f"Przywrócono „{path}” z archiwum.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/delete")
|
||||||
|
def files_delete(request: Request, path: str = Form(...)):
|
||||||
|
try:
|
||||||
|
logic.files_delete(path)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/rules")
|
||||||
|
def files_rules(request: Request, extensions: str = Form(".xlsx"),
|
||||||
|
max_size_mb: float = Form(50), min_rows: int = Form(1),
|
||||||
|
required_columns: str = Form(""),
|
||||||
|
reject_duplicate_content: str = Form("")):
|
||||||
|
rules = {
|
||||||
|
"extensions": [e.strip() for e in extensions.split(",") if e.strip()],
|
||||||
|
"max_size_mb": max_size_mb,
|
||||||
|
"min_rows": min_rows,
|
||||||
|
"required_columns": [c.strip() for c in required_columns.split(",") if c.strip()],
|
||||||
|
"reject_duplicate_content": reject_duplicate_content.strip().lower()
|
||||||
|
in {"1", "true", "on", "tak"},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
logic.files_rules(rules)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done="Zapisano reguły walidacji.")
|
||||||
|
|
||||||
|
|
||||||
|
def _http_detail(e: httpx.HTTPStatusError) -> str:
|
||||||
|
try:
|
||||||
|
return str(e.response.json().get("detail") or e)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return str(e)
|
||||||
|
|
||||||
|
features.zarejestruj(
|
||||||
|
ekran=Feature("files", "Pliki", "Wybór plików, z których korzysta program.",
|
||||||
|
"Ekrany", "/files", kolejnosc=70),
|
||||||
|
# Kasowanie, przywracanie i REGUŁY PRZYJMOWANIA to wyłącznie administrator —
|
||||||
|
# o istnieniu reguł nikt poza nim nie ma skąd wiedzieć (DAN-27).
|
||||||
|
trasy={("GET", "/files"): "files", ("POST", "/files/use"): "files",
|
||||||
|
("POST", "/files/upload"): "files_input",
|
||||||
|
("POST", "/files/archive"): "files_input",
|
||||||
|
("POST", "/files/restore"): features.ADMIN,
|
||||||
|
("POST", "/files/delete"): features.ADMIN,
|
||||||
|
("POST", "/files/rules"): features.ADMIN},
|
||||||
|
zasoby={"s-files.css": "files"},
|
||||||
|
)
|
||||||
|
features.zarejestruj(rozszerzenie=Feature(
|
||||||
|
"files_input", "Wgrywanie i archiwizacja plików",
|
||||||
|
"Dodawanie nowych plików i wycofywanie ich z użytku (plik zostaje, zamrożony, "
|
||||||
|
"ze znacznikiem czasu). Kasować może wyłącznie administrator.",
|
||||||
|
"Rozszerzenia", kolejnosc=40))
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Skompiluj: zbiorczy raport z policzonych części (PRE-23), wraz z PDF-em."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features,
|
||||||
|
app,
|
||||||
|
templates,
|
||||||
|
logic,
|
||||||
|
chartwheel_mod,
|
||||||
|
DEFAULT_LOCATION_LABEL,
|
||||||
|
default_form,
|
||||||
|
_perms,
|
||||||
|
_limit_options,
|
||||||
|
_build_utc,
|
||||||
|
_logic_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
|
||||||
|
@app.get("/compile", response_class=HTMLResponse)
|
||||||
|
def compile_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "compile.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/compile", response_class=HTMLResponse)
|
||||||
|
def compile_build(
|
||||||
|
request: Request,
|
||||||
|
person: str = Form(""),
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
house_system: str = Form("whole_sign"),
|
||||||
|
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
|
||||||
|
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
|
||||||
|
aspect_luminary_bonus: float = Form(2.0),
|
||||||
|
aspect_minor: bool = Form(False),
|
||||||
|
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
||||||
|
zodiac: str = Form("tropical"),
|
||||||
|
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
||||||
|
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
|
||||||
|
):
|
||||||
|
"""Składa raport: horoskop liczymy TU NA NOWO, a części z przeglądarki (interpretacja
|
||||||
|
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||||
|
|
||||||
|
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
|
||||||
|
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
|
||||||
|
wybrano przy horoskopie (stacje, tabele, porównanie domów) — inaczej
|
||||||
|
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
|
||||||
|
zakładkami przez formsync.
|
||||||
|
"""
|
||||||
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "house_system": house_system,
|
||||||
|
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||||||
|
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||||
|
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||||
|
"wheel_orientation": wheel_orientation}
|
||||||
|
form = _limit_options(request, form)
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
ctx["result"] = logic.positions(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||||
|
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||||
|
aspect_minor=form["aspect_minor"],
|
||||||
|
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||||
|
)
|
||||||
|
from app import chartwheel
|
||||||
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||||
|
orientation=form["wheel_orientation"])
|
||||||
|
if "extra_charts" in _perms(request):
|
||||||
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
|
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||||
|
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||||
|
except (httpx.HTTPError,) as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "compile.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
features.zarejestruj(
|
||||||
|
ekran=Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.",
|
||||||
|
"Ekrany", "/compile", kolejnosc=60),
|
||||||
|
trasy={("GET", "/compile"): "compile", ("POST", "/compile"): "compile"},
|
||||||
|
zasoby={"s-compile.css": "compile", "compile.js": "compile"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Sygnifikatory: wyszukiwarka po bazach."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features, app, templates, logic, security, _logic_error)
|
||||||
|
|
||||||
|
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
|
||||||
|
@app.get("/significators", response_class=HTMLResponse)
|
||||||
|
def significators_form(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/significators", response_class=HTMLResponse)
|
||||||
|
def significators_search(
|
||||||
|
request: Request,
|
||||||
|
query: str = Form(...),
|
||||||
|
field: str = Form("name"),
|
||||||
|
exact: bool = Form(False),
|
||||||
|
limit: int = Form(25),
|
||||||
|
):
|
||||||
|
form = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None}
|
||||||
|
try:
|
||||||
|
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||||||
|
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
|
||||||
|
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
return templates.TemplateResponse(request, "significators.html", ctx)
|
||||||
|
|
||||||
|
features.zarejestruj(
|
||||||
|
ekran=Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach.",
|
||||||
|
"Ekrany", "/significators", kolejnosc=50),
|
||||||
|
trasy={("GET", "/significators"): "significators",
|
||||||
|
("POST", "/significators"): "significators"},
|
||||||
|
zasoby={"s-significators.css": "significators"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Synastria: porównanie dwóch horoskopów."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features, app, templates, logic, _build_utc, _logic_error)
|
||||||
|
|
||||||
|
# ---------------- Synastria (technika relacyjna) ----------------
|
||||||
|
@app.get("/synastry", response_class=HTMLResponse)
|
||||||
|
def synastry_form(request: Request):
|
||||||
|
# domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce)
|
||||||
|
form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0,
|
||||||
|
"aspect_orb": 8, "aspect_luminary_bonus": 2}
|
||||||
|
return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/synastry", response_class=HTMLResponse)
|
||||||
|
def synastry_run(
|
||||||
|
request: Request,
|
||||||
|
a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...),
|
||||||
|
a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0),
|
||||||
|
b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...),
|
||||||
|
b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0),
|
||||||
|
zodiac: str = Form("tropical"),
|
||||||
|
aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0),
|
||||||
|
aspect_minor: bool = Form(False),
|
||||||
|
):
|
||||||
|
"""Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami."""
|
||||||
|
form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset,
|
||||||
|
"a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date,
|
||||||
|
"b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon,
|
||||||
|
"zodiac": zodiac, "aspect_orb": aspect_orb,
|
||||||
|
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None}
|
||||||
|
try:
|
||||||
|
iso_a, _ = _build_utc(a_date, a_time, a_tz_offset)
|
||||||
|
iso_b, _ = _build_utc(b_date, b_time, b_tz_offset)
|
||||||
|
ctx["result"] = logic.synastry(
|
||||||
|
{"when_utc": iso_a, "lat": a_lat, "lon": a_lon},
|
||||||
|
{"when_utc": iso_b, "lat": b_lat, "lon": b_lon},
|
||||||
|
zodiac=zodiac, aspect_orb=aspect_orb,
|
||||||
|
aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "synastry.html", ctx)
|
||||||
|
|
||||||
|
features.zarejestruj(
|
||||||
|
ekran=Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.",
|
||||||
|
"Ekrany", "/synastry", kolejnosc=40),
|
||||||
|
trasy={("GET", "/synastry"): "synastry", ("POST", "/synastry"): "synastry"},
|
||||||
|
zasoby={"s-synastry.css": "synastry"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Ustawienia: podgląd baz widocznych na udziale (DAN-15/PRE-09)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.features import Feature
|
||||||
|
from app.podstawa import (features, app, templates, logic, _logic_error)
|
||||||
|
|
||||||
|
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
|
||||||
|
@app.get("/settings", response_class=HTMLResponse)
|
||||||
|
def settings_view(request: Request):
|
||||||
|
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
|
||||||
|
|
||||||
|
Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są
|
||||||
|
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"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""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 ją zamknąć, a nie otworzyć. Odwrotny domyślny
|
||||||
|
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
|
||||||
|
if path.startswith("/static/"):
|
||||||
|
return static_required(path[len("/static/"):])
|
||||||
|
return ROUTES.get((method.upper(), path), ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
|
||||||
|
"""Czy te uprawnienia otwierają tę funkcję.
|
||||||
|
|
||||||
|
Zbiór znaczy „wystarczy którekolwiek": jeden zasób bywa wspólny dla kilku
|
||||||
|
ekranów i nie ma powodu wymagać ich wszystkich naraz."""
|
||||||
|
if feature is None:
|
||||||
|
return True
|
||||||
|
if isinstance(feature, str):
|
||||||
|
return feature in perms
|
||||||
|
return bool(perms & feature)
|
||||||
|
|
||||||
|
|
||||||
|
def screens_for(perms: frozenset[str]) -> list[Feature]:
|
||||||
|
"""Zakładki do pokazania w nawigacji — w 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)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Nazwy stanów pliku bazy — wspólne dla prezentacji i warstwy danych (DAN-27).
|
||||||
|
|
||||||
|
Osobny, króciutki moduł, bo prezentacja NIE MOŻE importować warstwy danych (to
|
||||||
|
inna usługa i inny obraz), a wpisanie tych napisów wprost w handlerach skończyłoby
|
||||||
|
się literówką, która przejdzie testy i wyjdzie dopiero na produkcji.
|
||||||
|
"""
|
||||||
|
ACTIVE = "active"
|
||||||
|
READY = "ready"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
QUARANTINE = "quarantine"
|
||||||
|
|
||||||
|
LABELS = {
|
||||||
|
ACTIVE: "w użyciu",
|
||||||
|
READY: "gotowa, odstawiona",
|
||||||
|
ARCHIVED: "zarchiwizowana",
|
||||||
|
QUARANTINE: "wstrzymana",
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""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)
|
||||||
@@ -153,7 +153,7 @@ class Link:
|
|||||||
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
frame: bytes) -> bytes:
|
frame: bytes) -> bytes:
|
||||||
if not frame.startswith(MAGIC):
|
if not frame.startswith(MAGIC):
|
||||||
raise LinkError("ramka bez znacznika astrololo")
|
raise LinkError("ramka bez znacznika protokołu")
|
||||||
body = frame[len(MAGIC):]
|
body = frame[len(MAGIC):]
|
||||||
if len(body) <= NONCE_BYTES:
|
if len(body) <= NONCE_BYTES:
|
||||||
raise LinkError("ramka za krótka")
|
raise LinkError("ramka za krótka")
|
||||||
|
|||||||
@@ -1,435 +1,39 @@
|
|||||||
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
|
"""Astrololo — ZŁOŻENIE aplikacji z ekranów i modułów opcjonalnych.
|
||||||
|
|
||||||
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
Ten plik nie zawiera już żadnej trasy. Odpowiada wyłącznie na pytanie „z czego
|
||||||
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
|
składa się ten produkt": które ekrany wchodzą i co jest podpięte dodatkowo.
|
||||||
|
|
||||||
Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
|
Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją produktu.
|
||||||
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
|
Inny produkt to inna lista i inny zestaw plików w obrazie — nie ten sam program
|
||||||
„/significators".
|
z wyłączonymi kawałkami.
|
||||||
"""
|
"""
|
||||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from app import rozszerzenia
|
||||||
from datetime import datetime, timedelta, timezone
|
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 httpx
|
# Nazwy przekazywane dalej dla wygody wołających (testy, uvicorn).
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from app import geocode, security
|
# ── moduły opcjonalne ──────────────────────────────────────────────────────
|
||||||
from app.clients.logic_client import LogicClient
|
# Na końcu, bo moduł dostaje gotowe zależności, a te muszą już istnieć. Bez
|
||||||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
# modułu w obrazie to wywołanie nie robi nic i aplikacja nie ma pojęcia,
|
||||||
|
# że czegoś brakuje.
|
||||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
buduj_utc=_build_utc, blad=_logic_error,
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
security=security, licz_rekordy=_report_records)
|
||||||
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 ją 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 SVG→PDF 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 == "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"}
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Moduły opcjonalne — każdy podkatalog to jedna odłączalna część produktu.
|
||||||
|
|
||||||
|
O tym, co produkt umie, decyduje ZAWARTOŚĆ tego katalogu w obrazie. Most
|
||||||
|
(`app.rozszerzenia`) odkrywa moduły, przechodząc po podkatalogach, i nie zna
|
||||||
|
żadnej nazwy — gdyby znał, nazwa nieobecnego modułu i tak jechałaby do obrazu,
|
||||||
|
w którym go nie ma.
|
||||||
|
|
||||||
|
Moduł może wnieść: pozycję do katalogu funkcji, trasy, zasoby, akcje formularza,
|
||||||
|
pola formularza, wpisy do kontekstu szablonu, własne szablony i własne statyki.
|
||||||
|
Wszystko jest opcjonalne — most pyta o to, czego moduł nie ma, i dostaje pusto.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Generowanie tekstu przez model językowy — moduł CAŁKOWICIE ODŁĄCZALNY.
|
||||||
|
|
||||||
|
DLACZEGO OSOBNY KATALOG, A NIE `{% if %}` W SZABLONACH. Warunek w szablonie
|
||||||
|
wystarcza, żeby funkcji nie było WIDAĆ, ale nie żeby jej NIE BYŁO. Plik szablonu
|
||||||
|
i tak leży w obrazie i i tak zawiera słowa opisujące funkcję — `grep` po
|
||||||
|
kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie.
|
||||||
|
|
||||||
|
Astrololo zawiera ten katalog. Astroklient — nie, i to jest jedyna różnica
|
||||||
|
między nimi po stronie funkcji astrologicznych. Dlatego granica MUSI być
|
||||||
|
katalogiem: co jest w środku, znika razem z nim, a co jest na zewnątrz, zostaje.
|
||||||
|
|
||||||
|
ZASADA DLA KOGOŚ, KTO TU DOPISUJE. Poza tym katalogiem nie wolno napisać ani
|
||||||
|
słowa o modelu, prompcie czy dostawcy — nawet w komentarzu, nawet zaprzeczając.
|
||||||
|
Szablony wspólne mają neutralne gniazda (`_dodatki_*`), a plik wstawiany w
|
||||||
|
gniazdo sam sprawdza uprawnienie. Pilnuje tego test `test_ai_tylko_w_module.py`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.moduly.dodatki.katalog import AKCJE, FUNKCJA, TRASY, ZASOBY, katalog_dla, obsluz, pola
|
||||||
|
from app.moduly.dodatki.trasy import zamontuj
|
||||||
|
|
||||||
|
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "ZASOBY", "katalog_dla", "obsluz", "pola",
|
||||||
|
"zamontuj"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user