Compare commits
2 Commits
master
..
c1f5bea9f7
| Author | SHA1 | Date | |
|---|---|---|---|
| c1f5bea9f7 | |||
| d094a145b1 |
@@ -32,14 +32,3 @@ 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,112 +1,19 @@
|
|||||||
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}
|
||||||
USLUGI="data logic presentation astrodemo astroklient"
|
for SVC in data logic presentation; do
|
||||||
# Usługi z własnym pipeline'em — celowo poza tą pętlą.
|
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||||
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
|
|
||||||
|
|||||||
@@ -91,48 +91,6 @@ jobs:
|
|||||||
PYTHONPATH: .
|
PYTHONPATH: .
|
||||||
run: pytest tests -q -rs
|
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
|
||||||
@@ -261,33 +219,3 @@ 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
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,14 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też katalog
|
|
||||||
# tests/ — a leży w nim lista słów, które w tej usłudze nie mają prawa paść,
|
|
||||||
# wypisanych wprost. Obraz tej usługi się KOMUŚ ODDAJE, więc niósłby dokładnie
|
|
||||||
# to, co ten test ma pilnować. Przy okazji obraz jest mniejszy.
|
|
||||||
COPY app ./app
|
|
||||||
|
|
||||||
EXPOSE 8005
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"]
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# astrodemo — wersja demonstracyjna (PRE-28)
|
|
||||||
|
|
||||||
Osobna warstwa prezentacji o **dwóch funkcjach**:
|
|
||||||
|
|
||||||
1. **dodanie pliku bazy** — wgranie i włączenie do użytku jedną czynnością,
|
|
||||||
2. **zapytanie o interpretację urodzeniową** dla podanych danych wejściowych.
|
|
||||||
|
|
||||||
I na tym koniec. Nie ma tu kosmogramu, synastrii, kalendarza, promptów do modelu,
|
|
||||||
eksportu ani kont — nie są ukryte, tylko **nie istnieją w tym obrazie**.
|
|
||||||
|
|
||||||
## Dlaczego osobna usługa, a nie konto z ograniczeniami
|
|
||||||
|
|
||||||
Mechanizm uprawnień z PRE-27 umiałby to ukryć w pełnej aplikacji. Ale **ukrycie
|
|
||||||
a nieobecność to dwie różne rzeczy**: tutaj nie ma tras, nie ma szablonów, nie ma
|
|
||||||
nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy
|
|
||||||
okazji kodu reszty programu.
|
|
||||||
|
|
||||||
## Izolacja: własne dane, pula na konto
|
|
||||||
|
|
||||||
```
|
|
||||||
astrodemo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie)
|
|
||||||
└── klientA/ ← pula konta
|
|
||||||
└── klientB/ ← pula konta
|
|
||||||
```
|
|
||||||
|
|
||||||
**Oryginalne bazy interpretacyjne są dla demo nieosiągalne.** Nie chodzi
|
|
||||||
o uprawnienia: demo ma własną warstwę danych, pracującą na osobnym udziale.
|
|
||||||
Osobna jest też warstwa logiczna, bo zna ona JEDEN adres warstwy danych — demo
|
|
||||||
korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.
|
|
||||||
|
|
||||||
**Każde konto ma własną pulę** plików: swój podkatalog na tym udziale. Konta nie
|
|
||||||
widzą swoich baz nawzajem ani w liście, ani w wynikach wyszukiwania. Dlatego demo
|
|
||||||
można rozdawać szeroko.
|
|
||||||
|
|
||||||
Pula bierze się **z loginu zalogowanej osoby** i z niczego innego. Gdyby
|
|
||||||
przychodziła z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze
|
|
||||||
bazy — osobny test wysyła `tenant`, `user` i `login` w formularzu i sprawdza, że
|
|
||||||
nie mają na nią wpływu.
|
|
||||||
|
|
||||||
Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
|
|
||||||
w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział.
|
|
||||||
|
|
||||||
**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza
|
|
||||||
utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`,
|
|
||||||
`astrololo/README-astrodemo.md`).
|
|
||||||
|
|
||||||
## Konfiguracja
|
|
||||||
|
|
||||||
| zmienna | znaczenie |
|
|
||||||
|---|---|
|
|
||||||
| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) |
|
|
||||||
| `ASTRODEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików |
|
|
||||||
| `ASTRODEMO_USER` / `ASTRODEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `ASTRODEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie |
|
|
||||||
| `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług |
|
|
||||||
| `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja |
|
|
||||||
| `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza |
|
|
||||||
| `REPORT_LIMIT` | górny limit rekordów w jednej odpowiedzi, domyślnie 2000 |
|
|
||||||
| `TRUST_PROXY` | `true` za Ingressem — inaczej limit liczy wszystkich do jednego wiadra |
|
|
||||||
|
|
||||||
Hash hasła zrobisz tym samym skryptem, co dla głównej aplikacji:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd services/presentation && python scripts/make_user.py demo
|
|
||||||
```
|
|
||||||
|
|
||||||
## Uruchomienie lokalne
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd services/astrodemo
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
ASTRODEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \
|
|
||||||
uvicorn app.main:app --port 8005
|
|
||||||
```
|
|
||||||
|
|
||||||
Testy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd services/astrodemo && PYTHONPATH=. pytest tests -q
|
|
||||||
```
|
|
||||||
|
|
||||||
## Co pilnują testy
|
|
||||||
|
|
||||||
Poza zwykłą poprawnością — dwie własności, które łatwo zepsuć bez zauważenia:
|
|
||||||
|
|
||||||
* **powierzchnia**: zbiór tras aplikacji musi być dokładnie taki, jak dwie funkcje
|
|
||||||
wymagają; klient warstwy logicznej ma tylko te metody, których używa. Trasa,
|
|
||||||
o której nikt nie pamiętał, jest tu realnym ryzykiem, bo ta usługa rozmawia
|
|
||||||
z produkcyjną warstwą danych.
|
|
||||||
* **milczenie o regułach walidacji**: plik odrzucony daje komunikat bez powodu.
|
|
||||||
Reguły zna wyłącznie administrator w pełnej aplikacji (DAN-27), a demo nie ma
|
|
||||||
prawa ich zdradzić nawet mimochodem.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28).
|
|
||||||
|
|
||||||
Ma tu być wyłącznie to, czego ta usługa naprawdę używa. Metoda, której nie ma,
|
|
||||||
nie zostanie przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest
|
|
||||||
coś więcej.
|
|
||||||
|
|
||||||
Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym
|
|
||||||
tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app import link_crypto
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_headers() -> dict[str, str]:
|
|
||||||
token = os.getenv("INTERNAL_TOKEN", "")
|
|
||||||
return {"X-Astrololo-Token": token} if token else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _link() -> link_crypto.Link | None:
|
|
||||||
"""Klucz łącza czytany przy KAŻDYM wywołaniu — pod przeżywa rotację sekretu
|
|
||||||
bez restartu, a brak klucza przy wymaganym szyfrowaniu ma zatrzymać żądanie
|
|
||||||
ZANIM cokolwiek wyjdzie jawnym tekstem."""
|
|
||||||
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
|
||||||
return link_crypto.Link(key) if key else None
|
|
||||||
|
|
||||||
|
|
||||||
class LogicClient:
|
|
||||||
"""Rozmówca warstwy logicznej ZWIĄZANY Z PULĄ KONTA (PRE-29).
|
|
||||||
|
|
||||||
Pulę podaje się przy tworzeniu klienta, a bierze się ją WYŁĄCZNIE z loginu
|
|
||||||
zalogowanej osoby — nigdy z pola formularza. Gdyby przychodziła z żądania,
|
|
||||||
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
|
|
||||||
|
|
||||||
def __init__(self, tenant: str = "", base_url: str | None = None) -> None:
|
|
||||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
|
||||||
self.tenant = tenant or ""
|
|
||||||
|
|
||||||
def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]:
|
|
||||||
"""Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach.
|
|
||||||
|
|
||||||
Jedyne pytanie, jakie astrodemo zadaje o treść — bez grupowania,
|
|
||||||
bez grupowania i bez żadnej obróbki treści."""
|
|
||||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
|
||||||
"limit": settings.report_limit, "group": False,
|
|
||||||
"tenant": self.tenant}
|
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
||||||
return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report",
|
|
||||||
payload=payload, headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
|
||||||
return link_crypto.call_json(
|
|
||||||
client, "POST", f"{self.base_url}/files/upload",
|
|
||||||
payload={"filename": filename, "content_b64": content_b64,
|
|
||||||
"by": by, "tenant": self.tenant},
|
|
||||||
headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
|
||||||
"""Włączenie wgranego pliku do użytku. W demo wgranie i włączenie to JEDNA
|
|
||||||
czynność: „dodać do bazy" ma znaczyć, że plik od razu bierze udział
|
|
||||||
w wyszukiwaniu — inaczej demo wyglądałoby na zepsute."""
|
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
||||||
return link_crypto.call_json(
|
|
||||||
client, "POST", f"{self.base_url}/files/status",
|
|
||||||
payload={"path": path, "status": status, "by": by, "tenant": self.tenant},
|
|
||||||
headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
||||||
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Konfiguracja astrodemo — wersji demonstracyjnej (PRE-28).
|
|
||||||
|
|
||||||
Zna WYŁĄCZNIE adres warstwy logicznej. Nie wie nic o bazach, Excelu ani SQL-u.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Settings:
|
|
||||||
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
|
|
||||||
# Szukanie interpretacji potrafi zajrzeć do wszystkich baz naraz, więc limit
|
|
||||||
# czasu jest wyższy niż domyślny dla zwykłego żądania.
|
|
||||||
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "30")))
|
|
||||||
# Górny limit rekordów w jednej odpowiedzi. Nie jest to optymalizacja, tylko
|
|
||||||
# ograniczenie, ile treści baz może wyjść jednym zapytaniem.
|
|
||||||
report_limit: int = field(default_factory=lambda: int(os.getenv("REPORT_LIMIT", "2000")))
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
|
||||||
|
|
||||||
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
|
||||||
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
|
||||||
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
|
||||||
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
|
||||||
zrzut z węzła), miał je w całości.
|
|
||||||
|
|
||||||
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
|
||||||
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
|
||||||
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
|
||||||
podatne na modyfikację".
|
|
||||||
|
|
||||||
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
|
||||||
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
|
||||||
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
|
||||||
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
|
||||||
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
|
||||||
|
|
||||||
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
|
||||||
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
|
||||||
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
|
||||||
|
|
||||||
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
|
||||||
|
|
||||||
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
|
||||||
|
|
||||||
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
|
||||||
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
|
||||||
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
|
||||||
|
|
||||||
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
|
||||||
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
|
||||||
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
|
||||||
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import time
|
|
||||||
from typing import Iterable, Iterator
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidTag
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
||||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
||||||
|
|
||||||
log = logging.getLogger("astrololo.link")
|
|
||||||
|
|
||||||
MAGIC = b"AL1"
|
|
||||||
VERSION = "v1"
|
|
||||||
NONCE_BYTES = 12
|
|
||||||
KEY_BYTES = 32 # AES-256
|
|
||||||
LENGTH_PREFIX = 4
|
|
||||||
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
|
||||||
MAX_SKEW_SECONDS = 300.0
|
|
||||||
|
|
||||||
HEADER_ENC = "X-Astrololo-Enc"
|
|
||||||
HEADER_TS = "X-Astrololo-Enc-Ts"
|
|
||||||
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
|
||||||
|
|
||||||
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
|
||||||
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
|
||||||
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
|
||||||
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
|
||||||
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
|
||||||
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
|
||||||
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
|
||||||
|
|
||||||
REQUEST, RESPONSE = b"req", b"res"
|
|
||||||
|
|
||||||
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
|
||||||
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
|
||||||
PUBLIC_PATHS = frozenset({"/health"})
|
|
||||||
|
|
||||||
|
|
||||||
class LinkError(Exception):
|
|
||||||
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- klucze
|
|
||||||
|
|
||||||
def parse_key(raw: str) -> bytes:
|
|
||||||
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
|
||||||
text = raw.strip()
|
|
||||||
if not text:
|
|
||||||
raise LinkError("pusty klucz łącza")
|
|
||||||
try:
|
|
||||||
key = bytes.fromhex(text)
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
key = base64.b64decode(text, validate=True)
|
|
||||||
except (binascii.Error, ValueError) as exc:
|
|
||||||
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
|
||||||
if len(key) != KEY_BYTES:
|
|
||||||
raise LinkError(
|
|
||||||
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
|
||||||
f"`openssl rand -hex 32`"
|
|
||||||
)
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
def key_from_env(env_name: str) -> bytes | None:
|
|
||||||
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
|
||||||
raw = os.getenv(env_name, "")
|
|
||||||
return parse_key(raw) if raw.strip() else None
|
|
||||||
|
|
||||||
|
|
||||||
def encryption_required() -> bool:
|
|
||||||
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
|
||||||
|
|
||||||
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
|
||||||
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
|
||||||
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
|
||||||
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
|
||||||
"""
|
|
||||||
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
|
||||||
return HKDF(
|
|
||||||
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
|
||||||
info=b"astrololo/link/" + direction,
|
|
||||||
).derive(link_key)
|
|
||||||
|
|
||||||
|
|
||||||
class Link:
|
|
||||||
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
|
||||||
|
|
||||||
def __init__(self, link_key: bytes) -> None:
|
|
||||||
self._by_direction = {
|
|
||||||
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
|
||||||
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------- pojedyncza ramka
|
|
||||||
|
|
||||||
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
|
||||||
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
|
||||||
stamp.encode("ascii"), str(seq).encode("ascii")])
|
|
||||||
|
|
||||||
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
|
||||||
plaintext: bytes) -> bytes:
|
|
||||||
nonce = os.urandom(NONCE_BYTES)
|
|
||||||
sealed = self._by_direction[direction].encrypt(
|
|
||||||
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
|
||||||
return MAGIC + nonce + sealed
|
|
||||||
|
|
||||||
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
|
||||||
frame: bytes) -> bytes:
|
|
||||||
if not frame.startswith(MAGIC):
|
|
||||||
raise LinkError("ramka bez znacznika protokołu")
|
|
||||||
body = frame[len(MAGIC):]
|
|
||||||
if len(body) <= NONCE_BYTES:
|
|
||||||
raise LinkError("ramka za krótka")
|
|
||||||
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
|
||||||
try:
|
|
||||||
return self._by_direction[direction].decrypt(
|
|
||||||
nonce, sealed, self._aad(direction, path, stamp, seq))
|
|
||||||
except InvalidTag as exc:
|
|
||||||
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
|
||||||
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
|
||||||
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
|
||||||
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ strumień ramek
|
|
||||||
|
|
||||||
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
|
||||||
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
|
||||||
for seq, chunk in enumerate(chunks):
|
|
||||||
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
|
||||||
|
|
||||||
def open_stream(self, direction: bytes, path: str, stamp: str,
|
|
||||||
raw: bytes) -> Iterator[bytes]:
|
|
||||||
for seq, frame in enumerate(frames_in(raw)):
|
|
||||||
yield self.open(direction, path, stamp, seq, frame)
|
|
||||||
|
|
||||||
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
|
||||||
return b"".join(self.open_stream(direction, path, stamp, raw))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- ramkowanie
|
|
||||||
|
|
||||||
def frame_out(payload: bytes) -> bytes:
|
|
||||||
return struct.pack(">I", len(payload)) + payload
|
|
||||||
|
|
||||||
|
|
||||||
def frames_in(raw: bytes) -> Iterator[bytes]:
|
|
||||||
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
|
||||||
offset = 0
|
|
||||||
while offset < len(raw):
|
|
||||||
if offset + LENGTH_PREFIX > len(raw):
|
|
||||||
raise LinkError("urwana ramka (brak nagłówka długości)")
|
|
||||||
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
|
||||||
if size > MAX_FRAME:
|
|
||||||
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
|
||||||
offset += LENGTH_PREFIX
|
|
||||||
if offset + size > len(raw):
|
|
||||||
raise LinkError("urwana ramka (za mało danych)")
|
|
||||||
yield raw[offset:offset + size]
|
|
||||||
offset += size
|
|
||||||
|
|
||||||
|
|
||||||
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
|
||||||
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
|
||||||
|
|
||||||
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
|
||||||
rozjechać się między dwa odczyty.
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
if len(buffer) < LENGTH_PREFIX:
|
|
||||||
return
|
|
||||||
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
|
||||||
if size > MAX_FRAME:
|
|
||||||
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
|
||||||
if len(buffer) < LENGTH_PREFIX + size:
|
|
||||||
return
|
|
||||||
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
|
||||||
del buffer[:LENGTH_PREFIX + size]
|
|
||||||
yield frame
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- świeżość ruchu
|
|
||||||
|
|
||||||
def stamp_now() -> str:
|
|
||||||
return f"{time.time():.3f}"
|
|
||||||
|
|
||||||
|
|
||||||
def check_stamp(stamp: str) -> None:
|
|
||||||
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
|
||||||
odtworzyć w dowolnym momencie w przyszłości."""
|
|
||||||
try:
|
|
||||||
sent = float(stamp)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise LinkError("brak albo błędny znacznik czasu") from exc
|
|
||||||
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
|
||||||
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================== strona serwerowa
|
|
||||||
|
|
||||||
class LinkCryptoMiddleware:
|
|
||||||
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
|
||||||
|
|
||||||
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
|
||||||
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
|
||||||
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, app, link: Link | None, layer: str) -> None:
|
|
||||||
self.app = app
|
|
||||||
self.link = link
|
|
||||||
self.layer = layer
|
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
|
||||||
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
|
||||||
return await self.app(scope, receive, send)
|
|
||||||
|
|
||||||
path = scope["path"]
|
|
||||||
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
|
||||||
|
|
||||||
if headers.get(HEADER_ENC.lower()) != VERSION:
|
|
||||||
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
|
||||||
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
|
||||||
# chcemy po cichu wrócić do jawnego ruchu.
|
|
||||||
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
|
||||||
self.layer, path)
|
|
||||||
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
|
||||||
|
|
||||||
stamp = headers.get(HEADER_TS.lower(), "")
|
|
||||||
try:
|
|
||||||
check_stamp(stamp)
|
|
||||||
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
|
||||||
except LinkError as exc:
|
|
||||||
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
|
||||||
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
|
||||||
|
|
||||||
scope = dict(scope)
|
|
||||||
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
|
||||||
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
|
||||||
|
|
||||||
def _sealing_send(self, send, path: str):
|
|
||||||
state: dict = {"stamp": "", "seq": 0}
|
|
||||||
|
|
||||||
async def sealing(message):
|
|
||||||
if message["type"] == "http.response.start":
|
|
||||||
state["stamp"] = stamp_now()
|
|
||||||
keep = [(k, v) for k, v in message.get("headers", [])
|
|
||||||
if k.lower() not in (b"content-length", b"content-type")]
|
|
||||||
message = dict(message)
|
|
||||||
message["headers"] = keep + [
|
|
||||||
(b"content-type", CONTENT_TYPE.encode()),
|
|
||||||
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
|
||||||
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
|
||||||
]
|
|
||||||
return await send(message)
|
|
||||||
|
|
||||||
if message["type"] == "http.response.body":
|
|
||||||
chunk = message.get("body", b"")
|
|
||||||
sealed = b""
|
|
||||||
if chunk:
|
|
||||||
sealed = frame_out(self.link.seal(
|
|
||||||
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
|
||||||
state["seq"] += 1
|
|
||||||
return await send({"type": "http.response.body", "body": sealed,
|
|
||||||
"more_body": message.get("more_body", False)})
|
|
||||||
|
|
||||||
return await send(message)
|
|
||||||
|
|
||||||
return sealing
|
|
||||||
|
|
||||||
|
|
||||||
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
|
||||||
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
|
||||||
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
|
||||||
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
|
||||||
kept.append((b"content-length", str(length).encode()))
|
|
||||||
if length:
|
|
||||||
kept.append((b"content-type", b"application/json"))
|
|
||||||
return kept
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_body(receive) -> bytes:
|
|
||||||
body = bytearray()
|
|
||||||
while True:
|
|
||||||
message = await receive()
|
|
||||||
if message["type"] == "http.disconnect":
|
|
||||||
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
|
||||||
body += message.get("body", b"")
|
|
||||||
if not message.get("more_body", False):
|
|
||||||
return bytes(body)
|
|
||||||
|
|
||||||
|
|
||||||
def _replay(body: bytes, original):
|
|
||||||
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
|
||||||
|
|
||||||
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
|
||||||
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
|
||||||
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
|
||||||
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
|
||||||
"""
|
|
||||||
delivered = False
|
|
||||||
|
|
||||||
async def receive():
|
|
||||||
nonlocal delivered
|
|
||||||
if delivered:
|
|
||||||
return await original()
|
|
||||||
delivered = True
|
|
||||||
return {"type": "http.request", "body": body, "more_body": False}
|
|
||||||
|
|
||||||
return receive
|
|
||||||
|
|
||||||
|
|
||||||
async def _refuse(send, detail: str) -> None:
|
|
||||||
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
|
||||||
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
|
||||||
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
|
||||||
await send({"type": "http.response.start", "status": 400, "headers": [
|
|
||||||
(b"content-type", b"application/json"),
|
|
||||||
(b"content-length", str(len(payload)).encode()),
|
|
||||||
]})
|
|
||||||
await send({"type": "http.response.body", "body": payload})
|
|
||||||
|
|
||||||
|
|
||||||
def install(app, env_name: str, layer: str):
|
|
||||||
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
|
||||||
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
|
||||||
link_key = key_from_env(env_name)
|
|
||||||
if link_key is None and encryption_required():
|
|
||||||
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
|
||||||
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
|
||||||
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
|
||||||
raise LinkError(
|
|
||||||
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
|
||||||
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
|
||||||
)
|
|
||||||
if link_key is None:
|
|
||||||
log.warning(
|
|
||||||
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
|
||||||
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
|
||||||
"ruch wewnątrz sieci.", env_name, layer,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
link = Link(link_key)
|
|
||||||
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
|
||||||
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
|
||||||
return link
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================ strona kliencka
|
|
||||||
|
|
||||||
def call(client, method: str, url: str, *, payload=None,
|
|
||||||
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
|
||||||
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
|
||||||
|
|
||||||
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
|
||||||
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
|
||||||
się niejasnym błędem odszyfrowania.
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
request_headers = dict(headers or {})
|
|
||||||
if link is None:
|
|
||||||
if encryption_required():
|
|
||||||
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
|
||||||
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
|
||||||
# sygnifikatory, o które pytamy bazę.
|
|
||||||
raise LinkError(
|
|
||||||
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
|
||||||
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
|
||||||
)
|
|
||||||
response = client.request(method, url, json=payload, headers=request_headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
path = httpx.URL(url).path
|
|
||||||
stamp = stamp_now()
|
|
||||||
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
|
||||||
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
|
||||||
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
|
||||||
"Content-Type": CONTENT_TYPE})
|
|
||||||
|
|
||||||
response = client.request(method, url, content=body, headers=request_headers)
|
|
||||||
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
|
||||||
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
|
||||||
response.raise_for_status()
|
|
||||||
if response.headers.get(HEADER_ENC) != VERSION:
|
|
||||||
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
|
||||||
reply_stamp = response.headers.get(HEADER_TS, "")
|
|
||||||
check_stamp(reply_stamp)
|
|
||||||
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
|
||||||
|
|
||||||
|
|
||||||
def call_json(client, method: str, url: str, *, payload=None,
|
|
||||||
headers: dict[str, str] | None = None, link: Link | None):
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
return _json.loads(call(client, method, url, payload=payload,
|
|
||||||
headers=headers, link=link))
|
|
||||||
|
|
||||||
|
|
||||||
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
|
||||||
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
|
||||||
|
|
||||||
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
|
||||||
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
|
||||||
"""
|
|
||||||
if link is None:
|
|
||||||
yield from response.iter_bytes()
|
|
||||||
return
|
|
||||||
if response.headers.get(HEADER_ENC) != VERSION:
|
|
||||||
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
|
||||||
stamp = response.headers.get(HEADER_TS, "")
|
|
||||||
check_stamp(stamp)
|
|
||||||
path = response.request.url.path
|
|
||||||
buffer = bytearray()
|
|
||||||
seq = 0
|
|
||||||
for chunk in response.iter_bytes():
|
|
||||||
buffer += chunk
|
|
||||||
for frame in unframe_incremental(buffer):
|
|
||||||
yield link.open(RESPONSE, path, stamp, seq, frame)
|
|
||||||
seq += 1
|
|
||||||
if buffer:
|
|
||||||
raise LinkError("strumień urwał się w połowie ramki")
|
|
||||||
|
|
||||||
|
|
||||||
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
|
||||||
link: Link | None) -> Iterator[str]:
|
|
||||||
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
|
||||||
|
|
||||||
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
|
||||||
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
|
||||||
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
|
||||||
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
|
||||||
dopiero na znakach nowej linii.
|
|
||||||
|
|
||||||
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
|
||||||
działał bez zmian.
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
import httpx as _httpx
|
|
||||||
|
|
||||||
request_headers = dict(headers or {})
|
|
||||||
if link is None:
|
|
||||||
if encryption_required():
|
|
||||||
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
|
||||||
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
|
||||||
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
|
||||||
raise LinkError(
|
|
||||||
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
|
||||||
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
|
||||||
)
|
|
||||||
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
for text_line in response.iter_lines():
|
|
||||||
if text_line:
|
|
||||||
yield text_line
|
|
||||||
return
|
|
||||||
|
|
||||||
path = _httpx.URL(url).path
|
|
||||||
stamp = stamp_now()
|
|
||||||
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
|
||||||
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
|
||||||
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
buffer = bytearray()
|
|
||||||
for plain in open_response_stream(response, link):
|
|
||||||
buffer += plain
|
|
||||||
while True:
|
|
||||||
nl = buffer.find(b"\n")
|
|
||||||
if nl < 0:
|
|
||||||
break
|
|
||||||
text_line = bytes(buffer[:nl])
|
|
||||||
del buffer[:nl + 1]
|
|
||||||
if text_line:
|
|
||||||
yield text_line.decode("utf-8")
|
|
||||||
if buffer:
|
|
||||||
yield bytes(buffer).decode("utf-8")
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"""astrodemo — wersja DEMONSTRACYJNA (PRE-28).
|
|
||||||
|
|
||||||
DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ:
|
|
||||||
1. dodanie pliku bazy (wgranie + włączenie do użytku),
|
|
||||||
2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych.
|
|
||||||
|
|
||||||
DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Ukrycie a nieobecność to
|
|
||||||
dwie różne rzeczy. Tutaj niczego poza tymi dwiema funkcjami NIE MA W OBRAZIE: nie
|
|
||||||
ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Obraz
|
|
||||||
tej usługi można komuś oddać i nie oddaje się przy tym niczego ponad to, co widać
|
|
||||||
na ekranie — łącznie z komentarzami w tym pliku, bo one też jadą razem z obrazem.
|
|
||||||
|
|
||||||
PULE SĄ ROZDZIELONE (PRE-29). Każde konto ma własny, izolowany zbiór plików,
|
|
||||||
niewidoczny dla pozostałych kont; pula bierze się WYŁĄCZNIE z loginu zalogowanej
|
|
||||||
osoby, nigdy z pola formularza. Konta są osobne (ASTRODEMO_USERS) — żeby dało się
|
|
||||||
odciąć tę usługę jedną zmienną, bez
|
|
||||||
ruszania kont głównej aplikacji.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from app import link_crypto, security, session
|
|
||||||
from app.clients.logic_client import LogicClient
|
|
||||||
|
|
||||||
# Automatyczna dokumentacja WYŁĄCZONA. /docs wypisałoby komplet tras, a ta usługa
|
|
||||||
# ma nie zdradzać nawet własnej powierzchni.
|
|
||||||
app = FastAPI(title="astrodemo", docs_url=None, redoc_url=None, openapi_url=None)
|
|
||||||
security.install(app)
|
|
||||||
|
|
||||||
# ── zasoby (PRE-27) ────────────────────────────────────────────────────────
|
|
||||||
# Katalog NIE jest montowany. Montowanie serwuje wszystko, co w nim leży, każdemu
|
|
||||||
# i bez śladu w dzienniku — a komentarz w arkuszu stylów mówił wprost, że istnieje
|
|
||||||
# więcej, niż widać na ekranie. Tu jest jawna lista: arkusz logowania jest
|
|
||||||
# publiczny, bo
|
|
||||||
# potrzebuje go strona logowania, a cokolwiek innego jest nieznane i nie istnieje.
|
|
||||||
ZASOBY_PUBLICZNE = frozenset({"styles.css"})
|
|
||||||
_KATALOG_ZASOBOW = Path("app/static")
|
|
||||||
|
|
||||||
|
|
||||||
def _bez_komentarzy(tresc: str) -> str:
|
|
||||||
"""Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt.
|
|
||||||
W repozytorium zostaje — do odpowiedzi nie ma po co trafiać."""
|
|
||||||
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
|
|
||||||
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
|
||||||
def _zasob(nazwa: str) -> bytes | None:
|
|
||||||
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
|
|
||||||
if nazwa not in ZASOBY_PUBLICZNE:
|
|
||||||
return None
|
|
||||||
sciezka = (_KATALOG_ZASOBOW / nazwa).resolve()
|
|
||||||
# Wyjście poza katalog: `..` w ścieżce albo dowiązanie na zewnątrz.
|
|
||||||
if _KATALOG_ZASOBOW.resolve() not in sciezka.parents or not sciezka.is_file():
|
|
||||||
return None
|
|
||||||
if sciezka.suffix in (".css", ".js"):
|
|
||||||
return _bez_komentarzy(sciezka.read_text(encoding="utf-8")).encode("utf-8")
|
|
||||||
return sciezka.read_bytes()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/static/{nazwa:path}")
|
|
||||||
def zasob(nazwa: str):
|
|
||||||
tresc = _zasob(nazwa)
|
|
||||||
if tresc is None:
|
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
typ = "text/css; charset=utf-8" if nazwa.endswith(".css") else "application/octet-stream"
|
|
||||||
return Response(tresc, media_type=typ,
|
|
||||||
headers={"Cache-Control": "public, max-age=3600"})
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
|
||||||
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _blad).
|
|
||||||
log = logging.getLogger("astrodemo")
|
|
||||||
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
|
|
||||||
templates.env.globals["auth_on"] = security.auth_enabled
|
|
||||||
|
|
||||||
def _logic(request: Request) -> LogicClient:
|
|
||||||
"""Klient związany z pulą ZALOGOWANEGO konta.
|
|
||||||
|
|
||||||
Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się
|
|
||||||
zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state,
|
|
||||||
które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy
|
|
||||||
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
|
|
||||||
return LogicClient(tenant=getattr(request.state, "user", "") or "")
|
|
||||||
|
|
||||||
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
|
|
||||||
# tutaj wprost, bo astrodemo nie importuje tamtej warstwy; literówka objawiłaby
|
|
||||||
# się dopiero na żywo, więc pilnuje jej test.
|
|
||||||
ACTIVE = "active"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
|
||||||
"""Data + godzina lokalna + offset → chwila w UTC.
|
|
||||||
|
|
||||||
Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma
|
|
||||||
dwie funkcje, a strefy to trzecia."""
|
|
||||||
try:
|
|
||||||
local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M")
|
|
||||||
except ValueError as e:
|
|
||||||
raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e
|
|
||||||
moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset)))
|
|
||||||
return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M")
|
|
||||||
|
|
||||||
|
|
||||||
# Jedno zdanie dla wszystkich awarii i CELOWO żadnego szczegółu. Treść wyjątku
|
|
||||||
# niosła nazwę usługi, jej port i nazwy jej tras — czyli mapę tego, co stoi za tym
|
|
||||||
# ekranem, podaną komuś, kto ma widzieć wyłącznie dwa formularze.
|
|
||||||
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
|
|
||||||
|
|
||||||
|
|
||||||
def _blad(e: Exception) -> str:
|
|
||||||
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran."""
|
|
||||||
log.warning("operacja nieudana: %s: %s", type(e).__name__, e)
|
|
||||||
return _AWARIA
|
|
||||||
|
|
||||||
|
|
||||||
def _ile_rekordow(report: dict) -> int:
|
|
||||||
return sum(len(s.get("samples") or []) for p in (report.get("positions") or [])
|
|
||||||
for s in (p.get("significators") or []))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
def formularz(request: Request, error: str = "", done: str = ""):
|
|
||||||
return templates.TemplateResponse(request, "index.html",
|
|
||||||
{"result": None, "form": {}, "error": error, "done": done})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/", response_class=HTMLResponse)
|
|
||||||
def interpretacja(request: Request, date: str = Form(...), time: str = Form(...),
|
|
||||||
tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)):
|
|
||||||
"""Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść."""
|
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
|
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None}
|
|
||||||
try:
|
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
|
||||||
ctx["moment"] = label
|
|
||||||
ctx["result"] = _logic(request).report(when_utc_iso=iso_utc, lat=lat, lon=lon)
|
|
||||||
# Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17).
|
|
||||||
security.audit_records(request, _ile_rekordow(ctx["result"]))
|
|
||||||
except ValueError as e:
|
|
||||||
ctx["error"] = str(e)
|
|
||||||
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
|
||||||
ctx["error"] = _blad(e)
|
|
||||||
return templates.TemplateResponse(request, "index.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/plik")
|
|
||||||
async def dodaj_plik(request: Request, upload: UploadFile = File(...)):
|
|
||||||
"""Dodanie pliku bazy: wgranie ORAZ włączenie do użytku.
|
|
||||||
|
|
||||||
Jedna czynność, nie dwie: „dodać plik" ma tu znaczyć, że od razu bierze udział
|
|
||||||
w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i wyglądałoby to
|
|
||||||
na zepsute.
|
|
||||||
|
|
||||||
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
|
|
||||||
zapisany i czeka."""
|
|
||||||
raw = await upload.read()
|
|
||||||
if not raw:
|
|
||||||
return RedirectResponse("/?error=Pusty+plik.", status_code=303)
|
|
||||||
|
|
||||||
who = getattr(request.state, "user", "") or "demo"
|
|
||||||
try:
|
|
||||||
out = _logic(request).files_upload(upload.filename or "baza.xlsx",
|
|
||||||
base64.b64encode(raw).decode("ascii"), by=who)
|
|
||||||
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
|
||||||
return _wroc(error=_blad(e))
|
|
||||||
|
|
||||||
nazwa = out.get("name") or "plik"
|
|
||||||
if not out.get("accepted"):
|
|
||||||
# Bez słowa o regułach — te zna wyłącznie administrator (DAN-27).
|
|
||||||
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
|
|
||||||
f"do użycia w tej postaci.")
|
|
||||||
try:
|
|
||||||
_logic(request).files_status(out["path"], ACTIVE, by=who)
|
|
||||||
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
|
||||||
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}")
|
|
||||||
return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.")
|
|
||||||
|
|
||||||
|
|
||||||
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
|
|
||||||
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
|
||||||
wgrać pliku drugi raz."""
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
|
||||||
return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
|
|
||||||
# Bez własnego wolumenu nie ma gdzie trzymać licznika pokolenia sesji, więc
|
|
||||||
# „wyrzuć ze wszystkich urządzeń" robi się zmianą hasła w ASTRODEMO_USERS.
|
|
||||||
# Wylogowanie działa natychmiast, bo polega na skasowaniu ciasteczka.
|
|
||||||
|
|
||||||
def _bezpieczne_dokad(dokad: str) -> str:
|
|
||||||
"""Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl`
|
|
||||||
zamieniłby ekran logowania w narzędzie do wyłudzania haseł."""
|
|
||||||
dokad = (dokad or "").strip()
|
|
||||||
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
|
|
||||||
return "/"
|
|
||||||
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logowanie", response_class=HTMLResponse)
|
|
||||||
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
|
|
||||||
if security.auth_enabled() and security.principal(request):
|
|
||||||
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
|
|
||||||
return templates.TemplateResponse(request, "logowanie.html",
|
|
||||||
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/logowanie")
|
|
||||||
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
|
|
||||||
dokad: str = Form("/")):
|
|
||||||
cel = _bezpieczne_dokad(dokad)
|
|
||||||
kto = security.verify(login.strip(), haslo)
|
|
||||||
if kto is None:
|
|
||||||
security.audit_login(request, login.strip(), udane=False)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request, "logowanie.html",
|
|
||||||
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
|
|
||||||
status_code=401)
|
|
||||||
security.audit_login(request, kto, udane=True)
|
|
||||||
odpowiedz = RedirectResponse(cel, status_code=303)
|
|
||||||
odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto),
|
|
||||||
max_age=session.max_age(),
|
|
||||||
**session.cookie_params(secure=security.cookies_secure()))
|
|
||||||
return odpowiedz
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/wyloguj")
|
|
||||||
def wyloguj(request: Request):
|
|
||||||
"""POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie,
|
|
||||||
żeby wylogować kogoś bez jego wiedzy."""
|
|
||||||
security.audit_logout(request)
|
|
||||||
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
|
|
||||||
odpowiedz.delete_cookie(session.COOKIE,
|
|
||||||
**session.cookie_params(secure=security.cookies_secure()))
|
|
||||||
return odpowiedz
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health() -> dict:
|
|
||||||
# Sama „ok". Nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa;
|
|
||||||
# kubernetes i compose czytają wyłącznie kod odpowiedzi.
|
|
||||||
return {"status": "ok"}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
"""Logowanie do astrodemo (PRE-28).
|
|
||||||
|
|
||||||
OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej
|
|
||||||
warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy
|
|
||||||
interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną
|
|
||||||
środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek.
|
|
||||||
|
|
||||||
Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont
|
|
||||||
i bez uprawnień. Ta usługa ma dwie funkcje i obie są dla tego samego człowieka —
|
|
||||||
model uprawnień z PRE-27 nie miałby tu czego dzielić.
|
|
||||||
|
|
||||||
Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem
|
|
||||||
co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
||||||
|
|
||||||
log = logging.getLogger("astrodemo.security")
|
|
||||||
audit_log = logging.getLogger("astrodemo.audit")
|
|
||||||
|
|
||||||
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
|
||||||
_HASH_PREFIX = "scrypt$"
|
|
||||||
|
|
||||||
# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być
|
|
||||||
# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji.
|
|
||||||
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/styles.css"})
|
|
||||||
# Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy
|
|
||||||
# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani
|
|
||||||
# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony
|
|
||||||
# z nazwy, bo potrzebuje go ekran logowania.
|
|
||||||
PUBLIC_PREFIXES: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
_hits: dict[str, deque[float]] = {}
|
|
||||||
MAX_TRACKED_CLIENTS = 4096
|
|
||||||
|
|
||||||
|
|
||||||
def accounts() -> dict[str, str]:
|
|
||||||
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_USERS.
|
|
||||||
|
|
||||||
WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29).
|
|
||||||
Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem
|
|
||||||
swoje wgrania.
|
|
||||||
|
|
||||||
Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten
|
|
||||||
sam skrypt make_user.py, co w głównej aplikacji.
|
|
||||||
|
|
||||||
Zgodność wstecz: pojedyncze ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS
|
|
||||||
jest puste."""
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for entry in os.getenv("ASTRODEMO_USERS", "").split(","):
|
|
||||||
entry = entry.strip()
|
|
||||||
if not entry:
|
|
||||||
continue
|
|
||||||
user, sep, secret = entry.partition(":")
|
|
||||||
if sep and user.strip() and secret.strip():
|
|
||||||
out[user.strip()] = secret.strip()
|
|
||||||
if out:
|
|
||||||
return out
|
|
||||||
single = os.getenv("ASTRODEMO_PASSWORD", "")
|
|
||||||
return {os.getenv("ASTRODEMO_USER", "demo"): single} if single else {}
|
|
||||||
|
|
||||||
|
|
||||||
def rate_limit_per_min() -> int:
|
|
||||||
return int(os.getenv("RATE_LIMIT_PER_MIN", "60"))
|
|
||||||
|
|
||||||
|
|
||||||
def trust_proxy() -> bool:
|
|
||||||
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def auth_enabled() -> bool:
|
|
||||||
return bool(accounts())
|
|
||||||
|
|
||||||
|
|
||||||
def _secret_matches(spec: str, password: str) -> bool:
|
|
||||||
if spec.startswith(_HASH_PREFIX):
|
|
||||||
try:
|
|
||||||
_, salt_hex, hash_hex = spec.split("$", 2)
|
|
||||||
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
|
||||||
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
|
||||||
return hmac.compare_digest(actual, expected)
|
|
||||||
# BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach
|
|
||||||
# spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy.
|
|
||||||
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def verify(login: str, password: str) -> str | None:
|
|
||||||
"""Sprawdza login i hasło; zwraca login albo None.
|
|
||||||
|
|
||||||
Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
|
|
||||||
konto istnieje, a przy demo rozdawanym szeroko to realna informacja."""
|
|
||||||
spec = accounts().get(login)
|
|
||||||
if spec is None:
|
|
||||||
return None
|
|
||||||
return login if _secret_matches(spec, password) else None
|
|
||||||
|
|
||||||
|
|
||||||
def issue_session(login: str) -> str:
|
|
||||||
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
|
||||||
|
|
||||||
W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie
|
|
||||||
konta z ASTRODEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny
|
|
||||||
sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu,
|
|
||||||
bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów
|
|
||||||
byłby pomieszaniem dwóch zupełnie różnych rzeczy."""
|
|
||||||
from app import session
|
|
||||||
|
|
||||||
return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0)
|
|
||||||
|
|
||||||
|
|
||||||
def principal(request: Request) -> str | None:
|
|
||||||
"""Ciasteczko → login zalogowanej osoby albo None."""
|
|
||||||
from app import session
|
|
||||||
|
|
||||||
dane = session.read(request.cookies.get(session.COOKIE))
|
|
||||||
if dane is None:
|
|
||||||
return None
|
|
||||||
spec = accounts().get(dane["login"])
|
|
||||||
if spec is None or session.fingerprint(spec) != dane["odcisk"]:
|
|
||||||
return None
|
|
||||||
return dane["login"]
|
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str:
|
|
||||||
"""Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy —
|
|
||||||
inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit."""
|
|
||||||
peer = request.client.host if request.client else "?"
|
|
||||||
if not trust_proxy():
|
|
||||||
return peer
|
|
||||||
forwarded = request.headers.get("x-forwarded-for", "")
|
|
||||||
if forwarded:
|
|
||||||
last = forwarded.rsplit(",", 1)[-1].strip()
|
|
||||||
if last:
|
|
||||||
return last
|
|
||||||
return request.headers.get("x-real-ip", "").strip() or peer
|
|
||||||
|
|
||||||
|
|
||||||
def _rate_limited(client: str) -> bool:
|
|
||||||
cap = rate_limit_per_min()
|
|
||||||
if cap <= 0:
|
|
||||||
return False
|
|
||||||
now = time.monotonic()
|
|
||||||
window = _hits.get(client)
|
|
||||||
if window is None:
|
|
||||||
if len(_hits) >= MAX_TRACKED_CLIENTS:
|
|
||||||
_hits.clear()
|
|
||||||
window = _hits[client] = deque()
|
|
||||||
while window and now - window[0] > 60.0:
|
|
||||||
window.popleft()
|
|
||||||
if len(window) >= cap:
|
|
||||||
return True
|
|
||||||
window.append(now)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_audit_logging() -> None:
|
|
||||||
"""Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje
|
|
||||||
naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był
|
|
||||||
pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17)."""
|
|
||||||
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
|
|
||||||
if not audit_log.handlers:
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
||||||
audit_log.addHandler(handler)
|
|
||||||
audit_log.propagate = False
|
|
||||||
|
|
||||||
|
|
||||||
def audit_records(request: Request, count: int) -> None:
|
|
||||||
try:
|
|
||||||
request.state.audit_records = int(count)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def cookies_secure() -> bool:
|
|
||||||
"""Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http —
|
|
||||||
inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa"
|
|
||||||
bez żadnego komunikatu."""
|
|
||||||
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def audit_login(request: Request, login: str, udane: bool) -> None:
|
|
||||||
"""Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza
|
|
||||||
nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak,
|
|
||||||
hasło NIGDY."""
|
|
||||||
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
|
|
||||||
"logowanie-ok" if udane else "logowanie-odmowa",
|
|
||||||
login or "-", client_ip(request))
|
|
||||||
|
|
||||||
|
|
||||||
def audit_logout(request: Request) -> None:
|
|
||||||
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
|
|
||||||
getattr(request.state, "user", "") or "-", client_ip(request))
|
|
||||||
|
|
||||||
|
|
||||||
def install(app) -> None:
|
|
||||||
from app import session
|
|
||||||
|
|
||||||
_setup_audit_logging()
|
|
||||||
|
|
||||||
# Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od
|
|
||||||
# podrobionych. Lepiej, żeby usługa nie wstała.
|
|
||||||
if auth_enabled() and not session.available():
|
|
||||||
raise session.SessionError(
|
|
||||||
"Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
|
||||||
"podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32")
|
|
||||||
|
|
||||||
if not auth_enabled():
|
|
||||||
log.warning(
|
|
||||||
"UWAGA: brak kont (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo stoi OTWARTY. "
|
|
||||||
"Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym "
|
|
||||||
"zbiorze baz, widząc nawzajem swoje wgrania."
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def _guard(request: Request, call_next):
|
|
||||||
if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
started = time.monotonic()
|
|
||||||
if _rate_limited(client_ip(request)):
|
|
||||||
return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
|
||||||
status_code=429, headers={"Retry-After": "60"})
|
|
||||||
|
|
||||||
user, odswiez = "", None
|
|
||||||
if auth_enabled():
|
|
||||||
user = principal(request) or ""
|
|
||||||
if not user:
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
cel = request.url.path
|
|
||||||
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
|
|
||||||
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
|
|
||||||
from app import session as _s
|
|
||||||
|
|
||||||
dane = _s.read(request.cookies.get(_s.COOKIE))
|
|
||||||
odswiez = _s.refresh(dane) if dane else None
|
|
||||||
request.state.user = user
|
|
||||||
|
|
||||||
response = await call_next(request)
|
|
||||||
if odswiez:
|
|
||||||
from app import session as _s
|
|
||||||
|
|
||||||
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
|
|
||||||
**_s.cookie_params(secure=cookies_secure()))
|
|
||||||
records = getattr(request.state, "audit_records", None)
|
|
||||||
audit_log.info(
|
|
||||||
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
|
||||||
user or "-", client_ip(request), request.method, request.url.path,
|
|
||||||
response.status_code, "-" if records is None else records,
|
|
||||||
(time.monotonic() - started) * 1000)
|
|
||||||
return response
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"""Sesje logowania — podpisane ciasteczko (LOG-34).
|
|
||||||
|
|
||||||
Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi
|
|
||||||
obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka.
|
|
||||||
|
|
||||||
|
|
||||||
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
|
|
||||||
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
|
|
||||||
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
|
|
||||||
w środku, i może tę decyzję cofnąć.
|
|
||||||
|
|
||||||
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
|
|
||||||
|
|
||||||
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
|
|
||||||
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
|
|
||||||
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
|
|
||||||
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
|
|
||||||
o tym — i też nie wymaga magazynu.
|
|
||||||
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
|
|
||||||
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
|
|
||||||
nie poda licznika, poziomy 1 i 2 nadal działają.
|
|
||||||
|
|
||||||
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
|
|
||||||
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
|
|
||||||
dostępu nie odbierałoby dostępu.
|
|
||||||
|
|
||||||
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
|
|
||||||
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
|
|
||||||
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
|
|
||||||
|
|
||||||
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
|
|
||||||
w PRE-27 i AES w PRE-16.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
|
|
||||||
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
|
|
||||||
# z jednej, logując się do drugiej.
|
|
||||||
COOKIE = "astrodemo_sesja"
|
|
||||||
|
|
||||||
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
|
|
||||||
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
|
|
||||||
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
|
|
||||||
|
|
||||||
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
|
|
||||||
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
|
|
||||||
REFRESH_AFTER = 300
|
|
||||||
|
|
||||||
|
|
||||||
class SessionError(RuntimeError):
|
|
||||||
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
|
|
||||||
|
|
||||||
|
|
||||||
def _env_int(name: str, default: int) -> int:
|
|
||||||
try:
|
|
||||||
return int(os.getenv(name, "") or default)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def max_age() -> int:
|
|
||||||
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
|
|
||||||
|
|
||||||
|
|
||||||
def idle_timeout() -> int:
|
|
||||||
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
|
|
||||||
|
|
||||||
|
|
||||||
def secret() -> bytes:
|
|
||||||
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
|
|
||||||
|
|
||||||
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
|
|
||||||
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
|
|
||||||
raw = os.getenv("SESSION_SECRET", "").strip()
|
|
||||||
if not raw:
|
|
||||||
raise SessionError(
|
|
||||||
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
|
|
||||||
"Wygeneruj: openssl rand -hex 32")
|
|
||||||
if len(raw) < 32:
|
|
||||||
raise SessionError(
|
|
||||||
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
|
|
||||||
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
|
|
||||||
return raw.encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def available() -> bool:
|
|
||||||
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
|
|
||||||
try:
|
|
||||||
secret()
|
|
||||||
except SessionError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(secret_spec: str) -> str:
|
|
||||||
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
|
|
||||||
|
|
||||||
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
|
|
||||||
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
|
|
||||||
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
|
|
||||||
unieważniły jego sesje same z siebie."""
|
|
||||||
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def _sign(payload: bytes) -> bytes:
|
|
||||||
return hmac.new(secret(), payload, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _b64(raw: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
||||||
|
|
||||||
|
|
||||||
def _unb64(text: str) -> bytes:
|
|
||||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
||||||
|
|
||||||
|
|
||||||
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
|
|
||||||
"""Nowe ciasteczko sesji dla konta."""
|
|
||||||
teraz = int(now if now is not None else time.time())
|
|
||||||
payload = json.dumps(
|
|
||||||
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
|
|
||||||
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
||||||
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
|
||||||
|
|
||||||
|
|
||||||
def read(token: str | None, *, now: float | None = None) -> dict | None:
|
|
||||||
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
|
|
||||||
|
|
||||||
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
|
|
||||||
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
|
|
||||||
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
|
|
||||||
if not token or "." not in token:
|
|
||||||
return None
|
|
||||||
surowy, _, podpis = token.rpartition(".")
|
|
||||||
try:
|
|
||||||
payload = _unb64(surowy)
|
|
||||||
oczekiwany = _sign(payload)
|
|
||||||
podany = _unb64(podpis)
|
|
||||||
except (ValueError, SessionError):
|
|
||||||
return None
|
|
||||||
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
|
|
||||||
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
|
|
||||||
if not hmac.compare_digest(oczekiwany, podany):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
dane = json.loads(payload.decode("utf-8"))
|
|
||||||
login, odcisk = str(dane["u"]), str(dane["f"])
|
|
||||||
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
|
|
||||||
except (ValueError, KeyError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
teraz = int(now if now is not None else time.time())
|
|
||||||
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
|
|
||||||
# w obu wypadkach nie ma powodu mu ufać.
|
|
||||||
if wyd > teraz + 60:
|
|
||||||
return None
|
|
||||||
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
|
|
||||||
return None
|
|
||||||
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
|
|
||||||
|
|
||||||
|
|
||||||
def refresh(dane: dict, *, now: float | None = None) -> str | None:
|
|
||||||
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
|
|
||||||
|
|
||||||
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
|
|
||||||
Set-Cookie — także po arkusze stylów i obrazki."""
|
|
||||||
teraz = int(now if now is not None else time.time())
|
|
||||||
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
|
|
||||||
return None
|
|
||||||
payload = json.dumps(
|
|
||||||
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
|
|
||||||
"wyd": dane["wydano"], "akt": teraz},
|
|
||||||
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
||||||
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
|
||||||
|
|
||||||
|
|
||||||
def cookie_params(secure: bool = True) -> dict:
|
|
||||||
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
|
|
||||||
|
|
||||||
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
|
|
||||||
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
|
|
||||||
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
|
|
||||||
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/* Arkusz CELOWO minimalny: dwa formularze i tabela wyników. Każda klasa ponad to
|
|
||||||
niosłaby nazwę funkcji, której tu nie ma. */
|
|
||||||
:root {
|
|
||||||
--bg: #11121b; --panel: #191b26; --line: #2b2e3d;
|
|
||||||
--fg: #e8e9f0; --muted: #9aa0b4; --accent: #8ab4ff;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; background: var(--bg); color: var(--fg);
|
|
||||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
|
||||||
.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line);
|
|
||||||
display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
|
||||||
.kto { display: flex; align-items: center; gap: .5rem; }
|
|
||||||
.whoami { color: var(--muted); font-size: .85rem; }
|
|
||||||
.wyloguj-form { display: inline; margin: 0; }
|
|
||||||
button.wyloguj {
|
|
||||||
background: none; border: 1px solid var(--line); color: var(--muted);
|
|
||||||
font: inherit; font-weight: 400; padding: .35rem .8rem; border-radius: 8px; cursor: pointer;
|
|
||||||
}
|
|
||||||
button.wyloguj:hover { color: var(--fg); }
|
|
||||||
|
|
||||||
/* Ekran logowania — wąski, bez niczego poza formularzem. */
|
|
||||||
.login-card label { display: block; margin-top: .75rem; }
|
|
||||||
.login-card input { width: 100%; }
|
|
||||||
h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; }
|
|
||||||
h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
|
||||||
h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
margin-top: 1.25rem; padding: 1rem 1.25rem;
|
|
||||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
|
||||||
}
|
|
||||||
.row { display: flex; gap: .75rem; flex-wrap: wrap; align-items: flex-end; margin-top: .6rem; }
|
|
||||||
label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 10rem; }
|
|
||||||
input {
|
|
||||||
width: 100%; padding: .45rem .6rem; background: #0e0f18; color: var(--fg);
|
|
||||||
border: 1px solid var(--line); border-radius: 8px; font: inherit;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: .5rem 1.25rem; background: var(--accent); color: #10121a;
|
|
||||||
border: none; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: .3rem; }
|
|
||||||
td { padding: .35rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; font-size: .92rem; }
|
|
||||||
td.key { color: var(--accent); white-space: nowrap; padding-right: 1rem; }
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.sig { margin-top: .75rem; }
|
|
||||||
.sig-head { color: var(--muted); font-size: .85rem; }
|
|
||||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.small { font-size: .85rem; }
|
|
||||||
.foot { margin-top: 2.5rem; color: var(--muted); font-size: .8rem; }
|
|
||||||
|
|
||||||
.alert, .ok {
|
|
||||||
margin: 1rem 0; padding: .6rem .9rem; border-radius: 8px; font-size: .92rem;
|
|
||||||
border: 1px solid var(--line); border-left-width: 4px;
|
|
||||||
}
|
|
||||||
.alert { border-left-color: #b8860b; background: rgba(184, 134, 11, .1); }
|
|
||||||
.ok { border-left-color: var(--accent); background: rgba(138, 180, 255, .08); }
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pl">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>astrodemo · {% block title %}{% endblock %}</title>
|
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<header class="topbar">
|
|
||||||
<h1>astrodemo</h1>
|
|
||||||
{# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach,
|
|
||||||
więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #}
|
|
||||||
{% if auth_on() %}
|
|
||||||
<div class="kto">
|
|
||||||
<span class="whoami">{{ current_user(request) }}</span>
|
|
||||||
<form method="post" action="/wyloguj" class="wyloguj-form">
|
|
||||||
<button type="submit" class="wyloguj">Wyloguj</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie,
|
|
||||||
a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
|
|
||||||
</header>
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
<footer class="foot">wersja demonstracyjna</footer>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Interpretacja{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
|
|
||||||
{% if done %}<p class="ok">{{ done }}</p>{% endif %}
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Dodaj bazę</h2>
|
|
||||||
<p class="muted small">
|
|
||||||
Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji.
|
|
||||||
</p>
|
|
||||||
<form method="post" action="/plik" enctype="multipart/form-data">
|
|
||||||
<div class="row">
|
|
||||||
<label>Plik <input type="file" name="upload" required></label>
|
|
||||||
<button type="submit">Dodaj</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Zapytaj o interpretację urodzeniową</h2>
|
|
||||||
<form method="post" action="/">
|
|
||||||
<div class="row">
|
|
||||||
<label>Data urodzenia
|
|
||||||
<input type="date" name="date" value="{{ form.date or '' }}" required></label>
|
|
||||||
<label>Godzina (lokalna)
|
|
||||||
<input type="time" name="time" value="{{ form.time or '' }}" required></label>
|
|
||||||
<label>Offset względem GMT (h)
|
|
||||||
<input type="number" name="tz_offset" step="0.5" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}"></label>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Szerokość (lat, + N)
|
|
||||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}" required></label>
|
|
||||||
<label>Długość (lon, + E)
|
|
||||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}" required></label>
|
|
||||||
<button type="submit">Szukaj interpretacji</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if result %}
|
|
||||||
<p class="meta">
|
|
||||||
Moment: <strong>{{ moment }}</strong>
|
|
||||||
{% if result.engine %}· silnik: {{ result.engine }}{% endif %}
|
|
||||||
{% if result.objects %}· obiektów: {{ result.objects | length }}{% endif %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{# Kształt odpowiedzi: objects[] → facets[] → samples[]. Pytamy bez grupowania,
|
|
||||||
więc `groups` tu nie wystąpi i nie ma czego obsługiwać. #}
|
|
||||||
{% set znaleziono = namespace(ile=0) %}
|
|
||||||
{% for o in result.objects or [] %}
|
|
||||||
{% set trafienia = o.facets | selectattr('samples') | list %}
|
|
||||||
{% if trafienia %}
|
|
||||||
{% set znaleziono.ile = znaleziono.ile + trafienia | length %}
|
|
||||||
<section class="card">
|
|
||||||
<h3>{{ o.object }}
|
|
||||||
<span class="muted">w {{ o.sign }}{% if o.house %}, {{ o.house }}. dom{% endif %}
|
|
||||||
{%- if o.direction == 'Rx' %} · Rx{% endif %}</span>
|
|
||||||
</h3>
|
|
||||||
{% for f in trafienia %}
|
|
||||||
<div class="sig">
|
|
||||||
<div class="sig-head">{{ f.label }} <span class="muted small">— {{ f.count }} dopasowań</span></div>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{% for s in f.samples %}
|
|
||||||
<tr>
|
|
||||||
<td class="key" title="{{ s.significator }}">{{ s.expanded }}</td>
|
|
||||||
<td>{{ s.effect }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if znaleziono.ile == 0 %}
|
|
||||||
<p class="muted">
|
|
||||||
Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę
|
|
||||||
zawierającą pasujące opisy.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pl">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>astrodemo · logowanie</title>
|
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="login-main">
|
|
||||||
<header class="topbar"><h1>astrodemo</h1></header>
|
|
||||||
|
|
||||||
{% if request.query_params.get('wylogowano') %}
|
|
||||||
<p class="ok">Wylogowano.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if blad %}<p class="alert">{{ blad }}</p>{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/logowanie" class="card login-card">
|
|
||||||
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
|
|
||||||
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
|
|
||||||
<input type="hidden" name="dokad" value="{{ dokad }}">
|
|
||||||
<label>Login
|
|
||||||
<input type="text" name="login" value="{{ login or '' }}" required
|
|
||||||
autocomplete="username" autofocus></label>
|
|
||||||
<label>Hasło
|
|
||||||
<input type="password" name="haslo" required autocomplete="current-password"></label>
|
|
||||||
<div class="actions"><button type="submit">Zaloguj</button></div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="muted small login-note">
|
|
||||||
Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała
|
|
||||||
Ci dostęp — kont nie da się odzyskać samodzielnie.
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-r requirements.txt
|
|
||||||
pytest>=8.0
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# CELOWO KRÓTSZA niż w warstwie prezentacji. Astroklient ma dwie funkcje, więc nie
|
|
||||||
# potrzebuje ani Excela (brak eksportu), ani stref czasowych z lokalizacji (offset
|
|
||||||
# podaje się wprost), ani niczego pod kosmogram — a każda zbędna zależność w obrazie
|
|
||||||
# demo to kolejna rzecz do pilnowania i kolejna droga do środka.
|
|
||||||
fastapi>=0.115
|
|
||||||
uvicorn[standard]>=0.34
|
|
||||||
httpx>=0.28
|
|
||||||
jinja2>=3.1
|
|
||||||
python-multipart>=0.0.20
|
|
||||||
# Szyfrowanie łącza do warstwy logicznej (PRE-16): AES-256-GCM + HKDF
|
|
||||||
cryptography>=44.0
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
"""astrodemo — wersja demonstracyjna (PRE-28).
|
|
||||||
|
|
||||||
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
|
|
||||||
|
|
||||||
1. Demo ma DWIE funkcje i ani jednej więcej. Sprawdzamy to na trasach aplikacji,
|
|
||||||
nie w szablonie: trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem,
|
|
||||||
bo ta usługa rozmawia z warstwą danych produkcji.
|
|
||||||
|
|
||||||
2. „Dodać plik do bazy" znaczy, że plik ZACZYNA BYĆ UŻYWANY. W pełnej aplikacji
|
|
||||||
wgranie i włączenie to dwie decyzje (DAN-27); tutaj muszą być jedną, inaczej
|
|
||||||
po wgraniu nic się nie zmienia i demo wygląda na zepsute.
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app import security
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def env(monkeypatch):
|
|
||||||
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB")
|
|
||||||
monkeypatch.delenv("ASTRODEMO_USER", raising=False)
|
|
||||||
monkeypatch.delenv("ASTRODEMO_PASSWORD", raising=False)
|
|
||||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
|
||||||
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
|
|
||||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
|
||||||
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
|
|
||||||
|
|
||||||
|
|
||||||
def _auth(user="demo", password=""):
|
|
||||||
"""Nagłówek z WAŻNĄ SESJĄ — odpowiednik bycia zalogowanym.
|
|
||||||
|
|
||||||
Po przejściu z Basic na sesje (LOG-34) „zalogowany" znaczy „ma podpisane
|
|
||||||
ciasteczko", nie „ma nagłówek z hasłem". Hasło przyjmowane tylko po to, żeby
|
|
||||||
nie przepisywać wszystkich wywołań — sprawdzanie poświadczeń ma własne testy."""
|
|
||||||
from app import security, session
|
|
||||||
|
|
||||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
|
||||||
|
|
||||||
|
|
||||||
REPORT = {
|
|
||||||
"engine": "test",
|
|
||||||
"objects": [{
|
|
||||||
"object": "Sun", "sign": "Taurus", "house": 11, "direction": "D",
|
|
||||||
"planet_token": "Su", "planet_total": 3,
|
|
||||||
"facets": [{"label": "w znaku Taurus", "token": "Su Tau", "count": 1, "score": 5.0,
|
|
||||||
"samples": [{"significator": "Su Tau", "expanded": "Sun in Taurus",
|
|
||||||
"effect": "wytrwały, praktyczny"}]}],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _client(monkeypatch, calls=None, upload=None):
|
|
||||||
"""Podmieniamy METODY KLASY, nie obiekt modułowy: klient jest budowany per
|
|
||||||
żądanie, żeby pula wynikała z zalogowanego konta. Dzięki temu podglądamy
|
|
||||||
też `self.tenant`, czyli to, do której puli faktycznie poszło zapytanie."""
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
|
|
||||||
from app.clients.logic_client import LogicClient
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
calls = calls if calls is not None else []
|
|
||||||
domyslny = {"path": "nowa.xlsx", "name": "nowa.xlsx", "accepted": True}
|
|
||||||
|
|
||||||
monkeypatch.setattr(LogicClient, "report",
|
|
||||||
lambda self, **kw: (calls.append(("report", self.tenant, kw)), REPORT)[1])
|
|
||||||
monkeypatch.setattr(LogicClient, "files_upload",
|
|
||||||
lambda self, *a, **kw: (calls.append(("upload", self.tenant, a)),
|
|
||||||
upload or domyslny)[1])
|
|
||||||
monkeypatch.setattr(LogicClient, "files_status",
|
|
||||||
lambda self, *a, **kw: (calls.append(("status", self.tenant, a)), {})[1])
|
|
||||||
return TestClient(app), calls
|
|
||||||
|
|
||||||
|
|
||||||
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
|
||||||
"lat": "50.06", "lon": "19.94"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── powierzchnia: dokładnie dwie funkcje ────────────────────────────────
|
|
||||||
|
|
||||||
def test_the_service_exposes_nothing_beyond_its_two_functions():
|
|
||||||
"""Trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem — ta usługa
|
|
||||||
rozmawia z warstwą danych PRODUKCJI."""
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set())
|
|
||||||
if not r.path.startswith("/static") and m in {"GET", "POST"}}
|
|
||||||
assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health"),
|
|
||||||
("GET", "/logowanie"), ("POST", "/logowanie"), ("POST", "/wyloguj")}
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_client_can_only_ask_for_what_the_demo_needs():
|
|
||||||
"""Klient warstwy logicznej jest okrojony CELOWO: metody, której nie ma, nikt
|
|
||||||
przypadkiem nie zawoła i nie podpowie ona, że po drugiej stronie jest więcej."""
|
|
||||||
from app.clients.logic_client import LogicClient
|
|
||||||
|
|
||||||
publiczne = {n for n in dir(LogicClient) if not n.startswith("_")}
|
|
||||||
assert publiczne == {"report", "files_upload", "files_status", "health"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_docs_are_off():
|
|
||||||
"""/docs wypisałoby komplet tras — demo ma nie zdradzać własnej powierzchni."""
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
assert app.docs_url is None and app.openapi_url is None and app.redoc_url is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── logowanie ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_without_credentials_nothing_is_served(env, monkeypatch):
|
|
||||||
c, _ = _client(monkeypatch)
|
|
||||||
for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")):
|
|
||||||
r = getattr(c, method)(path, follow_redirects=False)
|
|
||||||
assert r.status_code == 303, path
|
|
||||||
assert "/logowanie" in r.headers["location"], path
|
|
||||||
assert c.get("/health").status_code == 200, "sonda musi działać bez hasła"
|
|
||||||
|
|
||||||
|
|
||||||
def test_demo_account_is_separate_from_the_main_application(env, monkeypatch):
|
|
||||||
"""Konto astrodemo MUSI być własne: demo pracuje na tej samej warstwie
|
|
||||||
danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł
|
|
||||||
w głównej aplikacji."""
|
|
||||||
monkeypatch.setenv("APP_USER", "astrololo")
|
|
||||||
monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji")
|
|
||||||
c, _ = _client(monkeypatch)
|
|
||||||
assert security.verify("astrololo", "haslo-glownej-aplikacji") is None
|
|
||||||
assert security.verify("demo", "tajne-demo") == "demo"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
|
|
||||||
monkeypatch.setenv("ASTRODEMO_USERS", "demo:zażółć-gęślą")
|
|
||||||
c, _ = _client(monkeypatch)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── funkcja 1: dodanie pliku ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch):
|
|
||||||
"""Sedno wymagania: jedna czynność, nie dwie."""
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
|
||||||
files={"upload": ("baza.xlsx", b"zawartosc", "application/vnd.ms-excel")})
|
|
||||||
assert r.status_code == 303
|
|
||||||
rodzaje = [k[0] for k in calls]
|
|
||||||
assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}"
|
|
||||||
assert calls[1][2][1] == "active", "plik nie został włączony do użytku"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch):
|
|
||||||
"""Walidacja zostaje, ale jej REGUŁY zna wyłącznie administrator (DAN-27) —
|
|
||||||
demo nie ma prawa ich zdradzić, nawet mimochodem."""
|
|
||||||
from urllib.parse import unquote_plus
|
|
||||||
|
|
||||||
c, calls = _client(monkeypatch, upload={"path": "zla.xlsx", "name": "zla.xlsx",
|
|
||||||
"accepted": False})
|
|
||||||
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
|
||||||
files={"upload": ("zla.xlsx", b"x", "text/plain")})
|
|
||||||
assert r.status_code == 303
|
|
||||||
assert [k[0] for k in calls] == ["upload"], "odrzucony plik został włączony do użytku"
|
|
||||||
komunikat = unquote_plus(r.headers["location"])
|
|
||||||
for slowo in ("walidacj", "reguł", "kolumn", "rozszerzeni", "rozmiar"):
|
|
||||||
assert slowo not in komunikat, f"komunikat zdradza mechanizm: „{slowo}”"
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_file_is_refused_before_anything_is_sent(env, monkeypatch):
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
|
||||||
files={"upload": ("pusty.xlsx", b"", "application/vnd.ms-excel")})
|
|
||||||
assert r.status_code == 303 and calls == []
|
|
||||||
|
|
||||||
|
|
||||||
# ── funkcja 2: interpretacja urodzeniowa ────────────────────────────────
|
|
||||||
|
|
||||||
def test_asking_for_an_interpretation_returns_the_matches(env, monkeypatch):
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
r = c.post("/", headers=_auth(), data=FORM)
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert "Sun in Taurus" in r.text and "wytrwały, praktyczny" in r.text
|
|
||||||
assert [k[0] for k in calls] == ["report"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch):
|
|
||||||
"""11:20 przy offsecie +2 to 09:20 UTC. Pomyłka tutaj przesuwa CAŁY horoskop,
|
|
||||||
a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost."""
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
c.post("/", headers=_auth(), data=FORM)
|
|
||||||
assert calls[0][2]["when_utc_iso"].startswith("1984-04-30T09:20")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch):
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
r = c.post("/", headers=_auth(), data={**FORM, "date": "30 kwietnia"})
|
|
||||||
assert r.status_code == 200 and "RRRR-MM-DD" in r.text
|
|
||||||
assert calls == [], "poszło zapytanie mimo błędnych danych"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch):
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.clients.logic_client import LogicClient
|
|
||||||
|
|
||||||
c, _ = _client(monkeypatch)
|
|
||||||
monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw(
|
|
||||||
httpx.ConnectError("brak połączenia")))
|
|
||||||
r = c.post("/", headers=_auth(), data=FORM)
|
|
||||||
# Komunikat jest CELOWO nieinformacyjny: treść wyjątku niosła nazwę usługi,
|
|
||||||
# jej port i nazwy tras, czyli mapę tego, co stoi za tym ekranem.
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert "Nie udało się teraz wykonać tej operacji" in r.text
|
|
||||||
assert "Warstwa logiczna" not in r.text and "8001" not in r.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch):
|
|
||||||
"""Demo ma wyglądać na KOMPLETNE w swojej postaci — żadnych śladów funkcji,
|
|
||||||
których nie ma (ta sama zasada, co przy uprawnieniach w PRE-27)."""
|
|
||||||
c, _ = _client(monkeypatch)
|
|
||||||
html = c.get("/", headers=_auth()).text
|
|
||||||
for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia",
|
|
||||||
"Konta", "PDF", "Excel", "prompt", "model"):
|
|
||||||
assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie"
|
|
||||||
|
|
||||||
|
|
||||||
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
|
|
||||||
# Demo jest udostępniane szeroko i różnym osobom, więc pula musi wynikać
|
|
||||||
# z tego, KTO SIĘ ZALOGOWAŁ — i z niczego innego. Wyciek byłby cichy: klient
|
|
||||||
# zobaczyłby cudze bazy jako własne.
|
|
||||||
|
|
||||||
def test_the_pool_is_taken_from_the_login(env, monkeypatch):
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
c.post("/", headers=_auth("klientA", "hasloA"), data=FORM)
|
|
||||||
c.post("/", headers=_auth("klientB", "hasloB"), data=FORM)
|
|
||||||
assert [k[1] for k in calls] == ["klientA", "klientB"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_uploads_go_to_the_pool_of_the_account_that_sent_them(env, monkeypatch):
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
c.post("/plik", headers=_auth("klientA", "hasloA"), follow_redirects=False,
|
|
||||||
files={"upload": ("baza.xlsx", b"x", "application/vnd.ms-excel")})
|
|
||||||
assert {k[1] for k in calls} == {"klientA"}, "wgranie trafiło do cudzej puli"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_pool_cannot_be_chosen_from_the_form(env, monkeypatch):
|
|
||||||
"""Gdyby pula przychodziła z żądania, wystarczyłoby podstawić cudzy login."""
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
c.post("/", headers=_auth("klientA", "hasloA"),
|
|
||||||
data={**FORM, "tenant": "klientB", "user": "klientB", "login": "klientB"})
|
|
||||||
assert [k[1] for k in calls] == ["klientA"], "pula dała się podmienić z formularza"
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_account_has_its_own_pool_by_construction(env, monkeypatch):
|
|
||||||
"""Każdy login = inna pula. Dwa konta nie mogą dzielić zbioru baz."""
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
for login, haslo in (("demo", "tajne-demo"), ("klientA", "hasloA"), ("klientB", "hasloB")):
|
|
||||||
c.post("/", headers=_auth(login, haslo), data=FORM)
|
|
||||||
pule = [k[1] for k in calls]
|
|
||||||
assert len(set(pule)) == len(pule) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_account_gets_nothing(env, monkeypatch):
|
|
||||||
"""Bez ważnej sesji żądanie nie schodzi niżej — nie wystarczy, że skończy
|
|
||||||
się przekierowaniem: nie może po drodze dotknąć warstwy logicznej."""
|
|
||||||
c, calls = _client(monkeypatch)
|
|
||||||
r = c.post("/", data=FORM, follow_redirects=False)
|
|
||||||
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
|
|
||||||
assert calls == [], "poszło zapytanie mimo braku sesji"
|
|
||||||
|
|
||||||
|
|
||||||
def test_logging_in_and_out_of_the_demo(env, monkeypatch):
|
|
||||||
"""Pełny obieg: demo bywa używane na cudzych komputerach, więc wyjście musi
|
|
||||||
naprawdę kończyć sesję, a nie tylko tak wyglądać."""
|
|
||||||
from app import session
|
|
||||||
|
|
||||||
c, _ = _client(monkeypatch)
|
|
||||||
r = c.post("/logowanie", follow_redirects=False,
|
|
||||||
data={"login": "klientA", "haslo": "hasloA", "dokad": "/"})
|
|
||||||
assert r.status_code == 303 and session.COOKIE in r.cookies
|
|
||||||
|
|
||||||
wyjscie = c.post("/wyloguj", headers=_auth("klientA"), follow_redirects=False)
|
|
||||||
assert wyjscie.status_code == 303
|
|
||||||
ustawione = wyjscie.headers.get("set-cookie", "")
|
|
||||||
assert session.COOKIE in ustawione and "Max-Age=0" in ustawione
|
|
||||||
|
|
||||||
|
|
||||||
def test_removing_an_account_kills_its_open_session(env, monkeypatch):
|
|
||||||
"""Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi
|
|
||||||
się przez ASTRODEMO_USERS — usunięcie albo zmiana hasła ubija sesję odciskiem."""
|
|
||||||
c, _ = _client(monkeypatch)
|
|
||||||
ciasteczko = _auth("klientA")
|
|
||||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
|
||||||
|
|
||||||
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA
|
|
||||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_demo_login_cannot_be_turned_into_a_redirector(env, monkeypatch):
|
|
||||||
c, _ = _client(monkeypatch)
|
|
||||||
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x"):
|
|
||||||
r = c.post("/logowanie", follow_redirects=False,
|
|
||||||
data={"login": "demo", "haslo": "tajne-demo", "dokad": zly})
|
|
||||||
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
"""Zapora słownikowa astrodemo: czego użytkownik demo nie może zobaczyć.
|
|
||||||
|
|
||||||
astrodemo stoi najniżej w drabinie trzech produktów (astrodemo → astroklient →
|
|
||||||
astrololo) i jest dawane najszerzej. Ma dwie funkcje i ma wyglądać, jakby to
|
|
||||||
było wszystko, co program potrafi.
|
|
||||||
|
|
||||||
Test nie sprawdza pojedynczych miejsc, tylko przechodzi WSZYSTKO, co da się
|
|
||||||
pobrać, i szuka słów, które nie mają prawa paść. Sprawdza też DRZEWO PLIKÓW
|
|
||||||
usługi — bo „nie ma śladu w kodzie" znaczy, że `grep` po obrazie ma nic nie
|
|
||||||
znaleźć, a nie tylko że odpowiedź jest czysta.
|
|
||||||
|
|
||||||
Powód, dla którego to powstało: komentarz w pierwszej linii styles.css mówił
|
|
||||||
„Nie kopiujemy stylów pełnej aplikacji", a arkusz pobierał anonim.
|
|
||||||
"""
|
|
||||||
import pathlib
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
|
||||||
|
|
||||||
# (a) że istnieje wersja obszerniejsza i jak się nazywa
|
|
||||||
WIEKSZY_BRAT = ["pełna aplikacja", "pełnej aplikacji", "pełna wersja", "pełnej wersji",
|
|
||||||
"astrololo", "astroklient", "wersji podstawowej", "upgrade", "rozszerzon"]
|
|
||||||
# (b) że tekst pisze model językowy
|
|
||||||
# Bez samego „token": w tej usłudze znaczy token sesji i token międzywarstwowy,
|
|
||||||
# a nie jednostkę rozliczeniową modelu. Zamiast tego zwroty jednoznaczne.
|
|
||||||
MODEL = ["LLM", "anthropic", "openai", "gpt", "claude", "prompt",
|
|
||||||
"generowanie tekstu", "sztuczn", "okno kontekstu", "model językow"]
|
|
||||||
# (c) funkcje, których astrodemo nie ma
|
|
||||||
INNE_FUNKCJE = ["kosmogram", "aspektarian", "synastri", "kalendarz", "predykcj",
|
|
||||||
"skompiluj", "system domów", "systemy domów", "deklinacj", "antyscj"]
|
|
||||||
# (d) że jest ktoś, kto widzi więcej, i że produkt jest w budowie
|
|
||||||
MECHANIZMY = ["administrator", "uprawnieni", "walidacj", "kwarantann", "roadmap",
|
|
||||||
"w budowie", "widok testowy", "warstwa logiczna", "layer"]
|
|
||||||
# Dwa poziomy, bo dwie różne drogi wycieku.
|
|
||||||
#
|
|
||||||
# WSZĘDZIE — także w kodzie serwera, którego przeglądarka nigdy nie zobaczy.
|
|
||||||
# Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie, przeczyta też
|
|
||||||
# komentarze i docstringi. Zdanie „w pełnej aplikacji to działa inaczej" mówi mu
|
|
||||||
# wprost, że istnieje pełna aplikacja — a to jest dokładnie to, czego ma nie
|
|
||||||
# wiedzieć. Komentarz ma tłumaczyć TĘ usługę, bez powoływania się na rodzeństwo.
|
|
||||||
WSZEDZIE = WIEKSZY_BRAT + MODEL + INNE_FUNKCJE
|
|
||||||
# TYLKO DO PRZEGLĄDARKI — słownictwo inżynierskie opisujące własne działanie tej
|
|
||||||
# usługi. W komentarzu serwera jest na miejscu; w odpowiedzi HTTP nie ma czego
|
|
||||||
# szukać, bo mówi o mechanizmach, o których użytkownik nie ma wiedzieć.
|
|
||||||
DO_PRZEGLADARKI = WSZEDZIE + MECHANIZMY
|
|
||||||
ZAKAZANE = DO_PRZEGLADARKI
|
|
||||||
WZORCE = [(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania"),
|
|
||||||
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
|
|
||||||
(r"(?i)\bAI\b", "model językowy")]
|
|
||||||
|
|
||||||
|
|
||||||
def _grzechy(tresc: str, gdzie: str, slownik: list[str] | None = None) -> list[str]:
|
|
||||||
out = []
|
|
||||||
for igla in (slownik if slownik is not None else ZAKAZANE):
|
|
||||||
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
|
|
||||||
out.append(f"{gdzie}: „{igla}”")
|
|
||||||
for wzor, powod in (WZORCE if slownik is None else WZORCE[-1:]):
|
|
||||||
m = re.search(wzor, tresc)
|
|
||||||
if m:
|
|
||||||
out.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def klient(monkeypatch):
|
|
||||||
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo")
|
|
||||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
|
||||||
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
|
||||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
return TestClient(app, follow_redirects=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_nothing_forbidden_reaches_an_anonymous_visitor(klient):
|
|
||||||
grzechy, obejrzane = [], 0
|
|
||||||
for adres in ("/logowanie", "/health", "/static/styles.css"):
|
|
||||||
r = klient.get(adres)
|
|
||||||
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
|
||||||
obejrzane += 1
|
|
||||||
grzechy += _grzechy(r.text, adres)
|
|
||||||
assert obejrzane == 3
|
|
||||||
assert not grzechy, "\n" + "\n".join(grzechy)
|
|
||||||
|
|
||||||
|
|
||||||
def test_nothing_forbidden_reaches_a_logged_in_visitor(klient, monkeypatch):
|
|
||||||
from app import security, session
|
|
||||||
|
|
||||||
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
|
|
||||||
# Warstwa logiczna niedostępna CELOWO: ekran idzie wtedy ścieżką błędu,
|
|
||||||
# a to komunikaty o awarii wypisywały nazwy warstw i tras.
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.main import LogicClient
|
|
||||||
|
|
||||||
monkeypatch.setattr(LogicClient, "report",
|
|
||||||
lambda *a, **k: (_ for _ in ()).throw(httpx.ConnectError("nie da rady")))
|
|
||||||
grzechy = []
|
|
||||||
for adres in ("/", "/static/styles.css"):
|
|
||||||
r = klient.get(adres, headers=naglowki)
|
|
||||||
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
|
||||||
grzechy += _grzechy(r.text, adres)
|
|
||||||
r = klient.post("/", headers=naglowki,
|
|
||||||
data={"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
|
||||||
"lat": "50.06", "lon": "19.94"})
|
|
||||||
grzechy += _grzechy(r.text, "POST /")
|
|
||||||
assert not grzechy, "\n" + "\n".join(grzechy)
|
|
||||||
|
|
||||||
|
|
||||||
# Stałe protokołu łącza (nagłówki, typ treści, etykieta HKDF) niosą nazwę rodziny
|
|
||||||
# produktów, ale są WSPÓLNE z warstwą logiczną: zmiana wymaga jednoczesnej zmiany
|
|
||||||
# we wszystkich usługach i rotacji, więc to osobna decyzja. Nie idą do przeglądarki.
|
|
||||||
BEZ_KONTROLI = {"app/link_crypto.py"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_source_tree_itself_carries_no_trace():
|
|
||||||
"""„Bez śladu w kodzie" znaczy: grep po obrazie ma nic nie znaleźć.
|
|
||||||
|
|
||||||
Odpowiedź może być czysta, a plik i tak nieść komentarz — dokładnie tak
|
|
||||||
wyciekał arkusz stylów, zanim komentarze zaczęły być zdejmowane."""
|
|
||||||
# Identyfikatory wymagań (PRE-28, LOG-34) zostają: to notatki inżynierskie
|
|
||||||
# o tej usłudze, nie informacja o istnieniu czegoś większego.
|
|
||||||
grzechy = []
|
|
||||||
for f in APP.rglob("*"):
|
|
||||||
wzgledna = str(f.relative_to(APP.parent))
|
|
||||||
if (not f.is_file() or "__pycache__" in str(f) or f.suffix == ".pyc"
|
|
||||||
or wzgledna in BEZ_KONTROLI):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
grzechy += _grzechy(f.read_text(encoding="utf-8"),
|
|
||||||
str(f.relative_to(APP.parent)), WSZEDZIE)
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
continue
|
|
||||||
assert not grzechy, "\n" + "\n".join(grzechy)
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_asset_is_indistinguishable_from_one_that_never_existed(klient):
|
|
||||||
from app import security, session
|
|
||||||
|
|
||||||
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
|
|
||||||
nieistniejacy = klient.get("/static/nie-ma-takiego.js", headers=naglowki)
|
|
||||||
for adres in ("/static/main.py", "/static/../app/config.py", "/static/vendor/cokolwiek.js"):
|
|
||||||
r = klient.get(adres, headers=naglowki)
|
|
||||||
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
|
|
||||||
assert r.text == nieistniejacy.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_protocol_constants_are_the_one_known_exception():
|
|
||||||
"""Świadomie zostawiona dziura — opisana, żeby nie była przypadkiem.
|
|
||||||
|
|
||||||
Stałe łącza międzywarstwowego (`X-Astrololo-Token`, `X-Astrololo-Enc`, typ
|
|
||||||
treści, etykieta HKDF) niosą nazwę rodziny produktów. Są WSPÓLNE z warstwą
|
|
||||||
logiczną, więc zmiana wymaga jednoczesnej podmiany we wszystkich usługach
|
|
||||||
i rotacji sekretów — to osobna decyzja, nie efekt uboczny tej zmiany.
|
|
||||||
|
|
||||||
Warunek, pod jakim to zostaje: te stałe nie mają prawa dotrzeć do
|
|
||||||
przeglądarki. Idą wyłącznie w nagłówkach żądań DO warstwy logicznej.
|
|
||||||
Ten test pilnuje właśnie tego warunku, a nie samego istnienia stałych.
|
|
||||||
"""
|
|
||||||
rdzen = "astrololo"
|
|
||||||
protokol = [f for f in APP.rglob("*.py")
|
|
||||||
if f.is_file() and rdzen in f.read_text(encoding="utf-8").lower()]
|
|
||||||
assert protokol, "stałe zniknęły — jeśli je przemianowano, ten wyjątek jest zbędny"
|
|
||||||
for f in protokol:
|
|
||||||
tresc = f.read_text(encoding="utf-8").lower()
|
|
||||||
for linia in tresc.splitlines():
|
|
||||||
if rdzen not in linia:
|
|
||||||
continue
|
|
||||||
# Każde wystąpienie musi być stałą protokołu albo nazwą kanału logów —
|
|
||||||
# nigdy tekstem, który mógłby trafić do szablonu.
|
|
||||||
assert any(z in linia for z in ("x-astrololo", "vnd.astrololo",
|
|
||||||
"astrololo/link/", 'getlogger("astrololo')), \
|
|
||||||
f"{f.name}: „{linia.strip()[:70]}” to nie jest stała protokołu"
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_application_code_ships_in_the_image():
|
|
||||||
"""Obraz nie może wieźć tego testu.
|
|
||||||
|
|
||||||
Ironia, którą łatwo przeoczyć: plik pilnujący, żeby pewne słowa nie padły,
|
|
||||||
sam je wypisuje — więc gdy trafi do obrazu, staje się tym wyciekiem, przed
|
|
||||||
którym broni. `COPY . .` wnosiło cały katalog usługi, razem z tests/.
|
|
||||||
"""
|
|
||||||
# Same instrukcje, bez komentarzy: komentarz obok tej linii CYTUJE dawną
|
|
||||||
# postać, więc szukanie po całym pliku zgłaszałoby własne wyjaśnienie.
|
|
||||||
instrukcje = [l for l in (APP.parent / "Dockerfile").read_text(encoding="utf-8").splitlines()
|
|
||||||
if l.strip() and not l.lstrip().startswith("#")]
|
|
||||||
assert not any(l.strip() == "COPY . ." for l in instrukcje), "obraz wnosi wszystko, w tym testy"
|
|
||||||
assert any(l.strip() == "COPY app ./app" for l in instrukcje)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY services/astroklient/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Rdzeń wspólny z pełną aplikacją: ekrany astrologiczne, szablony, zasoby.
|
|
||||||
# Jedno źródło, dwa produkty — inaczej te same 2500 linii szablonów żyłyby
|
|
||||||
# w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu.
|
|
||||||
COPY services/presentation/app ./app
|
|
||||||
|
|
||||||
# …minus wszystko, czego ten produkt nie ma. Lista jest DANYMI, a nie tekstem
|
|
||||||
# w tym pliku: czyta ją także test, który pilnuje, żeby zgadzała się ze
|
|
||||||
# złożeniem w main.py. Rozjazd między nimi znaczyłby albo martwy kod w obrazie,
|
|
||||||
# albo brakujący plik i błąd dopiero przy uruchomieniu.
|
|
||||||
COPY services/astroklient/usun.txt .
|
|
||||||
RUN while read -r SCIEZKA; do \
|
|
||||||
[ -n "$SCIEZKA" ] || continue; \
|
|
||||||
test -e "app/$SCIEZKA" || { echo "usun.txt wskazuje nieistniejące: $SCIEZKA"; exit 1; }; \
|
|
||||||
rm -rf "app/$SCIEZKA"; \
|
|
||||||
done < usun.txt && rm usun.txt
|
|
||||||
|
|
||||||
# Nakładka: własne logowanie, własna pula plików, własny ekran wgrywania,
|
|
||||||
# własne złożenie. Nadpisuje pliki o tych samych nazwach.
|
|
||||||
COPY services/astroklient/app ./app
|
|
||||||
|
|
||||||
EXPOSE 8006
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8006"]
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""Klient warstwy logicznej — z PULĄ KONTA (PRE-29).
|
|
||||||
|
|
||||||
Każde konto ma własny, niewidoczny dla pozostałych zbiór plików. Pula jedzie
|
|
||||||
w każdym żądaniu w dół i bierze się WYŁĄCZNIE z kontekstu ustawianego przy
|
|
||||||
wejściu, nigdy z pola formularza: gdyby przychodziła z żądania, wystarczyłoby
|
|
||||||
podstawić cudzy login, żeby czytać cudze pliki.
|
|
||||||
|
|
||||||
Kontekst zamiast argumentu, bo ekrany są wspólne z pełną aplikacją i wołają
|
|
||||||
klienta jako obiekt modułowy. Przekazywanie puli przez wszystkie wywołania
|
|
||||||
znaczyłoby, że pominięcie jednego z nich cicho otwiera dostęp do całego udziału —
|
|
||||||
a takiego pominięcia nie widać.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextvars
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
# Login zalogowanego konta na czas obsługi żądania. Ustawia go bramka wejścia.
|
|
||||||
KONTO: contextvars.ContextVar[str] = contextvars.ContextVar("konto", default="")
|
|
||||||
|
|
||||||
|
|
||||||
from app import link_crypto
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_headers() -> dict[str, str]:
|
|
||||||
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
|
|
||||||
token = os.getenv("INTERNAL_TOKEN", "")
|
|
||||||
return {"X-Astrololo-Token": token} if token else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _link() -> link_crypto.Link | None:
|
|
||||||
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
|
|
||||||
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
|
||||||
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
|
||||||
return link_crypto.Link(key) if key else None
|
|
||||||
|
|
||||||
|
|
||||||
class LogicClient:
|
|
||||||
def __init__(self, base_url: str | None = None) -> None:
|
|
||||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
|
||||||
|
|
||||||
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
||||||
# Pula konta dokładana do KAŻDEGO żądania w dół. Bierze się z kontekstu
|
|
||||||
# ustawianego przy wejściu, nigdy z pola formularza — gdyby przychodziła
|
|
||||||
# z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze pliki.
|
|
||||||
# Trasy, których pula nie dotyczy, po prostu ignorują nadmiarowe pole.
|
|
||||||
payload = {**payload, "tenant": KONTO.get("")}
|
|
||||||
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
|
|
||||||
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
|
|
||||||
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
|
|
||||||
with httpx.Client(timeout=timeout) as client:
|
|
||||||
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
|
||||||
payload=payload, headers=_auth_headers(),
|
|
||||||
link=_link())
|
|
||||||
|
|
||||||
# ── transport dla modułów opcjonalnych ─────────────────────────────────
|
|
||||||
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
|
|
||||||
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
|
|
||||||
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
|
|
||||||
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
|
|
||||||
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
||||||
return self._post(sciezka, payload, timeout)
|
|
||||||
|
|
||||||
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
|
|
||||||
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
|
|
||||||
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
|
|
||||||
headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
def strumien(self, sciezka: str, payload: dict[str, Any]):
|
|
||||||
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
|
|
||||||
|
|
||||||
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
|
|
||||||
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
|
|
||||||
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
|
||||||
yield from link_crypto.stream_lines(
|
|
||||||
client, f"{self.base_url}{sciezka}",
|
|
||||||
payload=payload, headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
|
||||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
|
||||||
return self._post("/api/query", payload, settings.http_timeout)
|
|
||||||
|
|
||||||
def positions(
|
|
||||||
self,
|
|
||||||
when_utc_iso: str,
|
|
||||||
lat: float,
|
|
||||||
lon: float,
|
|
||||||
objects: list[str] | None = None,
|
|
||||||
house_system: str = "whole_sign",
|
|
||||||
stations: bool = False,
|
|
||||||
zodiac: str = "tropical",
|
|
||||||
tables: bool = False,
|
|
||||||
house_systems: list[str] | None = None,
|
|
||||||
aspect_orb: float = 8.0,
|
|
||||||
aspect_luminary_bonus: float = 2.0,
|
|
||||||
aspect_minor: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
|
||||||
payload = {
|
|
||||||
"when_utc": when_utc_iso,
|
|
||||||
"lat": lat,
|
|
||||||
"lon": lon,
|
|
||||||
"objects": objects,
|
|
||||||
"house_system": house_system,
|
|
||||||
"house_systems": house_systems,
|
|
||||||
"aspect_orb": aspect_orb,
|
|
||||||
"aspect_luminary_bonus": aspect_luminary_bonus,
|
|
||||||
"aspect_minor": aspect_minor,
|
|
||||||
"stations": stations,
|
|
||||||
"zodiac": zodiac,
|
|
||||||
"tables": tables,
|
|
||||||
}
|
|
||||||
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
|
|
||||||
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
|
||||||
return self._post("/chart/positions", payload, timeout)
|
|
||||||
|
|
||||||
def synastry(
|
|
||||||
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
|
|
||||||
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
|
|
||||||
payload = {
|
|
||||||
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
|
|
||||||
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
|
|
||||||
"aspect_minor": aspect_minor,
|
|
||||||
}
|
|
||||||
return self._post("/chart/synastry", payload, settings.http_timeout)
|
|
||||||
|
|
||||||
def report(
|
|
||||||
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
|
||||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
|
||||||
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
|
||||||
|
|
||||||
def bases(self) -> dict[str, Any]:
|
|
||||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
||||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
|
||||||
headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
|
|
||||||
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
|
|
||||||
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
|
|
||||||
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
|
|
||||||
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
|
|
||||||
|
|
||||||
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
|
||||||
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
|
|
||||||
|
|
||||||
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
|
||||||
return self._files_post("/files/upload",
|
|
||||||
{"filename": filename, "content_b64": content_b64, "by": by})
|
|
||||||
|
|
||||||
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
|
||||||
payload = {**payload, "tenant": KONTO.get("")}
|
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
||||||
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
|
||||||
payload=payload, headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
def timeline(
|
|
||||||
self, when_utc_iso: str, lat: float, lon: float,
|
|
||||||
from_date: str, to_date: str, interpret: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
|
|
||||||
payload = {
|
|
||||||
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
|
||||||
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
|
||||||
}
|
|
||||||
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Wgrywanie plików: jedna czynność, nie dwie.
|
|
||||||
|
|
||||||
„Dodać plik" ma tu znaczyć, że od razu bierze udział w wyszukiwaniu — inaczej po
|
|
||||||
wgraniu nic by się nie zmieniło i wyglądałoby to na zepsute. Nie ma listy plików
|
|
||||||
ani ich stanów: ta usługa ma jeden poziom dostępu, więc nie ma komu ich
|
|
||||||
pokazywać ani po co.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import File, Request, UploadFile
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
|
|
||||||
from app.features import Feature
|
|
||||||
from app.podstawa import app, features, logic, templates, _logic_error
|
|
||||||
|
|
||||||
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
|
|
||||||
# tutaj wprost, bo ta usługa tamtej warstwy nie importuje; literówka objawiłaby
|
|
||||||
# się dopiero na żywo, więc pilnuje jej test.
|
|
||||||
W_UZYCIU = "active"
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/pliki", response_class=HTMLResponse)
|
|
||||||
def wgrywanie_form(request: Request, error: str = "", done: str = ""):
|
|
||||||
return templates.TemplateResponse(request, "wgrywanie.html",
|
|
||||||
{"error": error, "done": done})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/pliki/wgraj")
|
|
||||||
async def wgraj(request: Request, upload: UploadFile = File(...)):
|
|
||||||
"""Wgranie ORAZ włączenie do użytku.
|
|
||||||
|
|
||||||
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
|
|
||||||
zapisany i czeka. Komunikat nie mówi, CZYM się nie nadaje: różnica w treści
|
|
||||||
byłaby wyrocznią, z której da się odczytać, czego program oczekuje.
|
|
||||||
"""
|
|
||||||
raw = await upload.read()
|
|
||||||
if not raw:
|
|
||||||
return _wroc(error="Pusty plik.")
|
|
||||||
|
|
||||||
kto = getattr(request.state, "user", "") or "?"
|
|
||||||
try:
|
|
||||||
out = logic.files_upload(upload.filename or "dane.xlsx",
|
|
||||||
base64.b64encode(raw).decode("ascii"), by=kto)
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
return _wroc(error=_logic_error(e))
|
|
||||||
|
|
||||||
nazwa = out.get("name") or "plik"
|
|
||||||
if not out.get("accepted"):
|
|
||||||
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
|
|
||||||
f"do użycia w tej postaci.")
|
|
||||||
try:
|
|
||||||
logic.files_status(out["path"], W_UZYCIU, by=kto)
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć.")
|
|
||||||
return _wroc(done=f"Dodano „{nazwa}”. Plik jest już używany przy wyszukiwaniu.")
|
|
||||||
|
|
||||||
|
|
||||||
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
|
|
||||||
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
|
||||||
wgrać pliku drugi raz."""
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
|
||||||
return RedirectResponse(f"/pliki{'?' + q if q else ''}", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
features.zarejestruj(
|
|
||||||
ekran=Feature("files", "Pliki", "Dodawanie plików używanych przy wyszukiwaniu.",
|
|
||||||
"Ekrany", "/pliki", kolejnosc=70),
|
|
||||||
trasy={("GET", "/pliki"): "files", ("POST", "/pliki/wgraj"): "files"},
|
|
||||||
zasoby={"s-wgrywanie.css": "files"},
|
|
||||||
)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""Astroklient — ZŁOŻENIE produktu.
|
|
||||||
|
|
||||||
Pełne funkcje astrologiczne, bez generowania tekstu i bez administracji. Ten plik
|
|
||||||
nie zawiera żadnej trasy: odpowiada wyłącznie na pytanie, z czego składa się ten
|
|
||||||
produkt. Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją.
|
|
||||||
|
|
||||||
Poza tą listą nie ma nic. Nie chodzi o funkcje wyłączone: plików, których tu nie
|
|
||||||
widać, nie ma w obrazie (patrz usun.txt), więc nie istnieje uprawnienie, którym
|
|
||||||
dałoby się cokolwiek odsłonić — ani nazwa, po której dałoby się zgadnąć, czego
|
|
||||||
szukać.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app import rozszerzenia
|
|
||||||
from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error, app,
|
|
||||||
logic, static_url, templates)
|
|
||||||
from app.ekrany import ( # noqa: F401 - import rejestruje trasy
|
|
||||||
horoskop,
|
|
||||||
interpretacje,
|
|
||||||
kalendarz,
|
|
||||||
logowanie,
|
|
||||||
lokalizacja,
|
|
||||||
sygnifikatory,
|
|
||||||
synastria,
|
|
||||||
wgrywanie,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
|
|
||||||
|
|
||||||
# Most modułów opcjonalnych zostaje CELOWO. W tym obrazie nie ma katalogu
|
|
||||||
# `app/moduly/`, więc odkrywa zero modułów i wszystkie odpowiedzi są puste —
|
|
||||||
# aplikacja zachowuje się tak, jakby takiego pojęcia nigdy nie było.
|
|
||||||
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
|
|
||||||
buduj_utc=_build_utc, blad=_logic_error)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Nazwa produktu — nakładka astroklienta.
|
|
||||||
|
|
||||||
Nadpisuje wersję z rdzenia. Test słownikowy pilnuje, żeby po nadpisaniu nazwa
|
|
||||||
tamtego produktu nie została nigdzie w szablonach, zasobach ani w odpowiedziach.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
NAZWA = "astroklient"
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
"""Logowanie i bramka uprawnień astroklienta.
|
|
||||||
|
|
||||||
Konta pochodzą WYŁĄCZNIE z konfiguracji środowiska (`ASTROKLIENT_USERS`), a każde
|
|
||||||
dostaje ten sam zestaw: wszystko, co ta usługa umie. Konta rozdziela się po to,
|
|
||||||
żeby każde miało własną, niewidoczną dla pozostałych pulę plików — nie po to, żeby
|
|
||||||
dawać im różne poziomy dostępu. Poziom jest jeden i nie ma go z czym porównywać.
|
|
||||||
|
|
||||||
Reszta jest wspólna z pozostałymi usługami i celowo nietknięta: hasła jako scrypt,
|
|
||||||
sesja w podpisanym ciasteczku, limit żądań, dziennik audytowy, oraz odmowa jako
|
|
||||||
404 zamiast 403 — bo 403 samo w sobie mówi, że coś tam jest.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
||||||
|
|
||||||
log = logging.getLogger("astrololo.security")
|
|
||||||
|
|
||||||
MAX_TRACKED_CLIENTS_DEFAULT = 4096
|
|
||||||
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
|
|
||||||
|
|
||||||
|
|
||||||
# ── konta ─────────────────────────────────────────────────────────────────
|
|
||||||
# Wyłącznie z konfiguracji środowiska. Konta rozdziela się po to, żeby każde
|
|
||||||
# miało własną pulę plików — nie po to, żeby różniły się tym, co widzą.
|
|
||||||
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
|
|
||||||
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
|
|
||||||
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
|
|
||||||
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
|
||||||
_HASH_PREFIX = "scrypt$"
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str, salt: bytes | None = None) -> str:
|
|
||||||
"""Hasło → wpis do ASTROKLIENT_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
|
|
||||||
salt = salt or secrets.token_bytes(16)
|
|
||||||
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
|
||||||
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
|
||||||
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
|
|
||||||
|
|
||||||
|
|
||||||
def accounts() -> dict[str, str]:
|
|
||||||
"""Konta z `ASTROKLIENT_USERS` — `login:sekret`, po przecinku.
|
|
||||||
|
|
||||||
Sekret to hash scrypt (`scrypt$sól$hash`), ten sam format co w pozostałych
|
|
||||||
usługach — jedna implementacja, więc nie ma czego rozjechać."""
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for entry in os.getenv("ASTROKLIENT_USERS", "").split(","):
|
|
||||||
entry = entry.strip()
|
|
||||||
if not entry:
|
|
||||||
continue
|
|
||||||
user, sep, secret = entry.partition(":")
|
|
||||||
if sep and user.strip() and secret.strip():
|
|
||||||
out[user.strip()] = secret.strip()
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _secret_matches(spec: str, password: str) -> bool:
|
|
||||||
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
|
|
||||||
if spec.startswith(_HASH_PREFIX):
|
|
||||||
try:
|
|
||||||
_, salt_hex, hash_hex = spec.split("$", 2)
|
|
||||||
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
|
||||||
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
|
||||||
return hmac.compare_digest(actual, expected)
|
|
||||||
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
|
|
||||||
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
|
|
||||||
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
|
|
||||||
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def rate_limit_per_min() -> int:
|
|
||||||
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
|
||||||
|
|
||||||
|
|
||||||
def cookies_secure() -> bool:
|
|
||||||
"""Czy ciasteczko sesji ma być wyłącznie dla https.
|
|
||||||
|
|
||||||
Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej
|
|
||||||
przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa"
|
|
||||||
bez żadnego komunikatu, co jest wyjątkowo mylącą awarią."""
|
|
||||||
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def trust_proxy() -> bool:
|
|
||||||
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
|
|
||||||
# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego
|
|
||||||
# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym
|
|
||||||
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
|
|
||||||
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
|
|
||||||
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
|
|
||||||
# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
|
|
||||||
# jedynym publicznym zasobem — i dlatego nie wolno w nim trzymać niczego, co
|
|
||||||
# nazywa funkcję (patrz features.STATIC).
|
|
||||||
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/base.css"})
|
|
||||||
# Pusto CELOWO. Dopóki był tu "/static/", cały katalog omijał bramkę: każdy plik
|
|
||||||
# JS i arkusz stylów pobierał ktokolwiek, także niezalogowany, pod zgadywalnym
|
|
||||||
# adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie baz.
|
|
||||||
# Ruch do zasobów nie trafiał przy tym ani do dziennika, ani pod limit żądań,
|
|
||||||
# więc wyciek był dla właściciela niewidoczny.
|
|
||||||
PUBLIC_PREFIXES: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
_hits: dict[str, deque[float]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def auth_enabled() -> bool:
|
|
||||||
return bool(accounts())
|
|
||||||
|
|
||||||
|
|
||||||
def _is_public(path: str) -> bool:
|
|
||||||
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Principal:
|
|
||||||
"""Kto się zalogował i co wolno mu zobaczyć."""
|
|
||||||
|
|
||||||
login: str
|
|
||||||
permissions: frozenset[str]
|
|
||||||
is_admin: bool
|
|
||||||
|
|
||||||
|
|
||||||
def _uprawnienia() -> frozenset[str]:
|
|
||||||
"""Każde konto dostaje wszystko, co ta usługa umie — liczone z KATALOGU.
|
|
||||||
|
|
||||||
Katalog składa się ze zgłoszeń ekranów obecnych w obrazie, więc ten zbiór
|
|
||||||
opisuje dokładnie ten produkt, a nie listę wpisaną kiedyś ręcznie. Dopisanie
|
|
||||||
ekranu nie wymaga pamiętania o niczym; usunięcie go zabiera uprawnienie samo.
|
|
||||||
|
|
||||||
Nie ma tu uprawnienia administracyjnego i nie ma go skąd wziąć: `features.ADMIN`
|
|
||||||
celowo nie należy do GRANTABLE."""
|
|
||||||
from app import features
|
|
||||||
|
|
||||||
return frozenset(features.GRANTABLE)
|
|
||||||
|
|
||||||
|
|
||||||
def _sekret(login: str) -> str | None:
|
|
||||||
return accounts().get(login)
|
|
||||||
|
|
||||||
|
|
||||||
def verify(login: str, password: str) -> Principal | None:
|
|
||||||
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
|
|
||||||
|
|
||||||
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
|
|
||||||
konta istnieją, a to informacja warta czegoś dla zgadującego."""
|
|
||||||
spec = _sekret(login)
|
|
||||||
if spec is None or not _secret_matches(spec, password):
|
|
||||||
return None
|
|
||||||
return Principal(login, _uprawnienia(), False)
|
|
||||||
|
|
||||||
|
|
||||||
def issue_session(login: str) -> str:
|
|
||||||
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
|
||||||
|
|
||||||
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
|
|
||||||
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
|
|
||||||
i bez żadnego magazynu sesji."""
|
|
||||||
from app import session
|
|
||||||
|
|
||||||
# Wersja sesji zawsze 0: nie ma trwałego magazynu, więc nie ma gdzie trzymać
|
|
||||||
# licznika pokolenia. Wylogowanie i zmiana hasła i tak działają — pierwsze
|
|
||||||
# przez skasowanie ciasteczka, drugie przez odcisk, który przestaje pasować.
|
|
||||||
return session.issue(login, session.fingerprint(_sekret(login) or ""), 0)
|
|
||||||
|
|
||||||
|
|
||||||
def principal(request: Request) -> Principal | None:
|
|
||||||
"""Ciasteczko → kto to jest. None = nie wpuszczać."""
|
|
||||||
from app import session
|
|
||||||
|
|
||||||
dane = session.read(request.cookies.get(session.COOKIE))
|
|
||||||
if dane is None:
|
|
||||||
return None
|
|
||||||
spec = _sekret(dane["login"])
|
|
||||||
if spec is None:
|
|
||||||
return None
|
|
||||||
# Zmiana hasła w konfiguracji ubija sesje tego konta — to jest tutaj jedyny
|
|
||||||
# sposób na „wyloguj mnie ze wszystkich urządzeń", bo bez trwałego magazynu
|
|
||||||
# nie ma gdzie trzymać licznika pokolenia sesji.
|
|
||||||
if session.fingerprint(spec) != dane["odcisk"]:
|
|
||||||
return None
|
|
||||||
return Principal(dane["login"], _uprawnienia(), False)
|
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str:
|
|
||||||
"""Adres, po którym rozliczamy limit żądań.
|
|
||||||
|
|
||||||
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
|
|
||||||
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
|
|
||||||
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
|
|
||||||
wyklika limit, odcięłaby pozostałe.
|
|
||||||
|
|
||||||
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
|
|
||||||
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
|
|
||||||
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
|
|
||||||
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
|
|
||||||
klient, więc nie znaczą nic.
|
|
||||||
"""
|
|
||||||
peer = request.client.host if request.client else "?"
|
|
||||||
if not trust_proxy():
|
|
||||||
return peer
|
|
||||||
forwarded = request.headers.get("x-forwarded-for", "")
|
|
||||||
if forwarded:
|
|
||||||
last = forwarded.rsplit(",", 1)[-1].strip()
|
|
||||||
if last:
|
|
||||||
return last
|
|
||||||
return request.headers.get("x-real-ip", "").strip() or peer
|
|
||||||
|
|
||||||
|
|
||||||
def _rate_limited(client: str) -> bool:
|
|
||||||
cap = rate_limit_per_min()
|
|
||||||
if cap <= 0:
|
|
||||||
return False
|
|
||||||
now = time.monotonic()
|
|
||||||
window = _hits.get(client)
|
|
||||||
if window is None:
|
|
||||||
if len(_hits) >= MAX_TRACKED_CLIENTS:
|
|
||||||
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
|
|
||||||
window = _hits[client] = deque()
|
|
||||||
while window and now - window[0] > 60.0:
|
|
||||||
window.popleft()
|
|
||||||
if len(window) >= cap:
|
|
||||||
return True
|
|
||||||
window.append(now)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
|
|
||||||
audit_log = logging.getLogger("astrololo.audit")
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_audit_logging() -> None:
|
|
||||||
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
|
|
||||||
|
|
||||||
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
|
|
||||||
poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone
|
|
||||||
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
|
|
||||||
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
|
|
||||||
są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń.
|
|
||||||
"""
|
|
||||||
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
|
|
||||||
if not audit_log.handlers:
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
||||||
audit_log.addHandler(handler)
|
|
||||||
audit_log.propagate = False
|
|
||||||
|
|
||||||
|
|
||||||
def audit_records(request: Request, count: int) -> None:
|
|
||||||
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
|
|
||||||
|
|
||||||
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził",
|
|
||||||
a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
|
|
||||||
try:
|
|
||||||
request.state.audit_records = int(count)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def audit_login(request: Request, login: str, udane: bool) -> None:
|
|
||||||
"""Zdarzenie logowania w dzienniku (PRE-17).
|
|
||||||
|
|
||||||
Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale
|
|
||||||
seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy
|
|
||||||
LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku."""
|
|
||||||
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
|
|
||||||
"logowanie-ok" if udane else "logowanie-odmowa",
|
|
||||||
login or "-", client_ip(request))
|
|
||||||
|
|
||||||
|
|
||||||
def audit_logout(request: Request) -> None:
|
|
||||||
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
|
|
||||||
getattr(request.state, "user", "") or "-", client_ip(request))
|
|
||||||
|
|
||||||
|
|
||||||
def _audit(request: Request, user: str, status: int, ms: float) -> None:
|
|
||||||
records = getattr(request.state, "audit_records", None)
|
|
||||||
audit_log.info(
|
|
||||||
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
|
||||||
user or "-", client_ip(request), request.method, request.url.path,
|
|
||||||
status, "-" if records is None else records, ms,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def install(app) -> None:
|
|
||||||
"""Podpina ochronę pod wszystkie ścieżki poza publicznymi."""
|
|
||||||
from app import session
|
|
||||||
|
|
||||||
_setup_audit_logging()
|
|
||||||
|
|
||||||
# FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić
|
|
||||||
# ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała —
|
|
||||||
# i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka.
|
|
||||||
if auth_enabled() and not session.available():
|
|
||||||
raise session.SessionError(
|
|
||||||
"Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
|
||||||
"podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. "
|
|
||||||
"Wygeneruj: openssl rand -hex 32")
|
|
||||||
|
|
||||||
if not auth_enabled():
|
|
||||||
log.warning(
|
|
||||||
"UWAGA: brak kont (ASTROKLIENT_USERS) — usługa jest OTWARTA dla "
|
|
||||||
"każdego, kto ma do niej dostęp sieciowy, wraz z treścią plików."
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def _guard(request: Request, call_next):
|
|
||||||
if _is_public(request.url.path):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
started = time.monotonic()
|
|
||||||
if _rate_limited(client_ip(request)):
|
|
||||||
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
|
|
||||||
return JSONResponse(
|
|
||||||
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
|
||||||
status_code=429, headers={"Retry-After": "60"},
|
|
||||||
)
|
|
||||||
|
|
||||||
from app import features
|
|
||||||
|
|
||||||
user, who, odswiez = "", None, None
|
|
||||||
if auth_enabled():
|
|
||||||
who = principal(request)
|
|
||||||
if who is None:
|
|
||||||
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
|
|
||||||
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
|
|
||||||
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
|
|
||||||
# Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce.
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
cel = request.url.path
|
|
||||||
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
|
|
||||||
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
|
|
||||||
user = who.login
|
|
||||||
# Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy
|
|
||||||
# znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy
|
|
||||||
# każdym obrazku i arkuszu stylów.
|
|
||||||
from app import session as _s
|
|
||||||
|
|
||||||
dane = _s.read(request.cookies.get(_s.COOKIE))
|
|
||||||
odswiez = _s.refresh(dane) if dane else None
|
|
||||||
else:
|
|
||||||
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
|
|
||||||
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
|
|
||||||
who = Principal("", _uprawnienia(), False)
|
|
||||||
|
|
||||||
request.state.user = user
|
|
||||||
request.state.principal = who
|
|
||||||
# Pula plików na czas obsługi tego żądania. Ustawiana TU, w jednym
|
|
||||||
# miejscu, zaraz po ustaleniu kto to jest — klient czyta ją z kontekstu,
|
|
||||||
# więc żadne wywołanie w dół nie może o niej zapomnieć.
|
|
||||||
from app.clients.logic_client import KONTO
|
|
||||||
|
|
||||||
KONTO.set(user)
|
|
||||||
|
|
||||||
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
|
|
||||||
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
|
|
||||||
need = features.required(request.method, request.url.path)
|
|
||||||
if not features.can(who.permissions, need):
|
|
||||||
_audit(request, user, 404, (time.monotonic() - started) * 1000)
|
|
||||||
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
|
||||||
response = await call_next(request)
|
|
||||||
if odswiez:
|
|
||||||
from app import session as _s
|
|
||||||
|
|
||||||
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
|
|
||||||
**_s.cookie_params(secure=cookies_secure()))
|
|
||||||
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
|
||||||
return response
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
"""Sesje logowania — podpisane ciasteczko (LOG-34).
|
|
||||||
|
|
||||||
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
|
|
||||||
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
|
|
||||||
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
|
|
||||||
w środku, i może tę decyzję cofnąć.
|
|
||||||
|
|
||||||
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
|
|
||||||
|
|
||||||
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
|
|
||||||
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
|
|
||||||
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
|
|
||||||
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
|
|
||||||
o tym — i też nie wymaga magazynu.
|
|
||||||
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
|
|
||||||
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
|
|
||||||
nie poda licznika, poziomy 1 i 2 nadal działają.
|
|
||||||
|
|
||||||
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
|
|
||||||
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
|
|
||||||
dostępu nie odbierałoby dostępu.
|
|
||||||
|
|
||||||
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
|
|
||||||
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
|
|
||||||
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
|
|
||||||
|
|
||||||
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
|
|
||||||
w PRE-27 i AES w PRE-16.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
|
|
||||||
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
|
|
||||||
# z jednej, logując się do drugiej.
|
|
||||||
COOKIE = "astroklient_sesja"
|
|
||||||
|
|
||||||
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
|
|
||||||
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
|
|
||||||
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
|
|
||||||
|
|
||||||
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
|
|
||||||
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
|
|
||||||
REFRESH_AFTER = 300
|
|
||||||
|
|
||||||
|
|
||||||
class SessionError(RuntimeError):
|
|
||||||
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
|
|
||||||
|
|
||||||
|
|
||||||
def _env_int(name: str, default: int) -> int:
|
|
||||||
try:
|
|
||||||
return int(os.getenv(name, "") or default)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def max_age() -> int:
|
|
||||||
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
|
|
||||||
|
|
||||||
|
|
||||||
def idle_timeout() -> int:
|
|
||||||
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
|
|
||||||
|
|
||||||
|
|
||||||
def secret() -> bytes:
|
|
||||||
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
|
|
||||||
|
|
||||||
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
|
|
||||||
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
|
|
||||||
raw = os.getenv("SESSION_SECRET", "").strip()
|
|
||||||
if not raw:
|
|
||||||
raise SessionError(
|
|
||||||
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
|
|
||||||
"Wygeneruj: openssl rand -hex 32")
|
|
||||||
if len(raw) < 32:
|
|
||||||
raise SessionError(
|
|
||||||
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
|
|
||||||
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
|
|
||||||
return raw.encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def available() -> bool:
|
|
||||||
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
|
|
||||||
try:
|
|
||||||
secret()
|
|
||||||
except SessionError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(secret_spec: str) -> str:
|
|
||||||
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
|
|
||||||
|
|
||||||
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
|
|
||||||
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
|
|
||||||
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
|
|
||||||
unieważniły jego sesje same z siebie."""
|
|
||||||
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def _sign(payload: bytes) -> bytes:
|
|
||||||
return hmac.new(secret(), payload, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _b64(raw: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
||||||
|
|
||||||
|
|
||||||
def _unb64(text: str) -> bytes:
|
|
||||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
||||||
|
|
||||||
|
|
||||||
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
|
|
||||||
"""Nowe ciasteczko sesji dla konta."""
|
|
||||||
teraz = int(now if now is not None else time.time())
|
|
||||||
payload = json.dumps(
|
|
||||||
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
|
|
||||||
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
||||||
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
|
||||||
|
|
||||||
|
|
||||||
def read(token: str | None, *, now: float | None = None) -> dict | None:
|
|
||||||
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
|
|
||||||
|
|
||||||
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
|
|
||||||
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
|
|
||||||
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
|
|
||||||
if not token or "." not in token:
|
|
||||||
return None
|
|
||||||
surowy, _, podpis = token.rpartition(".")
|
|
||||||
try:
|
|
||||||
payload = _unb64(surowy)
|
|
||||||
oczekiwany = _sign(payload)
|
|
||||||
podany = _unb64(podpis)
|
|
||||||
except (ValueError, SessionError):
|
|
||||||
return None
|
|
||||||
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
|
|
||||||
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
|
|
||||||
if not hmac.compare_digest(oczekiwany, podany):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
dane = json.loads(payload.decode("utf-8"))
|
|
||||||
login, odcisk = str(dane["u"]), str(dane["f"])
|
|
||||||
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
|
|
||||||
except (ValueError, KeyError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
teraz = int(now if now is not None else time.time())
|
|
||||||
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
|
|
||||||
# w obu wypadkach nie ma powodu mu ufać.
|
|
||||||
if wyd > teraz + 60:
|
|
||||||
return None
|
|
||||||
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
|
|
||||||
return None
|
|
||||||
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
|
|
||||||
|
|
||||||
|
|
||||||
def refresh(dane: dict, *, now: float | None = None) -> str | None:
|
|
||||||
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
|
|
||||||
|
|
||||||
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
|
|
||||||
Set-Cookie — także po arkusze stylów i obrazki."""
|
|
||||||
teraz = int(now if now is not None else time.time())
|
|
||||||
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
|
|
||||||
return None
|
|
||||||
payload = json.dumps(
|
|
||||||
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
|
|
||||||
"wyd": dane["wydano"], "akt": teraz},
|
|
||||||
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
||||||
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
|
||||||
|
|
||||||
|
|
||||||
def cookie_params(secure: bool = True) -> dict:
|
|
||||||
"""Parametry ciasteczka wspólne dla ustawiania i kasowania.
|
|
||||||
|
|
||||||
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
|
|
||||||
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
|
|
||||||
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
|
|
||||||
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
|
|
||||||
|
|
||||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
|
||||||
padding: 1rem 1.1rem; margin-top: .4rem; }
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% set nav_active = "files" %}
|
|
||||||
{% block title %}Pliki{% endblock %}
|
|
||||||
|
|
||||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-wgrywanie.css') }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{# Opis czysto operacyjny: co robi wgranie, i nic o tym, do CZEGO te dane służą
|
|
||||||
ani co się z nimi dzieje dalej. #}
|
|
||||||
<p class="muted">Wgrany plik jest od razu używany przy wyszukiwaniu.</p>
|
|
||||||
|
|
||||||
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
|
|
||||||
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
|
||||||
|
|
||||||
<div class="meta">Wgraj plik</div>
|
|
||||||
<form method="post" action="/pliki/wgraj" enctype="multipart/form-data" class="card">
|
|
||||||
<div class="grid">
|
|
||||||
<label>Plik <input type="file" name="upload" required></label>
|
|
||||||
</div>
|
|
||||||
<div class="actions"><button type="submit">Wgraj</button></div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-r requirements.txt
|
|
||||||
pytest>=8.0
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
fastapi>=0.115
|
|
||||||
uvicorn[standard]>=0.34
|
|
||||||
httpx>=0.28
|
|
||||||
jinja2>=3.1
|
|
||||||
python-multipart>=0.0.20
|
|
||||||
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
|
||||||
cryptography>=44.0
|
|
||||||
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
|
|
||||||
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
|
|
||||||
# bez systemowej bazy stref.
|
|
||||||
tzfpy>=0.15
|
|
||||||
tzdata>=2024.1
|
|
||||||
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
|
|
||||||
openpyxl>=3.1
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Drzewo astroklienta budowane tak samo, jak buduje je Dockerfile.
|
|
||||||
|
|
||||||
Ta usługa nie ma własnego kompletu plików: bierze rdzeń z warstwy prezentacji,
|
|
||||||
usuwa z niego to, czego ten produkt nie ma (usun.txt), i nakłada własne. Testy
|
|
||||||
muszą patrzeć na WYNIK tego złożenia, a nie na katalog `app/` w repozytorium —
|
|
||||||
inaczej sprawdzałyby garść plików, a nie produkt.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
USLUGA = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
RDZEN = USLUGA.parents[0] / "presentation" / "app"
|
|
||||||
|
|
||||||
|
|
||||||
def zloz(cel: pathlib.Path) -> pathlib.Path:
|
|
||||||
"""Rdzeń minus usun.txt plus nakładka — dokładnie jak w Dockerfile."""
|
|
||||||
app = cel / "app"
|
|
||||||
shutil.copytree(RDZEN, app, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
||||||
for linia in (USLUGA / "usun.txt").read_text(encoding="utf-8").splitlines():
|
|
||||||
sciezka = linia.strip()
|
|
||||||
if not sciezka:
|
|
||||||
continue
|
|
||||||
do_usuniecia = app / sciezka
|
|
||||||
assert do_usuniecia.exists(), f"usun.txt wskazuje nieistniejące: {sciezka}"
|
|
||||||
shutil.rmtree(do_usuniecia) if do_usuniecia.is_dir() else do_usuniecia.unlink()
|
|
||||||
shutil.copytree(USLUGA / "app", app, dirs_exist_ok=True,
|
|
||||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
||||||
return cel
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def drzewo(tmp_path_factory) -> pathlib.Path:
|
|
||||||
return zloz(tmp_path_factory.mktemp("astroklient"))
|
|
||||||
|
|
||||||
|
|
||||||
SONDA = '''
|
|
||||||
import json, os, hashlib, secrets, re
|
|
||||||
sol = secrets.token_bytes(16)
|
|
||||||
dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32)
|
|
||||||
os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()),
|
|
||||||
SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0")
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
from app.main import app
|
|
||||||
from app import security, session, features
|
|
||||||
from app.clients import logic_client
|
|
||||||
c = TestClient(app, follow_redirects=False)
|
|
||||||
H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"}
|
|
||||||
strona = c.get("/", headers=H).text
|
|
||||||
zebrane = []
|
|
||||||
logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1]
|
|
||||||
c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20",
|
|
||||||
"tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"})
|
|
||||||
print("WYNIK:" + json.dumps({
|
|
||||||
"ekrany": [f.label for f in features.SCREENS],
|
|
||||||
"uprawnienia": sorted(security._uprawnienia()),
|
|
||||||
"dziala": {a: c.get(a, headers=H).status_code
|
|
||||||
for a in ("/", "/interpret", "/timeline", "/synastry",
|
|
||||||
"/significators", "/pliki")},
|
|
||||||
"nie_ma": {a: c.get(a, headers=H).status_code
|
|
||||||
for a in ("/compile", "/settings", "/accounts", "/files",
|
|
||||||
"/horoscope/stream", "/compile/pdf")},
|
|
||||||
"nawigacja": re.findall(r"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'),
|
|
||||||
"pula": (zebrane[0] or {}).get("tenant"),
|
|
||||||
"nazwa_w_naglowku": (re.search(r"<h1>(.*?)</h1>", strona) or [None, "?"])[1],
|
|
||||||
"nazwa_w_tytule": (re.search(r"<title>(.*?) ·", strona) or [None, "?"])[1],
|
|
||||||
"rdzen_w_tresci": "astrololo" in strona.lower(),
|
|
||||||
"dokumentacja": {a: c.get(a, headers=H).status_code
|
|
||||||
for a in ("/docs", "/openapi.json", "/redoc")},
|
|
||||||
"logowanie_dziala": bool(security.verify("klient", "tajne")),
|
|
||||||
"zle_haslo_odmowa": security.verify("klient", "inne") is None,
|
|
||||||
}))
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def wynik(drzewo):
|
|
||||||
"""Aplikacja uruchomiona w OSOBNYM PROCESIE.
|
|
||||||
|
|
||||||
Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez
|
|
||||||
modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie
|
|
||||||
pozytywny."""
|
|
||||||
r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True,
|
|
||||||
text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"})
|
|
||||||
linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None)
|
|
||||||
assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}"
|
|
||||||
return json.loads(linia[len("WYNIK:"):])
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Po czym astroklienta NIE MA — ani w odpowiedzi, ani w kodzie w obrazie.
|
|
||||||
|
|
||||||
Ta usługa dzieli kod z pełną aplikacją, więc granica między produktami nie jest
|
|
||||||
oczywista z samego patrzenia: przebiega przez listę importów w `main.py` i przez
|
|
||||||
`usun.txt`. Ten test sprawdza jej SKUTEK na złożonym drzewie — bo tylko skutek
|
|
||||||
trafia do obrazu, który komuś się oddaje.
|
|
||||||
|
|
||||||
„Bez śladu" znaczy tu również bez zaprzeczeń. Zdanie „nie ma tu generowania
|
|
||||||
tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak
|
|
||||||
przycisk. Pierwsza wersja tego testu wyłapała trzy moje własne docstringi
|
|
||||||
wyliczające nieobecne funkcje.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Funkcje, których ten produkt nie ma — ani ich słownictwo.
|
|
||||||
GENEROWANIE = ["prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
|
|
||||||
"anthropic", "openai", "gpt", "claude", "horoscope",
|
|
||||||
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js"]
|
|
||||||
NIEOBECNE_EKRANY = ["Skompiluj", "Ustawienia", "Konta i uprawnien", "Eksport plików"]
|
|
||||||
MECHANIZMY = ["administrator", "kwarantann", "walidacj", "reguły przyjmowania"]
|
|
||||||
SLOWA = GENEROWANIE + NIEOBECNE_EKRANY + MECHANIZMY
|
|
||||||
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
|
|
||||||
|
|
||||||
|
|
||||||
def _pliki(drzewo: pathlib.Path):
|
|
||||||
for f in sorted((drzewo / "app").rglob("*")):
|
|
||||||
if f.is_file() and f.suffix != ".pyc" and "__pycache__" not in str(f):
|
|
||||||
yield f
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_shipped_tree_carries_no_trace(drzewo):
|
|
||||||
grzechy = []
|
|
||||||
for f in _pliki(drzewo):
|
|
||||||
try:
|
|
||||||
tresc = f.read_text(encoding="utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
continue
|
|
||||||
gdzie = f.relative_to(drzewo)
|
|
||||||
for slowo in SLOWA:
|
|
||||||
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
|
|
||||||
grzechy.append(f"{gdzie}: „{slowo}”")
|
|
||||||
for wzor, powod in WZORCE:
|
|
||||||
m = re.search(wzor, tresc)
|
|
||||||
if m:
|
|
||||||
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
|
||||||
assert not grzechy, (
|
|
||||||
"\nW drzewie astroklienta padły słowa o funkcjach, których nie ma. "
|
|
||||||
"`grep` po kontenerze ma nic nie znaleźć:\n " + "\n ".join(grzechy))
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_check_would_notice_if_something_slipped_in(drzewo, tmp_path):
|
|
||||||
"""Kontrola negatywna: zapora ma coś łapać.
|
|
||||||
|
|
||||||
Bez niej test wyżej przechodziłby także wtedy, gdyby lista słów była pusta
|
|
||||||
albo gdyby przeszukiwanie trafiało w niewłaściwy katalog."""
|
|
||||||
podrzucony = drzewo / "app" / "podrzucony.py"
|
|
||||||
podrzucony.write_text("# generowanie tekstu przez model\n", encoding="utf-8")
|
|
||||||
try:
|
|
||||||
with pytest.raises(AssertionError):
|
|
||||||
test_the_shipped_tree_carries_no_trace(drzewo)
|
|
||||||
finally:
|
|
||||||
podrzucony.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_removal_list_matches_what_is_actually_composed(drzewo):
|
|
||||||
"""usun.txt i main.py muszą mówić to samo.
|
|
||||||
|
|
||||||
Rozjazd między nimi znaczy albo martwy kod w obrazie (moduł usunięty
|
|
||||||
z importów, ale nie z listy), albo błąd dopiero przy uruchomieniu (plik
|
|
||||||
usunięty, a wciąż importowany). Jedno i drugie wychodzi za późno.
|
|
||||||
"""
|
|
||||||
main = (drzewo / "app" / "main.py").read_text(encoding="utf-8")
|
|
||||||
zaimportowane = set(re.findall(r"^\s{4}(\w+),$", main, re.M))
|
|
||||||
obecne = {f.stem for f in (drzewo / "app" / "ekrany").glob("*.py")
|
|
||||||
if f.stem != "__init__"}
|
|
||||||
assert zaimportowane == obecne, (
|
|
||||||
f"ekrany w drzewie: {sorted(obecne)}\n"
|
|
||||||
f"ekrany importowane: {sorted(zaimportowane)}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_optional_module_survived(drzewo):
|
|
||||||
"""Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero."""
|
|
||||||
assert not (drzewo / "app" / "moduly").exists()
|
|
||||||
|
|
||||||
|
|
||||||
# ── nazwa produktu, z którego ten produkt powstał ─────────────────────────
|
|
||||||
# Osobno od listy wyżej, bo granica przebiega gdzie indziej: „astrololo" wolno
|
|
||||||
# występować w identyfikatorach WEWNĘTRZNYCH (nazwy loggerów, materiał do
|
|
||||||
# wyprowadzania kluczy, nagłówki między warstwami), a nie wolno w niczym, co
|
|
||||||
# dociera do przeglądarki. Zakaz na wszystko byłby albo obejściem przez zmianę
|
|
||||||
# nazw kluczy kryptograficznych, albo martwą regułą wyłączaną przy pierwszej
|
|
||||||
# okazji.
|
|
||||||
NAZWA_RDZENIA = "astrololo"
|
|
||||||
WIDOCZNE_DLA_KLIENTA = ("templates", "static")
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_parent_product_is_not_named_in_anything_the_browser_receives(drzewo):
|
|
||||||
"""Szablony i zasoby są WSPÓLNE z pełną aplikacją, więc jej nazwa jechała
|
|
||||||
z nimi do tego obrazu — i astroklient przedstawiał się jako astrololo.
|
|
||||||
|
|
||||||
To jest najpoważniejszy z możliwych śladów: mówi klientowi wprost, że trzyma
|
|
||||||
okrojoną wersję czegoś większego, i podaje frazę, po której da się wyszukać,
|
|
||||||
czego mu nie dano."""
|
|
||||||
grzechy = []
|
|
||||||
for katalog in WIDOCZNE_DLA_KLIENTA:
|
|
||||||
for f in sorted((drzewo / "app" / katalog).rglob("*")):
|
|
||||||
if not f.is_file() or "__pycache__" in str(f):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
tresc = f.read_text(encoding="utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
continue
|
|
||||||
if NAZWA_RDZENIA in tresc.lower():
|
|
||||||
grzechy.append(str(f.relative_to(drzewo)))
|
|
||||||
assert not grzechy, (
|
|
||||||
f"\nNazwa „{NAZWA_RDZENIA}” pada w plikach, które dostaje "
|
|
||||||
f"przeglądarka:\n " + "\n ".join(grzechy))
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_rendered_pages_carry_this_products_own_name(wynik):
|
|
||||||
"""Sprawdzane na ODPOWIEDZI, nie na szablonie: liczy się to, co dociera do
|
|
||||||
człowieka, a nie to, co jest w pliku przed złożeniem."""
|
|
||||||
assert wynik["nazwa_w_naglowku"] == "astroklient"
|
|
||||||
assert wynik["nazwa_w_tytule"] == "astroklient"
|
|
||||||
assert not wynik["rdzen_w_tresci"], (
|
|
||||||
"nazwa pełnej aplikacji pada w treści strony oddawanej klientowi")
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_api_documentation_is_not_reachable(wynik):
|
|
||||||
"""Tytuł aplikacji FastAPI wciąż niesie nazwę rdzenia, więc dokumentacja
|
|
||||||
OpenAPI byłaby wyciekiem tylnymi drzwiami — nawet gdy szablony są czyste."""
|
|
||||||
assert all(k == 404 for k in wynik["dokumentacja"].values()), wynik["dokumentacja"]
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Astroklient jest produktem, a nie pełną aplikacją z wyłączonymi kawałkami.
|
|
||||||
|
|
||||||
Sprawdzamy trzy rzeczy, i wszystkie na ZŁOŻONYM drzewie, nie na kodzie w repo:
|
|
||||||
czego w nim nie ma, że to, co zostało, działa, i że nie zostało po niczym słowo.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
def test_the_build_starts_at_all(wynik):
|
|
||||||
assert wynik["dziala"]["/"] == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_astrological_screen_works(wynik):
|
|
||||||
assert all(k == 200 for k in wynik["dziala"].values()), wynik["dziala"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_what_it_lacks_does_not_exist_rather_than_being_refused(wynik):
|
|
||||||
"""404, nie 403 i nie 500. Odmowa z powodem jest informacją, że coś tam jest."""
|
|
||||||
assert all(k == 404 for k in wynik["nie_ma"].values()), wynik["nie_ma"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_catalogue_describes_this_product(wynik):
|
|
||||||
assert set(wynik["ekrany"]) == {"Horoskop", "Interpretacje", "Kalendarz",
|
|
||||||
"Synastria", "Sygnifikatory", "Pliki"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_account_can_hold_a_permission_this_product_does_not_have(wynik):
|
|
||||||
"""Uprawnienia liczą się z KATALOGU, więc opisują ten obraz. Gdyby była tu
|
|
||||||
lista wpisana ręcznie, zostałaby po usuniętych funkcjach."""
|
|
||||||
assert "ai" not in wynik["uprawnienia"]
|
|
||||||
assert "export" not in wynik["uprawnienia"]
|
|
||||||
assert "admin" not in wynik["uprawnienia"]
|
|
||||||
assert "chart" in wynik["uprawnienia"] and "significators" in wynik["uprawnienia"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_has_no_dead_links(wynik):
|
|
||||||
assert wynik["nawigacja"] == len(wynik["ekrany"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_still_tells_right_password_from_wrong(wynik):
|
|
||||||
assert wynik["logowanie_dziala"] and wynik["zle_haslo_odmowa"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_pool_comes_from_the_session_not_from_the_form(wynik):
|
|
||||||
"""Pula jedzie z zalogowanego konta. Gdyby brała się z żądania, wystarczyłoby
|
|
||||||
podstawić cudzy login, żeby czytać cudze pliki — sonda właśnie to próbuje."""
|
|
||||||
assert wynik["pula"] == "klient"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
moduly
|
|
||||||
ekrany/skompiluj.py
|
|
||||||
ekrany/ustawienia.py
|
|
||||||
ekrany/konta.py
|
|
||||||
ekrany/pliki.py
|
|
||||||
accounts.py
|
|
||||||
files_state.py
|
|
||||||
templates/compile.html
|
|
||||||
templates/settings.html
|
|
||||||
templates/accounts.html
|
|
||||||
templates/files.html
|
|
||||||
static/s-compile.css
|
|
||||||
static/s-settings.css
|
|
||||||
static/s-accounts.css
|
|
||||||
static/s-files.css
|
|
||||||
static/compile.js
|
|
||||||
@@ -36,22 +36,6 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
|
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})
|
USABLE = frozenset({ACTIVE})
|
||||||
|
|
||||||
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
|
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
|
||||||
|
|||||||
@@ -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 protokołu")
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
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")
|
||||||
|
|||||||
@@ -71,35 +71,29 @@ def bases() -> dict:
|
|||||||
|
|
||||||
class FilesQuery(BaseModel):
|
class FilesQuery(BaseModel):
|
||||||
for_admin: bool = False
|
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):
|
class FileAction(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
status: str = ""
|
status: str = ""
|
||||||
by: str = ""
|
by: str = ""
|
||||||
tenant: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class FileUpload(BaseModel):
|
class FileUpload(BaseModel):
|
||||||
filename: str
|
filename: str
|
||||||
content_b64: str
|
content_b64: str
|
||||||
by: str = ""
|
by: str = ""
|
||||||
tenant: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class RulesUpdate(BaseModel):
|
class RulesUpdate(BaseModel):
|
||||||
rules: dict
|
rules: dict
|
||||||
tenant: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/files")
|
@app.post("/files")
|
||||||
def files_list(q: FilesQuery) -> dict:
|
def files_list(q: FilesQuery) -> dict:
|
||||||
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
|
"""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."""
|
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)
|
root = settings.excel_dir
|
||||||
return {"files": files.registry(root, for_admin=q.for_admin),
|
return {"files": files.registry(root, for_admin=q.for_admin),
|
||||||
"rules": files.rules(root) if q.for_admin else {},
|
"rules": files.rules(root) if q.for_admin else {},
|
||||||
"root": str(root)}
|
"root": str(root)}
|
||||||
@@ -108,8 +102,7 @@ def files_list(q: FilesQuery) -> dict:
|
|||||||
@app.post("/files/status")
|
@app.post("/files/status")
|
||||||
def files_status(a: FileAction) -> dict:
|
def files_status(a: FileAction) -> dict:
|
||||||
try:
|
try:
|
||||||
row = files.set_status(files.tenant_root(settings.excel_dir, a.tenant),
|
row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by)
|
||||||
a.path, a.status, by=a.by)
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(422, str(e)) from e
|
raise HTTPException(422, str(e)) from e
|
||||||
return {"path": a.path, "status": row.get("status")}
|
return {"path": a.path, "status": row.get("status")}
|
||||||
@@ -124,14 +117,13 @@ def files_upload(u: FileUpload) -> dict:
|
|||||||
raw = base64.b64decode(u.content_b64, validate=True)
|
raw = base64.b64decode(u.content_b64, validate=True)
|
||||||
except (binascii.Error, ValueError) as e:
|
except (binascii.Error, ValueError) as e:
|
||||||
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
|
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
|
||||||
return files.store_upload(files.tenant_root(settings.excel_dir, u.tenant),
|
return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by)
|
||||||
u.filename, raw, by=u.by)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/files/delete")
|
@app.post("/files/delete")
|
||||||
def files_delete(a: FileAction) -> dict:
|
def files_delete(a: FileAction) -> dict:
|
||||||
try:
|
try:
|
||||||
files.delete(files.tenant_root(settings.excel_dir, a.tenant), a.path)
|
files.delete(settings.excel_dir, a.path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(422, str(e)) from e
|
raise HTTPException(422, str(e)) from e
|
||||||
return {"deleted": a.path}
|
return {"deleted": a.path}
|
||||||
@@ -139,7 +131,7 @@ def files_delete(a: FileAction) -> dict:
|
|||||||
|
|
||||||
@app.post("/files/rules")
|
@app.post("/files/rules")
|
||||||
def files_rules(u: RulesUpdate) -> dict:
|
def files_rules(u: RulesUpdate) -> dict:
|
||||||
return {"rules": files.set_rules(files.tenant_root(settings.excel_dir, u.tenant), u.rules)}
|
return {"rules": files.set_rules(settings.excel_dir, u.rules)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthInfo)
|
@app.get("/health", response_model=HealthInfo)
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ 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):
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ 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]:
|
def _enabled_files(self, paths: list[str]) -> list[str]:
|
||||||
"""Bazy biorące udział w wyszukiwaniu.
|
"""Bazy biorące udział w wyszukiwaniu.
|
||||||
|
|
||||||
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
|
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
|
||||||
@@ -105,7 +105,7 @@ class ExcelDataProvider(DataProvider):
|
|||||||
# przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych.
|
# przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych.
|
||||||
from app import bases, files
|
from app import bases, files
|
||||||
|
|
||||||
usable = set(files.usable_paths(files.tenant_root(self.s.excel_dir, tenant)))
|
usable = set(files.usable_paths(self.s.excel_dir))
|
||||||
out = [p for p in paths if p in usable]
|
out = [p for p in paths if p in usable]
|
||||||
entries = bases.disabled_entries()
|
entries = bases.disabled_entries()
|
||||||
if entries:
|
if entries:
|
||||||
@@ -128,11 +128,7 @@ class ExcelDataProvider(DataProvider):
|
|||||||
from app import bases
|
from app import bases
|
||||||
|
|
||||||
disabled = ",".join(bases.disabled_entries())
|
disabled = ",".join(bases.disabled_entries())
|
||||||
# PULA MUSI BYĆ W KLUCZU. Bez niej wynik policzony dla jednego konta
|
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
|
||||||
# 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:
|
||||||
@@ -144,7 +140,7 @@ class ExcelDataProvider(DataProvider):
|
|||||||
# 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
|
# 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))
|
allowed = set(self._enabled_files([p for p, _ in candidates]))
|
||||||
candidates = [(p, s) for p, s in candidates if p in allowed]
|
candidates = [(p, s) for p, s in candidates if p in allowed]
|
||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
|
|||||||
@@ -110,80 +110,3 @@ def test_switching_a_base_off_is_visible_in_both_places(excel_dir):
|
|||||||
p2.warmup()
|
p2.warmup()
|
||||||
assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0
|
assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0
|
||||||
assert [b["enabled"] for b in p2.list_bases()] == [False]
|
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
|
|
||||||
|
|||||||
@@ -28,18 +28,8 @@ def _link() -> link_crypto.Link | None:
|
|||||||
|
|
||||||
|
|
||||||
class DataClient:
|
class DataClient:
|
||||||
"""Rozmówca warstwy danych, opcjonalnie ZWIĄZANY Z PULĄ KONTA (PRE-29).
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
|
|
||||||
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,
|
||||||
@@ -49,8 +39,7 @@ 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,
|
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
||||||
"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(),
|
||||||
@@ -69,20 +58,20 @@ class DataClient:
|
|||||||
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
|
# `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]:
|
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
|
||||||
return self._files_post("/files", {"for_admin": for_admin, "tenant": self.tenant})
|
return self._files_post("/files", {"for_admin": for_admin})
|
||||||
|
|
||||||
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
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})
|
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]:
|
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
||||||
return self._files_post("/files/upload",
|
return self._files_post("/files/upload",
|
||||||
{"filename": filename, "content_b64": content_b64, "by": by, "tenant": self.tenant})
|
{"filename": filename, "content_b64": content_b64, "by": by})
|
||||||
|
|
||||||
def files_delete(self, path: str) -> dict[str, Any]:
|
def files_delete(self, path: str) -> dict[str, Any]:
|
||||||
return self._files_post("/files/delete", {"path": path, "tenant": self.tenant})
|
return self._files_post("/files/delete", {"path": path})
|
||||||
|
|
||||||
def files_rules(self, rules: dict) -> dict[str, Any]:
|
def files_rules(self, rules: dict) -> dict[str, Any]:
|
||||||
return self._files_post("/files/rules", {"rules": rules, "tenant": self.tenant})
|
return self._files_post("/files/rules", {"rules": rules})
|
||||||
|
|
||||||
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
|||||||
@@ -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 protokołu")
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
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")
|
||||||
|
|||||||
@@ -133,33 +133,29 @@ def bases() -> dict:
|
|||||||
|
|
||||||
class FilesQuery(BaseModel):
|
class FilesQuery(BaseModel):
|
||||||
for_admin: bool = False
|
for_admin: bool = False
|
||||||
tenant: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class FileAction(BaseModel):
|
class FileAction(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
status: str = ""
|
status: str = ""
|
||||||
by: str = ""
|
by: str = ""
|
||||||
tenant: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class FileUpload(BaseModel):
|
class FileUpload(BaseModel):
|
||||||
filename: str
|
filename: str
|
||||||
content_b64: str
|
content_b64: str
|
||||||
by: str = ""
|
by: str = ""
|
||||||
tenant: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class RulesUpdate(BaseModel):
|
class RulesUpdate(BaseModel):
|
||||||
rules: dict
|
rules: dict
|
||||||
tenant: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def _files_call(fn, *args, tenant: str = "", **kw) -> dict:
|
def _files_call(fn, *args, **kw) -> dict:
|
||||||
from app.clients.data_client import DataClient
|
from app.clients.data_client import DataClient
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return fn(DataClient(tenant=tenant), *args, **kw)
|
return fn(DataClient(), *args, **kw)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
|
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
|
||||||
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
|
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
|
||||||
@@ -179,27 +175,27 @@ def _detail(e: httpx.HTTPStatusError) -> str:
|
|||||||
|
|
||||||
@app.post("/files")
|
@app.post("/files")
|
||||||
def files_list(q: FilesQuery) -> dict:
|
def files_list(q: FilesQuery) -> dict:
|
||||||
return _files_call(lambda c: c.files_list(q.for_admin), tenant=q.tenant)
|
return _files_call(lambda c: c.files_list(q.for_admin))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/files/status")
|
@app.post("/files/status")
|
||||||
def files_status(a: FileAction) -> dict:
|
def files_status(a: FileAction) -> dict:
|
||||||
return _files_call(lambda c: c.files_status(a.path, a.status, a.by), tenant=a.tenant)
|
return _files_call(lambda c: c.files_status(a.path, a.status, a.by))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/files/upload")
|
@app.post("/files/upload")
|
||||||
def files_upload(u: FileUpload) -> dict:
|
def files_upload(u: FileUpload) -> dict:
|
||||||
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by), tenant=u.tenant)
|
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/files/delete")
|
@app.post("/files/delete")
|
||||||
def files_delete(a: FileAction) -> dict:
|
def files_delete(a: FileAction) -> dict:
|
||||||
return _files_call(lambda c: c.files_delete(a.path), tenant=a.tenant)
|
return _files_call(lambda c: c.files_delete(a.path))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/files/rules")
|
@app.post("/files/rules")
|
||||||
def files_rules(u: RulesUpdate) -> dict:
|
def files_rules(u: RulesUpdate) -> dict:
|
||||||
return _files_call(lambda c: c.files_rules(u.rules), tenant=u.tenant)
|
return _files_call(lambda c: c.files_rules(u.rules))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/chart/synastry")
|
@app.post("/chart/synastry")
|
||||||
@@ -252,10 +248,6 @@ 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")
|
||||||
@@ -271,7 +263,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(tenant=req.tenant),
|
chart["positions"], DataClient(),
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -599,10 +591,6 @@ 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,10 +8,6 @@ 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ć.")
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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:
|
||||||
@@ -22,11 +21,8 @@ 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 — do PULI KONTA, jeśli podano.
|
# 2) zapytanie w dół do warstwy bazodanowej
|
||||||
# Klient budowany na żądanie, bo pula wynika z tego, kto pyta, a nie ze
|
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
|
||||||
# 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", [])
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ from __future__ import annotations
|
|||||||
from fastapi import Form, Request
|
from fastapi import Form, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
from app import accounts as accounts_store
|
|
||||||
from app.features import Feature
|
from app.features import Feature
|
||||||
from app.podstawa import (app, templates, security, features)
|
from app.podstawa import (app, templates, security, features, accounts_store)
|
||||||
|
|
||||||
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
||||||
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
||||||
@@ -95,7 +94,7 @@ def accounts_delete(request: Request, login: str = Form(...)):
|
|||||||
# Ekran wyłącznie administracyjny — nie ma pozycji w katalogu funkcji, bo ADMIN
|
# Ekran wyłącznie administracyjny — nie ma pozycji w katalogu funkcji, bo ADMIN
|
||||||
# nie jest uprawnieniem do nadania (patrz features.GRANTABLE).
|
# nie jest uprawnieniem do nadania (patrz features.GRANTABLE).
|
||||||
features.zarejestruj(
|
features.zarejestruj(
|
||||||
nawigacja=("accounts", "/accounts", "Konta", features.ADMIN),
|
nawigacja_admina=("accounts", "/accounts", "Konta"),
|
||||||
trasy={("GET", "/accounts"): features.ADMIN,
|
trasy={("GET", "/accounts"): features.ADMIN,
|
||||||
("POST", "/accounts/create"): features.ADMIN,
|
("POST", "/accounts/create"): features.ADMIN,
|
||||||
("POST", "/accounts/update"): features.ADMIN,
|
("POST", "/accounts/update"): features.ADMIN,
|
||||||
|
|||||||
@@ -6,14 +6,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
import base64
|
import base64
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app import files_state
|
|
||||||
from app.features import Feature
|
from app.features import Feature
|
||||||
from app.podstawa import (app, templates, logic, features, _perms, _logic_error)
|
from app.podstawa import (app, templates, logic, features, files_state, _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) ----------------
|
# ---------------- Pliki baz (DAN-27) ----------------
|
||||||
# Trzy poziomy dostępu, opisane w features.ROUTES:
|
# Trzy poziomy dostępu, opisane w features.ROUTES:
|
||||||
|
|||||||
@@ -45,12 +45,10 @@ class Feature:
|
|||||||
# `kolejnosc` jest jawna, bo o zawartości decyduje lista importów w `main.py`,
|
# `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.
|
# a kolejność importów nie może rządzić kolejnością zakładek w nawigacji.
|
||||||
SCREENS: list[Feature] = []
|
SCREENS: list[Feature] = []
|
||||||
# Pozycje nawigacji dla uprawnień NIENADAWALNYCH — tych, których nie ma w
|
# Pozycje nawigacji dla administratora. Osobno od SCREENS, bo ADMIN nie jest
|
||||||
# GRANTABLE, więc nie da się ich przyznać z żadnego ekranu. Osobno od SCREENS
|
# uprawnieniem do nadania — a wpisane na sztywno w szablonie zostawiały nazwę
|
||||||
# i wraz z wymaganym uprawnieniem, żeby szablon nie musiał go wypowiadać: wpisane
|
# ekranu (i martwy odsyłacz) w produkcie, który tego ekranu nie ma.
|
||||||
# w szablonie na sztywno zostawiało nazwę ekranu i martwy odsyłacz w produkcie,
|
NAWIGACJA_ADMINA: list[tuple[str, str, str]] = [] # (klucz, adres, etykieta)
|
||||||
# który tego ekranu nie ma.
|
|
||||||
NAWIGACJA_SPECJALNA: list[tuple[str, str, str, str]] = [] # (klucz, adres, etykieta, uprawnienie)
|
|
||||||
EXTRAS: list[Feature] = []
|
EXTRAS: list[Feature] = []
|
||||||
ALL: tuple[Feature, ...] = ()
|
ALL: tuple[Feature, ...] = ()
|
||||||
BY_KEY: dict[str, Feature] = {}
|
BY_KEY: dict[str, Feature] = {}
|
||||||
@@ -72,7 +70,7 @@ def _przelicz() -> None:
|
|||||||
def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None = None,
|
def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None = None,
|
||||||
trasy: dict[tuple[str, str], str | None] | None = None,
|
trasy: dict[tuple[str, str], str | None] | None = None,
|
||||||
zasoby: dict | None = None,
|
zasoby: dict | None = None,
|
||||||
nawigacja: tuple[str, str, str, str] | None = None) -> None:
|
nawigacja_admina: tuple[str, str, str] | None = None) -> None:
|
||||||
"""Zgłoszenie funkcji, jej tras i jej zasobów.
|
"""Zgłoszenie funkcji, jej tras i jej zasobów.
|
||||||
|
|
||||||
Idempotentne po kluczu: `export` zgłaszają dwa ekrany (raport do PDF-a
|
Idempotentne po kluczu: `export` zgłaszają dwa ekrany (raport do PDF-a
|
||||||
@@ -80,8 +78,8 @@ def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None =
|
|||||||
for pozycja, zbior in ((ekran, SCREENS), (rozszerzenie, EXTRAS)):
|
for pozycja, zbior in ((ekran, SCREENS), (rozszerzenie, EXTRAS)):
|
||||||
if pozycja and pozycja.key not in {f.key for f in zbior}:
|
if pozycja and pozycja.key not in {f.key for f in zbior}:
|
||||||
zbior.append(pozycja)
|
zbior.append(pozycja)
|
||||||
if nawigacja and nawigacja[0] not in {p[0] for p in NAWIGACJA_SPECJALNA}:
|
if nawigacja_admina and nawigacja_admina[0] not in {p[0] for p in NAWIGACJA_ADMINA}:
|
||||||
NAWIGACJA_SPECJALNA.append(nawigacja)
|
NAWIGACJA_ADMINA.append(nawigacja_admina)
|
||||||
ROUTES.update(trasy or {})
|
ROUTES.update(trasy or {})
|
||||||
STATIC.update(zasoby or {})
|
STATIC.update(zasoby or {})
|
||||||
_przelicz()
|
_przelicz()
|
||||||
|
|||||||
@@ -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 protokołu")
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
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")
|
||||||
|
|||||||
@@ -93,5 +93,5 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.kosmogramNatal = { read: read, clear: clear, key: KEY };
|
window.astrololoNatal = { read: read, clear: clear, key: KEY };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -146,5 +146,5 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
|
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
|
||||||
window.kosmogramPredictions = { read: read, remove: remove, key: KEY };
|
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ from fastapi.responses import Response
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
|
||||||
|
from app import accounts as accounts_store
|
||||||
from app import rozszerzenia
|
from app import rozszerzenia
|
||||||
from app import chartwheel as chartwheel_mod
|
from app import chartwheel as chartwheel_mod
|
||||||
from app import features
|
from app import features
|
||||||
from app import produkt
|
|
||||||
from app import session
|
from app import session
|
||||||
|
from app import files_state
|
||||||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||||||
|
|
||||||
from app import geocode, security
|
from app import geocode, security
|
||||||
@@ -143,20 +144,14 @@ def static_file(nazwa: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Nazwa produktu z modułu, nie z szablonu: szablony są wspólne dla kilku
|
|
||||||
# produktów, a nazwa nie.
|
|
||||||
templates.env.globals["produkt"] = produkt.NAZWA
|
|
||||||
templates.env.globals["static"] = static_url
|
templates.env.globals["static"] = static_url
|
||||||
# Pozycje specjalne przefiltrowane uprawnieniem — szablon dostaje gotową listę
|
templates.env.globals["nav_admin"] = lambda: features.NAWIGACJA_ADMINA
|
||||||
# i nie musi wiedzieć, czego która wymaga.
|
|
||||||
templates.env.globals["nav_extra"] = lambda request: [
|
|
||||||
(k, a, e) for k, a, e, u in features.NAWIGACJA_SPECJALNA
|
|
||||||
if features.can(_perms(request), u)]
|
|
||||||
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
|
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
|
||||||
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
||||||
templates.env.globals["house_label"] = house_label
|
templates.env.globals["house_label"] = house_label
|
||||||
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
||||||
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||||||
|
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
|
||||||
|
|
||||||
|
|
||||||
def _perms(request: Request) -> frozenset[str]:
|
def _perms(request: Request) -> frozenset[str]:
|
||||||
@@ -174,10 +169,6 @@ templates.env.globals["nav_screens"] = lambda request: features.screens_for(_per
|
|||||||
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
|
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
|
||||||
templates.env.globals["auth_on"] = security.auth_enabled
|
templates.env.globals["auth_on"] = security.auth_enabled
|
||||||
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
||||||
# Czy to konto ma uprawnienie NIENADAWALNE — czyli takie, którego nie da się
|
|
||||||
# przyznać z żadnego ekranu. Nazwa neutralna, bo szablon jest wspólny.
|
|
||||||
templates.env.globals["widok_diagnostyczny"] = lambda request: features.can(
|
|
||||||
_perms(request), features.ADMIN)
|
|
||||||
|
|
||||||
|
|
||||||
def _limit_options(request: Request, opts: dict) -> dict:
|
def _limit_options(request: Request, opts: dict) -> dict:
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
"""Nazwa produktu — to, co widzi człowiek w tytule i nagłówku.
|
|
||||||
|
|
||||||
Osobny moduł, bo szablony są WSPÓLNE dla kilku produktów, a nazwa nie. Wpisana
|
|
||||||
w szablon na sztywno jechała z nim do każdego obrazu, który go bierze — i tak
|
|
||||||
astroklient przedstawiał się jako astrololo, czyli mówił klientowi wprost, że
|
|
||||||
trzyma okrojoną wersję czegoś większego, i podawał frazę do wyszukania.
|
|
||||||
|
|
||||||
Produkt składający się z tego rdzenia NADPISUJE ten plik swoją nakładką.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
NAZWA = "astrololo"
|
|
||||||
@@ -175,7 +175,7 @@ def refresh(dane: dict, *, now: float | None = None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def cookie_params(secure: bool = True) -> dict:
|
def cookie_params(secure: bool = True) -> dict:
|
||||||
"""Parametry ciasteczka wspólne dla ustawiania i kasowania.
|
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
|
||||||
|
|
||||||
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
|
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
|
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readNatal() {
|
function readNatal() {
|
||||||
return (window.kosmogramNatal && window.kosmogramNatal.read()) || null;
|
return (window.astrololoNatal && window.astrololoNatal.read()) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPredictions() {
|
function readPredictions() {
|
||||||
return (window.kosmogramPredictions && window.kosmogramPredictions.read()) || [];
|
return (window.astrololoPredictions && window.astrololoPredictions.read()) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function item(ok, label, hint) {
|
function item(ok, label, hint) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var KEY = 'kosmogram.form.v1';
|
var KEY = 'astrololo.form.v1';
|
||||||
|
|
||||||
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
|
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
|
||||||
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
|
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
|
|
||||||
// 5) geo.js ustawia lat/lon z mapy poza polami formularza — po takiej zmianie
|
// 5) geo.js ustawia lat/lon z mapy poza polami formularza — po takiej zmianie
|
||||||
// też chcemy zapamiętać (zdarzenie leci z „Tu i teraz" oraz z mapy)
|
// też chcemy zapamiętać (zdarzenie leci z „Tu i teraz" oraz z mapy)
|
||||||
document.addEventListener('kosmogram:coords', function () {
|
document.addEventListener('astrololo:coords', function () {
|
||||||
setTimeout(function () { save(collect()); }, 0);
|
setTimeout(function () { save(collect()); }, 0);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Wpina się do formularza z polami input[name=lat] / input[name=lon]:
|
// Wpina się do formularza z polami input[name=lat] / input[name=lon]:
|
||||||
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon,
|
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon,
|
||||||
// - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę,
|
// - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę,
|
||||||
// - „Tu i teraz" (now.js) emituje event 'kosmogram:coords' -> mapa się synchronizuje.
|
// - „Tu i teraz" (now.js) emituje event 'astrololo:coords' -> mapa się synchronizuje.
|
||||||
// Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
|
// Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
|
||||||
(function () {
|
(function () {
|
||||||
function ready(fn) {
|
function ready(fn) {
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno
|
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno
|
||||||
// miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
|
// miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
|
||||||
// na który faktycznie ustawiła się mapa.
|
// na który faktycznie ustawiła się mapa.
|
||||||
document.addEventListener('kosmogram:coords', function () {
|
document.addEventListener('astrololo:coords', function () {
|
||||||
marker.setLatLng([curLat(), curLon()]);
|
marker.setLatLng([curLat(), curLon()]);
|
||||||
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
||||||
reverseName(curLat(), curLon());
|
reverseName(curLat(), curLon());
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
function (pos) {
|
function (pos) {
|
||||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||||
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||||
document.dispatchEvent(new CustomEvent('kosmogram:coords')); // zsynchronizuj mapę (geo.js)
|
document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js)
|
||||||
say('Lokalizacja pobrana ✓');
|
say('Lokalizacja pobrana ✓');
|
||||||
},
|
},
|
||||||
function (err) {
|
function (err) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i raportu zbiorczego (/compile). Jeden plik,
|
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik,
|
||||||
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
|
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
|
||||||
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
|
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
|
||||||
{% if can(request, 'advanced_calc') %}
|
{% if can(request, 'advanced_calc') %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{# Tabele wyniku horoskopu — używane przez raportu zbiorczego (/compile), żeby
|
{# Tabele wyniku horoskopu — używane przez „Skompiluj" (/compile), żeby
|
||||||
podsumowanie pokazywało dokładnie to, co policzono (regresja 2026-07-28:
|
podsumowanie pokazywało dokładnie to, co policzono (regresja 2026-07-28:
|
||||||
podsumowanie miało braki). Skopiowane 1:1 z sekcji tabel na „Horoskop" (/).
|
podsumowanie miało braki). Skopiowane 1:1 z sekcji tabel na „Horoskop" (/).
|
||||||
TODO: przełączyć też chart.html na ten include, żeby oba widoki nie mogły się
|
TODO: przełączyć też chart.html na ten include, żeby oba widoki nie mogły się
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ produkt }} · {% block title %}{% endblock %}</title>
|
<title>astrololo · {% block title %}{% endblock %}</title>
|
||||||
{# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa
|
{# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa
|
||||||
jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`.
|
jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`.
|
||||||
Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści
|
Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1>{{ produkt }}</h1>
|
<h1>astrololo</h1>
|
||||||
{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych —
|
{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych —
|
||||||
ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że
|
ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że
|
||||||
program umie więcej, niż pokazuje (PRE-26). #}
|
program umie więcej, niż pokazuje (PRE-26). #}
|
||||||
@@ -28,12 +28,14 @@
|
|||||||
{% for tab in nav_screens(request) %}
|
{% for tab in nav_screens(request) %}
|
||||||
<a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
|
<a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{# Pozycje specjalne z REJESTRU, już przefiltrowane uprawnieniem. Wpisany
|
{# Pozycje administracyjne z REJESTRU, nie z listy w szablonie: wpisany tu
|
||||||
tu na sztywno odsyłacz zostawał w produkcie, który tego ekranu nie ma —
|
na sztywno odsyłacz zostawał w produkcie, który tego ekranu nie ma —
|
||||||
razem z nazwą ekranu i martwym adresem. #}
|
razem z nazwą ekranu i martwym adresem. #}
|
||||||
{% for klucz, adres, etykieta in nav_extra(request) %}
|
{% if can(request, 'admin') %}
|
||||||
|
{% for klucz, adres, etykieta in nav_admin() %}
|
||||||
<a href="{{ adres }}" class="{{ 'active' if nav_active is defined and nav_active == klucz else '' }}">{{ etykieta }}</a>
|
<a href="{{ adres }}" class="{{ 'active' if nav_active is defined and nav_active == klucz else '' }}">{{ etykieta }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
{# Wyjście na końcu paska i odsunięte: to nie jest zakładka, tylko
|
{# Wyjście na końcu paska i odsunięte: to nie jest zakładka, tylko
|
||||||
czynność — pomylenie go z zakładką kosztuje przypadkowe wylogowania.
|
czynność — pomylenie go z zakładką kosztuje przypadkowe wylogowania.
|
||||||
Pokazujemy też KTO jest zalogowany, bo bez tego przycisk wyjścia jest
|
Pokazujemy też KTO jest zalogowany, bo bez tego przycisk wyjścia jest
|
||||||
@@ -50,11 +52,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
{# Stopka wymienia warstwy architektury i nazywa całość „widokiem testowym" —
|
{# Stopka wymieniała warstwy architektury i nazywała całość „widokiem
|
||||||
czyli mówi, że to nie jest gotowy produkt i że za nim stoi łańcuch usług.
|
testowym" — czyli mówiła, że to nie jest gotowy produkt i że za nim stoi
|
||||||
Widzi ją wyłącznie konto z uprawnieniem nienadawalnym; dla pozostałych
|
łańcuch usług. Dla konta ograniczonego program ma wyglądać na kompletny,
|
||||||
program ma wyglądać na kompletny. #}
|
więc ten opis widzi wyłącznie administrator. #}
|
||||||
{% if widok_diagnostyczny(request) %}
|
{% if can(request, 'admin') %}
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
prezentacja → logika → dane · widok testowy
|
prezentacja → logika → dane · widok testowy
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ produkt }} · logowanie</title>
|
<title>astrololo · logowanie</title>
|
||||||
{# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc
|
{# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc
|
||||||
linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
|
linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
|
||||||
<link rel="stylesheet" href="{{ static('base.css') }}">
|
<link rel="stylesheet" href="{{ static('base.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="login-main">
|
<main class="login-main">
|
||||||
<header class="topbar"><h1>{{ produkt }}</h1></header>
|
<header class="topbar"><h1>astrololo</h1></header>
|
||||||
|
|
||||||
{% if request.query_params.get('wylogowano') %}
|
{% if request.query_params.get('wylogowano') %}
|
||||||
<p class="done-note">Wylogowano.</p>
|
<p class="done-note">Wylogowano.</p>
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ def _pdf_sample_chart() -> dict:
|
|||||||
def test_reads_stores_through_their_api_not_raw_storage():
|
def test_reads_stores_through_their_api_not_raw_storage():
|
||||||
"""Format danych ma jednego właściciela — moduł, który je zapisuje.
|
"""Format danych ma jednego właściciela — moduł, który je zapisuje.
|
||||||
Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
|
Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
|
||||||
assert "window.kosmogramNatal" in JS
|
assert "window.astrololoNatal" in JS
|
||||||
assert "window.kosmogramPredictions" in JS
|
assert "window.astrololoPredictions" in JS
|
||||||
# chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
|
# chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
|
||||||
assert "localStorage.getItem" not in JS
|
assert "localStorage.getItem" not in JS
|
||||||
assert "localStorage.setItem" not in JS
|
assert "localStorage.setItem" not in JS
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ def test_corrupt_storage_does_not_break_the_page():
|
|||||||
|
|
||||||
def test_storage_is_exposed_for_the_compile_tab():
|
def test_storage_is_exposed_for_the_compile_tab():
|
||||||
"""Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy."""
|
"""Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy."""
|
||||||
assert "window.kosmogramPredictions" in JS
|
assert "window.astrololoPredictions" in JS
|
||||||
|
|
||||||
|
|
||||||
def test_does_nothing_on_other_tabs():
|
def test_does_nothing_on_other_tabs():
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ def _geo_source() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _handler_body() -> str:
|
def _handler_body() -> str:
|
||||||
"""Ciało handlera zdarzenia kosmogram:coords (wysyłanego przez „Tu i teraz").
|
"""Ciało handlera zdarzenia astrololo:coords (wysyłanego przez „Tu i teraz").
|
||||||
|
|
||||||
Szukamy KONKRETNIE `addEventListener`, nie samej nazwy zdarzenia — ta pada
|
Szukamy KONKRETNIE `addEventListener`, nie samej nazwy zdarzenia — ta pada
|
||||||
także w komentarzu nagłówkowym pliku i wtedy wycięlibyśmy komentarz zamiast
|
także w komentarzu nagłówkowym pliku i wtedy wycięlibyśmy komentarz zamiast
|
||||||
kodu (test przechodziłby albo padał z niewłaściwego powodu).
|
kodu (test przechodziłby albo padał z niewłaściwego powodu).
|
||||||
"""
|
"""
|
||||||
src = _geo_source()
|
src = _geo_source()
|
||||||
start = src.index("addEventListener('kosmogram:coords'")
|
start = src.index("addEventListener('astrololo:coords'")
|
||||||
end = src.index("});", start)
|
end = src.index("});", start)
|
||||||
return src[start:end]
|
return src[start:end]
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ def test_now_button_handler_refreshes_place_name():
|
|||||||
a w polu tekstowym zostawała stara nazwa — formularz pokazywał jedno miejsce,
|
a w polu tekstowym zostawała stara nazwa — formularz pokazywał jedno miejsce,
|
||||||
a liczył dla innego."""
|
a liczył dla innego."""
|
||||||
assert "reverseName(" in _handler_body(), (
|
assert "reverseName(" in _handler_body(), (
|
||||||
"handler kosmogram:coords nie odświeża nazwy miejsca (reverseName)"
|
"handler astrololo:coords nie odświeża nazwy miejsca (reverseName)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 protokołu")
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user