Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 320a0ab24e | |||
| 1d0ff2f72e | |||
| f0d07ee8c3 | |||
| 71bb3b9c0a | |||
| ee3c515d59 | |||
| ac8a0e9fc6 | |||
| b838cf4723 | |||
| e2b50b284f | |||
| 003deb9404 | |||
| 6466ab89a9 |
@@ -16,6 +16,8 @@ services/logic/.ephemeris/
|
|||||||
|
|
||||||
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
||||||
services/data/data_files/*.xlsx
|
services/data/data_files/*.xlsx
|
||||||
|
# Rejestr plików (DAN-27) powstaje przy uruchomieniu — to stan, nie kod.
|
||||||
|
services/data/data_files/.files-state.json
|
||||||
!services/data/data_files/.gitkeep
|
!services/data/data_files/.gitkeep
|
||||||
|
|
||||||
# Narzędzia
|
# Narzędzia
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./services/data/data_files:/app/data_files
|
- ./services/data/data_files:/app/data_files
|
||||||
- data_cache:/app/.cache
|
- data_cache:/app/.cache
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8002:8002"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
logic:
|
logic:
|
||||||
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
||||||
@@ -25,8 +27,10 @@ services:
|
|||||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8001:8001"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
# docker compose --profile comparison up
|
# docker compose --profile comparison up
|
||||||
@@ -36,8 +40,10 @@ services:
|
|||||||
container_name: astrololo-engine-swisseph
|
container_name: astrololo-engine-swisseph
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["comparison"]
|
profiles: ["comparison"]
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8003:8003"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
presentation:
|
presentation:
|
||||||
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
||||||
|
|||||||
+12
-6
@@ -9,8 +9,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./services/data/data_files:/app/data_files
|
- ./services/data/data_files:/app/data_files
|
||||||
- data_cache:/app/.cache
|
- data_cache:/app/.cache
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8002:8002"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
logic:
|
logic:
|
||||||
build: ./services/logic
|
build: ./services/logic
|
||||||
@@ -21,8 +23,10 @@ services:
|
|||||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8001:8001"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
# docker compose --profile comparison up
|
# docker compose --profile comparison up
|
||||||
@@ -30,8 +34,10 @@ services:
|
|||||||
engine-swisseph:
|
engine-swisseph:
|
||||||
build: ./services/engine-swisseph
|
build: ./services/engine-swisseph
|
||||||
profiles: ["comparison"]
|
profiles: ["comparison"]
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8003:8003"
|
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||||
|
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||||
|
# `docker compose run --publish`.
|
||||||
|
|
||||||
presentation:
|
presentation:
|
||||||
build: ./services/presentation
|
build: ./services/presentation
|
||||||
|
|||||||
Binary file not shown.
@@ -64,6 +64,92 @@ jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wyma
|
|||||||
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
|
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
|
||||||
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
|
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
|
||||||
|
|
||||||
|
### Gdy jedna trasa robi kilka rzeczy
|
||||||
|
|
||||||
|
Mapa tras nie wystarcza tam, gdzie jedna trasa obsługuje kilka funkcji naraz.
|
||||||
|
`POST /interpret` liczy horoskop, ale to samo pole `action` prosi o wygenerowanie
|
||||||
|
promptu, napisanie horoskopu przez model albo eksport arkusza. Konto, które ma
|
||||||
|
mieć Interpretacje bez generowania, musi dostać tę trasę — więc granica przebiega
|
||||||
|
wewnątrz niej, po akcjach: `_AKCJE_POD_UPRAWNIENIEM` przypisuje akcji uprawnienie,
|
||||||
|
a `_dozwolona_akcja()` sprowadza żądanie bez uprawnienia do akcji domyślnej ekranu.
|
||||||
|
|
||||||
|
Sprowadza — nie odrzuca. Komunikat „brak uprawnień do generowania" sam w sobie
|
||||||
|
mówiłby, że taka funkcja istnieje, czyli łamałby zasadę drugą po to, żeby
|
||||||
|
wyegzekwować pierwszą. Akcja bez uprawnienia ma wyglądać na literówkę w formularzu.
|
||||||
|
|
||||||
|
### Ślad to nie tylko przycisk
|
||||||
|
|
||||||
|
Wymaganie brzmi „nie może być śladu", i to jest mocniejsze niż schowanie kontrolki.
|
||||||
|
Największym wyciekiem po stronie generowania nie był przycisk, tylko **katalog
|
||||||
|
modeli** — nazwy dostawców, nazwy modeli i rozmiary okien kontekstu — wstrzykiwany
|
||||||
|
w stronę blokiem JSON na każdym ekranie z generowaniem, niezależnie od uprawnień.
|
||||||
|
Dlatego `_llm_catalog_for()` oddaje pusty katalog kontu bez uprawnienia, a szablony
|
||||||
|
trzymają pod bramką także znaczniki (`natalNote`, `reportNatal`), pliki skryptów
|
||||||
|
(`models.js`, `progress.js`, `natal.js`, `predictions.js`) i **zdania opisujące
|
||||||
|
funkcję** — podtytuł ekranu Skompiluj wymieniał interpretację od AI z nazwy.
|
||||||
|
|
||||||
|
Testu na to nie da się napisać przez „sprawdź, czy przycisku nie ma": trzeba
|
||||||
|
sprawdzić, że w źródle strony nie ma żadnego z tych śladów, i mieć kontrolę
|
||||||
|
pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy,
|
||||||
|
gdy generowanie jest zepsute dla wszystkich.
|
||||||
|
|
||||||
|
## Pełna paranoja: ukrywanie jest nadrzędne
|
||||||
|
|
||||||
|
Właściciel produktu postawił to wyżej niż wygodę i wyżej niż czytelność
|
||||||
|
komunikatów: *„nie chcę, żeby osoba wrzucająca bazy wiedziała, po co to robi
|
||||||
|
i jak będzie w przyszłości działał program, bo to rozgada"*. Persona nazywa się
|
||||||
|
**wgrywacz** — konto z uprawnieniami `files` + `files_input` i niczym więcej.
|
||||||
|
|
||||||
|
Nie ma dowiedzieć się: jakie inne funkcje istnieją, że teksty pisze model
|
||||||
|
językowy i u jakiego dostawcy, do czego służą wgrywane pliki, co jest planowane,
|
||||||
|
że istnieje walidacja plików, ani że istnieje konto, które widzi więcej.
|
||||||
|
|
||||||
|
### Wyciek prawie nigdy nie siedzi tam, gdzie się go szuka
|
||||||
|
|
||||||
|
Audyt sześciu kanałów potwierdził 26 wycieków. Ani jeden nie był przyciskiem.
|
||||||
|
|
||||||
|
| Kanał | Co wyciekało |
|
||||||
|
|---|---|
|
||||||
|
| `/static/**` poza bramką | komplet skryptów i arkuszy dla **niezalogowanego** |
|
||||||
|
| komentarze w CSS/JS | pełne zdania po polsku o funkcjach, o kwarantannie i o tym, że administrator widzi więcej |
|
||||||
|
| `styles.css` jako jeden plik | nazwy selektorów = spis funkcji programu |
|
||||||
|
| `base.html` | skrypty kosmogramu na **każdej** stronie, łącznie z „przyszłą zakładką" |
|
||||||
|
| komunikaty błędu | nazwa trasy, nazwa podsystemu, nazwa gałęzi rozwojowej, wewnętrzny `host:port` |
|
||||||
|
| komunikat po wgraniu | słowo „administrator" — i **dwie różne treści**, czyli wyrocznia do odgadywania reguł walidacji |
|
||||||
|
| `/openapi.json` warstw wewnętrznych | katalog wszystkich funkcji, bez logowania |
|
||||||
|
|
||||||
|
### Trzy zasady, które z tego wynikają
|
||||||
|
|
||||||
|
**Zasób jest częścią funkcji.** Skrypt i arkusz przechodzą przez tę samą bramkę
|
||||||
|
co ekran (`features.STATIC`). Nazwa pliku jest zgadywalna, więc plik publiczny
|
||||||
|
opowiada o funkcji równie dokładnie jak przycisk. Publiczny został jeden
|
||||||
|
`base.css` — bo potrzebuje go ekran logowania — i dlatego nie wolno w nim
|
||||||
|
umieścić niczego, co nazywa funkcję.
|
||||||
|
|
||||||
|
**Komentarz nie jedzie na drut.** `_asset_body()` usuwa komentarze przy
|
||||||
|
serwowaniu. Zostają w repozytorium, gdzie są potrzebne. Ta jedna zmiana zamyka
|
||||||
|
cztery z sześciu kanałów naraz.
|
||||||
|
|
||||||
|
**Różnica jest informacją.** Dwa różne komunikaty po wgraniu pliku były
|
||||||
|
wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać odpowiedź. Teraz
|
||||||
|
komunikat jest jeden, niezależnie od wyniku. Z tego samego powodu odmowa to
|
||||||
|
404 identyczne z „nie ma takiej trasy", a akcja bez uprawnienia cofa się do
|
||||||
|
domyślnej zamiast tłumaczyć, czego brakuje.
|
||||||
|
|
||||||
|
### Zapora słownikowa
|
||||||
|
|
||||||
|
Łatanie punkt po punkcie przegrywa z następną zmianą. Dlatego
|
||||||
|
`test_slownik_zakazany.py` nie sprawdza miejsc, tylko przechodzi **wszystko**,
|
||||||
|
co dane konto może pobrać, i szuka słów, które nie mają prawa paść.
|
||||||
|
|
||||||
|
Dwie z trzech list biorą się wprost z katalogu funkcji — nazwa funkcji, adres
|
||||||
|
jej ekranu i nazwy jej zasobów — więc dopisanie funkcji automatycznie dopisuje
|
||||||
|
je do tego, czego konto bez niej nie może zobaczyć. Trzecia lista, słownictwo
|
||||||
|
dziedziny i mechanizmów, jest pisana ręcznie, bo katalog jej nie zna.
|
||||||
|
|
||||||
|
Test ma kontrolę pozytywną: dla administratora te same słowa **muszą** się
|
||||||
|
pojawiać. Bez niej przechodziłby także wtedy, gdyby program był pusty.
|
||||||
|
|
||||||
## Gdzie leżą konta
|
## Gdzie leżą konta
|
||||||
|
|
||||||
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
|
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class Settings:
|
|||||||
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
|
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
|
||||||
|
|
||||||
# Warstwa SQL (po migracji)
|
# Warstwa SQL (po migracji)
|
||||||
|
# Domyślnie SQLite w cache — do testów i pracy lokalnej, bez stawiania bazy.
|
||||||
|
# Na klastrze SQL_URL wskazuje Postgresa i przychodzi z SEKRETU, bo niesie
|
||||||
|
# hasło (patrz deploy: astrololo/README-postgres.md).
|
||||||
sql_url: str = field(
|
sql_url: str = field(
|
||||||
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
|
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""Rejestr plików baz: stan użycia, wgrywanie, archiwizacja, walidacja (DAN-27).
|
||||||
|
|
||||||
|
CO SIĘ ZMIENIA WZGLĘDEM DAN-15. Dotąd włączanie i wyłączanie baz szło przez
|
||||||
|
zmienną `DISABLED_BASES` — deklaratywnie, bo warstwa danych nie miała gdzie
|
||||||
|
zapisywać stanu (udział read-only, cache jako emptyDir). Teraz stan jest KLIKANY,
|
||||||
|
więc musi być trwały: udział jest zapisywalny, a stan leży w pliku obok baz.
|
||||||
|
|
||||||
|
STANY PLIKU
|
||||||
|
active — bierze udział w wyszukiwaniu,
|
||||||
|
ready — sprawny, ale świadomie odstawiony; można włączyć jednym kliknięciem,
|
||||||
|
archived — ZAMROŻONY: nie bierze udziału, ma znacznik czasu archiwizacji,
|
||||||
|
sam plik zostaje nietknięty. To jedyna forma „usuwania" dostępna
|
||||||
|
osobie wgrywającej dane,
|
||||||
|
quarantine — wgrany, ale nie przeszedł walidacji. NIE JEST TRACONY; decyzję,
|
||||||
|
czy go skasować, podejmuje wyłącznie administrator.
|
||||||
|
|
||||||
|
DLACZEGO KWARANTANNA JEST NIEWIDOCZNA POZA ADMINISTRATOREM. Zasada z PRE-27 mówi,
|
||||||
|
że konto ograniczone nie ma skąd wiedzieć o mechanizmach, których nie obsługuje.
|
||||||
|
Gdyby plik w kwarantannie był widoczny z powodem odrzucenia, każdy wgrywający
|
||||||
|
poznałby reguły walidacji — a te są narzędziem administratora. Osoba wgrywająca
|
||||||
|
widzi więc plik jako „oczekuje na zatwierdzenie", bez powodu i bez reguł.
|
||||||
|
|
||||||
|
REGUŁY WALIDACJI są danymi, nie kodem: administrator ustawia je z ekranu. Trzymamy
|
||||||
|
je w tym samym pliku stanu, bo stan i reguły zmieniają się razem i muszą przetrwać
|
||||||
|
restart tak samo.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
|
||||||
|
USABLE = frozenset({ACTIVE})
|
||||||
|
|
||||||
|
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
|
||||||
|
# Kwarantanna świadomie poza listą — patrz nagłówek modułu.
|
||||||
|
VISIBLE_TO_EVERYONE = frozenset({ACTIVE, READY, ARCHIVED})
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
DEFAULT_RULES: dict = {
|
||||||
|
"extensions": [".xlsx"],
|
||||||
|
"max_size_mb": 50,
|
||||||
|
"min_rows": 1,
|
||||||
|
"required_columns": [], # puste = bez wymagań co do nagłówków
|
||||||
|
"reject_duplicate_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def state_path(root: Path | str) -> Path:
|
||||||
|
"""Plik stanu — obok baz, chyba że wskazano inaczej.
|
||||||
|
|
||||||
|
Sprawdzamy NAPIS ze środowiska, nie Path(napis): Path("") to Path("."),
|
||||||
|
czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna` zawsze
|
||||||
|
wybierało pustą zmienną i zapisywało stan do katalogu bieżącego."""
|
||||||
|
override = os.getenv("FILES_STATE", "").strip()
|
||||||
|
return Path(override) if override else Path(root) / ".files-state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_of(path: Path | str) -> str:
|
||||||
|
"""Skrót treści pliku — tożsamość pliku niezależna od nazwy.
|
||||||
|
|
||||||
|
Przyda się też krokowi drugiemu (lustro w SQL): to po nim poznamy, że plik
|
||||||
|
na dysku rozjechał się z tym, co wczytano do bazy."""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ── stan ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_state(root: Path) -> dict:
|
||||||
|
try:
|
||||||
|
with open(state_path(root), encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
files = data.get("files")
|
||||||
|
rules = data.get("rules")
|
||||||
|
return {
|
||||||
|
"files": files if isinstance(files, dict) else {},
|
||||||
|
"rules": {**DEFAULT_RULES, **(rules if isinstance(rules, dict) else {})},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(root: Path, data: dict) -> None:
|
||||||
|
path = state_path(root)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Atomowo: plik stanu opisuje CAŁY zbiór baz, więc obcięcie go w połowie
|
||||||
|
# zapisu skasowałoby wiedzę o wszystkich naraz.
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
Path(tmp).unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def rules(root: Path | str) -> dict:
|
||||||
|
return _read_state(Path(root))["rules"]
|
||||||
|
|
||||||
|
|
||||||
|
def set_rules(root: Path | str, new: dict) -> dict:
|
||||||
|
root = Path(root)
|
||||||
|
with _lock:
|
||||||
|
data = _read_state(root)
|
||||||
|
merged = {**data["rules"]}
|
||||||
|
for key, value in (new or {}).items():
|
||||||
|
if key in DEFAULT_RULES:
|
||||||
|
merged[key] = value
|
||||||
|
data["rules"] = merged
|
||||||
|
_write_state(root, data)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# ── walidacja ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def validate(path: Path | str, root: Path | str, *, digest: str = "",
|
||||||
|
known_digests: dict[str, str] | None = None) -> list[str]:
|
||||||
|
"""Lista POWODÓW odrzucenia. Pusta lista = plik nadaje się do użytku.
|
||||||
|
|
||||||
|
Zwracamy powody, a nie samo „tak/nie", bo administrator ma zobaczyć, CZEGO
|
||||||
|
plikowi brakuje — inaczej poprawianie bazy byłoby zgadywanką. Poza konto
|
||||||
|
administracyjne ta lista nie wychodzi."""
|
||||||
|
p, rs = Path(path), rules(root)
|
||||||
|
why: list[str] = []
|
||||||
|
|
||||||
|
exts = [str(e).lower() for e in rs.get("extensions") or []]
|
||||||
|
if exts and p.suffix.lower() not in exts:
|
||||||
|
why.append(f"rozszerzenie {p.suffix or '(brak)'} spoza dozwolonych: {', '.join(exts)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
size_mb = p.stat().st_size / (1024 * 1024)
|
||||||
|
except OSError:
|
||||||
|
return why + ["pliku nie da się odczytać"]
|
||||||
|
cap = float(rs.get("max_size_mb") or 0)
|
||||||
|
if cap and size_mb > cap:
|
||||||
|
why.append(f"rozmiar {size_mb:.1f} MB przekracza limit {cap:g} MB")
|
||||||
|
|
||||||
|
if rs.get("reject_duplicate_content") and known_digests:
|
||||||
|
digest = digest or sha256_of(p)
|
||||||
|
twin = next((name for name, d in known_digests.items()
|
||||||
|
if d == digest and name != p.name), None)
|
||||||
|
if twin:
|
||||||
|
why.append(f"treść identyczna z plikiem „{twin}”")
|
||||||
|
|
||||||
|
required = [str(c).strip() for c in (rs.get("required_columns") or []) if str(c).strip()]
|
||||||
|
min_rows = int(rs.get("min_rows") or 0)
|
||||||
|
if required or min_rows:
|
||||||
|
why += _inspect_workbook(p, required, min_rows)
|
||||||
|
return why
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_workbook(path: Path, required: list[str], min_rows: int) -> list[str]:
|
||||||
|
"""Zagląda do arkusza: nagłówki i liczba wierszy.
|
||||||
|
|
||||||
|
read_only + tylko pierwszy arkusz — plik bazy potrafi mieć kilkadziesiąt MB,
|
||||||
|
a wczytanie go w całości przy każdym wgraniu zatkałoby usługę."""
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||||
|
except Exception as e: # noqa: BLE001 — każdy błąd = powód
|
||||||
|
return [f"nie udało się otworzyć arkusza ({type(e).__name__})"]
|
||||||
|
why: list[str] = []
|
||||||
|
try:
|
||||||
|
ws = wb[wb.sheetnames[0]]
|
||||||
|
rows = ws.iter_rows(values_only=True)
|
||||||
|
header = [str(c).strip().lower() for c in (next(rows, ()) or ()) if c is not None]
|
||||||
|
missing = [c for c in required if c.strip().lower() not in header]
|
||||||
|
if missing:
|
||||||
|
why.append(f"brak wymaganych kolumn: {', '.join(missing)}")
|
||||||
|
if min_rows:
|
||||||
|
seen = sum(1 for i, _ in enumerate(rows) if i < min_rows)
|
||||||
|
if seen < min_rows:
|
||||||
|
why.append(f"za mało wierszy danych ({seen} < {min_rows})")
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
return why
|
||||||
|
|
||||||
|
|
||||||
|
# ── rejestr ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _scan(root: Path) -> list[Path]:
|
||||||
|
"""Pliki na udziale, bez śmieci technicznych.
|
||||||
|
|
||||||
|
Pomijamy nie tylko ukryte PLIKI, ale i wszystko, co leży w ukrytym KATALOGU:
|
||||||
|
filtr po samej nazwie pliku wciągał do rejestru zawartość `.cache`, bo pliki
|
||||||
|
w środku nie zaczynają się od kropki. Efekt: cache podawany jako baza, a przy
|
||||||
|
pierwszym uruchomieniu jeszcze przyjmowany jako aktywny."""
|
||||||
|
def ukryta_sciezka(p: Path) -> bool:
|
||||||
|
return any(part.startswith(".") for part in p.relative_to(root).parts[:-1])
|
||||||
|
|
||||||
|
return [p for p in sorted(root.glob("**/*"))
|
||||||
|
if p.is_file() and not p.name.startswith((".", "~$"))
|
||||||
|
and not ukryta_sciezka(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def _adopt_existing(root: Path) -> dict:
|
||||||
|
"""Pierwsze uruchomienie: bazy zastane na udziale są OD RAZU w użyciu.
|
||||||
|
|
||||||
|
Bez tego wdrożenie DAN-27 wyłączyłoby wyszukiwanie. Dotąd bazy działały
|
||||||
|
domyślnie (wyłączało się je jawnie przez DISABLED_BASES); po przejściu na
|
||||||
|
rejestr plik bez wpisu dostaje `ready`, czyli NIE w użyciu — więc pusty stan
|
||||||
|
po wdrożeniu oznaczałby, że program nagle niczego nie znajduje. Ta cicha
|
||||||
|
zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza.
|
||||||
|
|
||||||
|
Rozróżnienie jest celowe: `ready` dotyczy plików WGRANYCH przez ekran (te
|
||||||
|
ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr.
|
||||||
|
|
||||||
|
Zapis stanu może się nie udać (udział read-only) — wtedy trudno, przy każdym
|
||||||
|
uruchomieniu przyjmiemy je na nowo. Zachowanie jest to samo, koszt żaden."""
|
||||||
|
files = {str(p.relative_to(root)): {"status": ACTIVE, "adopted_at": _now()}
|
||||||
|
for p in _scan(root)}
|
||||||
|
data = {"files": files, "rules": {**DEFAULT_RULES}}
|
||||||
|
try:
|
||||||
|
_write_state(root, data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]:
|
||||||
|
"""Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody.
|
||||||
|
|
||||||
|
Filtrowanie siedzi TUTAJ, a nie w szablonie: gdyby pliki w kwarantannie
|
||||||
|
dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd
|
||||||
|
źródła strony, żeby poznać reguły walidacji."""
|
||||||
|
root = Path(root)
|
||||||
|
# Brak PLIKU stanu = pierwsze uruchomienie. Pusty słownik przy istniejącym
|
||||||
|
# pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy.
|
||||||
|
data = _read_state(root) if state_path(root).exists() else _adopt_existing(root)
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in _scan(root):
|
||||||
|
rel = str(p.relative_to(root))
|
||||||
|
row = data["files"].get(rel, {})
|
||||||
|
status = row.get("status") or READY
|
||||||
|
if status == QUARANTINE and not for_admin:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = p.stat()
|
||||||
|
size_mb = round(st.st_size / (1024 * 1024), 2)
|
||||||
|
modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
except OSError:
|
||||||
|
size_mb, modified = None, None
|
||||||
|
entry = {
|
||||||
|
"name": p.name, "path": rel, "size_mb": size_mb, "modified": modified,
|
||||||
|
"status": status, "in_use": status in USABLE,
|
||||||
|
# `enabled` to TA SAMA informacja pod nazwą, której używa reszta
|
||||||
|
# świata: endpoint /bases, warstwa logiczna i ekran „Ustawienia"
|
||||||
|
# (DAN-15/PRE-09). Rejestr wszedł w miejsce starej listy baz, więc
|
||||||
|
# musi mówić jej językiem — inaczej każdy odbiorca dostaje KeyError,
|
||||||
|
# a to była właśnie awaria /bases po wdrożeniu DAN-27.
|
||||||
|
"enabled": status in USABLE,
|
||||||
|
"archived_at": row.get("archived_at") or "",
|
||||||
|
"uploaded_at": row.get("uploaded_at") or "",
|
||||||
|
"uploaded_by": row.get("uploaded_by") or "",
|
||||||
|
"sha256": row.get("sha256") or "",
|
||||||
|
}
|
||||||
|
if for_admin:
|
||||||
|
entry["rejected_for"] = list(row.get("rejected_for") or [])
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def usable_paths(root: Path | str) -> list[str]:
|
||||||
|
"""Ścieżki baz, które FAKTYCZNIE biorą udział w wyszukiwaniu."""
|
||||||
|
root = Path(root)
|
||||||
|
return [str(root / e["path"]) for e in registry(root, for_admin=True) if e["in_use"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(root: Path, rel: str, **fields) -> dict:
|
||||||
|
with _lock:
|
||||||
|
data = _read_state(root)
|
||||||
|
row = {**data["files"].get(rel, {}), **fields}
|
||||||
|
data["files"][rel] = row
|
||||||
|
_write_state(root, data)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(root: Path | str, rel: str, status: str, *, by: str = "") -> dict:
|
||||||
|
"""Zmienia stan pliku. Włączyć do użytku można TYLKO plik, który przeszedł
|
||||||
|
walidację — to jest właśnie ta bramka, o której mowa w wymaganiu."""
|
||||||
|
root = Path(root)
|
||||||
|
target = root / rel
|
||||||
|
if not target.is_file():
|
||||||
|
raise ValueError(f"Nie ma pliku „{rel}”.")
|
||||||
|
if status not in {ACTIVE, READY, ARCHIVED, QUARANTINE}:
|
||||||
|
raise ValueError(f"Nieznany stan: {status}")
|
||||||
|
|
||||||
|
data = _read_state(root)
|
||||||
|
current = (data["files"].get(rel) or {}).get("status") or READY
|
||||||
|
if status == ACTIVE:
|
||||||
|
if current == QUARANTINE:
|
||||||
|
raise ValueError("Plik nie może trafić do użytku.")
|
||||||
|
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"]}
|
||||||
|
why = validate(target, root, known_digests=known)
|
||||||
|
if why:
|
||||||
|
_touch(root, rel, status=QUARANTINE, rejected_for=why, checked_at=_now())
|
||||||
|
raise ValueError("Plik nie może trafić do użytku.")
|
||||||
|
|
||||||
|
fields = {"status": status, "changed_at": _now(), "changed_by": by}
|
||||||
|
if status == ARCHIVED:
|
||||||
|
# Znacznik czasu archiwizacji to wymóg: „zamrożona forma z timestampem".
|
||||||
|
fields["archived_at"] = _now()
|
||||||
|
elif status == ACTIVE:
|
||||||
|
fields["archived_at"] = ""
|
||||||
|
fields["rejected_for"] = []
|
||||||
|
return _touch(root, rel, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def store_upload(root: Path | str, filename: str, content: bytes, *, by: str = "") -> dict:
|
||||||
|
"""Zapisuje wgrany plik i od razu go sprawdza.
|
||||||
|
|
||||||
|
Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś
|
||||||
|
wgrał. Zmienia się tylko to, czy da się go włączyć do użytku."""
|
||||||
|
root = Path(root)
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9._ -]", "_", Path(filename or "").name).strip() or "plik"
|
||||||
|
target = root / safe
|
||||||
|
stem, suffix, n = Path(safe).stem, Path(safe).suffix, 1
|
||||||
|
while target.exists(): # nie nadpisujemy cudzej bazy
|
||||||
|
target = root / f"{stem}-{n}{suffix}"
|
||||||
|
n += 1
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(content)
|
||||||
|
|
||||||
|
rel = str(target.relative_to(root))
|
||||||
|
digest = sha256_of(target)
|
||||||
|
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True)
|
||||||
|
if e["sha256"] and e["path"] != rel}
|
||||||
|
why = validate(target, root, digest=digest, known_digests=known)
|
||||||
|
_touch(root, rel, status=QUARANTINE if why else READY, rejected_for=why,
|
||||||
|
sha256=digest, uploaded_at=_now(), uploaded_by=by, checked_at=_now())
|
||||||
|
return {"path": rel, "name": target.name, "accepted": not why}
|
||||||
|
|
||||||
|
|
||||||
|
def delete(root: Path | str, rel: str) -> None:
|
||||||
|
"""Nieodwracalne skasowanie pliku — wyłącznie dla administratora."""
|
||||||
|
root = Path(root)
|
||||||
|
target = root / rel
|
||||||
|
if not target.is_file():
|
||||||
|
raise ValueError(f"Nie ma pliku „{rel}”.")
|
||||||
|
target.unlink()
|
||||||
|
with _lock:
|
||||||
|
data = _read_state(root)
|
||||||
|
data["files"].pop(rel, None)
|
||||||
|
_write_state(root, data)
|
||||||
@@ -7,13 +7,16 @@ ani prezentacji.
|
|||||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
from app import canary, link_crypto, security
|
from app import canary, files, link_crypto, security
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||||
|
from pydantic import BaseModel
|
||||||
from app.providers.factory import build_provider
|
from app.providers.factory import build_provider
|
||||||
|
|
||||||
provider = build_provider(settings)
|
provider = build_provider(settings)
|
||||||
@@ -25,7 +28,12 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||||
|
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||||
|
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||||
|
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||||
|
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan,
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||||
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
||||||
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
@@ -54,6 +62,78 @@ def bases() -> dict:
|
|||||||
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
|
||||||
|
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
|
||||||
|
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
|
||||||
|
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
|
||||||
|
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
|
||||||
|
# z nią wyłącznie warstwa logiczna.
|
||||||
|
|
||||||
|
class FilesQuery(BaseModel):
|
||||||
|
for_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FileAction(BaseModel):
|
||||||
|
path: str
|
||||||
|
status: str = ""
|
||||||
|
by: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpload(BaseModel):
|
||||||
|
filename: str
|
||||||
|
content_b64: str
|
||||||
|
by: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RulesUpdate(BaseModel):
|
||||||
|
rules: dict
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files")
|
||||||
|
def files_list(q: FilesQuery) -> dict:
|
||||||
|
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
|
||||||
|
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
|
||||||
|
root = settings.excel_dir
|
||||||
|
return {"files": files.registry(root, for_admin=q.for_admin),
|
||||||
|
"rules": files.rules(root) if q.for_admin else {},
|
||||||
|
"root": str(root)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/status")
|
||||||
|
def files_status(a: FileAction) -> dict:
|
||||||
|
try:
|
||||||
|
row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(422, str(e)) from e
|
||||||
|
return {"path": a.path, "status": row.get("status")}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/upload")
|
||||||
|
def files_upload(u: FileUpload) -> dict:
|
||||||
|
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
|
||||||
|
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
|
||||||
|
byłby obejściem PRE-16."""
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(u.content_b64, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as e:
|
||||||
|
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
|
||||||
|
return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/delete")
|
||||||
|
def files_delete(a: FileAction) -> dict:
|
||||||
|
try:
|
||||||
|
files.delete(settings.excel_dir, a.path)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(422, str(e)) from e
|
||||||
|
return {"deleted": a.path}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/rules")
|
||||||
|
def files_rules(u: RulesUpdate) -> dict:
|
||||||
|
return {"rules": files.set_rules(settings.excel_dir, u.rules)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthInfo)
|
@app.get("/health", response_model=HealthInfo)
|
||||||
def health() -> HealthInfo:
|
def health() -> HealthInfo:
|
||||||
return provider.health()
|
return provider.health()
|
||||||
|
|||||||
@@ -92,20 +92,33 @@ class ExcelDataProvider(DataProvider):
|
|||||||
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]) -> list[str]:
|
def _enabled_files(self, paths: list[str]) -> list[str]:
|
||||||
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
|
"""Bazy biorące udział w wyszukiwaniu.
|
||||||
w interpretacji, choć fizycznie leżą na udziale."""
|
|
||||||
from app import bases
|
|
||||||
|
|
||||||
|
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
|
||||||
|
trwały na udziale. Zmienna DISABLED_BASES z DAN-15 zostaje jako awaryjne
|
||||||
|
wyłączenie z konfiguracji: gdy jest ustawiona, odsiewa DODATKOWO. Nie
|
||||||
|
odwrotnie — inaczej ktoś z dostępem do ekranu mógłby włączyć bazę
|
||||||
|
wyłączoną świadomie na poziomie wdrożenia.
|
||||||
|
"""
|
||||||
|
# `bases` MUSI być zaimportowane tutaj — modułowego importu nie ma,
|
||||||
|
# a przepisując tę funkcję pod rejestr usunąłem lokalny. Efekt: NameError
|
||||||
|
# przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych.
|
||||||
|
from app import bases, files
|
||||||
|
|
||||||
|
usable = set(files.usable_paths(self.s.excel_dir))
|
||||||
|
out = [p for p in paths if p in usable]
|
||||||
entries = bases.disabled_entries()
|
entries = bases.disabled_entries()
|
||||||
if not entries:
|
if entries:
|
||||||
return paths
|
out = [p for p in out if bases.is_enabled(p, self.s.excel_dir, entries)]
|
||||||
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
|
return out
|
||||||
|
|
||||||
def list_bases(self) -> list[dict]:
|
def list_bases(self) -> list[dict]:
|
||||||
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
|
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
|
||||||
from app import bases
|
from app import bases
|
||||||
|
|
||||||
return bases.list_bases(self.s.excel_dir, self._excel_files())
|
from app import files
|
||||||
|
|
||||||
|
return files.registry(self.s.excel_dir, for_admin=True)
|
||||||
|
|
||||||
# ---- publiczne API ----
|
# ---- publiczne API ----
|
||||||
def search(self, query: SearchQuery) -> SearchResult:
|
def search(self, query: SearchQuery) -> SearchResult:
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ pandas>=2.2
|
|||||||
openpyxl>=3.1
|
openpyxl>=3.1
|
||||||
pyarrow>=18.0
|
pyarrow>=18.0
|
||||||
SQLAlchemy>=2.0
|
SQLAlchemy>=2.0
|
||||||
|
# Sterownik Postgresa (DAN-28). Sam SQLAlchemy nie rozmawia z bazą — bez tego
|
||||||
|
# `postgresql+psycopg://…` wywala się dopiero przy PIERWSZYM połączeniu, już na
|
||||||
|
# klastrze, komunikatem o braku modułu. [binary] = gotowe koło, bez kompilacji
|
||||||
|
# libpq w obrazie.
|
||||||
|
psycopg[binary]>=3.2
|
||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
cryptography>=44.0
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Rejestr plików baz: stany, walidacja, archiwizacja (DAN-27).
|
||||||
|
|
||||||
|
Testujemy tu RDZEŃ — bez HTTP i bez uprawnień, bo uprawnienia rozstrzyga
|
||||||
|
prezentacja (patrz services/presentation/tests/test_pliki.py). Tutaj chodzi
|
||||||
|
o to, żeby żadna operacja nie gubiła pliku i żeby bramka „do użytku tylko po
|
||||||
|
walidacji" faktycznie trzymała.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import files
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def root(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("FILES_STATE", raising=False)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _xlsx(path, rows=3, header=("id", "opis")):
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(list(header))
|
||||||
|
for i in range(rows):
|
||||||
|
ws.append([i, f"treść {i}"])
|
||||||
|
wb.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ── rejestr i stany ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_a_file_dropped_on_the_share_is_adopted_as_active(root):
|
||||||
|
"""Baza położona na udziale poza aplikacją (np. przez NFS) ma działać —
|
||||||
|
tak było przed DAN-27 i tak ma zostać. Plik WGRANY EKRANEM to inna sprawa:
|
||||||
|
ten wymaga świadomego włączenia (patrz test niżej)."""
|
||||||
|
_xlsx(root / "baza.xlsx")
|
||||||
|
entry = files.registry(root)[0]
|
||||||
|
assert entry["status"] == files.ACTIVE
|
||||||
|
assert entry["in_use"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_active_files_reach_the_search(root):
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
_xlsx(root / "b.xlsx")
|
||||||
|
files.registry(root) # przyjęcie zastanych
|
||||||
|
files.set_status(root, "b.xlsx", files.READY) # świadome odstawienie
|
||||||
|
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"]
|
||||||
|
files.set_status(root, "b.xlsx", files.ACTIVE)
|
||||||
|
assert len(files.usable_paths(root)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_survives_a_restart(root):
|
||||||
|
"""Stan jest KLIKANY, więc musi być trwały — inaczej restart poda po cichu
|
||||||
|
przywracałby bazy wyłączone świadomie."""
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
files.set_status(root, "a.xlsx", files.ACTIVE)
|
||||||
|
assert files.state_path(root).exists()
|
||||||
|
assert files.registry(root)[0]["in_use"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── archiwizacja ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_archiving_freezes_the_file_but_never_removes_it(root):
|
||||||
|
"""To jest najdalej idąca operacja osoby wgrywającej dane: plik ZOSTAJE."""
|
||||||
|
p = _xlsx(root / "stara.xlsx")
|
||||||
|
files.set_status(root, "stara.xlsx", files.ACTIVE)
|
||||||
|
files.set_status(root, "stara.xlsx", files.ARCHIVED, by="dane")
|
||||||
|
entry = files.registry(root)[0]
|
||||||
|
assert p.exists(), "plik zniknął z dysku — archiwizacja ma go zachować"
|
||||||
|
assert entry["status"] == files.ARCHIVED
|
||||||
|
assert entry["in_use"] is False
|
||||||
|
assert entry["archived_at"], "brak znacznika czasu archiwizacji"
|
||||||
|
|
||||||
|
|
||||||
|
def test_archived_file_cannot_slip_back_into_use_by_itself(root):
|
||||||
|
_xlsx(root / "stara.xlsx")
|
||||||
|
files.set_status(root, "stara.xlsx", files.ARCHIVED)
|
||||||
|
assert files.usable_paths(root) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── walidacja: bramka do użytku ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_upload_keeps_a_file_that_fails_validation(root):
|
||||||
|
"""Rzecz najważniejsza: wgranego pliku NIE TRACIMY, choćby nie przeszedł."""
|
||||||
|
files.set_rules(root, {"extensions": [".xlsx"]})
|
||||||
|
out = files.store_upload(root, "notatka.txt", "to nie jest baza".encode("utf-8"), by="dane")
|
||||||
|
assert out["accepted"] is False
|
||||||
|
assert (root / out["path"]).exists(), "plik odrzucony zniknął z dysku"
|
||||||
|
admin_view = files.registry(root, for_admin=True)[0]
|
||||||
|
assert admin_view["status"] == files.QUARANTINE
|
||||||
|
assert admin_view["rejected_for"], "administrator ma widzieć powód"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_held_file_is_invisible_without_admin_rights(root):
|
||||||
|
"""Gdyby plik wstrzymany był widoczny z powodem odrzucenia, każdy wgrywający
|
||||||
|
poznałby reguły walidacji — a te są narzędziem administratora."""
|
||||||
|
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
|
||||||
|
assert files.registry(root, for_admin=False) == []
|
||||||
|
assert len(files.registry(root, for_admin=True)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_held_file_cannot_be_switched_into_use(root):
|
||||||
|
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
|
||||||
|
rel = files.registry(root, for_admin=True)[0]["path"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
files.set_status(root, rel, files.ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_activation_revalidates_and_holds_a_file_that_stopped_qualifying(root):
|
||||||
|
"""Reguły mogą się zmienić PO wgraniu — bramka sprawdza w chwili włączania,
|
||||||
|
a nie tylko przy wgrywaniu."""
|
||||||
|
_xlsx(root / "mala.xlsx", rows=2)
|
||||||
|
files.set_status(root, "mala.xlsx", files.ACTIVE)
|
||||||
|
files.set_rules(root, {"min_rows": 500})
|
||||||
|
files.set_status(root, "mala.xlsx", files.READY)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
files.set_status(root, "mala.xlsx", files.ACTIVE)
|
||||||
|
assert (root / "mala.xlsx").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rule,value,bad", [
|
||||||
|
("extensions", [".xlsx"], "plik.csv"),
|
||||||
|
("max_size_mb", 0.000001, "plik.xlsx"),
|
||||||
|
])
|
||||||
|
def test_rules_reject_what_they_are_meant_to(root, rule, value, bad):
|
||||||
|
files.set_rules(root, {rule: value})
|
||||||
|
out = files.store_upload(root, bad, b"x" * 2048)
|
||||||
|
assert out["accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_columns_are_checked_inside_the_workbook(root):
|
||||||
|
files.set_rules(root, {"required_columns": ["id", "znaczenie"]})
|
||||||
|
_xlsx(root / "tmp.xlsx", header=("id", "opis"))
|
||||||
|
why = files.validate(root / "tmp.xlsx", root)
|
||||||
|
assert why and "znaczenie" in why[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_content_is_rejected_by_hash_not_by_name(root):
|
||||||
|
files.set_rules(root, {"reject_duplicate_content": True})
|
||||||
|
data = _xlsx(root / "wzor.xlsx").read_bytes()
|
||||||
|
first = files.store_upload(root, "pierwsza.xlsx", data)
|
||||||
|
assert first["accepted"] is True
|
||||||
|
second = files.store_upload(root, "inna-nazwa.xlsx", data)
|
||||||
|
assert second["accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_never_overwrites_someone_elses_base(root):
|
||||||
|
files.store_upload(root, "baza.xlsx", _xlsx(root / "w.xlsx").read_bytes())
|
||||||
|
(root / "w.xlsx").unlink()
|
||||||
|
files.set_rules(root, {"reject_duplicate_content": False})
|
||||||
|
out = files.store_upload(root, "baza.xlsx", "inna treść".encode("utf-8"))
|
||||||
|
assert out["name"] != "baza.xlsx"
|
||||||
|
assert (root / "baza.xlsx").exists() and (root / out["path"]).exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ── kasowanie ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_delete_removes_the_file_and_its_entry(root):
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
files.set_status(root, "a.xlsx", files.ACTIVE)
|
||||||
|
files.delete(root, "a.xlsx")
|
||||||
|
assert not (root / "a.xlsx").exists()
|
||||||
|
assert files.registry(root, for_admin=True) == []
|
||||||
|
assert files.usable_paths(root) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── przejście na rejestr nie może wyłączyć wyszukiwania ─────────────────
|
||||||
|
|
||||||
|
def test_bases_already_on_the_share_stay_in_use_after_the_switch(root):
|
||||||
|
"""Dotąd bazy działały domyślnie (wyłączało się je przez DISABLED_BASES).
|
||||||
|
Po przejściu na rejestr pusty stan oznaczałby, że program nagle niczego nie
|
||||||
|
znajduje — cicha zmiana zachowania gorsza od awarii, bo wygląda jak pusta baza."""
|
||||||
|
_xlsx(root / "main_base.xlsx")
|
||||||
|
_xlsx(root / "zodiac_pl.xlsx")
|
||||||
|
assert len(files.usable_paths(root)) == 2, "zastane bazy wypadły z wyszukiwania"
|
||||||
|
assert all(e["in_use"] for e in files.registry(root))
|
||||||
|
|
||||||
|
|
||||||
|
def test_adoption_happens_once_and_respects_later_decisions(root):
|
||||||
|
"""Po przyjęciu stan jest zapisany, więc świadome odstawienie bazy ZOSTAJE —
|
||||||
|
kolejny odczyt nie może jej wskrzesić."""
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
_xlsx(root / "b.xlsx")
|
||||||
|
files.registry(root) # przyjęcie
|
||||||
|
files.set_status(root, "a.xlsx", files.READY) # świadome odstawienie
|
||||||
|
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["b.xlsx"]
|
||||||
|
files.set_status(root, "b.xlsx", files.READY) # odstawiamy wszystko
|
||||||
|
assert files.usable_paths(root) == [], "pusty wybór został wskrzeszony"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_files_still_need_an_explicit_switch_on(root):
|
||||||
|
"""Przyjęcie dotyczy TYLKO baz zastanych. Plik wgrany ekranem ktoś musi
|
||||||
|
świadomie włączyć — inaczej nowa baza wchodziłaby do wyników sama."""
|
||||||
|
_xlsx(root / "zastana.xlsx")
|
||||||
|
files.registry(root)
|
||||||
|
out = files.store_upload(root, "nowa.xlsx", _xlsx(root / "tmp.xlsx").read_bytes())
|
||||||
|
assert out["accepted"] is True
|
||||||
|
names = [pathlib.Path(p).name for p in files.usable_paths(root)]
|
||||||
|
assert "nowa.xlsx" not in names, "wgrana baza weszła do wyników bez decyzji"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adoption_survives_a_read_only_share(root, monkeypatch):
|
||||||
|
"""Na udziale tylko do odczytu stanu nie da się zapisać — zachowanie ma
|
||||||
|
zostać to samo, tylko przyjęcie powtórzy się przy każdym uruchomieniu."""
|
||||||
|
_xlsx(root / "a.xlsx")
|
||||||
|
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise OSError("read-only file system")
|
||||||
|
|
||||||
|
monkeypatch.setattr(files, "_write_state", boom)
|
||||||
|
assert len(files.usable_paths(root)) == 1
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Rejestr plików a RESZTA warstwy danych — punkty styku (DAN-27).
|
||||||
|
|
||||||
|
DLACZEGO OSOBNY PLIK. test_files.py sprawdza sam rejestr w izolacji i przechodził
|
||||||
|
na zielono, podczas gdy na produkcji leżało wyszukiwanie (500) i lista baz (502).
|
||||||
|
Rejestr wszedł w miejsce starego mechanizmu włączania baz, więc groźne jest nie
|
||||||
|
to, co robi w środku, tylko czy MÓWI TYM SAMYM JĘZYKIEM, co jego odbiorcy.
|
||||||
|
|
||||||
|
Oba tamte błędy były jednolinijkowe i oba niewidoczne dla testów jednostkowych:
|
||||||
|
* NameError, bo przepisując `_enabled_files` usunąłem lokalny import `bases`,
|
||||||
|
* KeyError, bo rejestr oddawał `in_use`, a endpoint /bases czytał `enabled`.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def excel_dir(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("EXCEL_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("CACHE_DIR", str(tmp_path / ".cache"))
|
||||||
|
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||||
|
monkeypatch.delenv("FILES_STATE", raising=False)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _xlsx(path, rows=(("Ma Ari", "odważny"), ("Ve Tau", "zgodny"))):
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(["significator", "effect"])
|
||||||
|
for r in rows:
|
||||||
|
ws.append(list(r))
|
||||||
|
wb.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _provider(excel_dir):
|
||||||
|
from app.config import Settings
|
||||||
|
from app.providers.excel_provider import ExcelDataProvider
|
||||||
|
|
||||||
|
return ExcelDataProvider(Settings())
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_does_not_explode_on_the_registry(excel_dir):
|
||||||
|
"""Regresja: `_enabled_files` wołało bases.disabled_entries() bez importu,
|
||||||
|
więc KAŻDE wyszukiwanie kończyło się NameError → 500 z warstwy danych."""
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_xlsx(excel_dir / "baza.xlsx")
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
|
||||||
|
assert out.total >= 1, "zastana baza nie weszła do wyszukiwania"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_still_works_with_disabled_bases_set(excel_dir, monkeypatch):
|
||||||
|
"""DISABLED_BASES zostaje jako awaryjne wyłączenie i ma odsiewać DODATKOWO —
|
||||||
|
to właśnie ta gałąź kodu wywalała się na braku importu."""
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_xlsx(excel_dir / "a.xlsx")
|
||||||
|
_xlsx(excel_dir / "b.xlsx")
|
||||||
|
monkeypatch.setenv("DISABLED_BASES", "b.xlsx")
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
|
||||||
|
assert out.total >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_speaks_the_language_the_bases_endpoint_reads(excel_dir):
|
||||||
|
"""Regresja: endpoint /bases liczy `b["enabled"]`, rejestr oddawał `in_use`.
|
||||||
|
KeyError → 500 z danych → 502 z logiki → „Warstwa logiczna niedostępna"
|
||||||
|
na ekranie Ustawienia."""
|
||||||
|
_xlsx(excel_dir / "baza.xlsx")
|
||||||
|
items = _provider(excel_dir).list_bases()
|
||||||
|
assert items, "lista baz jest pusta"
|
||||||
|
for row in items:
|
||||||
|
for key in ("name", "path", "enabled", "in_use", "size_mb", "modified"):
|
||||||
|
assert key in row, f"brak pola `{key}` — odbiorca dostanie KeyError"
|
||||||
|
assert row["enabled"] == row["in_use"], "dwa pola, jedna prawda"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bases_endpoint_answers_end_to_end(excel_dir):
|
||||||
|
"""Przez TRASĘ, nie przez dostawcę: to ona wywalała się na produkcji."""
|
||||||
|
_xlsx(excel_dir / "baza.xlsx")
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from app import main as data_main
|
||||||
|
|
||||||
|
importlib.reload(data_main)
|
||||||
|
body = data_main.bases()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["enabled"] == 1, "zastana baza powinna być włączona po adopcji"
|
||||||
|
assert body["bases"][0]["name"] == "baza.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_a_base_off_is_visible_in_both_places(excel_dir):
|
||||||
|
"""Odstawienie bazy ma zniknąć i z wyszukiwania, i z licznika na Ustawieniach."""
|
||||||
|
from app import files
|
||||||
|
from app.models import SearchQuery
|
||||||
|
|
||||||
|
_xlsx(excel_dir / "baza.xlsx")
|
||||||
|
p = _provider(excel_dir)
|
||||||
|
p.warmup()
|
||||||
|
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1
|
||||||
|
|
||||||
|
files.set_status(excel_dir, "baza.xlsx", files.READY)
|
||||||
|
p2 = _provider(excel_dir)
|
||||||
|
p2.warmup()
|
||||||
|
assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0
|
||||||
|
assert [b["enabled"] for b in p2.list_bases()] == [False]
|
||||||
@@ -20,7 +20,12 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
import swisseph as swe
|
import swisseph as swe
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
|
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||||
|
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||||
|
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||||
|
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||||
|
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)",
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
||||||
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
||||||
|
|||||||
@@ -51,6 +51,33 @@ class DataClient:
|
|||||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||||
headers=_auth_headers(), link=_link())
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
|
||||||
|
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
|
||||||
|
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
|
||||||
|
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
|
||||||
|
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
|
||||||
|
|
||||||
|
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
|
||||||
|
return self._files_post("/files", {"for_admin": for_admin})
|
||||||
|
|
||||||
|
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
|
||||||
|
|
||||||
|
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/upload",
|
||||||
|
{"filename": filename, "content_b64": content_b64, "by": by})
|
||||||
|
|
||||||
|
def files_delete(self, path: str) -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/delete", {"path": path})
|
||||||
|
|
||||||
|
def files_rules(self, rules: dict) -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/rules", {"rules": rules})
|
||||||
|
|
||||||
|
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||||
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||||
# nie mają, a nie przechodzi tędy nic z baz.
|
# nie mają, a nie przechodzi tędy nic z baz.
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ from app.clients.data_client import DataClient
|
|||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
from app.service import QueryService
|
from app.service import QueryService
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||||
|
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||||
|
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||||
|
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||||
|
app = FastAPI(title="astrololo · warstwa logiczna",
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
service = QueryService()
|
service = QueryService()
|
||||||
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||||
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
||||||
@@ -121,6 +126,78 @@ def bases() -> dict:
|
|||||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── zarządzanie plikami baz (DAN-27) — czysty przelot ───────────────────
|
||||||
|
# Warstwa logiczna niczego tu nie rozstrzyga: uprawnienia zna PREZENTACJA
|
||||||
|
# (PRE-27), właścicielem plików jest warstwa DANYCH. Ta warstwa tylko przenosi,
|
||||||
|
# bo prezentacja nie ma prawa rozmawiać z danymi wprost.
|
||||||
|
|
||||||
|
class FilesQuery(BaseModel):
|
||||||
|
for_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FileAction(BaseModel):
|
||||||
|
path: str
|
||||||
|
status: str = ""
|
||||||
|
by: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpload(BaseModel):
|
||||||
|
filename: str
|
||||||
|
content_b64: str
|
||||||
|
by: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RulesUpdate(BaseModel):
|
||||||
|
rules: dict
|
||||||
|
|
||||||
|
|
||||||
|
def _files_call(fn, *args, **kw) -> dict:
|
||||||
|
from app.clients.data_client import DataClient
|
||||||
|
|
||||||
|
try:
|
||||||
|
return fn(DataClient(), *args, **kw)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
|
||||||
|
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
|
||||||
|
# się pokazać powód zamiast „usługa niedostępna".
|
||||||
|
raise HTTPException(status_code=e.response.status_code,
|
||||||
|
detail=_detail(e)) from e
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(e: httpx.HTTPStatusError) -> str:
|
||||||
|
try:
|
||||||
|
return str(e.response.json().get("detail") or e)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return str(e)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files")
|
||||||
|
def files_list(q: FilesQuery) -> dict:
|
||||||
|
return _files_call(lambda c: c.files_list(q.for_admin))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/status")
|
||||||
|
def files_status(a: FileAction) -> dict:
|
||||||
|
return _files_call(lambda c: c.files_status(a.path, a.status, a.by))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/upload")
|
||||||
|
def files_upload(u: FileUpload) -> dict:
|
||||||
|
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/delete")
|
||||||
|
def files_delete(a: FileAction) -> dict:
|
||||||
|
return _files_call(lambda c: c.files_delete(a.path))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/rules")
|
||||||
|
def files_rules(u: RulesUpdate) -> dict:
|
||||||
|
return _files_call(lambda c: c.files_rules(u.rules))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/chart/synastry")
|
@app.post("/chart/synastry")
|
||||||
def chart_synastry(req: SynastryRequest) -> dict:
|
def chart_synastry(req: SynastryRequest) -> dict:
|
||||||
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ def all_users() -> dict[str, dict]:
|
|||||||
for login, row in _read()["users"].items():
|
for login, row in _read()["users"].items():
|
||||||
out[login] = {
|
out[login] = {
|
||||||
"features": sorted(features.normalise(row.get("features"))),
|
"features": sorted(features.normalise(row.get("features"))),
|
||||||
|
"session_version": int(row.get("session_version") or 0),
|
||||||
"note": str(row.get("note") or ""),
|
"note": str(row.get("note") or ""),
|
||||||
"created": str(row.get("created") or ""),
|
"created": str(row.get("created") or ""),
|
||||||
}
|
}
|
||||||
@@ -131,6 +132,31 @@ def secret_of(login: str) -> str | None:
|
|||||||
return (_read()["users"].get(login) or {}).get("secret")
|
return (_read()["users"].get(login) or {}).get("secret")
|
||||||
|
|
||||||
|
|
||||||
|
def session_version(login: str) -> int:
|
||||||
|
"""Numer pokolenia sesji konta (LOG-34).
|
||||||
|
|
||||||
|
Podbicie unieważnia WSZYSTKIE otwarte sesje tego konta, także na innych
|
||||||
|
urządzeniach — bez zmiany hasła. Zmiana hasła robi to samo niejako przy
|
||||||
|
okazji (zmienia się odcisk w ciasteczku), więc licznik jest potrzebny tylko
|
||||||
|
do „wyrzuć zewsząd, ale hasło zostaw"."""
|
||||||
|
try:
|
||||||
|
return int((_read()["users"].get(login) or {}).get("session_version") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def bump_session(login: str) -> int:
|
||||||
|
"""Unieważnia wszystkie sesje konta. Podnosi ValueError, gdy konta nie ma."""
|
||||||
|
with _lock:
|
||||||
|
data = _read()
|
||||||
|
row = data["users"].get(login)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"Nie ma konta „{login}”.")
|
||||||
|
row["session_version"] = session_version(login) + 1
|
||||||
|
_write(data)
|
||||||
|
return row["session_version"]
|
||||||
|
|
||||||
|
|
||||||
def permissions_of(login: str) -> frozenset[str]:
|
def permissions_of(login: str) -> frozenset[str]:
|
||||||
return features.normalise((_read()["users"].get(login) or {}).get("features"))
|
return features.normalise((_read()["users"].get(login) or {}).get("features"))
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,33 @@ class LogicClient:
|
|||||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||||
headers=_auth_headers(), link=_link())
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
|
||||||
|
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
|
||||||
|
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
|
||||||
|
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
|
||||||
|
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
|
||||||
|
|
||||||
|
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
|
||||||
|
return self._files_post("/files", {"for_admin": for_admin})
|
||||||
|
|
||||||
|
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
|
||||||
|
|
||||||
|
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/upload",
|
||||||
|
{"filename": filename, "content_b64": content_b64, "by": by})
|
||||||
|
|
||||||
|
def files_delete(self, path: str) -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/delete", {"path": path})
|
||||||
|
|
||||||
|
def files_rules(self, rules: dict) -> dict[str, Any]:
|
||||||
|
return self._files_post("/files/rules", {"rules": rules})
|
||||||
|
|
||||||
|
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||||
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
def llm_models(self) -> dict[str, Any]:
|
def llm_models(self) -> dict[str, Any]:
|
||||||
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ SCREENS: tuple[Feature, ...] = (
|
|||||||
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
|
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
|
||||||
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
|
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
|
||||||
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
|
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
|
||||||
|
Feature("files", "Pliki", "Wybór baz, z których korzysta program.", "Ekrany", "/files"),
|
||||||
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
|
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,6 +58,10 @@ EXTRAS: tuple[Feature, ...] = (
|
|||||||
Feature("ai", "Generowanie tekstu przez model",
|
Feature("ai", "Generowanie tekstu przez model",
|
||||||
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
|
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
|
||||||
"Rozszerzenia"),
|
"Rozszerzenia"),
|
||||||
|
Feature("files_input", "Wgrywanie i archiwizacja baz",
|
||||||
|
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
|
||||||
|
"zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
|
||||||
|
"Rozszerzenia"),
|
||||||
Feature("export", "Eksport plików",
|
Feature("export", "Eksport plików",
|
||||||
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
|
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
|
||||||
)
|
)
|
||||||
@@ -84,6 +89,17 @@ ROUTES: dict[tuple[str, str], str | None] = {
|
|||||||
("POST", "/compile"): "compile",
|
("POST", "/compile"): "compile",
|
||||||
("POST", "/compile/pdf"): "export",
|
("POST", "/compile/pdf"): "export",
|
||||||
("GET", "/settings"): "settings",
|
("GET", "/settings"): "settings",
|
||||||
|
# Zarządzanie plikami baz (DAN-27). Trzy poziomy: „files" wybiera, z czego
|
||||||
|
# program korzysta; „files_input" dokłada wgrywanie i archiwizację;
|
||||||
|
# kasowanie, przywracanie i REGUŁY WALIDACJI to wyłącznie administrator —
|
||||||
|
# o istnieniu walidacji nikt poza nim nie ma skąd wiedzieć.
|
||||||
|
("GET", "/files"): "files",
|
||||||
|
("POST", "/files/use"): "files",
|
||||||
|
("POST", "/files/upload"): "files_input",
|
||||||
|
("POST", "/files/archive"): "files_input",
|
||||||
|
("POST", "/files/restore"): ADMIN,
|
||||||
|
("POST", "/files/delete"): ADMIN,
|
||||||
|
("POST", "/files/rules"): ADMIN,
|
||||||
("POST", "/horoscope/stream"): "ai",
|
("POST", "/horoscope/stream"): "ai",
|
||||||
("GET", "/accounts"): ADMIN,
|
("GET", "/accounts"): ADMIN,
|
||||||
("POST", "/accounts/create"): ADMIN,
|
("POST", "/accounts/create"): ADMIN,
|
||||||
@@ -93,20 +109,86 @@ ROUTES: dict[tuple[str, str], str | None] = {
|
|||||||
("GET", "/reverse"): None,
|
("GET", "/reverse"): None,
|
||||||
("GET", "/timezone"): None,
|
("GET", "/timezone"): None,
|
||||||
("GET", "/health"): None,
|
("GET", "/health"): None,
|
||||||
|
# Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego,
|
||||||
|
# co mu przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
|
||||||
|
("GET", "/logowanie"): None,
|
||||||
|
("POST", "/logowanie"): None,
|
||||||
|
("POST", "/wyloguj"): None,
|
||||||
|
("POST", "/accounts/sesje"): ADMIN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def required(method: str, path: str) -> str | None:
|
# ── zasoby statyczne ─────────────────────────────────────────────────────
|
||||||
|
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
|
||||||
|
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `models.js`
|
||||||
|
# wymienia dostawców modeli, `compile.js` — składanie raportu. Dlatego każdy
|
||||||
|
# zasób przechodzi przez tę samą bramkę co ekrany.
|
||||||
|
#
|
||||||
|
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
|
||||||
|
# siedzi na trzech ekranach i konto z którymkolwiek z nich musi ją dostać.
|
||||||
|
# `None` = każdy zalogowany. Zasób spoza mapy wymaga admina — jak trasa.
|
||||||
|
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
|
||||||
|
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
|
||||||
|
_KOLO = frozenset({"chart", "compile"})
|
||||||
|
|
||||||
|
STATIC: dict[str, frozenset[str] | str | None] = {
|
||||||
|
# arkusze ekranów — jeden ekran, jedno uprawnienie
|
||||||
|
"s-chart.css": "chart",
|
||||||
|
"s-interpret.css": "interpret",
|
||||||
|
"s-timeline.css": "timeline",
|
||||||
|
"s-synastry.css": "synastry",
|
||||||
|
"s-significators.css": "significators",
|
||||||
|
"s-compile.css": "compile",
|
||||||
|
"s-files.css": "files",
|
||||||
|
"s-settings.css": "settings",
|
||||||
|
"s-accounts.css": ADMIN,
|
||||||
|
"x-ai.css": "ai",
|
||||||
|
# skrypty
|
||||||
|
"formsync.js": _FORMULARZE,
|
||||||
|
"now.js": _LOKALIZACJA,
|
||||||
|
"geo.js": _LOKALIZACJA,
|
||||||
|
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
|
||||||
|
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
|
||||||
|
"wheelzoom.js": _KOLO,
|
||||||
|
"wheeltip.js": _KOLO,
|
||||||
|
"copy.js": frozenset({"interpret", "timeline"}),
|
||||||
|
"compile.js": "compile",
|
||||||
|
"models.js": "ai",
|
||||||
|
"progress.js": "ai",
|
||||||
|
"natal.js": "ai",
|
||||||
|
"predictions.js": "ai",
|
||||||
|
# base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
|
||||||
|
# publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
|
||||||
|
# niczego, co nazywa jakąkolwiek funkcję.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def static_required(name: str) -> frozenset[str] | str | None:
|
||||||
|
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
|
||||||
|
return STATIC.get(name, ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
def required(method: str, path: str) -> frozenset[str] | str | None:
|
||||||
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
|
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
|
||||||
|
|
||||||
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
|
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
|
||||||
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
|
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
|
||||||
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
|
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
|
||||||
|
if path.startswith("/static/"):
|
||||||
|
return static_required(path[len("/static/"):])
|
||||||
return ROUTES.get((method.upper(), path), ADMIN)
|
return ROUTES.get((method.upper(), path), ADMIN)
|
||||||
|
|
||||||
|
|
||||||
def can(perms: frozenset[str], feature: str | None) -> bool:
|
def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
|
||||||
return feature is None or feature in perms
|
"""Czy te uprawnienia otwierają tę funkcję.
|
||||||
|
|
||||||
|
Zbiór znaczy „wystarczy którekolwiek": jeden zasób bywa wspólny dla kilku
|
||||||
|
ekranów i nie ma powodu wymagać ich wszystkich naraz."""
|
||||||
|
if feature is None:
|
||||||
|
return True
|
||||||
|
if isinstance(feature, str):
|
||||||
|
return feature in perms
|
||||||
|
return bool(perms & feature)
|
||||||
|
|
||||||
|
|
||||||
def screens_for(perms: frozenset[str]) -> list[Feature]:
|
def screens_for(perms: frozenset[str]) -> list[Feature]:
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Nazwy stanów pliku bazy — wspólne dla prezentacji i warstwy danych (DAN-27).
|
||||||
|
|
||||||
|
Osobny, króciutki moduł, bo prezentacja NIE MOŻE importować warstwy danych (to
|
||||||
|
inna usługa i inny obraz), a wpisanie tych napisów wprost w handlerach skończyłoby
|
||||||
|
się literówką, która przejdzie testy i wyjdzie dopiero na produkcji.
|
||||||
|
"""
|
||||||
|
ACTIVE = "active"
|
||||||
|
READY = "ready"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
QUARANTINE = "quarantine"
|
||||||
|
|
||||||
|
LABELS = {
|
||||||
|
ACTIVE: "w użyciu",
|
||||||
|
READY: "gotowa, odstawiona",
|
||||||
|
ARCHIVED: "zarchiwizowana",
|
||||||
|
QUARANTINE: "wstrzymana",
|
||||||
|
}
|
||||||
@@ -12,19 +12,24 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
from app import accounts as accounts_store
|
from app import accounts as accounts_store
|
||||||
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 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
|
||||||
@@ -37,8 +42,9 @@ from app.config import DEFAULT_LOCATION_LABEL, default_form
|
|||||||
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
||||||
app = FastAPI(title="astrololo · warstwa prezentacji",
|
app = FastAPI(title="astrololo · warstwa prezentacji",
|
||||||
docs_url=None, redoc_url=None, openapi_url=None)
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
|
||||||
|
log = logging.getLogger("astrololo.prezentacja")
|
||||||
logic = LogicClient()
|
logic = LogicClient()
|
||||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
security.install(app) # logowanie + limit żądań (LOG-32)
|
||||||
|
|
||||||
@@ -51,24 +57,93 @@ security.install(app) # logowanie + limit żądań (LOG-32)
|
|||||||
_STATIC_DIR = Path("app/static")
|
_STATIC_DIR = Path("app/static")
|
||||||
|
|
||||||
|
|
||||||
|
_TYPY_ZASOBOW = {
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bez_komentarzy(tresc: str, sufiks: str) -> str:
|
||||||
|
"""Usuwa komentarze z arkusza albo skryptu przed wysłaniem.
|
||||||
|
|
||||||
|
Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt, a te
|
||||||
|
komentarze opisują funkcje pełnym zdaniem po polsku — łącznie z tym, czego
|
||||||
|
konto ograniczone NIE widzi i kto to widzi. Zostają w repozytorium, bo tam
|
||||||
|
są potrzebne; do odpowiedzi nie mają po co trafiać.
|
||||||
|
|
||||||
|
Kasujemy tylko komentarze blokowe i CAŁE wiersze `//`. Komentarz na końcu
|
||||||
|
wiersza zostaje CELOWO: żeby go usunąć, trzeba rozpoznać, czy `//` nie jest
|
||||||
|
w napisie albo w wyrażeniu regularnym — a pomyłka tutaj psuje skrypt po
|
||||||
|
cichu. Test pilnuje słownika zakazanych słów, więc reszta by i tak wyszła.
|
||||||
|
"""
|
||||||
|
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
|
||||||
|
if sufiks == ".js":
|
||||||
|
tresc = re.sub(r"(?m)^[ \t]*//.*$\n?", "", tresc)
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def _asset_body(name: str) -> bytes | None:
|
||||||
|
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
|
||||||
|
sciezka = (_STATIC_DIR / name).resolve()
|
||||||
|
korzen = _STATIC_DIR.resolve()
|
||||||
|
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
|
||||||
|
if korzen not in sciezka.parents or not sciezka.is_file():
|
||||||
|
return None
|
||||||
|
surowe = sciezka.read_bytes()
|
||||||
|
sufiks = sciezka.suffix.lower()
|
||||||
|
# Kod obcy zostaje nietknięty: minifikowany plik potrafi mieć `//` w napisie
|
||||||
|
# albo w wyrażeniu regularnym, a on i tak nie mówi nic o naszym programie.
|
||||||
|
if sufiks in (".css", ".js") and not name.startswith("vendor/"):
|
||||||
|
try:
|
||||||
|
return _bez_komentarzy(surowe.decode("utf-8"), sufiks).encode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return surowe
|
||||||
|
return surowe
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
def _asset_version(name: str) -> str:
|
def _asset_version(name: str) -> str:
|
||||||
try:
|
tresc = _asset_body(name)
|
||||||
return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
|
# Hash liczony z treści WYSYŁANEJ, nie z pliku na dysku: inaczej zmiana
|
||||||
except OSError:
|
# samego komentarza podbijałaby adres, choć odpowiedź jest ta sama.
|
||||||
return "0" # brak pliku nie może wywrócić strony
|
return hashlib.md5(tresc).hexdigest()[:8] if tresc is not None else "0"
|
||||||
|
|
||||||
|
|
||||||
def static_url(name: str) -> str:
|
def static_url(name: str) -> str:
|
||||||
return f"/static/{name}?v={_asset_version(name)}"
|
return f"/static/{name}?v={_asset_version(name)}"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/static/{nazwa:path}")
|
||||||
|
def static_file(nazwa: str):
|
||||||
|
"""Zasoby idą przez bramkę uprawnień jak każda inna trasa.
|
||||||
|
|
||||||
|
Dopóki `/static/` było w PUBLIC_PREFIXES, katalog omijał całe sprawdzanie:
|
||||||
|
ukrycie przycisku w szablonie nic nie dawało, bo obok leżał skrypt tej samej
|
||||||
|
funkcji, pod nazwą, którą wystarczyło zgadnąć. Uprawnienie zasobu sprawdza
|
||||||
|
middleware (features.static_required); tutaj zostaje samo wydanie pliku.
|
||||||
|
"""
|
||||||
|
tresc = _asset_body(nazwa)
|
||||||
|
if tresc is None:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
return Response(
|
||||||
|
tresc,
|
||||||
|
media_type=_TYPY_ZASOBOW.get(Path(nazwa).suffix.lower(), "application/octet-stream"),
|
||||||
|
# Adres niesie hash treści, więc wersja pod danym adresem jest niezmienna.
|
||||||
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
templates.env.globals["static"] = static_url
|
templates.env.globals["static"] = static_url
|
||||||
# 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]:
|
||||||
@@ -82,6 +157,9 @@ def _perms(request: Request) -> frozenset[str]:
|
|||||||
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
|
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
|
||||||
# nikt już nie pamięta.
|
# nikt już nie pamięta.
|
||||||
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
|
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
|
||||||
|
# Kto jest zalogowany i czy logowanie w ogóle działa — pod przycisk wyjścia.
|
||||||
|
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
|
||||||
|
templates.env.globals["auth_on"] = security.auth_enabled
|
||||||
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +199,35 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
|||||||
return utc.isoformat(), label
|
return utc.isoformat(), label
|
||||||
|
|
||||||
|
|
||||||
|
# Akcja formularza → uprawnienie, którego wymaga. Akcji spoza tej mapy nie
|
||||||
|
# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak
|
||||||
|
# NIEZNANA — czyli wraca do zachowania domyślnego ekranu.
|
||||||
|
_AKCJE_POD_UPRAWNIENIEM = {"prompt": "ai", "horoscope": "ai", "export": "export"}
|
||||||
|
|
||||||
|
|
||||||
|
def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
|
||||||
|
"""Akcja, której konto nie ma prawa użyć, zachowuje się jak literówka.
|
||||||
|
|
||||||
|
NIE zwracamy błędu ani 404: komunikat „brak uprawnień do generowania" sam
|
||||||
|
w sobie mówiłby, że taka funkcja istnieje (PRE-27). Ukrycie przycisku chroni
|
||||||
|
tylko przed przypadkiem — kto zna nazwę pola, wyśle ją wprost, i to TU musi
|
||||||
|
się zatrzymać."""
|
||||||
|
wymagane = _AKCJE_POD_UPRAWNIENIEM.get(action)
|
||||||
|
if wymagane and wymagane not in _perms(request):
|
||||||
|
return domyslna
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_catalog_for(request: Request) -> dict:
|
||||||
|
"""Katalog modeli — pusty, gdy konto nie generuje tekstu przez model.
|
||||||
|
|
||||||
|
Katalog trafia do strony jako blok JSON, więc bez tego ograniczenia nazwy
|
||||||
|
dostawców, modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony
|
||||||
|
Interpretacji i Kalendarza, także dla kont, które o modelach nie mają prawa
|
||||||
|
wiedzieć."""
|
||||||
|
return _llm_catalog() if "ai" in _perms(request) else {}
|
||||||
|
|
||||||
|
|
||||||
def _llm_catalog() -> dict:
|
def _llm_catalog() -> dict:
|
||||||
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||||||
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||||||
@@ -142,13 +249,24 @@ def _report_records(report: dict | None) -> int:
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
# Jedno zdanie dla wszystkich awarii — i CELOWO żadnego szczegółu.
|
||||||
|
# Poprzednia treść wypisywała na ekran nazwę endpointu (/chart/positions), nazwę
|
||||||
|
# podsystemu („silnik efemeryd"), nazwę gałęzi rozwojowej i — przez str(wyjątku
|
||||||
|
# httpx) — wewnętrzny adres z portem sąsiedniej usługi. Dla konta, które ma
|
||||||
|
# widzieć wyłącznie Pliki, to był komplet: co program liczy, że stoi za nim
|
||||||
|
# łańcuch usług, i gdzie zapukać.
|
||||||
|
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
|
||||||
|
|
||||||
|
|
||||||
def _logic_error(e: Exception) -> str:
|
def _logic_error(e: Exception) -> str:
|
||||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.
|
||||||
return (
|
|
||||||
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
|
Odsiew siedzi TUTAJ, a nie w siedemnastu miejscach wywołania: gdyby zależał
|
||||||
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
|
od tego, czy ktoś pamiętał przekazać uprawnienia, prędzej czy później jedno
|
||||||
)
|
wywołanie zostałoby pominięte — i nie byłoby tego jak zauważyć.
|
||||||
return f"Warstwa logiczna niedostępna: {e}"
|
"""
|
||||||
|
log.warning("warstwa logiczna: %s: %s", type(e).__name__, e)
|
||||||
|
return _AWARIA
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||||
@@ -161,13 +279,15 @@ def chart_form(request: Request):
|
|||||||
target = _landing(request)
|
target = _landing(request)
|
||||||
if target:
|
if target:
|
||||||
return RedirectResponse(target, status_code=303)
|
return RedirectResponse(target, status_code=303)
|
||||||
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>"
|
# Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
|
||||||
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
|
# mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
|
||||||
status_code=200)
|
# czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
|
||||||
|
# a twoje konto zostało okrojone.
|
||||||
|
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "chart.html",
|
request, "chart.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
"llm_catalog": _llm_catalog()},
|
"llm_catalog": _llm_catalog_for(request)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -464,7 +584,7 @@ def interpret_form(request: Request):
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "interpret.html",
|
request, "interpret.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
"llm_catalog": _llm_catalog()},
|
"llm_catalog": _llm_catalog_for(request)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -486,8 +606,9 @@ def interpret_run(
|
|||||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||||
|
action = _dozwolona_akcja(request, action, "report")
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
"llm_catalog": _llm_catalog()}
|
"llm_catalog": _llm_catalog_for(request)}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
@@ -532,7 +653,7 @@ def timeline_form(request: Request):
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "timeline.html",
|
request, "timeline.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
"llm_catalog": _llm_catalog()},
|
"llm_catalog": _llm_catalog_for(request)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -555,8 +676,9 @@ def timeline_run(
|
|||||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||||
|
action = _dozwolona_akcja(request, action, "timeline")
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
"llm_catalog": _llm_catalog()}
|
"llm_catalog": _llm_catalog_for(request)}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
@@ -672,6 +794,142 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00")
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Pliki baz (DAN-27) ----------------
|
||||||
|
# Trzy poziomy dostępu, opisane w features.ROUTES:
|
||||||
|
# „files" — widzi listę i decyduje, z czego program korzysta,
|
||||||
|
# „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony
|
||||||
|
# ze znacznikiem czasu, znika tylko z użytku),
|
||||||
|
# administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
|
||||||
|
#
|
||||||
|
# Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE
|
||||||
|
# DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby
|
||||||
|
# takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać
|
||||||
|
# reguły — a te ma znać wyłącznie administrator.
|
||||||
|
|
||||||
|
def _files_context(request: Request, error: str = "", done: str = "") -> dict:
|
||||||
|
is_admin = features.ADMIN in _perms(request)
|
||||||
|
try:
|
||||||
|
data = logic.files_list(for_admin=is_admin)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"files": [], "rules": {}, "is_admin": is_admin,
|
||||||
|
"error": _logic_error(e), "done": ""}
|
||||||
|
return {"files": data.get("files") or [], "rules": data.get("rules") or {},
|
||||||
|
"is_admin": is_admin, "error": error, "done": done}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/files", response_class=HTMLResponse)
|
||||||
|
def files_view(request: Request, error: str = "", done: str = ""):
|
||||||
|
return templates.TemplateResponse(request, "files.html",
|
||||||
|
_files_context(request, error, done))
|
||||||
|
|
||||||
|
|
||||||
|
def _files_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||||
|
return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
def _who(request: Request) -> str:
|
||||||
|
return getattr(request.state, "user", "") or "-"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/use")
|
||||||
|
def files_use(request: Request, path: str = Form(...), use: str = Form("")):
|
||||||
|
"""Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji
|
||||||
|
w warstwie danych — odmowa wraca BEZ POWODU, bo powód zdradzałby reguły."""
|
||||||
|
want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \
|
||||||
|
else files_state.READY
|
||||||
|
try:
|
||||||
|
logic.files_status(path, want, by=_who(request))
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
detail = _http_detail(e)
|
||||||
|
if features.ADMIN not in _perms(request):
|
||||||
|
detail = "Tego pliku nie da się teraz włączyć do użytku."
|
||||||
|
return _files_redirect(error=detail)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done="Zapisano." if want == files_state.READY
|
||||||
|
else "Plik włączony do użytku.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/upload")
|
||||||
|
async def files_upload(request: Request, upload: UploadFile = File(...)):
|
||||||
|
"""Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji —
|
||||||
|
nie tracimy niczego, co ktoś wgrał."""
|
||||||
|
raw = await upload.read()
|
||||||
|
if not raw:
|
||||||
|
return _files_redirect(error="Pusty plik.")
|
||||||
|
try:
|
||||||
|
out = logic.files_upload(upload.filename or "plik.xlsx",
|
||||||
|
base64.b64encode(raw).decode("ascii"), by=_who(request))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
# DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
|
||||||
|
# komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
|
||||||
|
# odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
|
||||||
|
# Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
|
||||||
|
# musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
|
||||||
|
# i że plik przechodzi przez jakąś bramkę.
|
||||||
|
return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/archive")
|
||||||
|
def files_archive(request: Request, path: str = Form(...)):
|
||||||
|
"""Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko
|
||||||
|
z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane."""
|
||||||
|
try:
|
||||||
|
logic.files_status(path, files_state.ARCHIVED, by=_who(request))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/restore")
|
||||||
|
def files_restore(request: Request, path: str = Form(...)):
|
||||||
|
try:
|
||||||
|
logic.files_status(path, files_state.READY, by=_who(request))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done=f"Przywrócono „{path}” z archiwum.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/delete")
|
||||||
|
def files_delete(request: Request, path: str = Form(...)):
|
||||||
|
try:
|
||||||
|
logic.files_delete(path)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/files/rules")
|
||||||
|
def files_rules(request: Request, extensions: str = Form(".xlsx"),
|
||||||
|
max_size_mb: float = Form(50), min_rows: int = Form(1),
|
||||||
|
required_columns: str = Form(""),
|
||||||
|
reject_duplicate_content: str = Form("")):
|
||||||
|
rules = {
|
||||||
|
"extensions": [e.strip() for e in extensions.split(",") if e.strip()],
|
||||||
|
"max_size_mb": max_size_mb,
|
||||||
|
"min_rows": min_rows,
|
||||||
|
"required_columns": [c.strip() for c in required_columns.split(",") if c.strip()],
|
||||||
|
"reject_duplicate_content": reject_duplicate_content.strip().lower()
|
||||||
|
in {"1", "true", "on", "tak"},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
logic.files_rules(rules)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return _files_redirect(error=_logic_error(e))
|
||||||
|
return _files_redirect(done="Zapisano reguły walidacji.")
|
||||||
|
|
||||||
|
|
||||||
|
def _http_detail(e: httpx.HTTPStatusError) -> str:
|
||||||
|
try:
|
||||||
|
return str(e.response.json().get("detail") or e)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return str(e)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
# ---------------- 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
|
||||||
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
||||||
@@ -731,6 +989,20 @@ def accounts_update(request: Request, login: str = Form(...), password: str = Fo
|
|||||||
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
|
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/sesje")
|
||||||
|
def accounts_sesje(request: Request, login: str = Form(...)):
|
||||||
|
"""Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu.
|
||||||
|
|
||||||
|
Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek
|
||||||
|
„wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany
|
||||||
|
komputer i nie chce wpisywać nowego hasła wszędzie."""
|
||||||
|
try:
|
||||||
|
accounts_store.bump_session(login)
|
||||||
|
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||||||
|
return _accounts_redirect(error=str(e))
|
||||||
|
return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/accounts/delete")
|
@app.post("/accounts/delete")
|
||||||
def accounts_delete(request: Request, login: str = Form(...)):
|
def accounts_delete(request: Request, login: str = Form(...)):
|
||||||
try:
|
try:
|
||||||
@@ -740,6 +1012,73 @@ def accounts_delete(request: Request, login: str = Form(...)):
|
|||||||
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
|
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
|
||||||
|
# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
|
||||||
|
# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
|
||||||
|
# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
|
||||||
|
|
||||||
|
def _bezpieczne_dokad(dokad: str) -> str:
|
||||||
|
"""Adres powrotu po zalogowaniu, przepuszczony przez sito.
|
||||||
|
|
||||||
|
Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
|
||||||
|
do przekierowywania ludzi gdzie indziej — klasyczna droga do wyłudzenia hasła,
|
||||||
|
bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
|
||||||
|
wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
|
||||||
|
zacząć adres obcego serwera."""
|
||||||
|
dokad = (dokad or "").strip()
|
||||||
|
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
|
||||||
|
return "/"
|
||||||
|
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logowanie", response_class=HTMLResponse)
|
||||||
|
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
|
||||||
|
# Zalogowanego nie ma po co pytać o hasło jeszcze raz.
|
||||||
|
if security.auth_enabled() and security.principal(request) is not None:
|
||||||
|
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
|
||||||
|
return templates.TemplateResponse(request, "logowanie.html",
|
||||||
|
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logowanie")
|
||||||
|
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
|
||||||
|
dokad: str = Form("/")):
|
||||||
|
cel = _bezpieczne_dokad(dokad)
|
||||||
|
who = security.verify(login.strip(), haslo)
|
||||||
|
if who is None:
|
||||||
|
# JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby,
|
||||||
|
# które konta istnieją — a to informacja warta czegoś dla zgadującego.
|
||||||
|
security.audit_login(request, login.strip(), udane=False)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "logowanie.html",
|
||||||
|
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
|
||||||
|
status_code=401)
|
||||||
|
|
||||||
|
security.audit_login(request, who.login, udane=True)
|
||||||
|
odpowiedz = RedirectResponse(cel, status_code=303)
|
||||||
|
odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login),
|
||||||
|
max_age=session.max_age(),
|
||||||
|
**session.cookie_params(secure=security.cookies_secure()))
|
||||||
|
return odpowiedz
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/wyloguj")
|
||||||
|
def wyloguj(request: Request):
|
||||||
|
"""Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe
|
||||||
|
i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać.
|
||||||
|
|
||||||
|
POST, nie GET: pod adresem GET wystarczyłby obrazek na obcej stronie, żeby
|
||||||
|
wylogować kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa
|
||||||
|
do uniknięcia."""
|
||||||
|
security.audit_logout(request)
|
||||||
|
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
|
||||||
|
odpowiedz.delete_cookie(session.COOKIE, **session.cookie_params(
|
||||||
|
secure=security.cookies_secure()))
|
||||||
|
return odpowiedz
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok", "layer": "presentation"}
|
# Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
|
||||||
|
# Kubernetes i compose czytają wyłącznie kod odpowiedzi.
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ from collections import deque
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
log = logging.getLogger("astrololo.security")
|
log = logging.getLogger("astrololo.security")
|
||||||
|
|
||||||
@@ -114,12 +114,35 @@ def rate_limit_per_min() -> int:
|
|||||||
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
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:
|
def trust_proxy() -> bool:
|
||||||
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
PUBLIC_PATHS = frozenset({"/health"})
|
# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
|
||||||
PUBLIC_PREFIXES = ("/static/",)
|
# 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]] = {}
|
_hits: dict[str, deque[float]] = {}
|
||||||
|
|
||||||
@@ -142,53 +165,99 @@ class Principal:
|
|||||||
|
|
||||||
|
|
||||||
def _admin_permissions() -> frozenset[str]:
|
def _admin_permissions() -> frozenset[str]:
|
||||||
"""Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie.
|
"""Administrator widzi wszystko — z definicji, bez wypisywania listy ręcznie.
|
||||||
|
|
||||||
Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania
|
Liczone z katalogu, więc dopisanie funkcji NIE wymaga pamiętania o koncie
|
||||||
o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja
|
administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja byłaby
|
||||||
byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać."""
|
niewidoczna dla jedynej osoby, która ma ją komu nadać."""
|
||||||
from app import features
|
from app import features
|
||||||
|
|
||||||
return frozenset(features.GRANTABLE) | {features.ADMIN}
|
return frozenset(features.GRANTABLE) | {features.ADMIN}
|
||||||
|
|
||||||
|
|
||||||
def principal(header: str | None) -> Principal | None:
|
def _admin_spec(login: str) -> str | None:
|
||||||
"""Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują.
|
"""Sekret konta administracyjnego z KONFIGURACJI ŚRODOWISKA albo None.
|
||||||
|
|
||||||
Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne
|
Sprawdzane ZAWSZE PIERWSZE i bez dotykania pliku kont — to jest cała
|
||||||
uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej
|
separacja konta administracyjnego (LOG-34):
|
||||||
założenie w pliku konta o loginie administratora przesłoniłoby konto
|
|
||||||
administracyjne i odebrało mu uprawnienia.
|
|
||||||
|
|
||||||
Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto
|
* konto z pliku o tym samym loginie nie przesłoni administratora, bo do
|
||||||
istnieje."""
|
pliku w ogóle nie zaglądamy, gdy login pasuje do konfiguracji,
|
||||||
if not header or not header.lower().startswith("basic "):
|
* administrator zaloguje się nawet wtedy, gdy plik kont jest uszkodzony
|
||||||
return None
|
albo nieczytelny — czyli w jedynej sytuacji, w której ktoś musi wejść,
|
||||||
try:
|
żeby to naprawić. Trzymanie jego stanu w tym samym pliku dawałoby
|
||||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
zakleszczenie: nie da się naprawić, bo nie da się wejść.
|
||||||
user, _, password = raw.partition(":")
|
"""
|
||||||
except (binascii.Error, UnicodeDecodeError, IndexError):
|
return accounts().get(login)
|
||||||
return None
|
|
||||||
|
|
||||||
spec = accounts().get(user)
|
|
||||||
|
def verify(login: str, password: str) -> Principal | None:
|
||||||
|
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
|
||||||
|
|
||||||
|
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
|
||||||
|
konta istnieją, a to informacja warta czegoś dla zgadującego."""
|
||||||
|
spec = _admin_spec(login)
|
||||||
if spec is not None:
|
if spec is not None:
|
||||||
return (Principal(user, _admin_permissions(), True)
|
return Principal(login, _admin_permissions(), True) if _secret_matches(spec, password) else None
|
||||||
if _secret_matches(spec, password) else None)
|
|
||||||
|
|
||||||
from app import accounts as store
|
from app import accounts as store
|
||||||
|
|
||||||
managed = store.secret_of(user)
|
try:
|
||||||
if managed is None:
|
zapisany = store.secret_of(login)
|
||||||
|
except store.AccountsUnavailable:
|
||||||
|
# Magazyn kont niedostępny — konta zarządzane nie mogą się zalogować,
|
||||||
|
# ale administrator (wyżej) już przeszedł, więc jest kto to naprawi.
|
||||||
return None
|
return None
|
||||||
if not _secret_matches(managed, password):
|
if zapisany is None or not _secret_matches(zapisany, password):
|
||||||
return None
|
return None
|
||||||
return Principal(user, store.permissions_of(user), False)
|
return Principal(login, store.permissions_of(login), False)
|
||||||
|
|
||||||
|
|
||||||
def authenticate(header: str | None) -> str | None:
|
def issue_session(login: str) -> str:
|
||||||
"""Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz."""
|
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
||||||
who = principal(header)
|
|
||||||
return who.login if who else None
|
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
|
||||||
|
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
|
||||||
|
i bez żadnego magazynu sesji."""
|
||||||
|
from app import accounts as store
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
spec = _admin_spec(login)
|
||||||
|
if spec is not None:
|
||||||
|
return session.issue(login, session.fingerprint(spec), 0)
|
||||||
|
return session.issue(login, session.fingerprint(store.secret_of(login) or ""),
|
||||||
|
store.session_version(login))
|
||||||
|
|
||||||
|
|
||||||
|
def principal(request: Request) -> Principal | None:
|
||||||
|
"""Ciasteczko → kto to jest i co mu wolno. None = nie wpuszczać."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
dane = session.read(request.cookies.get(session.COOKIE))
|
||||||
|
if dane is None:
|
||||||
|
return None
|
||||||
|
login = dane["login"]
|
||||||
|
|
||||||
|
spec = _admin_spec(login)
|
||||||
|
if spec is not None:
|
||||||
|
# Zmiana APP_PASSWORD ubija sesje administratora — to jest jego
|
||||||
|
# odpowiednik „wyloguj mnie ze wszystkich urządzeń".
|
||||||
|
if session.fingerprint(spec) != dane["odcisk"]:
|
||||||
|
return None
|
||||||
|
return Principal(login, _admin_permissions(), True)
|
||||||
|
|
||||||
|
from app import accounts as store
|
||||||
|
|
||||||
|
try:
|
||||||
|
zapisany = store.secret_of(login)
|
||||||
|
wersja = store.session_version(login)
|
||||||
|
except store.AccountsUnavailable:
|
||||||
|
return None
|
||||||
|
if zapisany is None or session.fingerprint(zapisany) != dane["odcisk"]:
|
||||||
|
return None
|
||||||
|
if wersja != dane["wersja"]:
|
||||||
|
return None
|
||||||
|
return Principal(login, store.permissions_of(login), False)
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str:
|
def client_ip(request: Request) -> str:
|
||||||
@@ -266,6 +335,22 @@ def audit_records(request: Request, count: int) -> None:
|
|||||||
pass
|
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:
|
def _audit(request: Request, user: str, status: int, ms: float) -> None:
|
||||||
records = getattr(request.state, "audit_records", None)
|
records = getattr(request.state, "audit_records", None)
|
||||||
audit_log.info(
|
audit_log.info(
|
||||||
@@ -276,8 +361,20 @@ def _audit(request: Request, user: str, status: int, ms: float) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def install(app) -> None:
|
def install(app) -> None:
|
||||||
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
|
"""Podpina ochronę pod wszystkie ścieżki poza publicznymi."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
_setup_audit_logging()
|
_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():
|
if not auth_enabled():
|
||||||
log.warning(
|
log.warning(
|
||||||
"UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
|
"UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
|
||||||
@@ -307,16 +404,27 @@ def install(app) -> None:
|
|||||||
|
|
||||||
from app import features
|
from app import features
|
||||||
|
|
||||||
user, who = "", None
|
user, who, odswiez = "", None, None
|
||||||
if auth_enabled():
|
if auth_enabled():
|
||||||
who = principal(request.headers.get("authorization"))
|
who = principal(request)
|
||||||
if who is None:
|
if who is None:
|
||||||
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
|
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
|
||||||
return HTMLResponse(
|
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
|
||||||
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
|
||||||
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
# 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
|
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:
|
else:
|
||||||
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
|
# 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.
|
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
|
||||||
@@ -332,5 +440,10 @@ def install(app) -> None:
|
|||||||
_audit(request, user, 404, (time.monotonic() - started) * 1000)
|
_audit(request, user, 404, (time.monotonic() - started) * 1000)
|
||||||
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||||||
response = await call_next(request)
|
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)
|
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Sesje logowania — podpisane ciasteczko (LOG-34).
|
||||||
|
|
||||||
|
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
|
||||||
|
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
|
||||||
|
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
|
||||||
|
w środku, i może tę decyzję cofnąć.
|
||||||
|
|
||||||
|
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
|
||||||
|
|
||||||
|
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
|
||||||
|
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
|
||||||
|
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
|
||||||
|
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
|
||||||
|
o tym — i też nie wymaga magazynu.
|
||||||
|
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
|
||||||
|
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
|
||||||
|
nie poda licznika, poziomy 1 i 2 nadal działają.
|
||||||
|
|
||||||
|
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
|
||||||
|
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
|
||||||
|
dostępu nie odbierałoby dostępu.
|
||||||
|
|
||||||
|
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
|
||||||
|
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
|
||||||
|
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
|
||||||
|
|
||||||
|
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
|
||||||
|
w PRE-27 i AES w PRE-16.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
COOKIE = "astrololo_sesja"
|
||||||
|
|
||||||
|
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
|
||||||
|
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
|
||||||
|
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
|
||||||
|
|
||||||
|
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
|
||||||
|
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
|
||||||
|
REFRESH_AFTER = 300
|
||||||
|
|
||||||
|
|
||||||
|
class SessionError(RuntimeError):
|
||||||
|
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.getenv(name, "") or default)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def max_age() -> int:
|
||||||
|
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
|
||||||
|
|
||||||
|
|
||||||
|
def idle_timeout() -> int:
|
||||||
|
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
|
||||||
|
|
||||||
|
|
||||||
|
def secret() -> bytes:
|
||||||
|
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
|
||||||
|
|
||||||
|
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
|
||||||
|
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
|
||||||
|
raw = os.getenv("SESSION_SECRET", "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise SessionError(
|
||||||
|
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
|
||||||
|
"Wygeneruj: openssl rand -hex 32")
|
||||||
|
if len(raw) < 32:
|
||||||
|
raise SessionError(
|
||||||
|
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
|
||||||
|
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
|
||||||
|
return raw.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> bool:
|
||||||
|
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
|
||||||
|
try:
|
||||||
|
secret()
|
||||||
|
except SessionError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(secret_spec: str) -> str:
|
||||||
|
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
|
||||||
|
|
||||||
|
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
|
||||||
|
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
|
||||||
|
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
|
||||||
|
unieważniły jego sesje same z siebie."""
|
||||||
|
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(payload: bytes) -> bytes:
|
||||||
|
return hmac.new(secret(), payload, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(raw: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(text: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
|
||||||
|
"""Nowe ciasteczko sesji dla konta."""
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
payload = json.dumps(
|
||||||
|
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
|
||||||
|
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
||||||
|
|
||||||
|
|
||||||
|
def read(token: str | None, *, now: float | None = None) -> dict | None:
|
||||||
|
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
|
||||||
|
|
||||||
|
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
|
||||||
|
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
|
||||||
|
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
surowy, _, podpis = token.rpartition(".")
|
||||||
|
try:
|
||||||
|
payload = _unb64(surowy)
|
||||||
|
oczekiwany = _sign(payload)
|
||||||
|
podany = _unb64(podpis)
|
||||||
|
except (ValueError, SessionError):
|
||||||
|
return None
|
||||||
|
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
|
||||||
|
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
|
||||||
|
if not hmac.compare_digest(oczekiwany, podany):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dane = json.loads(payload.decode("utf-8"))
|
||||||
|
login, odcisk = str(dane["u"]), str(dane["f"])
|
||||||
|
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
|
||||||
|
# w obu wypadkach nie ma powodu mu ufać.
|
||||||
|
if wyd > teraz + 60:
|
||||||
|
return None
|
||||||
|
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
|
||||||
|
return None
|
||||||
|
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh(dane: dict, *, now: float | None = None) -> str | None:
|
||||||
|
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
|
||||||
|
|
||||||
|
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
|
||||||
|
Set-Cookie — także po arkusze stylów i obrazki."""
|
||||||
|
teraz = int(now if now is not None else time.time())
|
||||||
|
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
|
||||||
|
return None
|
||||||
|
payload = json.dumps(
|
||||||
|
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
|
||||||
|
"wyd": dane["wydano"], "akt": teraz},
|
||||||
|
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_params(secure: bool = True) -> dict:
|
||||||
|
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
|
||||||
|
|
||||||
|
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
|
||||||
|
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
|
||||||
|
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
|
||||||
|
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/* Powłoka strony i ekran logowania — JEDYNY arkusz publiczny.
|
||||||
|
Nie wolno tu wstawić niczego, co nazywa funkcję: ten plik pobiera
|
||||||
|
każdy, także niezalogowany, bo potrzebuje go ekran logowania. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1020;
|
||||||
|
--panel: #1a1c33;
|
||||||
|
--ink: #e8e8f0;
|
||||||
|
--muted: #9aa0c0;
|
||||||
|
--accent: #8b7bf0;
|
||||||
|
--line: #2a2d4a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
||||||
|
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
display: flex; justify-content: center; padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
main { width: 100%; max-width: 880px; }
|
||||||
|
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||||
|
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
|
||||||
|
.topbar nav { display: flex; gap: .25rem; }
|
||||||
|
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
|
||||||
|
.topbar nav a:hover { color: var(--ink); }
|
||||||
|
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
|
||||||
|
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
|
||||||
|
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||||
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
|
input, select, button {
|
||||||
|
font: inherit; padding: .6rem .75rem; border-radius: 8px;
|
||||||
|
border: 1px solid var(--line); background: #12132a; color: var(--ink);
|
||||||
|
}
|
||||||
|
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
||||||
|
button:hover { filter: brightness(1.1); }
|
||||||
|
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
||||||
|
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||||
|
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
|
||||||
|
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
|
||||||
|
podział domów pochodzi z innego systemu, niż zamówiono. */
|
||||||
|
.notice-warn {
|
||||||
|
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
|
||||||
|
border: 1px solid var(--warn-border, #b8860b);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--warn-bg, rgba(184, 134, 11, .10));
|
||||||
|
color: var(--warn-fg, inherit);
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
||||||
|
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||||
|
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: .85rem; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
margin-top: 1rem; padding: 1rem 1.25rem;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
}
|
||||||
|
.card .actions { margin-top: .9rem; }
|
||||||
|
/* Potwierdzenie operacji. Odróżnia się od .notice-warn, bo tamto ostrzega,
|
||||||
|
a to tylko potwierdza — pomylenie ich uczy ignorować oba. */
|
||||||
|
.done-note {
|
||||||
|
margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
|
||||||
|
border: 1px solid var(--line); border-left: 4px solid var(--accent);
|
||||||
|
border-radius: 4px; background: rgba(255, 255, 255, .03);
|
||||||
|
}
|
||||||
|
/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */
|
||||||
|
/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce,
|
||||||
|
a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy
|
||||||
|
marginesem, nie rozpychaczem — `nav` ma szerokość treści, więc nie ma czego
|
||||||
|
rozpychać. */
|
||||||
|
.whoami { color: var(--muted); font-size: .85rem; align-self: center;
|
||||||
|
margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
|
||||||
|
/* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak
|
||||||
|
reszta paska, nie jak przycisk akcji w formularzu. */
|
||||||
|
.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; }
|
||||||
|
button.wyloguj {
|
||||||
|
background: none; border: 1px solid transparent; color: var(--muted);
|
||||||
|
font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button.wyloguj:hover { color: var(--ink); }
|
||||||
|
/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */
|
||||||
|
.login-main { max-width: 26rem; }
|
||||||
|
.login-card label { display: block; margin-top: .75rem; }
|
||||||
|
.login-card input { width: 100%; }
|
||||||
|
.login-note { margin-top: 1.25rem; }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/* Arkusz ekranu „admin” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||||
|
.grid input { width: 100%; }
|
||||||
|
.loc { margin-top: .8rem; }
|
||||||
|
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||||
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
|
.row { display: flex; gap: .5rem; }
|
||||||
|
.row input[type=text] { flex: 1; }
|
||||||
|
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
||||||
|
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
||||||
|
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
||||||
|
kolumn jest po prostu mniej. */
|
||||||
|
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||||
|
gap: .35rem 1rem; align-items: start; }
|
||||||
|
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
||||||
|
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
||||||
|
.opts .hint { font-style: normal; opacity: .75; }
|
||||||
|
.opts input[type=number] { width: 5rem; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
|
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
|
||||||
|
.account-note input { width: 100%; }
|
||||||
|
button.danger { background: #8b2f2f; }
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/* Arkusz ekranu „chart” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||||
|
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||||
|
.grid input { width: 100%; }
|
||||||
|
.loc { margin-top: .8rem; }
|
||||||
|
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||||
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||||
|
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||||
|
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||||
|
2) font-variant-emoji:text — nowsza własność CSS,
|
||||||
|
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||||
|
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||||
|
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||||
|
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||||
|
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||||
|
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||||
|
"DejaVu Sans", sans-serif; }
|
||||||
|
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||||
|
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
||||||
|
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
||||||
|
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
||||||
|
.wheel-fig figcaption { margin-top: .4rem; }
|
||||||
|
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||||
|
svg.wheel text.glyph { font-size: inherit; }
|
||||||
|
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
|
||||||
|
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
|
||||||
|
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
|
||||||
|
.aspectarian-fig figcaption { margin-top: .4rem; }
|
||||||
|
svg.aspectarian text.glyph { font-size: inherit; }
|
||||||
|
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
|
||||||
|
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
|
||||||
|
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
|
||||||
|
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
|
||||||
|
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
|
||||||
|
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
||||||
|
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
||||||
|
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
||||||
|
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||||
|
zostać na wierzchu. */
|
||||||
|
.wheel-fig svg.wheel { cursor: zoom-in; }
|
||||||
|
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||||
|
.wheel-fig.zoomed {
|
||||||
|
position: fixed; inset: 0; z-index: 1100; margin: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: .5rem; padding: 1rem; background: var(--bg);
|
||||||
|
}
|
||||||
|
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
|
||||||
|
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
|
||||||
|
.wheel-fig.zoomed svg.wheel {
|
||||||
|
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
||||||
|
}
|
||||||
|
body.wheel-zoom-open { overflow: hidden; }
|
||||||
|
/* nie przewijaj treści pod spodem */
|
||||||
|
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
|
||||||
|
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
|
||||||
|
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
|
||||||
|
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
|
||||||
|
.wheel-tip {
|
||||||
|
position: fixed; z-index: 1150; pointer-events: none;
|
||||||
|
max-width: 22rem; padding: .4rem .6rem;
|
||||||
|
background: var(--panel); color: var(--fg, inherit);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
font-size: .85rem; line-height: 1.35;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
.wheel-tip[hidden] { display: none; }
|
||||||
|
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
||||||
|
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
||||||
|
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
||||||
|
kolumn jest po prostu mniej. */
|
||||||
|
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||||
|
gap: .35rem 1rem; align-items: start; }
|
||||||
|
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
||||||
|
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
||||||
|
.opts .hint { font-style: normal; opacity: .75; }
|
||||||
|
.opts input[type=number] { width: 5rem; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
|
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
||||||
|
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
||||||
|
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
||||||
|
się sama tabela, nigdy strona. */
|
||||||
|
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
||||||
|
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
||||||
|
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
||||||
|
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
||||||
|
nie wiadomo, który to wiersz. */
|
||||||
|
.table-scroll th:first-child, .table-scroll td:first-child {
|
||||||
|
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
||||||
|
box-shadow: 1px 0 0 var(--line);
|
||||||
|
}
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
|
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||||
|
.geo { margin-top: .9rem; }
|
||||||
|
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||||
|
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||||
|
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||||
|
.geo-results li:last-child { border-bottom: none; }
|
||||||
|
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||||
|
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.geo-map[hidden] { display: none; }
|
||||||
|
.geo-attrib { margin: .4rem 0 0; }
|
||||||
|
.geo-attrib a { color: var(--accent); }
|
||||||
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
|
.leaflet-control-attribution { font-size: .68rem; }
|
||||||
|
button.toggle {
|
||||||
|
background: none; border: 1px solid var(--line); border-radius: 50%;
|
||||||
|
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
|
||||||
|
}
|
||||||
|
button.toggle.on { color: var(--accent); border-color: var(--accent); }
|
||||||
|
button.toggle.off { color: var(--muted); }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/* Arkusz ekranu „compile” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||||
|
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||||
|
.grid input { width: 100%; }
|
||||||
|
.loc { margin-top: .8rem; }
|
||||||
|
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||||
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||||
|
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||||
|
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||||
|
2) font-variant-emoji:text — nowsza własność CSS,
|
||||||
|
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||||
|
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||||
|
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||||
|
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||||
|
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||||
|
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||||
|
"DejaVu Sans", sans-serif; }
|
||||||
|
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||||
|
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
||||||
|
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
||||||
|
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
||||||
|
.wheel-fig figcaption { margin-top: .4rem; }
|
||||||
|
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||||
|
svg.wheel text.glyph { font-size: inherit; }
|
||||||
|
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
|
||||||
|
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
|
||||||
|
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
|
||||||
|
.aspectarian-fig figcaption { margin-top: .4rem; }
|
||||||
|
svg.aspectarian text.glyph { font-size: inherit; }
|
||||||
|
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
|
||||||
|
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
|
||||||
|
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
|
||||||
|
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
|
||||||
|
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
|
||||||
|
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
||||||
|
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
||||||
|
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
||||||
|
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||||
|
zostać na wierzchu. */
|
||||||
|
.wheel-fig svg.wheel { cursor: zoom-in; }
|
||||||
|
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||||
|
.wheel-fig.zoomed {
|
||||||
|
position: fixed; inset: 0; z-index: 1100; margin: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: .5rem; padding: 1rem; background: var(--bg);
|
||||||
|
}
|
||||||
|
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
|
||||||
|
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
|
||||||
|
.wheel-fig.zoomed svg.wheel {
|
||||||
|
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
||||||
|
}
|
||||||
|
body.wheel-zoom-open { overflow: hidden; }
|
||||||
|
/* nie przewijaj treści pod spodem */
|
||||||
|
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
|
||||||
|
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
|
||||||
|
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
|
||||||
|
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
|
||||||
|
.wheel-tip {
|
||||||
|
position: fixed; z-index: 1150; pointer-events: none;
|
||||||
|
max-width: 22rem; padding: .4rem .6rem;
|
||||||
|
background: var(--panel); color: var(--fg, inherit);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
font-size: .85rem; line-height: 1.35;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
.wheel-tip[hidden] { display: none; }
|
||||||
|
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
||||||
|
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
||||||
|
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
||||||
|
kolumn jest po prostu mniej. */
|
||||||
|
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||||
|
gap: .35rem 1rem; align-items: start; }
|
||||||
|
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
||||||
|
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
||||||
|
.opts .hint { font-style: normal; opacity: .75; }
|
||||||
|
.opts input[type=number] { width: 5rem; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
|
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
||||||
|
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
||||||
|
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
||||||
|
się sama tabela, nigdy strona. */
|
||||||
|
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
||||||
|
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
||||||
|
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
||||||
|
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
||||||
|
nie wiadomo, który to wiersz. */
|
||||||
|
.table-scroll th:first-child, .table-scroll td:first-child {
|
||||||
|
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
||||||
|
box-shadow: 1px 0 0 var(--line);
|
||||||
|
}
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
|
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
|
||||||
|
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
|
||||||
|
.readiness li { padding: .2rem 0; }
|
||||||
|
.readiness .rdy-ok { color: #7fd18b; }
|
||||||
|
.readiness .rdy-missing { color: #e0c060; }
|
||||||
|
.report-head { margin: 1.5rem 0 .5rem; }
|
||||||
|
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
||||||
|
/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
|
||||||
|
bez przepisywania ich na HTML. */
|
||||||
|
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
||||||
|
button.toggle {
|
||||||
|
background: none; border: 1px solid var(--line); border-radius: 50%;
|
||||||
|
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
|
||||||
|
}
|
||||||
|
button.toggle.on { color: var(--accent); border-color: var(--accent); }
|
||||||
|
button.toggle.off { color: var(--muted); }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||||
|
.grid input { width: 100%; }
|
||||||
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
|
.opts input[type=number] { width: 5rem; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
|
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
||||||
|
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
||||||
|
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
||||||
|
się sama tabela, nigdy strona. */
|
||||||
|
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
||||||
|
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
||||||
|
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
||||||
|
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
||||||
|
nie wiadomo, który to wiersz. */
|
||||||
|
.table-scroll th:first-child, .table-scroll td:first-child {
|
||||||
|
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
||||||
|
box-shadow: 1px 0 0 var(--line);
|
||||||
|
}
|
||||||
|
button.danger { background: #8b2f2f; }
|
||||||
|
/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
|
||||||
|
form.inline { display: inline; }
|
||||||
|
td.ops form.inline + form.inline { margin-left: .4rem; }
|
||||||
|
button.toggle {
|
||||||
|
background: none; border: 1px solid var(--line); border-radius: 50%;
|
||||||
|
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
|
||||||
|
}
|
||||||
|
button.toggle.on { color: var(--accent); border-color: var(--accent); }
|
||||||
|
button.toggle.off { color: var(--muted); }
|
||||||
|
tr.row-archived td { opacity: .55; }
|
||||||
|
/* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma
|
||||||
|
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
|
||||||
|
tr.row-held td { background: rgba(184, 134, 11, .07); }
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/* Arkusz ekranu „interpret” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||||
|
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||||
|
.grid input { width: 100%; }
|
||||||
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
.opts input[type=number] { width: 5rem; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
|
.sig-item { margin-top: 1.25rem; }
|
||||||
|
.sig-head { padding: .5rem 0; }
|
||||||
|
.samples { margin-top: .3rem; }
|
||||||
|
.samples td { font-size: .92rem; vertical-align: top; }
|
||||||
|
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||||
|
.facet { margin: .4rem 0 .6rem 1rem; }
|
||||||
|
.facet-head { color: var(--ink); font-size: .9rem; }
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
|
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||||
|
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||||
|
.geo { margin-top: .9rem; }
|
||||||
|
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||||
|
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||||
|
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||||
|
.geo-results li:last-child { border-bottom: none; }
|
||||||
|
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||||
|
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.geo-map[hidden] { display: none; }
|
||||||
|
.geo-attrib { margin: .4rem 0 0; }
|
||||||
|
.geo-attrib a { color: var(--accent); }
|
||||||
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
|
.leaflet-control-attribution { font-size: .68rem; }
|
||||||
|
/* Generator promptu do LLM (LOG-29/30) */
|
||||||
|
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||||
|
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||||
|
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/* Arkusz ekranu „settings” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/* Arkusz ekranu „significators” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||||
|
.row { display: flex; gap: .5rem; }
|
||||||
|
.row input[type=text] { flex: 1; }
|
||||||
|
.opts input[type=number] { width: 5rem; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
|
.empty { color: var(--muted); }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/* Arkusz ekranu „synastry” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||||
|
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||||
|
.grid input { width: 100%; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||||
|
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||||
|
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||||
|
2) font-variant-emoji:text — nowsza własność CSS,
|
||||||
|
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||||
|
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||||
|
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||||
|
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||||
|
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||||
|
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||||
|
"DejaVu Sans", sans-serif; }
|
||||||
|
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||||
|
svg.aspectarian text.glyph { font-size: inherit; }
|
||||||
|
.opts input[type=number] { width: 5rem; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/* Arkusz ekranu „timeline” — dokładany tylko dla konta z tym uprawnieniem. */
|
||||||
|
|
||||||
|
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||||
|
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||||
|
.grid input { width: 100%; }
|
||||||
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
|
.sig-item { margin-top: 1.25rem; }
|
||||||
|
.sig-head { padding: .5rem 0; }
|
||||||
|
.samples { margin-top: .3rem; }
|
||||||
|
.samples td { font-size: .92rem; vertical-align: top; }
|
||||||
|
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
|
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||||
|
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||||
|
.geo { margin-top: .9rem; }
|
||||||
|
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||||
|
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||||
|
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||||
|
.geo-results li:last-child { border-bottom: none; }
|
||||||
|
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||||
|
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.geo-map[hidden] { display: none; }
|
||||||
|
.geo-attrib { margin: .4rem 0 0; }
|
||||||
|
.geo-attrib a { color: var(--accent); }
|
||||||
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
|
.leaflet-control-attribution { font-size: .68rem; }
|
||||||
|
/* Generator promptu do LLM (LOG-29/30) */
|
||||||
|
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||||
|
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||||
|
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg: #0f1020;
|
|
||||||
--panel: #1a1c33;
|
|
||||||
--ink: #e8e8f0;
|
|
||||||
--muted: #9aa0c0;
|
|
||||||
--accent: #8b7bf0;
|
|
||||||
--line: #2a2d4a;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
|
||||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
||||||
display: flex; justify-content: center; padding: 3rem 1rem;
|
|
||||||
}
|
|
||||||
main { width: 100%; max-width: 880px; }
|
|
||||||
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
|
||||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
|
||||||
|
|
||||||
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
|
|
||||||
.topbar nav { display: flex; gap: .25rem; }
|
|
||||||
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
|
|
||||||
.topbar nav a:hover { color: var(--ink); }
|
|
||||||
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
|
|
||||||
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
|
|
||||||
|
|
||||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
|
||||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
|
||||||
.grid input { width: 100%; }
|
|
||||||
.loc { margin-top: .8rem; }
|
|
||||||
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
|
||||||
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
|
||||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
|
||||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
|
||||||
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
|
||||||
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
|
||||||
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
|
||||||
2) font-variant-emoji:text — nowsza własność CSS,
|
|
||||||
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
|
||||||
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
|
||||||
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
|
||||||
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
|
||||||
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
|
||||||
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
|
||||||
"DejaVu Sans", sans-serif; }
|
|
||||||
td.glyph { color: var(--accent); white-space: nowrap; }
|
|
||||||
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
|
||||||
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
|
||||||
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
|
||||||
.wheel-fig figcaption { margin-top: .4rem; }
|
|
||||||
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
|
||||||
svg.wheel text.glyph { font-size: inherit; }
|
|
||||||
|
|
||||||
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
|
|
||||||
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
|
|
||||||
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
|
|
||||||
.aspectarian-fig figcaption { margin-top: .4rem; }
|
|
||||||
svg.aspectarian text.glyph { font-size: inherit; }
|
|
||||||
|
|
||||||
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
|
|
||||||
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
|
|
||||||
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
|
|
||||||
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
|
|
||||||
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
|
|
||||||
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
|
||||||
|
|
||||||
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
|
||||||
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
|
||||||
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
|
||||||
zostać na wierzchu. */
|
|
||||||
.wheel-fig svg.wheel { cursor: zoom-in; }
|
|
||||||
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
||||||
.wheel-fig.zoomed {
|
|
||||||
position: fixed; inset: 0; z-index: 1100; margin: 0;
|
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
gap: .5rem; padding: 1rem; background: var(--bg);
|
|
||||||
}
|
|
||||||
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
|
|
||||||
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
|
|
||||||
.wheel-fig.zoomed svg.wheel {
|
|
||||||
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
|
||||||
}
|
|
||||||
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
|
|
||||||
|
|
||||||
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
|
|
||||||
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
|
|
||||||
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
|
|
||||||
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
|
|
||||||
.wheel-tip {
|
|
||||||
position: fixed; z-index: 1150; pointer-events: none;
|
|
||||||
max-width: 22rem; padding: .4rem .6rem;
|
|
||||||
background: var(--panel); color: var(--fg, inherit);
|
|
||||||
border: 1px solid var(--line); border-radius: 8px;
|
|
||||||
font-size: .85rem; line-height: 1.35;
|
|
||||||
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
|
|
||||||
}
|
|
||||||
.wheel-tip[hidden] { display: none; }
|
|
||||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
|
||||||
.row { display: flex; gap: .5rem; }
|
|
||||||
.row input[type=text] { flex: 1; }
|
|
||||||
input, select, button {
|
|
||||||
font: inherit; padding: .6rem .75rem; border-radius: 8px;
|
|
||||||
border: 1px solid var(--line); background: #12132a; color: var(--ink);
|
|
||||||
}
|
|
||||||
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
|
||||||
button:hover { filter: brightness(1.1); }
|
|
||||||
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
|
||||||
|
|
||||||
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
|
||||||
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
|
||||||
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
|
||||||
kolumn jest po prostu mniej. */
|
|
||||||
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
|
||||||
gap: .35rem 1rem; align-items: start; }
|
|
||||||
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
|
||||||
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
|
||||||
.opts .hint { font-style: normal; opacity: .75; }
|
|
||||||
.opts input[type=number] { width: 5rem; }
|
|
||||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
|
||||||
|
|
||||||
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
|
|
||||||
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
|
|
||||||
podział domów pochodzi z innego systemu, niż zamówiono. */
|
|
||||||
.house-warning {
|
|
||||||
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
|
|
||||||
border: 1px solid var(--warn-border, #b8860b);
|
|
||||||
border-left-width: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--warn-bg, rgba(184, 134, 11, .10));
|
|
||||||
color: var(--warn-fg, inherit);
|
|
||||||
}
|
|
||||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
|
||||||
.empty { color: var(--muted); }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
|
||||||
|
|
||||||
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
|
||||||
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
|
||||||
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
|
||||||
się sama tabela, nigdy strona. */
|
|
||||||
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
|
||||||
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
|
||||||
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
|
||||||
|
|
||||||
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
|
||||||
nie wiadomo, który to wiersz. */
|
|
||||||
.table-scroll th:first-child, .table-scroll td:first-child {
|
|
||||||
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
|
||||||
box-shadow: 1px 0 0 var(--line);
|
|
||||||
}
|
|
||||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
|
||||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.small { font-size: .85rem; }
|
|
||||||
.nowrap { white-space: nowrap; }
|
|
||||||
.sig-item { margin-top: 1.25rem; }
|
|
||||||
.sig-head { padding: .5rem 0; }
|
|
||||||
.samples { margin-top: .3rem; }
|
|
||||||
.samples td { font-size: .92rem; vertical-align: top; }
|
|
||||||
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
|
||||||
.facet { margin: .4rem 0 .6rem 1rem; }
|
|
||||||
.facet-head { color: var(--ink); font-size: .9rem; }
|
|
||||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
|
||||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
|
||||||
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
|
||||||
|
|
||||||
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
|
||||||
.geo { margin-top: .9rem; }
|
|
||||||
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
|
||||||
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
|
||||||
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
|
||||||
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
|
||||||
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
|
||||||
.geo-results li:last-child { border-bottom: none; }
|
|
||||||
.geo-results li:hover { background: var(--panel); color: #fff; }
|
|
||||||
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
|
||||||
.geo-map[hidden] { display: none; }
|
|
||||||
.geo-attrib { margin: .4rem 0 0; }
|
|
||||||
.geo-attrib a { color: var(--accent); }
|
|
||||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
|
||||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
|
||||||
.leaflet-control-attribution { font-size: .68rem; }
|
|
||||||
|
|
||||||
/* Generator promptu do LLM (LOG-29/30) */
|
|
||||||
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
|
||||||
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
|
||||||
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
|
||||||
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
||||||
|
|
||||||
/* Okno postępu przy generowaniu horoskopu */
|
|
||||||
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
|
||||||
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
|
||||||
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
|
||||||
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
|
||||||
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
z-index: 1200; padding: 1rem; }
|
|
||||||
.progress-overlay[hidden] { display: none; }
|
|
||||||
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
|
||||||
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
|
||||||
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
|
||||||
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
|
||||||
color: var(--muted); font-size: .9rem; }
|
|
||||||
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
|
||||||
border: 2px solid var(--line); border-top-color: var(--accent);
|
|
||||||
animation: spin .8s linear infinite; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
|
||||||
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
|
||||||
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
|
||||||
max-height: 15rem; overflow-y: auto;
|
|
||||||
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
|
||||||
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
|
||||||
.progress-log li.log-ok { color: #7fd18b; }
|
|
||||||
.progress-log li.log-warn { color: #e0c060; }
|
|
||||||
.progress-log li.log-err { color: #ef6b6b; }
|
|
||||||
|
|
||||||
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
|
|
||||||
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
|
|
||||||
.readiness li { padding: .2rem 0; }
|
|
||||||
.readiness .rdy-ok { color: #7fd18b; }
|
|
||||||
.readiness .rdy-missing { color: #e0c060; }
|
|
||||||
.report-head { margin: 1.5rem 0 .5rem; }
|
|
||||||
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
|
||||||
/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
|
|
||||||
bez przepisywania ich na HTML. */
|
|
||||||
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
|
||||||
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
|
||||||
|
|
||||||
|
|
||||||
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
|
|
||||||
.account-card {
|
|
||||||
margin-top: 1rem; padding: 1rem 1.25rem;
|
|
||||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
|
||||||
}
|
|
||||||
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
|
|
||||||
.account-note input { width: 100%; }
|
|
||||||
.account-card .actions { margin-top: .9rem; }
|
|
||||||
button.danger { background: #8b2f2f; }
|
|
||||||
|
|
||||||
/* Potwierdzenie operacji. Odróżnia się od .house-warning, bo tamto ostrzega,
|
|
||||||
a to tylko potwierdza — pomylenie ich uczy ignorować oba. */
|
|
||||||
.done-note {
|
|
||||||
margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
|
|
||||||
border: 1px solid var(--line); border-left: 4px solid var(--accent);
|
|
||||||
border-radius: 4px; background: rgba(255, 255, 255, .03);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* Generowanie tekstu przez model — dokładany wyłącznie, gdy konto ma
|
||||||
|
uprawnienie „ai”. Selektory nazywają funkcję, więc arkusz nie może
|
||||||
|
wchodzić do arkuszy ekranów, na których ta funkcja bywa wyłączona. */
|
||||||
|
|
||||||
|
/* Okno postępu przy generowaniu horoskopu */
|
||||||
|
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
||||||
|
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
||||||
|
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
||||||
|
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
||||||
|
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1200; padding: 1rem; }
|
||||||
|
.progress-overlay[hidden] { display: none; }
|
||||||
|
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||||
|
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
||||||
|
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
||||||
|
color: var(--muted); font-size: .9rem; }
|
||||||
|
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
||||||
|
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||||
|
animation: spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
||||||
|
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
||||||
|
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
max-height: 15rem; overflow-y: auto;
|
||||||
|
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
||||||
|
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
||||||
|
.progress-log li.log-ok { color: #7fd18b; }
|
||||||
|
.progress-log li.log-warn { color: #e0c060; }
|
||||||
|
.progress-log li.log-err { color: #ef6b6b; }
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
|
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
|
||||||
{% if result.house_warnings %}
|
{% if result.house_warnings %}
|
||||||
{% for w in result.house_warnings %}
|
{% for w in result.house_warnings %}
|
||||||
<p class="house-warning">{{ w }}</p>
|
<p class="notice-warn">{{ w }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
{% set nav_active = "accounts" %}
|
{% set nav_active = "accounts" %}
|
||||||
{% block title %}Konta{% endblock %}
|
{% block title %}Konta{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-accounts.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
|
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
|
||||||
@@ -15,7 +18,7 @@
|
|||||||
odpowiadają tak samo jak nieistniejące.
|
odpowiadają tak samo jak nieistniejące.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
|
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
|
||||||
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||||
|
|
||||||
<div class="meta">Konta ({{ users | length }})</div>
|
<div class="meta">Konta ({{ users | length }})</div>
|
||||||
@@ -24,7 +27,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for login, row in users | dictsort %}
|
{% for login, row in users | dictsort %}
|
||||||
<form method="post" action="/accounts/update" class="account-card">
|
<form method="post" action="/accounts/update" class="card">
|
||||||
<input type="hidden" name="login" value="{{ login }}">
|
<input type="hidden" name="login" value="{{ login }}">
|
||||||
<div class="account-head">
|
<div class="account-head">
|
||||||
<strong>{{ login }}</strong>
|
<strong>{{ login }}</strong>
|
||||||
@@ -53,6 +56,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Zapisz</button>
|
<button type="submit">Zapisz</button>
|
||||||
|
<button type="submit" formaction="/accounts/sesje" class="ghost"
|
||||||
|
title="Zamyka otwarte sesje tego konta na wszystkich urządzeniach. Hasło zostaje bez zmian.">
|
||||||
|
Zamknij sesje
|
||||||
|
</button>
|
||||||
<button type="submit" formaction="/accounts/delete" class="danger"
|
<button type="submit" formaction="/accounts/delete" class="danger"
|
||||||
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
|
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
|
||||||
Skasuj konto
|
Skasuj konto
|
||||||
@@ -62,7 +69,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="meta">Nowe konto</div>
|
<div class="meta">Nowe konto</div>
|
||||||
<form method="post" action="/accounts/create" class="account-card">
|
<form method="post" action="/accounts/create" class="card">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Login <input type="text" name="login" required autocomplete="off"></label>
|
<label>Login <input type="text" name="login" required autocomplete="off"></label>
|
||||||
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
|
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
|
||||||
|
|||||||
@@ -4,16 +4,18 @@
|
|||||||
<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>astrololo · {% block title %}{% endblock %}</title>
|
<title>astrololo · {% block title %}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ static('styles.css') }}">
|
{# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa
|
||||||
{# Wspólne dane formularza między zakładkami (PRE-21). W <head> z `defer`
|
jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`.
|
||||||
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży
|
Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści
|
||||||
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu
|
programu: nazwa selektora opowiada o funkcji tak samo dobrze jak przycisk. #}
|
||||||
we właściwym miejscu, bez przestawiania. #}
|
<link rel="stylesheet" href="{{ static('base.css') }}">
|
||||||
<script defer src="{{ static('formsync.js') }}"></script>
|
{% block styles %}{% endblock %}
|
||||||
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
|
{# Skrypty dokłada ekran, który ich potrzebuje. Ładowane stąd docierały na
|
||||||
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
|
KAŻDĄ stronę — także do konta, które ma wyłącznie Pliki — a ich treść
|
||||||
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
wymienia z nazwy ekrany, których takie konto nie ma prawa znać.
|
||||||
<script defer src="{{ static('wheeltip.js') }}"></script>
|
Blok jest w <head>, bo `defer` wykonuje skrypty w kolejności dokumentu:
|
||||||
|
formsync musi odtworzyć współrzędne, ZANIM geo.js zbuduje mapę. #}
|
||||||
|
{% block head_scripts %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
@@ -29,12 +31,31 @@
|
|||||||
{% if can(request, 'admin') %}
|
{% if can(request, 'admin') %}
|
||||||
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
|
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# Wyjście na końcu paska i odsunięte: to nie jest zakładka, tylko
|
||||||
|
czynność — pomylenie go z zakładką kosztuje przypadkowe wylogowania.
|
||||||
|
Pokazujemy też KTO jest zalogowany, bo bez tego przycisk wyjścia jest
|
||||||
|
w połowie bezużyteczny: na wspólnym komputerze nie wiadomo, kogo się
|
||||||
|
wylogowuje. Bez włączonego logowania nie ma z czego wychodzić. #}
|
||||||
|
{% if auth_on() %}
|
||||||
|
<span class="whoami" title="Zalogowano jako">{{ current_user(request) }}</span>
|
||||||
|
{# POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej
|
||||||
|
stronie, żeby wylogować kogoś bez jego wiedzy. #}
|
||||||
|
<form method="post" action="/wyloguj" class="wyloguj-form">
|
||||||
|
<button type="submit" class="wyloguj">Wyloguj</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
{# Stopka wymieniała warstwy architektury i nazywała całość „widokiem
|
||||||
|
testowym" — czyli mówiła, że to nie jest gotowy produkt i że za nim stoi
|
||||||
|
łańcuch usług. Dla konta ograniczonego program ma wyglądać na kompletny,
|
||||||
|
więc ten opis widzi wyłącznie administrator. #}
|
||||||
|
{% if can(request, 'admin') %}
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
prezentacja → logika → dane · widok testowy
|
prezentacja → logika → dane · widok testowy
|
||||||
</footer>
|
</footer>
|
||||||
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
{% block title %}Horoskop{% endblock %}
|
{% block title %}Horoskop{% endblock %}
|
||||||
{% set nav_active = "chart" %}
|
{% set nav_active = "chart" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-chart.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheeltip.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@
|
|||||||
{% if result %}
|
{% if result %}
|
||||||
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
|
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
|
||||||
{% for w in result.house_warnings or [] %}
|
{% for w in result.house_warnings or [] %}
|
||||||
<p class="house-warning">{{ w }}</p>
|
<p class="notice-warn">{{ w }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||||
|
|||||||
@@ -2,9 +2,26 @@
|
|||||||
{% block title %}Skompiluj{% endblock %}
|
{% block title %}Skompiluj{% endblock %}
|
||||||
{% set nav_active = "compile" %}
|
{% set nav_active = "compile" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-compile.css') }}">
|
||||||
|
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheeltip.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI
|
||||||
|
wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu
|
||||||
|
jest takim samym śladem jak przycisk. #}
|
||||||
|
{% if can(request, 'ai') %}
|
||||||
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
|
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
|
||||||
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
|
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="sub">Składa policzony horoskop w jeden raport. Dane pobiera z pozostałych
|
||||||
|
zakładek — nie trzeba nic przepisywać.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
|
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
|
||||||
kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
|
kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
|
||||||
@@ -120,14 +137,18 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
|||||||
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
|
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
|
||||||
{% include "_result_tables.html" %}
|
{% include "_result_tables.html" %}
|
||||||
|
|
||||||
|
{% if can(request, 'ai') %}
|
||||||
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
|
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
|
||||||
<div id="reportNatal"></div>
|
<div id="reportNatal"></div>
|
||||||
<div id="reportPredictions"></div>
|
<div id="reportPredictions"></div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can(request, 'ai') %}
|
||||||
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
|
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
|
||||||
każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
|
każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
|
||||||
<script src="{{ static('natal.js') }}"></script>
|
<script src="{{ static('natal.js') }}"></script>
|
||||||
<script src="{{ static('predictions.js') }}"></script>
|
<script src="{{ static('predictions.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
<script src="{{ static('compile.js') }}"></script>
|
<script src="{{ static('compile.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set nav_active = "files" %}
|
||||||
|
{% block title %}Pliki{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-files.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{# Opis czysto operacyjny: co robi zaznaczenie, i nic o tym, do CZEGO te dane
|
||||||
|
służą. Poprzednia treść nazywała je „bazami interpretacyjnymi" i mówiła
|
||||||
|
o „wyszukiwaniu" — czyli zdradzała i dziedzinę programu, i istnienie ekranu
|
||||||
|
wyszukiwarki. To jest jedyny ekran, który ogląda konto wgrywające dane, więc
|
||||||
|
każde zdanie na nim waży najwięcej. #}
|
||||||
|
<p class="muted">
|
||||||
|
Zaznaczony plik jest w użyciu. Odznaczony zostaje na dysku, ale nie jest używany.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
|
||||||
|
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||||
|
|
||||||
|
<div class="meta">Pliki ({{ files | length }})</div>
|
||||||
|
{% if not files %}
|
||||||
|
<p class="muted">Nie ma jeszcze żadnego pliku.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>W użyciu</th><th>Plik</th><th>Stan</th><th>Rozmiar</th><th>Zmieniony</th>
|
||||||
|
{% if can(request, 'files_input') or is_admin %}<th>Operacje</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in files %}
|
||||||
|
<tr class="{{ 'row-archived' if f.status == 'archived' else '' }}{{ ' row-held' if f.status == 'quarantine' else '' }}">
|
||||||
|
<td>
|
||||||
|
{# Archiwum jest ZAMROŻONE — nie da się go włączyć bez przywrócenia,
|
||||||
|
a przywrócić może wyłącznie administrator. #}
|
||||||
|
{% if f.status in ('active', 'ready') %}
|
||||||
|
<form method="post" action="/files/use" class="inline">
|
||||||
|
<input type="hidden" name="path" value="{{ f.path }}">
|
||||||
|
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
|
||||||
|
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
|
||||||
|
title="{{ 'Odstaw ten plik' if f.in_use else 'Włącz ten plik do użytku' }}">
|
||||||
|
{{ '●' if f.in_use else '○' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}<span class="muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ f.name }}</td>
|
||||||
|
<td>
|
||||||
|
{{ STATUS_LABELS.get(f.status, f.status) }}
|
||||||
|
{% if f.status == 'archived' and f.archived_at %}
|
||||||
|
<span class="muted small">({{ f.archived_at[:16] | replace('T', ' ') }})</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_admin and f.rejected_for %}
|
||||||
|
<div class="muted small">nie przeszedł: {{ f.rejected_for | join('; ') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ '%.2f'|format(f.size_mb) if f.size_mb is not none else '—' }} MB</td>
|
||||||
|
<td class="mono">{{ f.modified or '—' }}</td>
|
||||||
|
{% if can(request, 'files_input') or is_admin %}
|
||||||
|
<td class="ops">
|
||||||
|
{% if can(request, 'files_input') and f.status in ('active', 'ready') %}
|
||||||
|
<form method="post" action="/files/archive" class="inline">
|
||||||
|
<input type="hidden" name="path" value="{{ f.path }}">
|
||||||
|
<button type="submit" class="ghost"
|
||||||
|
title="Plik zostaje na dysku, zamrożony ze znacznikiem czasu — znika tylko z użytku.">Archiwizuj</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_admin %}
|
||||||
|
{% if f.status in ('archived', 'quarantine') %}
|
||||||
|
<form method="post" action="/files/restore" class="inline">
|
||||||
|
<input type="hidden" name="path" value="{{ f.path }}">
|
||||||
|
<button type="submit" class="ghost">Przywróć</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/files/delete" class="inline">
|
||||||
|
<input type="hidden" name="path" value="{{ f.path }}">
|
||||||
|
<button type="submit" class="danger"
|
||||||
|
onclick="return confirm('Skasować {{ f.name }} bezpowrotnie? Pliku nie da się odzyskać.')">Skasuj</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can(request, 'files_input') %}
|
||||||
|
<div class="meta">Wgraj plik</div>
|
||||||
|
<form method="post" action="/files/upload" enctype="multipart/form-data" class="card">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Plik <input type="file" name="upload" required></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button type="submit">Wgraj</button></div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
{# Reguły walidacji widzi WYŁĄCZNIE administrator — cała ta sekcja nie trafia
|
||||||
|
nawet do źródła strony dla pozostałych kont. #}
|
||||||
|
<div class="meta">Reguły przyjmowania baz</div>
|
||||||
|
<p class="muted small">
|
||||||
|
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
|
||||||
|
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/files/rules" class="card">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Dozwolone rozszerzenia (po przecinku)
|
||||||
|
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
|
||||||
|
<label>Maksymalny rozmiar (MB)
|
||||||
|
<input type="number" name="max_size_mb" step="1" min="0" value="{{ rules.max_size_mb }}"></label>
|
||||||
|
<label>Minimalna liczba wierszy
|
||||||
|
<input type="number" name="min_rows" step="1" min="0" value="{{ rules.min_rows }}"></label>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Wymagane kolumny (po przecinku, puste = bez wymagań)
|
||||||
|
<input type="text" name="required_columns"
|
||||||
|
value="{{ (rules.required_columns or []) | join(', ') }}"></label>
|
||||||
|
</div>
|
||||||
|
<div class="opts">
|
||||||
|
<label><input type="checkbox" name="reject_duplicate_content" value="1"
|
||||||
|
{{ 'checked' if rules.reject_duplicate_content else '' }}>
|
||||||
|
odrzucaj pliki o treści identycznej z już wgraną</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button type="submit">Zapisz reguły</button></div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
{% block title %}Interpretacje{% endblock %}
|
{% block title %}Interpretacje{% endblock %}
|
||||||
{% set nav_active = "interpret" %}
|
{% set nav_active = "interpret" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-interpret.css') }}">
|
||||||
|
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||||
|
|
||||||
@@ -35,14 +42,19 @@
|
|||||||
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
||||||
</div>
|
</div>
|
||||||
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
{% include "_prompt_block.html" %}
|
{# Cały blok generowania — budżet, dostawca, model i wynik — istnieje TYLKO
|
||||||
<p class="muted small" id="natalNote"></p>
|
dla kont z tym uprawnieniem. Nie ukrywamy go stylem: bez uprawnienia nie ma
|
||||||
|
go w źródle strony, razem z katalogiem modeli, który blok wstrzykuje. #}
|
||||||
|
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
||||||
|
{% if can(request, 'ai') %}<p class="muted small" id="natalNote"></p>{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||||
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
|
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
|
||||||
|
{% if can(request, 'ai') %}
|
||||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||||
|
{% endif %}
|
||||||
<span id="geoNote" class="muted small"></span>
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -99,8 +111,12 @@
|
|||||||
|
|
||||||
<script src="{{ static('now.js') }}"></script>
|
<script src="{{ static('now.js') }}"></script>
|
||||||
<script src="{{ static('copy.js') }}"></script>
|
<script src="{{ static('copy.js') }}"></script>
|
||||||
|
{# Skrypty obsługujące WYŁĄCZNIE generowanie przez model. Bez uprawnienia nie ma
|
||||||
|
ich nawet w odsyłaczach — sam adres pliku podpowiadałby, co program potrafi. #}
|
||||||
|
{% if can(request, 'ai') %}
|
||||||
<script src="{{ static('models.js') }}"></script>
|
<script src="{{ static('models.js') }}"></script>
|
||||||
<script src="{{ static('progress.js') }}"></script>
|
<script src="{{ static('progress.js') }}"></script>
|
||||||
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
|
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
|
||||||
<script src="{{ static('natal.js') }}"></script>
|
<script src="{{ static('natal.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>astrololo · logowanie</title>
|
||||||
|
{# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc
|
||||||
|
linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
|
||||||
|
<link rel="stylesheet" href="{{ static('base.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="login-main">
|
||||||
|
<header class="topbar"><h1>astrololo</h1></header>
|
||||||
|
|
||||||
|
{% if request.query_params.get('wylogowano') %}
|
||||||
|
<p class="done-note">Wylogowano.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if blad %}<p class="notice-warn">{{ blad }}</p>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/logowanie" class="card login-card">
|
||||||
|
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
|
||||||
|
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
|
||||||
|
<input type="hidden" name="dokad" value="{{ dokad }}">
|
||||||
|
<label>Login
|
||||||
|
<input type="text" name="login" value="{{ login or '' }}" required
|
||||||
|
autocomplete="username" autofocus></label>
|
||||||
|
<label>Hasło
|
||||||
|
<input type="password" name="haslo" required autocomplete="current-password"></label>
|
||||||
|
<div class="actions"><button type="submit">Zaloguj</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted small login-note">
|
||||||
|
{# Bez wskazywania, że dostęp ktoś „zakłada": to mówi wprost, że istnieje
|
||||||
|
konto o wyższych uprawnieniach i że własne jest okrojone. #}
|
||||||
|
Nie pamiętasz hasła? Skontaktuj się z osobą, od której masz dane logowania.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
{% block title %}Ustawienia{% endblock %}
|
{% block title %}Ustawienia{% endblock %}
|
||||||
{% set nav_active = "settings" %}
|
{% set nav_active = "settings" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-settings.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
{% block title %}Sygnifikatory{% endblock %}
|
{% block title %}Sygnifikatory{% endblock %}
|
||||||
{% set nav_active = "significators" %}
|
{% set nav_active = "significators" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-significators.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
{% block title %}Synastria{% endblock %}
|
{% block title %}Synastria{% endblock %}
|
||||||
{% set nav_active = "synastry" %}
|
{% set nav_active = "synastry" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-synastry.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
|
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
{% block title %}Kalendarz{% endblock %}
|
{% block title %}Kalendarz{% endblock %}
|
||||||
{% set nav_active = "timeline" %}
|
{% set nav_active = "timeline" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-timeline.css') }}">
|
||||||
|
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||||
|
|
||||||
@@ -40,12 +47,14 @@
|
|||||||
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% include "_prompt_block.html" %}
|
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
||||||
|
{% if can(request, 'ai') %}
|
||||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||||
|
{% endif %}
|
||||||
<span id="geoNote" class="muted small"></span>
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -93,8 +102,10 @@
|
|||||||
|
|
||||||
<script src="{{ static('now.js') }}"></script>
|
<script src="{{ static('now.js') }}"></script>
|
||||||
<script src="{{ static('copy.js') }}"></script>
|
<script src="{{ static('copy.js') }}"></script>
|
||||||
|
{% if can(request, 'ai') %}
|
||||||
<script src="{{ static('models.js') }}"></script>
|
<script src="{{ static('models.js') }}"></script>
|
||||||
<script src="{{ static('progress.js') }}"></script>
|
<script src="{{ static('progress.js') }}"></script>
|
||||||
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
|
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
|
||||||
<script src="{{ static('predictions.js') }}"></script>
|
<script src="{{ static('predictions.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ krytyczne i CICHE, gdy się zepsują: (1) odebranie dostępu jednej osobie musi
|
|||||||
faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać
|
faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać
|
||||||
treści baz (byłby wtedy kolejnym nośnikiem wycieku).
|
treści baz (byłby wtedy kolejnym nośnikiem wycieku).
|
||||||
"""
|
"""
|
||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -13,9 +12,13 @@ import pytest
|
|||||||
from app import security
|
from app import security
|
||||||
|
|
||||||
|
|
||||||
def _basic(user: str, password: str) -> dict:
|
def _kto(user: str, password: str) -> str | None:
|
||||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
"""Login, gdy poświadczenia pasują; None przy odmowie.
|
||||||
return {"Authorization": f"Basic {raw}"}
|
|
||||||
|
Po przejściu na sesje (LOG-34) sprawdzanie hasła robi `verify`, a nie
|
||||||
|
parsowanie nagłówka Basic — samego nagłówka nie ma już w ogóle."""
|
||||||
|
who = security.verify(user, password)
|
||||||
|
return who.login if who else None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -57,29 +60,29 @@ def test_named_accounts_are_parsed(monkeypatch):
|
|||||||
|
|
||||||
def test_login_with_hashed_password(monkeypatch):
|
def test_login_with_hashed_password(monkeypatch):
|
||||||
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
|
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
|
||||||
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
|
assert _kto("alicja", "tajne") == "alicja"
|
||||||
assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None
|
assert _kto("alicja", "złe") is None
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_user_is_denied(monkeypatch):
|
def test_unknown_user_is_denied(monkeypatch):
|
||||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||||
assert security.authenticate(_basic("obcy", "tajne")["Authorization"]) is None
|
assert _kto("obcy", "tajne") is None
|
||||||
|
|
||||||
|
|
||||||
def test_revoking_one_account_leaves_others_untouched(monkeypatch):
|
def test_revoking_one_account_leaves_others_untouched(monkeypatch):
|
||||||
"""Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym."""
|
"""Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym."""
|
||||||
monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne")
|
monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne")
|
||||||
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) == "bartek"
|
assert _kto("bartek", "inne") == "bartek"
|
||||||
monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka
|
monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka
|
||||||
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None
|
assert _kto("bartek", "inne") is None
|
||||||
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
|
assert _kto("alicja", "tajne") == "alicja"
|
||||||
|
|
||||||
|
|
||||||
def test_shared_password_still_works_alone(monkeypatch):
|
def test_shared_password_still_works_alone(monkeypatch):
|
||||||
"""Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji."""
|
"""Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji."""
|
||||||
monkeypatch.setenv("APP_USER", "astrololo")
|
monkeypatch.setenv("APP_USER", "astrololo")
|
||||||
monkeypatch.setenv("APP_PASSWORD", "wspolne")
|
monkeypatch.setenv("APP_PASSWORD", "wspolne")
|
||||||
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) == "astrololo"
|
assert _kto("astrololo", "wspolne") == "astrololo"
|
||||||
|
|
||||||
|
|
||||||
def test_named_accounts_disable_the_shared_password(monkeypatch):
|
def test_named_accounts_disable_the_shared_password(monkeypatch):
|
||||||
@@ -88,7 +91,7 @@ def test_named_accounts_disable_the_shared_password(monkeypatch):
|
|||||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||||
monkeypatch.setenv("APP_USER", "astrololo")
|
monkeypatch.setenv("APP_USER", "astrololo")
|
||||||
monkeypatch.setenv("APP_PASSWORD", "wspolne")
|
monkeypatch.setenv("APP_PASSWORD", "wspolne")
|
||||||
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) is None
|
assert _kto("astrololo", "wspolne") is None
|
||||||
|
|
||||||
|
|
||||||
def test_no_accounts_means_auth_disabled(monkeypatch):
|
def test_no_accounts_means_auth_disabled(monkeypatch):
|
||||||
@@ -97,28 +100,82 @@ def test_no_accounts_means_auth_disabled(monkeypatch):
|
|||||||
|
|
||||||
# ── dziennik audytowy ──
|
# ── dziennik audytowy ──
|
||||||
|
|
||||||
def _client():
|
def _client(monkeypatch):
|
||||||
|
# Klucz MUSI być w środowisku ZANIM zaimportujemy app.main: security.install
|
||||||
|
# biegnie przy imporcie i bez klucza celowo nie pozwala usłudze wstać.
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||||
|
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
return TestClient(app)
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
class _dziennik:
|
||||||
|
"""Przechwytuje wpisy audytowe.
|
||||||
|
|
||||||
|
`caplog` podpina się pod korzeń, a dziennik audytowy ma celowo
|
||||||
|
`propagate=False` — żeby wpisy nie dublowały się w logach usługi. Trzeba więc
|
||||||
|
podpiąć uchwyt WPROST pod niego, inaczej test widzi pustkę i wygląda to na
|
||||||
|
brak wpisów zamiast na brak podsłuchu."""
|
||||||
|
|
||||||
|
def __init__(self, caplog):
|
||||||
|
self.caplog = caplog
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
security._setup_audit_logging()
|
||||||
|
security.audit_log.setLevel(logging.INFO)
|
||||||
|
security.audit_log.addHandler(self.caplog.handler)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
security.audit_log.removeHandler(self.caplog.handler)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tekst(self) -> str:
|
||||||
|
return "\n".join(r.getMessage() for r in self.caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def _sesja(user: str) -> dict:
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||||
|
|
||||||
|
|
||||||
def test_audit_logs_who_and_how_many(monkeypatch, caplog):
|
def test_audit_logs_who_and_how_many(monkeypatch, caplog):
|
||||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||||
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
|
c = _client(monkeypatch)
|
||||||
_client().get("/synastry", headers=_basic("alicja", "tajne"))
|
with _dziennik(caplog) as d:
|
||||||
entry = "\n".join(r.getMessage() for r in caplog.records)
|
c.get("/synastry", headers=_sesja("alicja"))
|
||||||
|
entry = d.tekst
|
||||||
assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry
|
assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry
|
||||||
|
|
||||||
|
|
||||||
def test_audit_marks_failed_login_without_naming_the_account(monkeypatch, caplog):
|
def test_audit_marks_a_request_without_a_session(monkeypatch, caplog):
|
||||||
|
"""Odesłanie na ekran logowania też zostawia ślad — bez tego seria prób
|
||||||
|
dobicia się do chronionej ścieżki byłaby w dzienniku niewidoczna."""
|
||||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||||
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
|
c = _client(monkeypatch)
|
||||||
_client().get("/synastry", headers=_basic("alicja", "złe"))
|
with _dziennik(caplog) as d:
|
||||||
entry = "\n".join(r.getMessage() for r in caplog.records)
|
c.get("/synastry")
|
||||||
assert "status=401" in entry and "user=-" in entry
|
entry = d.tekst
|
||||||
|
assert "status=303" in entry and "user=-" in entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_login_is_logged_without_saying_which_account_exists(monkeypatch, caplog):
|
||||||
|
"""Nieudane próby są w dzienniku ważniejsze od udanych: pojedyncza nic nie
|
||||||
|
znaczy, ale seria pod jednym adresem to jedyny widoczny ślad zgadywania
|
||||||
|
haseł. Hasło ani jego fragment NIE mogą tam trafić."""
|
||||||
|
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
with _dziennik(caplog) as d:
|
||||||
|
r = c.post("/logowanie", data={"login": "alicja", "haslo": "zgaduje-haslo"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
entry = d.tekst
|
||||||
|
assert "logowanie-odmowa" in entry
|
||||||
|
assert "zgaduje-haslo" not in entry, "hasło trafiło do dziennika"
|
||||||
|
|
||||||
|
|
||||||
def test_audit_logger_actually_emits(monkeypatch):
|
def test_audit_logger_actually_emits(monkeypatch):
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ TEMPLATES = pathlib.Path(__file__).resolve().parents[1] / "app" / "templates"
|
|||||||
|
|
||||||
|
|
||||||
def test_static_url_carries_content_hash():
|
def test_static_url_carries_content_hash():
|
||||||
assert re.fullmatch(r"/static/styles\.css\?v=[0-9a-f]{8}", static_url("styles.css"))
|
assert re.fullmatch(r"/static/base\.css\?v=[0-9a-f]{8}", static_url("base.css"))
|
||||||
|
|
||||||
|
|
||||||
def test_version_is_content_based_so_differs_between_files():
|
def test_version_is_content_based_so_differs_between_files():
|
||||||
assert _asset_version("styles.css") != _asset_version("formsync.js")
|
assert _asset_version("base.css") != _asset_version("formsync.js")
|
||||||
|
|
||||||
|
|
||||||
def test_missing_file_does_not_crash():
|
def test_missing_file_does_not_crash():
|
||||||
@@ -35,7 +35,17 @@ def test_no_template_serves_raw_static_paths():
|
|||||||
assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
|
assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
|
||||||
|
|
||||||
|
|
||||||
def test_base_uses_helper_for_stylesheet_and_scripts():
|
def test_base_uses_helper_for_the_shared_stylesheet():
|
||||||
base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
|
base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
|
||||||
assert "static('styles.css')" in base
|
assert "static('base.css')" in base
|
||||||
assert "static('formsync.js')" in base
|
|
||||||
|
|
||||||
|
def test_version_follows_what_is_sent_not_what_is_on_disk():
|
||||||
|
"""Adres niesie hash treści WYSYŁANEJ, bo komentarze są przy serwowaniu
|
||||||
|
usuwane. Gdyby hash szedł z pliku na dysku, poprawka w samym komentarzu
|
||||||
|
podbijałaby adres i kasowała cache przeglądarki bez żadnej zmiany dla niej."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.main import _asset_body
|
||||||
|
|
||||||
|
assert _asset_version("base.css") == hashlib.md5(_asset_body("base.css")).hexdigest()[:8]
|
||||||
|
|||||||
@@ -65,12 +65,19 @@ def test_every_shared_field_is_synced(key):
|
|||||||
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
|
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
|
||||||
|
|
||||||
|
|
||||||
def test_sync_script_loaded_before_map_script():
|
@pytest.mark.parametrize("name", WITH_FORM)
|
||||||
"""KOLEJNOŚĆ MA ZNACZENIE: formsync w <head> z `defer` wykonuje się przed
|
def test_sync_script_loaded_before_map_script(name):
|
||||||
geo.js (skrypty defer idą w kolejności dokumentu), więc mapa startuje już
|
"""KOLEJNOŚĆ MA ZNACZENIE: formsync z `defer` wykonuje się przed geo.js
|
||||||
z odtworzonymi współrzędnymi zamiast skakać po chwili."""
|
(skrypty defer idą w kolejności dokumentu), więc mapa startuje już
|
||||||
assert "formsync.js" in BASE_HTML, "formsync.js nie jest ładowany globalnie"
|
z odtworzonymi współrzędnymi zamiast skakać po chwili.
|
||||||
assert "defer" in BASE_HTML.split("formsync.js")[0].rsplit("<script", 1)[-1] + "defer"
|
|
||||||
|
Skrypt siedzi teraz w bloku `head_scripts` EKRANU, nie w base.html. Ładowany
|
||||||
|
globalnie docierał także do konta, które ma wyłącznie Pliki — a jego treść
|
||||||
|
wymienia z nazwy zakładki, których takie konto nie ma."""
|
||||||
|
html = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
assert "head_scripts" in html and "formsync.js" in html
|
||||||
|
przed_geo = html.split("formsync.js")[0]
|
||||||
|
assert "defer" in przed_geo.rsplit("<script", 1)[-1] + "defer"
|
||||||
|
|
||||||
|
|
||||||
def test_sync_reacts_to_other_browser_tab():
|
def test_sync_reacts_to_other_browser_tab():
|
||||||
|
|||||||
@@ -113,7 +113,10 @@ def test_fallback_notice_is_shown_prominently():
|
|||||||
sygnałem, że podział domów jest z innego systemu."""
|
sygnałem, że podział domów jest z innego systemu."""
|
||||||
html = _render("_result_tables.html",
|
html = _render("_result_tables.html",
|
||||||
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
|
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
|
||||||
assert "house-warning" in html
|
# Klasa nazywa się neutralnie: `house-warning` siedziała też na ekranie
|
||||||
|
# logowania i na ekranie plików, czyli nazwa z astrologii lądowała tam,
|
||||||
|
# gdzie astrologii ma nie być widać.
|
||||||
|
assert "notice-warn" in html
|
||||||
assert "UWAGA: system domów nie ma definicji" in html
|
assert "UWAGA: system domów nie ma definicji" in html
|
||||||
|
|
||||||
|
|
||||||
@@ -184,7 +187,12 @@ def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
|
|||||||
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
|
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
|
||||||
# poziomy pasek przewijania na body i rozjechany układ.
|
# poziomy pasek przewijania na body i rozjechany układ.
|
||||||
|
|
||||||
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||||
|
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||||
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
|
# pilnuje osobny test pokrycia.
|
||||||
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
|
for f in sorted((APP / "static").glob("*.css")))
|
||||||
|
|
||||||
|
|
||||||
def test_comparison_table_scrolls_instead_of_widening_the_page():
|
def test_comparison_table_scrolls_instead_of_widening_the_page():
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
|
|||||||
pozycji w menu, brak rysunków w źródle strony.
|
pozycji w menu, brak rysunków w źródle strony.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -27,7 +28,10 @@ def env(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setenv("APP_USER", "szef")
|
monkeypatch.setenv("APP_USER", "szef")
|
||||||
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||||
monkeypatch.delenv("APP_USERS", raising=False)
|
monkeypatch.delenv("APP_USERS", raising=False)
|
||||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||||
|
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http # limit nie jest tu przedmiotem badania
|
||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
@@ -40,11 +44,16 @@ def _client(monkeypatch, chart=None):
|
|||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def _auth(user, password):
|
def _auth(user, password=""):
|
||||||
import base64
|
"""Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
|
||||||
|
|
||||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma
|
||||||
return {"Authorization": f"Basic {raw}"}
|
nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne
|
||||||
|
i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań —
|
||||||
|
sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie."""
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||||
|
|
||||||
|
|
||||||
def _chart():
|
def _chart():
|
||||||
@@ -129,7 +138,7 @@ def test_empty_password_on_update_keeps_the_old_one(env):
|
|||||||
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
|
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
|
||||||
|
|
||||||
def test_environment_account_is_the_administrator(env):
|
def test_environment_account_is_the_administrator(env):
|
||||||
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
|
who = security.verify("szef", "tajne-szefa")
|
||||||
assert who and who.is_admin
|
assert who and who.is_admin
|
||||||
assert features.ADMIN in who.permissions
|
assert features.ADMIN in who.permissions
|
||||||
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
|
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
|
||||||
@@ -137,7 +146,7 @@ def test_environment_account_is_the_administrator(env):
|
|||||||
|
|
||||||
def test_managed_account_gets_exactly_its_own_permissions(env):
|
def test_managed_account_gets_exactly_its_own_permissions(env):
|
||||||
store.create("ala", "hasło-ali", ["chart", "ai"])
|
store.create("ala", "hasło-ali", ["chart", "ai"])
|
||||||
who = security.principal(_auth("ala", "hasło-ali")["Authorization"])
|
who = security.verify("ala", "hasło-ali")
|
||||||
assert who and not who.is_admin
|
assert who and not who.is_admin
|
||||||
assert who.permissions == frozenset({"chart", "ai"})
|
assert who.permissions == frozenset({"chart", "ai"})
|
||||||
|
|
||||||
@@ -146,14 +155,14 @@ def test_managed_account_cannot_shadow_the_administrator(env):
|
|||||||
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
|
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
|
||||||
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
|
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
|
||||||
store.create("szef", "podszywam-się", [])
|
store.create("szef", "podszywam-się", [])
|
||||||
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
|
who = security.verify("szef", "tajne-szefa")
|
||||||
assert who and who.is_admin and features.ADMIN in who.permissions
|
assert who and who.is_admin and features.ADMIN in who.permissions
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
|
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
|
||||||
store.create("ala", "hasło-ali", ["chart"])
|
store.create("ala", "hasło-ali", ["chart"])
|
||||||
assert security.principal(_auth("ala", "złe")["Authorization"]) is None
|
assert security.verify("ala", "złe") is None
|
||||||
assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None
|
assert security.verify("nie-ma-takiego", "cokolwiek") is None
|
||||||
|
|
||||||
|
|
||||||
# ── niewidzialność funkcji ───────────────────────────────────────────────
|
# ── niewidzialność funkcji ───────────────────────────────────────────────
|
||||||
@@ -293,9 +302,10 @@ def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypa
|
|||||||
data={"login": "nowa", "granted": ["chart"]})
|
data={"login": "nowa", "granted": ["chart"]})
|
||||||
assert c.get("/significators", headers=her).status_code == 404
|
assert c.get("/significators", headers=her).status_code == 404
|
||||||
|
|
||||||
# skasowanie konta odcina logowanie
|
# skasowanie konta ubija też OTWARTĄ sesję — bez tego odebranie dostępu
|
||||||
|
# działałoby dopiero po jej wygaśnięciu
|
||||||
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
|
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
|
||||||
assert c.get("/", headers=her).status_code == 401
|
assert c.get("/", headers=her, follow_redirects=False).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
|
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
|
||||||
@@ -315,40 +325,80 @@ def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
|
|||||||
# Ekran kont to JEDYNE miejsce, z którego administrator może naprawić problem
|
# Ekran kont to JEDYNE miejsce, z którego administrator może naprawić problem
|
||||||
# z magazynem — więc musi na nim przeczytać, co i gdzie jest nie tak. Gołe 500
|
# z magazynem — więc musi na nim przeczytać, co i gdzie jest nie tak. Gołe 500
|
||||||
# (tak było na pierwszym wdrożeniu) zostawia go z niczym.
|
# (tak było na pierwszym wdrożeniu) zostawia go z niczym.
|
||||||
|
#
|
||||||
|
# AWARIĘ WYMUSZAMY PODMIANĄ, NIE PRAWAMI PLIKU. Pierwsza wersja robiła chmod 000
|
||||||
|
# i chmod 555 — i przechodziła lokalnie, a padała w CI, bo CI działa jako ROOT,
|
||||||
|
# a root omija bity uprawnień w Linuksie. Test zależny od tego, kto go uruchamia,
|
||||||
|
# jest gorszy niż jego brak: daje fałszywe poczucie pokrycia i zapala się
|
||||||
|
# w miejscu, które z badaną rzeczą nie ma nic wspólnego.
|
||||||
|
#
|
||||||
|
# Podmiana jest przy okazji CELNIEJSZA: sprawdzamy dokładnie ten punkt, w którym
|
||||||
|
# system plików mówi „nie", zamiast liczyć, że da się go do tego zmusić.
|
||||||
|
|
||||||
def _unreadable(tmp_path, monkeypatch, mode):
|
def _fail_reading_accounts(monkeypatch, exc=PermissionError(13, "Permission denied")):
|
||||||
d = tmp_path / "stan"
|
"""Odczyt PLIKU KONT wywala się; wszystko inne (szablony!) czyta normalnie."""
|
||||||
d.mkdir()
|
import builtins
|
||||||
monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json"))
|
|
||||||
d.chmod(mode)
|
from app import accounts as store
|
||||||
return d
|
|
||||||
|
real_open, target = builtins.open, store.store_path()
|
||||||
|
|
||||||
|
def guarded(file, *a, **kw):
|
||||||
|
if str(file) == target:
|
||||||
|
raise exc
|
||||||
|
return real_open(file, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "open", guarded)
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch):
|
def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch):
|
||||||
d = _unreadable(tmp_path, monkeypatch, 0o000)
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "stan" / "accounts.json"))
|
||||||
try:
|
_fail_reading_accounts(monkeypatch)
|
||||||
c = _client(monkeypatch)
|
|
||||||
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
c = _client(monkeypatch)
|
||||||
assert r.status_code == 200, "problem z magazynem nie może wywalać strony"
|
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
||||||
assert "Nie mogę odczytać pliku kont" in r.text
|
assert r.status_code == 200, "problem z magazynem nie może wywalać strony"
|
||||||
assert str(d / "accounts.json") in r.text, "komunikat ma podać ŚCIEŻKĘ"
|
assert "Nie mogę odczytać pliku kont" in r.text
|
||||||
finally:
|
assert "accounts.json" in r.text, "komunikat ma podać ŚCIEŻKĘ"
|
||||||
d.chmod(0o755)
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch):
|
def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch):
|
||||||
d = _unreadable(tmp_path, monkeypatch, 0o555)
|
"""Podmieniamy mkstemp, bo to ON wywala się PIERWSZY przy katalogu tylko do
|
||||||
|
odczytu — jeszcze zanim dojdzie do zapisu i podmiany pliku. Obudowanie samego
|
||||||
|
os.replace niczego by nie dało i właśnie tego dotyczyła poprawka."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "stan" / "accounts.json"))
|
||||||
|
monkeypatch.setattr(tempfile, "mkstemp",
|
||||||
|
lambda *a, **kw: (_ for _ in ()).throw(
|
||||||
|
PermissionError(13, "Read-only file system")))
|
||||||
|
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
|
||||||
|
follow_redirects=False,
|
||||||
|
data={"login": "ala", "password": "x", "granted": ["chart"]})
|
||||||
|
assert r.status_code == 303
|
||||||
|
# Komunikat jedzie w parametrze zapytania, więc jest zakodowany —
|
||||||
|
# porównanie na surowym nagłówku sprawdzałoby procenty, nie treść.
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
|
assert "Nie mogę zapisać pliku kont" in unquote_plus(r.headers["location"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.geteuid() == 0,
|
||||||
|
reason="root omija bity uprawnień — sprawdzian nic by nie znaczył")
|
||||||
|
def test_real_file_permissions_produce_the_same_message(env, tmp_path, monkeypatch):
|
||||||
|
"""Kontrola, że podmiana wyżej odpowiada RZECZYWISTOŚCI, a nie tylko sama
|
||||||
|
sobie. Pomijana przy uruchomieniu z roota — czyli w CI."""
|
||||||
|
d = tmp_path / "stan"
|
||||||
|
d.mkdir()
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json"))
|
||||||
|
d.chmod(0o000)
|
||||||
try:
|
try:
|
||||||
c = _client(monkeypatch)
|
c = _client(monkeypatch)
|
||||||
r = c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
|
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
||||||
follow_redirects=False,
|
assert r.status_code == 200
|
||||||
data={"login": "ala", "password": "x", "granted": ["chart"]})
|
assert "Nie mogę odczytać pliku kont" in r.text
|
||||||
assert r.status_code == 303
|
|
||||||
# Komunikat jedzie w parametrze zapytania, więc jest zakodowany —
|
|
||||||
# porównanie na surowym nagłówku sprawdzałoby procenty, nie treść.
|
|
||||||
from urllib.parse import unquote_plus
|
|
||||||
|
|
||||||
assert "Nie mogę zapisać pliku kont" in unquote_plus(r.headers["location"])
|
|
||||||
finally:
|
finally:
|
||||||
d.chmod(0o755)
|
d.chmod(0o755)
|
||||||
|
|
||||||
@@ -368,3 +418,136 @@ def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch
|
|||||||
follow_redirects=False, data={"login": "ala", "password": "x"})
|
follow_redirects=False, data={"login": "ala", "password": "x"})
|
||||||
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
|
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
|
||||||
"uszkodzony plik został nadpisany — konta by zniknęły"
|
"uszkodzony plik został nadpisany — konta by zniknęły"
|
||||||
|
|
||||||
|
|
||||||
|
# ── logowanie i wylogowanie (LOG-34) ─────────────────────────────────────
|
||||||
|
# Sesja zastąpiła HTTP Basic. Basic nie miał wylogowania, bo przeglądarka
|
||||||
|
# dosyłała hasło sama przy każdym żądaniu; teraz to serwer decyduje, czy dana
|
||||||
|
# przeglądarka jest w środku, i może to cofnąć.
|
||||||
|
|
||||||
|
def test_logging_in_hands_out_a_session_and_takes_you_where_you_were_going(env, monkeypatch):
|
||||||
|
store.create("ala", "hasło-ali", ["chart"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.post("/logowanie", follow_redirects=False,
|
||||||
|
data={"login": "ala", "haslo": "hasło-ali", "dokad": "/"})
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
assert session.COOKIE in r.cookies, "nie wydano ciasteczka sesji"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bad_password_says_the_same_thing_as_an_unknown_login(env, monkeypatch):
|
||||||
|
"""Rozróżnienie mówiłoby, które konta istnieją."""
|
||||||
|
store.create("ala", "hasło-ali", ["chart"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
zle = c.post("/logowanie", data={"login": "ala", "haslo": "złe"})
|
||||||
|
obcy = c.post("/logowanie", data={"login": "nie-ma-takiego", "haslo": "cokolwiek"})
|
||||||
|
assert zle.status_code == obcy.status_code == 401
|
||||||
|
# Odpowiedzi różnią się WYŁĄCZNIE odbitym polem loginu — czyli tym, co sam
|
||||||
|
# wpisał pytający. Wszystko poza tym musi być identyczne, inaczej dałoby się
|
||||||
|
# wyliczyć, które konta istnieją.
|
||||||
|
bez_loginu = lambda t, login: t.replace(f'value="{login}"', 'value="X"')
|
||||||
|
assert "Nieprawidłowy login lub hasło" in zle.text
|
||||||
|
assert bez_loginu(zle.text, "ala") == bez_loginu(obcy.text, "nie-ma-takiego")
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_out_drops_the_session(env, monkeypatch):
|
||||||
|
"""Sedno całej zmiany: po wylogowaniu przeglądarka nie ma już czego dosyłać."""
|
||||||
|
store.create("ala", "x", ["chart"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
assert c.get("/", headers=_auth("ala"), follow_redirects=False).status_code == 200
|
||||||
|
|
||||||
|
r = c.post("/wyloguj", headers=_auth("ala"), follow_redirects=False)
|
||||||
|
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
|
||||||
|
# Ciasteczko skasowane: przeglądarka dostaje puste z natychmiastowym kresem.
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
ustawione = r.headers.get("set-cookie", "")
|
||||||
|
assert session.COOKIE in ustawione
|
||||||
|
assert "Max-Age=0" in ustawione or 'expires=Thu, 01 Jan 1970' in ustawione.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_out_is_a_post_not_a_link(env, monkeypatch):
|
||||||
|
"""Pod adresem GET wystarczyłby obrazek na obcej stronie, żeby wylogować
|
||||||
|
kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa do uniknięcia."""
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
assert c.get("/wyloguj", headers=_auth("szef"),
|
||||||
|
follow_redirects=False).status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monkeypatch):
|
||||||
|
"""Bez pokazania, KTO jest zalogowany, przycisk wyjścia jest w połowie
|
||||||
|
bezużyteczny — na wspólnym komputerze nie wiadomo, kogo się wylogowuje."""
|
||||||
|
store.create("ala", "x", ["chart", "significators"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
for kto in ("szef", "ala"):
|
||||||
|
html = c.get("/", headers=_auth(kto)).text
|
||||||
|
assert 'action="/wyloguj"' in html, f"{kto}: brak wyjścia"
|
||||||
|
assert kto in html, f"{kto}: nie widać, kto jest zalogowany"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_logout_button_when_authentication_is_off(env, monkeypatch):
|
||||||
|
"""Bez logowania nie ma z czego wychodzić, a przycisk sugerowałby ochronę,
|
||||||
|
której nie ma."""
|
||||||
|
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||||
|
monkeypatch.delenv("APP_USERS", raising=False)
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
assert 'action="/wyloguj"' not in c.get("/").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch):
|
||||||
|
"""Konto bez ani jednego ekranu też ma prawo wyjść."""
|
||||||
|
store.create("bez", "x", [])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
assert c.post("/wyloguj", headers=_auth("bez"),
|
||||||
|
follow_redirects=False).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_login_screen_cannot_be_turned_into_a_redirector(env, monkeypatch):
|
||||||
|
"""`?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie do
|
||||||
|
odsyłania ludzi gdzie indziej — droga do wyłudzenia hasła, bo odsyłacz
|
||||||
|
prowadzi z zaufanego adresu."""
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x", "\\\\obcy.pl"):
|
||||||
|
r = c.post("/logowanie", follow_redirects=False,
|
||||||
|
data={"login": "szef", "haslo": "tajne-szefa", "dokad": zly})
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_changing_the_password_kills_open_sessions(env, monkeypatch):
|
||||||
|
"""Odebranie dostępu przez zmianę hasła MUSI ubić otwarte sesje — inaczej
|
||||||
|
odebranie dostępu nie odbiera dostępu aż do wygaśnięcia."""
|
||||||
|
store.create("ala", "stare", ["chart"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
ciasteczko = _auth("ala")
|
||||||
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||||
|
|
||||||
|
store.update("ala", password="nowe")
|
||||||
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_administrator_can_close_all_sessions_of_an_account(env, monkeypatch):
|
||||||
|
"""„Wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił
|
||||||
|
zalogowany komputer."""
|
||||||
|
store.create("ala", "x", ["chart"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
ciasteczko = _auth("ala")
|
||||||
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||||
|
|
||||||
|
c.post("/accounts/sesje", headers=_auth("szef"), follow_redirects=False,
|
||||||
|
data={"login": "ala"})
|
||||||
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||||
|
# ...a hasło dalej działa
|
||||||
|
assert security.verify("ala", "x") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_administrator_gets_in_even_when_the_accounts_file_is_broken(env, monkeypatch):
|
||||||
|
"""Jedyna sytuacja, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie
|
||||||
|
stanu administratora w tym samym pliku dawałoby zakleszczenie."""
|
||||||
|
import pathlib as _p
|
||||||
|
|
||||||
|
_p.Path(store.store_path()).write_text("{to nie jest json", encoding="utf-8")
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
assert c.get("/accounts", headers=_auth("szef")).status_code == 200
|
||||||
|
assert security.verify("szef", "tajne-szefa") is not None
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Trzy poziomy dostępu do plików baz (DAN-27 × PRE-27).
|
||||||
|
|
||||||
|
user („files") — widzi listę i decyduje, z czego program korzysta,
|
||||||
|
data_input (+„files_input")— dokłada wgrywanie i ARCHIWIZACJĘ,
|
||||||
|
administrator — kasowanie, przywracanie i REGUŁY WALIDACJI.
|
||||||
|
|
||||||
|
Osobno pilnujemy własności negatywnej: poza administratorem NIKT nie ma skąd
|
||||||
|
wiedzieć, że walidacja w ogóle istnieje. To łatwo zepsuć dobrą intencją —
|
||||||
|
„pokażmy człowiekowi, czemu plik nie przeszedł" — więc testy są wprost o tym.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import accounts as store
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||||
|
monkeypatch.setenv("APP_USER", "szef")
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||||
|
monkeypatch.delenv("APP_USERS", raising=False)
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||||
|
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(user, password=""):
|
||||||
|
"""Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
|
||||||
|
|
||||||
|
Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma
|
||||||
|
nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne
|
||||||
|
i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań —
|
||||||
|
sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie."""
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||||
|
|
||||||
|
|
||||||
|
REGISTRY = [
|
||||||
|
{"name": "glowna.xlsx", "path": "glowna.xlsx", "size_mb": 1.0, "modified": "2026-01-01",
|
||||||
|
"status": "active", "in_use": True, "archived_at": "", "uploaded_at": "",
|
||||||
|
"uploaded_by": "", "sha256": "abc"},
|
||||||
|
{"name": "stara.xlsx", "path": "stara.xlsx", "size_mb": 2.0, "modified": "2025-01-01",
|
||||||
|
"status": "archived", "in_use": False, "archived_at": "2026-02-02T10:00:00+00:00",
|
||||||
|
"uploaded_at": "", "uploaded_by": "", "sha256": "def"},
|
||||||
|
]
|
||||||
|
HELD = {"name": "podejrzana.xlsx", "path": "podejrzana.xlsx", "size_mb": 0.1,
|
||||||
|
"modified": "2026-03-03", "status": "quarantine", "in_use": False,
|
||||||
|
"archived_at": "", "uploaded_at": "2026-03-03T09:00:00+00:00",
|
||||||
|
"uploaded_by": "dane", "sha256": "ghi",
|
||||||
|
"rejected_for": ["brak wymaganych kolumn: znaczenie"]}
|
||||||
|
RULES = {"extensions": [".xlsx"], "max_size_mb": 50, "min_rows": 1,
|
||||||
|
"required_columns": ["id", "znaczenie"], "reject_duplicate_content": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch, calls=None):
|
||||||
|
"""Klient z zastubowaną warstwą logiczną. `calls` zbiera to, co poszło w dół."""
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, logic
|
||||||
|
|
||||||
|
calls = calls if calls is not None else []
|
||||||
|
|
||||||
|
def files_list(for_admin=False):
|
||||||
|
rows = list(REGISTRY) + ([HELD] if for_admin else [])
|
||||||
|
return {"files": rows, "rules": RULES if for_admin else {}, "root": "/x"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "files_list", files_list)
|
||||||
|
for name in ("files_status", "files_upload", "files_delete", "files_rules"):
|
||||||
|
monkeypatch.setattr(logic, name,
|
||||||
|
lambda *a, _n=name, **kw: (calls.append((_n, a, kw)),
|
||||||
|
{"accepted": True, "name": "x.xlsx"})[1])
|
||||||
|
return TestClient(app), calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── poziom „user" ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_user_sees_the_list_and_can_switch_bases_on_and_off(env, monkeypatch):
|
||||||
|
store.create("ula", "x", ["files"])
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
html = c.get("/files", headers=_auth("ula", "x")).text
|
||||||
|
assert "glowna.xlsx" in html and "stara.xlsx" in html
|
||||||
|
|
||||||
|
r = c.post("/files/use", headers=_auth("ula", "x"), follow_redirects=False,
|
||||||
|
data={"path": "glowna.xlsx", "use": "0"})
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert calls and calls[0][0] == "files_status"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_gets_no_upload_no_archive_no_delete(env, monkeypatch):
|
||||||
|
store.create("ula", "x", ["files"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get("/files", headers=_auth("ula", "x")).text
|
||||||
|
for absent in ("/files/upload", "/files/archive", "/files/delete",
|
||||||
|
"/files/restore", "/files/rules"):
|
||||||
|
assert absent not in html, f"strona zdradza {absent}"
|
||||||
|
for path in ("/files/upload", "/files/archive", "/files/delete",
|
||||||
|
"/files/restore", "/files/rules"):
|
||||||
|
r = c.post(path, headers=_auth("ula", "x"), data={"path": "glowna.xlsx"})
|
||||||
|
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── poziom „data_input" ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_data_input_can_upload_and_archive(env, monkeypatch):
|
||||||
|
store.create("dane", "x", ["files", "files_input"])
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
html = c.get("/files", headers=_auth("dane", "x")).text
|
||||||
|
assert "/files/upload" in html and "/files/archive" in html
|
||||||
|
|
||||||
|
r = c.post("/files/upload", headers=_auth("dane", "x"), follow_redirects=False,
|
||||||
|
files={"upload": ("nowa.xlsx", b"zawartosc", "application/vnd.ms-excel")})
|
||||||
|
assert r.status_code == 303 and calls[-1][0] == "files_upload"
|
||||||
|
|
||||||
|
r = c.post("/files/archive", headers=_auth("dane", "x"), follow_redirects=False,
|
||||||
|
data={"path": "glowna.xlsx"})
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert calls[-1][1][1] == "archived", "archiwizacja ma ustawiać stan `archived`"
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_input_cannot_delete_restore_or_set_rules(env, monkeypatch):
|
||||||
|
"""Wymóg wprost: osoba wgrywająca dane może CO NAJWYŻEJ zarchiwizować."""
|
||||||
|
store.create("dane", "x", ["files", "files_input"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get("/files", headers=_auth("dane", "x")).text
|
||||||
|
for absent in ("/files/delete", "/files/restore", "/files/rules"):
|
||||||
|
assert absent not in html, f"strona zdradza {absent}"
|
||||||
|
for path in ("/files/delete", "/files/restore", "/files/rules"):
|
||||||
|
assert c.post(path, headers=_auth("dane", "x"),
|
||||||
|
data={"path": "glowna.xlsx"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── własność negatywna: walidacja jest tajemnicą administratora ─────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("perms", [["files"], ["files", "files_input"]])
|
||||||
|
def test_nobody_below_admin_learns_that_validation_exists(env, monkeypatch, perms):
|
||||||
|
store.create("ktos", "x", perms)
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get("/files", headers=_auth("ktos", "x")).text
|
||||||
|
assert "podejrzana.xlsx" not in html, "plik wstrzymany nie ma prawa się pokazać"
|
||||||
|
for leak in ("walidacj", "Walidacj", "reguł", "Reguł", "znaczenie",
|
||||||
|
"brak wymaganych kolumn", "rozszerzeni", "Maksymalny rozmiar"):
|
||||||
|
assert leak not in html, f"strona zdradza mechanizm: „{leak}”"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_to_activate_gives_no_reason_below_admin(env, monkeypatch):
|
||||||
|
"""Powód odmowy zdradzałby regułę. Komunikat ma być bez treści."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.main import app, logic
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
store.create("ula", "x", ["files"])
|
||||||
|
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"422", request=httpx.Request("POST", "http://x"),
|
||||||
|
response=httpx.Response(422, json={"detail": "brak wymaganych kolumn: znaczenie"}))
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "files_list", lambda for_admin=False: {"files": [], "rules": {}})
|
||||||
|
monkeypatch.setattr(logic, "files_status", boom)
|
||||||
|
r = TestClient(app).post("/files/use", headers=_auth("ula", "x"),
|
||||||
|
follow_redirects=False, data={"path": "x.xlsx", "use": "1"})
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert "znaczenie" not in r.headers["location"]
|
||||||
|
assert "kolumn" not in r.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── administrator ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_admin_sees_held_files_with_the_reason_and_the_rules(env, monkeypatch):
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get("/files", headers=_auth("szef", "tajne-szefa")).text
|
||||||
|
assert "podejrzana.xlsx" in html
|
||||||
|
assert "brak wymaganych kolumn: znaczenie" in html
|
||||||
|
assert "Reguły przyjmowania baz" in html
|
||||||
|
for op in ("/files/delete", "/files/restore", "/files/rules",
|
||||||
|
"/files/upload", "/files/archive"):
|
||||||
|
assert op in html, f"administratorowi brakuje {op}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_delete_restore_and_change_the_rules(env, monkeypatch):
|
||||||
|
c, calls = _client(monkeypatch)
|
||||||
|
admin = _auth("szef", "tajne-szefa")
|
||||||
|
c.post("/files/delete", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
|
||||||
|
c.post("/files/restore", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
|
||||||
|
c.post("/files/rules", headers=admin, follow_redirects=False,
|
||||||
|
data={"extensions": ".xlsx, .xlsm", "max_size_mb": "10", "min_rows": "5",
|
||||||
|
"required_columns": "id, znaczenie", "reject_duplicate_content": "1"})
|
||||||
|
names = [c[0] for c in calls]
|
||||||
|
assert names == ["files_delete", "files_status", "files_rules"]
|
||||||
|
sent = calls[-1][1][0]
|
||||||
|
assert sent["extensions"] == [".xlsx", ".xlsm"]
|
||||||
|
assert sent["required_columns"] == ["id", "znaczenie"]
|
||||||
|
assert sent["reject_duplicate_content"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_archived_file_cannot_be_switched_on_from_the_page(env, monkeypatch):
|
||||||
|
"""Archiwum jest ZAMROŻONE: przełącznik przy nim w ogóle się nie pojawia,
|
||||||
|
a przywrócić może wyłącznie administrator."""
|
||||||
|
store.create("dane", "x", ["files", "files_input"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get("/files", headers=_auth("dane", "x")).text
|
||||||
|
row = html[html.index("stara.xlsx") - 700:html.index("stara.xlsx")]
|
||||||
|
assert "/files/use" not in row, "zarchiwizowana baza ma przełącznik użycia"
|
||||||
|
|
||||||
|
|
||||||
|
def test_files_tab_is_hidden_without_the_permission(env, monkeypatch):
|
||||||
|
store.create("bez", "x", ["chart"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get("/", headers=_auth("bez", "x")).text
|
||||||
|
assert 'href="/files"' not in html and "Pliki" not in html
|
||||||
|
assert c.get("/files", headers=_auth("bez", "x")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_map_covers_every_file_route():
|
||||||
|
for route in ("/files", "/files/use", "/files/upload", "/files/archive",
|
||||||
|
"/files/restore", "/files/delete", "/files/rules"):
|
||||||
|
method = "GET" if route == "/files" else "POST"
|
||||||
|
assert (method, route) in features.ROUTES, route
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Logowanie do aplikacji i limit żądań (LOG-32).
|
"""Logowanie do aplikacji i limit żądań (LOG-32/LOG-34).
|
||||||
|
|
||||||
To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci
|
To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci
|
||||||
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
|
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
|
||||||
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
|
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
|
|
||||||
|
Od LOG-34 bramą jest SESJA, nie HTTP Basic. Zmiana nie jest kosmetyczna: Basic
|
||||||
|
nie miał wylogowania, bo przeglądarka dosyłała hasło sama przy każdym żądaniu.
|
||||||
|
Teraz to serwer decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
|
||||||
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -16,9 +18,11 @@ from app import security
|
|||||||
USER, PASSWORD = "astrololo", "haslo-testowe"
|
USER, PASSWORD = "astrololo", "haslo-testowe"
|
||||||
|
|
||||||
|
|
||||||
def _basic(user: str, password: str) -> dict[str, str]:
|
def _sesja(user: str = USER) -> dict[str, str]:
|
||||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
"""Nagłówek z ważną sesją — odpowiednik bycia zalogowanym."""
|
||||||
return {"Authorization": f"Basic {raw}"}
|
from app import session
|
||||||
|
|
||||||
|
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||||
|
|
||||||
|
|
||||||
def _app() -> FastAPI:
|
def _app() -> FastAPI:
|
||||||
@@ -47,48 +51,93 @@ def _reset_rate_limit():
|
|||||||
def guarded(monkeypatch):
|
def guarded(monkeypatch):
|
||||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
|
||||||
return TestClient(_app())
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
|
||||||
|
return TestClient(_app(), follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ logowanie
|
# ------------------------------------------------------------------ logowanie
|
||||||
|
|
||||||
def test_blocks_anonymous_access(guarded):
|
def test_blocks_anonymous_access(guarded):
|
||||||
r = guarded.get("/significators")
|
r = guarded.get("/significators")
|
||||||
assert r.status_code == 401
|
assert r.status_code == 303
|
||||||
assert "treść z bazy" not in r.text
|
assert "treść z bazy" not in r.text
|
||||||
|
|
||||||
|
|
||||||
def test_challenges_with_basic_realm(guarded):
|
def test_sends_people_to_the_login_screen_not_a_system_dialog(guarded):
|
||||||
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
|
"""Basic odpowiadał 401 z WWW-Authenticate, co otwierało okienko systemowe:
|
||||||
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
|
nie dało się go opisać, ostylować ani z niego wyjść inaczej niż anulowaniem.
|
||||||
|
Ekran logowania jest zwykłą stroną, więc może wyjaśnić, co się stało."""
|
||||||
|
r = guarded.get("/significators")
|
||||||
|
assert r.headers["location"].startswith("/logowanie")
|
||||||
|
assert "WWW-Authenticate" not in r.headers, "okienko systemowe wróciło"
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_wrong_password(guarded):
|
def test_remembers_where_someone_was_heading(guarded):
|
||||||
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
|
"""Po zalogowaniu ma wrócić tam, gdzie szedł — nie na stronę główną."""
|
||||||
|
assert "dokad=/significators" in guarded.get("/significators").headers["location"]
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_wrong_user(guarded):
|
def test_no_session_no_content(guarded):
|
||||||
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
|
"""Popsute albo podrobione ciasteczko jest tym samym, co jego brak."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
# Same znaki ASCII — nagłówek Cookie nie przenosi innych.
|
||||||
|
for zle in ("", "smiec", "a.b", "...", session.issue(USER, "zly-odcisk")):
|
||||||
|
r = guarded.get("/significators", headers={"Cookie": f"{session.COOKIE}={zle}"})
|
||||||
|
assert r.status_code == 303, f"wpuszczono z ciasteczkiem: {zle!r}"
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_malformed_header(guarded):
|
def test_allows_a_valid_session(guarded):
|
||||||
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
|
r = guarded.get("/significators", headers=_sesja())
|
||||||
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_allows_correct_credentials(guarded):
|
|
||||||
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
|
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "treść z bazy" in r.text
|
assert "treść z bazy" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_credentials_are_checked_by_verify(monkeypatch):
|
||||||
|
"""Samo sprawdzanie hasła — bez HTTP, bo to czysta funkcja."""
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||||
|
monkeypatch.delenv("APP_USERS", raising=False)
|
||||||
|
assert security.verify(USER, PASSWORD) is not None
|
||||||
|
assert security.verify(USER, "zle") is None
|
||||||
|
assert security.verify("obcy", PASSWORD) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_session_dies_when_the_password_changes(monkeypatch):
|
||||||
|
"""Odebranie dostępu przez zmianę hasła MUSI ubić otwarte sesje — inaczej
|
||||||
|
odebranie dostępu nie odbiera dostępu aż do wygaśnięcia sesji."""
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||||
|
client = TestClient(_app(), follow_redirects=False)
|
||||||
|
ciasteczko = _sesja()
|
||||||
|
assert client.get("/significators", headers=ciasteczko).status_code == 200
|
||||||
|
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "zupełnie-nowe-hasło")
|
||||||
|
assert client.get("/significators", headers=ciasteczko).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
def test_health_stays_public(guarded):
|
def test_health_stays_public(guarded):
|
||||||
assert guarded.get("/health").status_code == 200
|
assert guarded.get("/health").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_key_no_service(monkeypatch):
|
||||||
|
"""Fail-closed: usługa z kontami, ale bez klucza podpisu, nie umiałaby
|
||||||
|
odróżnić ważnej sesji od podrobionej — więc nie wstaje."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||||
|
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||||
|
with pytest.raises(session.SessionError):
|
||||||
|
_app()
|
||||||
|
|
||||||
|
|
||||||
def test_open_when_password_unset(monkeypatch):
|
def test_open_when_password_unset(monkeypatch):
|
||||||
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
|
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie).
|
||||||
|
Klucz sesji nie jest wtedy potrzebny — nie ma czego podpisywać."""
|
||||||
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||||
|
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||||
assert not security.auth_enabled()
|
assert not security.auth_enabled()
|
||||||
assert TestClient(_app()).get("/significators").status_code == 200
|
assert TestClient(_app()).get("/significators").status_code == 200
|
||||||
|
|
||||||
@@ -99,8 +148,9 @@ def test_rate_limit_blocks_flood(monkeypatch):
|
|||||||
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
|
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
|
||||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
client = TestClient(_app())
|
client = TestClient(_app())
|
||||||
auth = _basic(USER, PASSWORD)
|
auth = _sesja()
|
||||||
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
|
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
|
||||||
assert codes[:5] == [200] * 5
|
assert codes[:5] == [200] * 5
|
||||||
assert 429 in codes[5:]
|
assert 429 in codes[5:]
|
||||||
@@ -109,8 +159,9 @@ def test_rate_limit_blocks_flood(monkeypatch):
|
|||||||
def test_rate_limited_response_has_retry_after(monkeypatch):
|
def test_rate_limited_response_has_retry_after(monkeypatch):
|
||||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
client = TestClient(_app())
|
client = TestClient(_app())
|
||||||
auth = _basic(USER, PASSWORD)
|
auth = _sesja()
|
||||||
client.get("/significators", headers=auth)
|
client.get("/significators", headers=auth)
|
||||||
r = client.get("/significators", headers=auth)
|
r = client.get("/significators", headers=auth)
|
||||||
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
|
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
|
||||||
@@ -121,7 +172,8 @@ def test_rate_limit_precedes_auth(monkeypatch):
|
|||||||
i sondowanie API jest darmowe."""
|
i sondowanie API jest darmowe."""
|
||||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
||||||
client = TestClient(_app())
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
client = TestClient(_app(), follow_redirects=False)
|
||||||
codes = [client.get("/significators").status_code for _ in range(6)]
|
codes = [client.get("/significators").status_code for _ in range(6)]
|
||||||
assert 429 in codes
|
assert 429 in codes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Sesje logowania — rdzeń podpisywania (LOG-34).
|
||||||
|
|
||||||
|
Testy dzielą się na dwie grupy i ta druga jest ważniejsza: „czy działa" oraz
|
||||||
|
„czy da się oszukać". Kod, który tylko przechodzi tę pierwszą, wpuszcza każdego,
|
||||||
|
kto spróbuje czegoś innego niż zamierzony przebieg.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
KLUCZ = "x" * 64
|
||||||
|
ODCISK = session.fingerprint("scrypt$sól$hash")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def klucz(monkeypatch):
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", KLUCZ)
|
||||||
|
monkeypatch.delenv("SESSION_MAX_AGE", raising=False)
|
||||||
|
monkeypatch.delenv("SESSION_IDLE", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── klucz: fail-closed ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_no_key_means_no_sessions_not_a_random_one(monkeypatch):
|
||||||
|
"""Losowy klucz przy starcie wylogowywałby wszystkich przy każdym restarcie
|
||||||
|
poda — wygląda jak awaria i uczy ludzi ignorować ekran logowania."""
|
||||||
|
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||||
|
with pytest.raises(session.SessionError):
|
||||||
|
session.secret()
|
||||||
|
assert session.available() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_key_is_refused_with_a_reason(monkeypatch):
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "krótki")
|
||||||
|
with pytest.raises(session.SessionError) as e:
|
||||||
|
session.secret()
|
||||||
|
assert "32" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── zwykły przebieg ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_issue_and_read_round_trip(klucz):
|
||||||
|
dane = session.read(session.issue("ala", ODCISK, 3))
|
||||||
|
assert dane["login"] == "ala" and dane["odcisk"] == ODCISK and dane["wersja"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_fingerprint_never_carries_the_password(klucz):
|
||||||
|
"""Odcisk jedzie do przeglądarki, więc nie może dać się cofnąć do hasła."""
|
||||||
|
odcisk = session.fingerprint("tajne-hasło-użytkownika")
|
||||||
|
assert "tajne" not in odcisk and "hasło" not in odcisk
|
||||||
|
assert len(odcisk) == 16
|
||||||
|
assert session.fingerprint("a") != session.fingerprint("b")
|
||||||
|
|
||||||
|
|
||||||
|
# ── próby oszukania ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_a_tampered_payload_is_refused(klucz):
|
||||||
|
token = session.issue("ala", ODCISK)
|
||||||
|
surowy, _, podpis = token.rpartition(".")
|
||||||
|
dane = json.loads(session._unb64(surowy))
|
||||||
|
dane["u"] = "szef" # podmiana konta
|
||||||
|
podrobiony = session._b64(json.dumps(dane, separators=(",", ":"),
|
||||||
|
sort_keys=True).encode()) + "." + podpis
|
||||||
|
assert session.read(podrobiony) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tampered_signature_is_refused(klucz):
|
||||||
|
token = session.issue("ala", ODCISK)
|
||||||
|
surowy, _, podpis = token.rpartition(".")
|
||||||
|
assert session.read(f"{surowy}.{podpis[:-2]}xx") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_token_signed_with_another_key_is_refused(klucz, monkeypatch):
|
||||||
|
token = session.issue("ala", ODCISK)
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "y" * 64)
|
||||||
|
assert session.read(token) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("smiec", ["", None, "bez-kropki", ".", "a.b",
|
||||||
|
"!!!.???", "a" * 500])
|
||||||
|
def test_garbage_never_raises_it_just_refuses(klucz, smiec):
|
||||||
|
"""Wejście przychodzi z przeglądarki, więc wyjątek tutaj to 500 na każdym
|
||||||
|
żądaniu z popsutym ciasteczkiem — czyli aplikacja nie do odratowania
|
||||||
|
inaczej niż przez wyczyszczenie przeglądarki."""
|
||||||
|
assert session.read(smiec) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_token_from_the_future_is_refused(klucz):
|
||||||
|
"""Przestawiony zegar albo podrobienie — w obu razach nie ma powodu ufać."""
|
||||||
|
przyszly = session.issue("ala", ODCISK, now=time.time() + 3600)
|
||||||
|
assert session.read(przyszly) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reading_without_a_key_refuses_instead_of_exploding(klucz, monkeypatch):
|
||||||
|
token = session.issue("ala", ODCISK)
|
||||||
|
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||||
|
assert session.read(token) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── wygasanie ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_a_session_dies_of_old_age(klucz, monkeypatch):
|
||||||
|
monkeypatch.setenv("SESSION_MAX_AGE", "100")
|
||||||
|
token = session.issue("ala", ODCISK, now=time.time() - 200)
|
||||||
|
assert session.read(token) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_session_dies_of_inactivity(klucz, monkeypatch):
|
||||||
|
"""Twardy kres jeszcze daleko, ale nikt nie ruszał — koniec."""
|
||||||
|
monkeypatch.setenv("SESSION_MAX_AGE", "100000")
|
||||||
|
monkeypatch.setenv("SESSION_IDLE", "60")
|
||||||
|
stary = session.issue("ala", ODCISK, now=time.time() - 600)
|
||||||
|
assert session.read(stary) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_extends_the_session_but_not_past_the_hard_limit(klucz, monkeypatch):
|
||||||
|
monkeypatch.setenv("SESSION_MAX_AGE", "1000")
|
||||||
|
monkeypatch.setenv("SESSION_IDLE", "100")
|
||||||
|
teraz = time.time()
|
||||||
|
token = session.issue("ala", ODCISK, now=teraz - 900)
|
||||||
|
odswiezony = session.refresh(session.read(token, now=teraz - 900), now=teraz - 10)
|
||||||
|
assert session.read(odswiezony, now=teraz) is not None, "ruch nie przedłużył sesji"
|
||||||
|
# ...ale twardego kresu nie przeskoczy
|
||||||
|
assert session.read(odswiezony, now=teraz + 200) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_stays_quiet_until_it_is_worth_it(klucz):
|
||||||
|
"""Bez progu każde żądanie odsyłałoby Set-Cookie — także po arkusze stylów."""
|
||||||
|
dane = session.read(session.issue("ala", ODCISK))
|
||||||
|
assert session.refresh(dane) is None
|
||||||
|
assert session.refresh(dane, now=time.time() + session.REFRESH_AFTER + 1) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── ustawienia ciasteczka ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_the_cookie_cannot_be_read_by_scripts_or_sent_cross_site(klucz):
|
||||||
|
p = session.cookie_params()
|
||||||
|
assert p["httponly"] is True, "jeden wstrzyknięty skrypt wyniósłby sesję"
|
||||||
|
assert p["samesite"] == "strict", "obca strona mogłaby działać w imieniu zalogowanego"
|
||||||
|
assert p["secure"] is True
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Zapora słownikowa: czego konto NIE MOŻE zobaczyć w żadnej swojej odpowiedzi.
|
||||||
|
|
||||||
|
Poprzednie testy pilnowały pojedynczych miejsc — tego przycisku, tamtego bloku.
|
||||||
|
Wyciek prawie nigdy nie siedzi w miejscu, o którym się pamięta: katalog modeli
|
||||||
|
jechał blokiem JSON, nazwy ekranów komentarzem w arkuszu stylów, mechanizm
|
||||||
|
kwarantanny zdaniem po polsku w komentarzu CSS, a nazwa gałęzi rozwojowej
|
||||||
|
w komunikacie o błędzie. Wspólne mają jedno — pewne SŁOWA nie mają prawa paść.
|
||||||
|
|
||||||
|
Więc test nie sprawdza miejsc, tylko przechodzi WSZYSTKO, co dane konto może
|
||||||
|
pobrać (strony i zasoby), i szuka słów zakazanych. Nowy ekran, nowy skrypt,
|
||||||
|
nowy komunikat wchodzą pod tę samą zaporę bez dopisywania czegokolwiek.
|
||||||
|
|
||||||
|
Dwie z trzech list biorą się wprost z katalogu funkcji, więc nie rozjadą się
|
||||||
|
z programem: dopisanie funkcji automatycznie dopisuje jej nazwę i jej adres do
|
||||||
|
tego, czego konto bez niej nie ma prawa zobaczyć.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import accounts as store
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
# ── persona ──────────────────────────────────────────────────────────────
|
||||||
|
# Osoba zatrudniona wyłącznie do wgrywania plików. Ma widzieć swój ekran i nic
|
||||||
|
# poza nim — ani co program robi, ani co będzie robił, ani że ktoś widzi więcej.
|
||||||
|
WGRYWACZ = ["files", "files_input"]
|
||||||
|
|
||||||
|
# ── słowa zakazane, których NIE DA SIĘ wyprowadzić z katalogu ────────────
|
||||||
|
# Katalog zna nazwy funkcji, ale nie zna dziedziny programu ani słownictwa jego
|
||||||
|
# mechanizmów. Te trzeba wypisać, i każde ma tu swój powód.
|
||||||
|
DZIEDZINA = [
|
||||||
|
# (c) po co są wgrywane pliki
|
||||||
|
"astrolog", "horoskop", "kosmogram", "zodiak", "efemeryd", "sygnifikator",
|
||||||
|
"interpretacyjn", "aspektarian", "antyscj", "deklinacj",
|
||||||
|
# „baza" mówi, że to nie są zwykłe pliki, tylko zbiory czegoś. Na ekranie
|
||||||
|
# wgrywającego wszystko nazywa się plikiem; w częściach administracyjnych
|
||||||
|
# słowo zostaje, bo tam jest właściwe (i tam ten test nie zagląda).
|
||||||
|
"baz",
|
||||||
|
# (b) że tekst pisze model językowy i u kogo
|
||||||
|
"LLM", "anthropic", "openai", "gpt", "claude", "prompt", "token",
|
||||||
|
# (e)/(f) mechanizmy i to, że istnieje ktoś, kto widzi więcej
|
||||||
|
"administrator", "uprawnieni", "walidacj", "kwarantann", "wstrzyman",
|
||||||
|
"zatwierdzi", "przypisan", "zakłada", "zakładał",
|
||||||
|
# „konto" w obu przypadkach: pierwsza wersja listy miała samo „konto",
|
||||||
|
# więc przepuszczała zdanie „Nie masz konta" — literówka, która kosztowałaby
|
||||||
|
# cały sens testu, gdyby nie wyszła przy oglądaniu strony.
|
||||||
|
"konto", "konta", "kontem",
|
||||||
|
# (d) że produkt jest w budowie i ma ponumerowany backlog
|
||||||
|
"roadmap", "przyszł", "planowan", "w budowie", "widok testowy",
|
||||||
|
# topologia: gdzie pukać, żeby ominąć logowanie
|
||||||
|
"warstwa logiczna", "warstwie logicznej", "openapi", "swagger",
|
||||||
|
# architektura: „to jedna z warstw" mówi, że warstw jest więcej
|
||||||
|
"layer", "presentation", "prezentacj",
|
||||||
|
"localhost", "http://logic", "8001", "8002", "8003",
|
||||||
|
]
|
||||||
|
WZORCE = [
|
||||||
|
(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania (ujawnia backlog)"),
|
||||||
|
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
|
||||||
|
(r"(?i)\bmodel(u|em|e|i|ach|owi)?\b", "generowanie tekstu przez model"),
|
||||||
|
(r"(?i)\bAI\b", "generowanie tekstu przez model"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _zakazane_dla(perms: set[str]) -> list[tuple[str, str]]:
|
||||||
|
"""(igła, powód) — wyprowadzone z katalogu plus lista dziedzinowa."""
|
||||||
|
out = [(s, "słownik dziedziny/mechanizmu") for s in DZIEDZINA]
|
||||||
|
for f in features.ALL:
|
||||||
|
if f.key in perms:
|
||||||
|
continue
|
||||||
|
out.append((f.label, f"nazwa funkcji, której to konto nie ma ({f.key})"))
|
||||||
|
if f.href and f.href != "/":
|
||||||
|
out.append((f.href, f"adres ekranu, którego to konto nie ma ({f.key})"))
|
||||||
|
# Nazwa zasobu też jest wyciekiem: `<script src=".../wheelzoom.js">` w źródle
|
||||||
|
# mówi, że istnieje coś do powiększania, nawet jeśli sam plik zwróci 404.
|
||||||
|
# Wykaz bierze się z mapy zasobów, więc nowy skrypt obejmuje sam z siebie.
|
||||||
|
for nazwa, need in features.STATIC.items():
|
||||||
|
if not features.can(frozenset(perms), need):
|
||||||
|
out.append((nazwa, f"nazwa zasobu, którego to konto nie może pobrać"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Ekran logowania MUSI mówić o koncie — inaczej nie da się go opisać. To jedyne
|
||||||
|
# odstępstwo i jest wąskie: dotyczy tej jednej strony i tych dwóch słów.
|
||||||
|
WYJATKI = {"/logowanie": {"konto", "konta", "kontem"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _przeskanuj(tresc: str, perms: set[str], gdzie: str) -> list[str]:
|
||||||
|
wolno = WYJATKI.get(gdzie, set())
|
||||||
|
grzechy = []
|
||||||
|
for igla, powod in _zakazane_dla(perms):
|
||||||
|
if igla in wolno:
|
||||||
|
continue
|
||||||
|
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
|
||||||
|
grzechy.append(f"{gdzie}: „{igla}” — {powod}")
|
||||||
|
for wzor, powod in WZORCE:
|
||||||
|
m = re.search(wzor, tresc)
|
||||||
|
if m:
|
||||||
|
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
||||||
|
return grzechy
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||||
|
monkeypatch.setenv("APP_USER", "szef")
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||||
|
monkeypatch.delenv("APP_USERS", raising=False)
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _klient(monkeypatch):
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, logic
|
||||||
|
|
||||||
|
# Warstwa logiczna niedostępna CELOWO: ekran plików idzie wtedy ścieżką
|
||||||
|
# błędu, a to właśnie komunikaty o błędach wypisywały nazwy tras, nazwę
|
||||||
|
# gałęzi i wewnętrzny adres z portem.
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
def pada(*a, **k):
|
||||||
|
raise httpx.ConnectError("[Errno 111] connect to http://logic:8001/files failed")
|
||||||
|
|
||||||
|
for nazwa in ("files_list", "report", "timeline", "prompt", "horoscope", "positions"):
|
||||||
|
if hasattr(logic, nazwa):
|
||||||
|
monkeypatch.setattr(logic, nazwa, pada, raising=False)
|
||||||
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _co_moze_pobrac(perms: set[str]) -> list[str]:
|
||||||
|
"""Wszystko, co to konto ma prawo dostać: strony i zasoby."""
|
||||||
|
strony = [p for (m, p), need in features.ROUTES.items()
|
||||||
|
if m == "GET" and features.can(frozenset(perms), need)]
|
||||||
|
zasoby = ["/static/base.css"] + [
|
||||||
|
f"/static/{n}" for n, need in features.STATIC.items()
|
||||||
|
if features.can(frozenset(perms), need)]
|
||||||
|
return sorted(set(strony + zasoby))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_uploader_never_reads_a_forbidden_word(env, monkeypatch):
|
||||||
|
store.create("wgrywacz", "x", WGRYWACZ)
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
c = _klient(monkeypatch)
|
||||||
|
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('wgrywacz')}"}
|
||||||
|
perms = set(WGRYWACZ)
|
||||||
|
|
||||||
|
grzechy, obejrzane = [], 0
|
||||||
|
for adres in _co_moze_pobrac(perms):
|
||||||
|
r = c.get(adres, headers=naglowki)
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
obejrzane += 1
|
||||||
|
grzechy += _przeskanuj(r.text, perms, adres)
|
||||||
|
assert obejrzane >= 3, f"test niczego nie obejrzał ({obejrzane}) — zapora byłaby pozorna"
|
||||||
|
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_anonymous_visitor_never_reads_a_forbidden_word(env, monkeypatch):
|
||||||
|
"""Przed zalogowaniem widać ekran logowania i jeden arkusz. Nic więcej,
|
||||||
|
i nic w nich nie może nazywać funkcji."""
|
||||||
|
store.create("wgrywacz", "x", WGRYWACZ)
|
||||||
|
c = _klient(monkeypatch)
|
||||||
|
grzechy, obejrzane = [], 0
|
||||||
|
for adres in ("/logowanie", "/health", "/static/base.css"):
|
||||||
|
r = c.get(adres)
|
||||||
|
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
||||||
|
obejrzane += 1
|
||||||
|
grzechy += _przeskanuj(r.text, set(), adres)
|
||||||
|
assert obejrzane == 3
|
||||||
|
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_other_asset_is_denied_to_the_uploader(env, monkeypatch):
|
||||||
|
"""Zasób spoza jego zestawu ma być NIE DO ODRÓŻNIENIA od nieistniejącego."""
|
||||||
|
store.create("wgrywacz", "x", WGRYWACZ)
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
c = _klient(monkeypatch)
|
||||||
|
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('wgrywacz')}"}
|
||||||
|
wolno = set(_co_moze_pobrac(set(WGRYWACZ)))
|
||||||
|
nieistniejacy = c.get("/static/nie-ma-takiego-pliku.js", headers=naglowki)
|
||||||
|
for nazwa in features.STATIC:
|
||||||
|
adres = f"/static/{nazwa}"
|
||||||
|
if adres in wolno:
|
||||||
|
continue
|
||||||
|
r = c.get(adres, headers=naglowki)
|
||||||
|
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
|
||||||
|
assert r.text == nieistniejacy.text, f"{adres}: odpowiedź różni się od „nie ma takiego pliku”"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_catalogue_drives_the_list_so_it_cannot_go_stale(env):
|
||||||
|
"""Zapora ma się uczyć z katalogu, nie z pamięci tego, kto ją pisał."""
|
||||||
|
zakazane = {i for i, _ in _zakazane_dla(set(WGRYWACZ))}
|
||||||
|
assert "Sygnifikatory" in zakazane and "/interpret" in zakazane
|
||||||
|
# a to, co konto MA, nie może trafić na listę zakazanych
|
||||||
|
assert "Pliki" not in zakazane
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_owner_sees_everything(env, monkeypatch):
|
||||||
|
"""Kontrola pozytywna: dla administratora te słowa MAJĄ się pojawiać.
|
||||||
|
Bez niej zapora przechodziłaby także wtedy, gdyby program był pusty."""
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
c = _klient(monkeypatch)
|
||||||
|
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('szef')}"}
|
||||||
|
html = c.get("/accounts", headers=naglowki).text
|
||||||
|
assert "Sygnifikatory" in html and "Generowanie tekstu przez model" in html
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Generowanie tekstu przez model znika bez uprawnienia (PRE-27 × LOG-29/30/31).
|
||||||
|
|
||||||
|
Wymaganie jest MOCNIEJSZE niż „przycisk ma być schowany": po niedostępnej funkcji
|
||||||
|
nie może zostać ŚLAD w źródle strony. To rozróżnienie ma znaczenie praktyczne —
|
||||||
|
ukrycie stylem albo atrybutem `hidden` zostawia wszystko w HTML-u, więc podgląd
|
||||||
|
źródła nadal opowiada, co program potrafi.
|
||||||
|
|
||||||
|
Największy wyciek nie był przyciskiem: blok generowania wstrzykiwał w stronę CAŁY
|
||||||
|
katalog modeli jako JSON — nazwy dostawców, nazwy modeli i rozmiary okien
|
||||||
|
kontekstu — i robił to na każdej stronie Interpretacji i Kalendarza, niezależnie
|
||||||
|
od uprawnień konta.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import accounts as store
|
||||||
|
|
||||||
|
# Słowa, które nie mają prawa paść przy koncie bez uprawnienia. Mieszanka
|
||||||
|
# widocznego tekstu, nazw pól formularza, identyfikatorów dla JS i nazw plików —
|
||||||
|
# bo ślad zostawia każde z nich z osobna.
|
||||||
|
SLADY_AI = [
|
||||||
|
"Generuj prompt", "Napisz horoskop", "llmCatalog", "llmProvider", "llmModel",
|
||||||
|
"llm_provider", "llm_model", "prompt_budget", "Budżet promptu", "Dostawca",
|
||||||
|
"anthropic", "openai", "models.js", "progress.js",
|
||||||
|
'value="prompt"', 'value="horoscope"',
|
||||||
|
]
|
||||||
|
|
||||||
|
RAPORT = {"engine": "test", "objects": []}
|
||||||
|
KALENDARZ = {"engine": "test", "days": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||||
|
monkeypatch.setenv("APP_USER", "szef")
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||||
|
monkeypatch.delenv("APP_USERS", raising=False)
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(user):
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch):
|
||||||
|
"""Klient ze śledzeniem tego, co poszło do warstwy logicznej."""
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, logic
|
||||||
|
|
||||||
|
wolania = []
|
||||||
|
monkeypatch.setattr(logic, "report", lambda **kw: (wolania.append("report"), RAPORT)[1])
|
||||||
|
monkeypatch.setattr(logic, "timeline", lambda **kw: (wolania.append("timeline"), KALENDARZ)[1])
|
||||||
|
monkeypatch.setattr(logic, "prompt",
|
||||||
|
lambda **kw: (wolania.append("prompt"), {"prompt": "TREŚĆ"})[1])
|
||||||
|
monkeypatch.setattr(logic, "horoscope",
|
||||||
|
lambda **kw: (wolania.append("horoscope"), {"text": "TREŚĆ"})[1])
|
||||||
|
monkeypatch.setattr(logic, "llm_models", lambda: {"providers": {"openai": ["gpt-x"]}})
|
||||||
|
return TestClient(app, follow_redirects=False), wolania
|
||||||
|
|
||||||
|
|
||||||
|
NATAL = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||||
|
"lat": "50.06", "lon": "19.94"}
|
||||||
|
OKRES = {**NATAL, "from_date": "2026-01-01", "to_date": "2026-02-01"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── brak śladu w źródle strony ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ekran", ["/interpret", "/timeline"])
|
||||||
|
def test_no_trace_of_the_model_features_without_the_permission(env, monkeypatch, ekran):
|
||||||
|
store.create("ala", "x", ["interpret", "timeline"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get(ekran, headers=_auth("ala")).text
|
||||||
|
for slad in SLADY_AI:
|
||||||
|
assert slad not in html, f"{ekran} zdradza „{slad}”"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ekran", ["/interpret", "/timeline"])
|
||||||
|
def test_the_same_screens_carry_them_with_the_permission(env, monkeypatch, ekran):
|
||||||
|
"""Kontrola pozytywna. Bez niej test wyżej przechodziłby także wtedy, gdyby
|
||||||
|
generowanie było zepsute dla wszystkich."""
|
||||||
|
store.create("ola", "x", ["interpret", "timeline", "ai"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get(ekran, headers=_auth("ola")).text
|
||||||
|
for musi_byc in ("Generuj prompt", "llmCatalog", "llmProvider", "models.js"):
|
||||||
|
assert musi_byc in html, f"{ekran}: brakuje „{musi_byc}” mimo uprawnienia"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_model_catalogue_never_reaches_a_page_it_should_not(env, monkeypatch):
|
||||||
|
"""Katalog jedzie do strony blokiem JSON. Bez ograniczenia nazwy dostawców,
|
||||||
|
modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony."""
|
||||||
|
store.create("ala", "x", ["interpret"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.get("/interpret", headers=_auth("ala")).text
|
||||||
|
assert "gpt-x" not in html and "providers" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── spreparowane żądanie nie kupuje funkcji ─────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("akcja", ["prompt", "horoscope"])
|
||||||
|
def test_a_crafted_request_cannot_generate_without_the_permission(env, monkeypatch, akcja):
|
||||||
|
"""Ukrycie przycisku chroni przed przypadkiem, nie przed kimś, kto zna nazwę
|
||||||
|
pola. Granicą jest handler."""
|
||||||
|
store.create("ala", "x", ["interpret"])
|
||||||
|
c, wolania = _client(monkeypatch)
|
||||||
|
r = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": akcja})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert akcja not in wolania, f"wygenerowano „{akcja}” bez uprawnienia"
|
||||||
|
assert wolania == ["report"], f"oczekiwano powrotu do domyślnej akcji, było: {wolania}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("akcja", ["prompt", "horoscope"])
|
||||||
|
def test_the_same_on_the_calendar_screen(env, monkeypatch, akcja):
|
||||||
|
store.create("ala", "x", ["timeline"])
|
||||||
|
c, wolania = _client(monkeypatch)
|
||||||
|
c.post("/timeline", headers=_auth("ala"), data={**OKRES, "action": akcja})
|
||||||
|
assert akcja not in wolania and wolania == ["timeline"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crafted_export_needs_the_export_permission_too(env, monkeypatch):
|
||||||
|
"""Ta sama dziura obejmowała eksport: szablon chował przycisk, handler nie
|
||||||
|
sprawdzał niczego, więc spreparowane żądanie pobierało arkusz."""
|
||||||
|
store.create("ala", "x", ["interpret"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
r = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": "export"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "spreadsheetml" not in r.headers.get("content-type", ""), "pobrano arkusz bez uprawnienia"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_streaming_route_stays_shut(env, monkeypatch):
|
||||||
|
"""Pisanie horoskopu idzie osobną trasą — ona też musi być zamknięta,
|
||||||
|
i to jako NIEISTNIEJĄCA, nie jako odmowa."""
|
||||||
|
store.create("ala", "x", ["interpret"])
|
||||||
|
c, wolania = _client(monkeypatch)
|
||||||
|
r = c.post("/horoscope/stream", headers=_auth("ala"), json={"profile": "natal"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert wolania == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_never_explains_itself(env, monkeypatch):
|
||||||
|
"""Komunikat „brak uprawnień do generowania" sam w sobie mówiłby, że taka
|
||||||
|
funkcja istnieje. Akcja bez uprawnienia ma zachowywać się jak literówka."""
|
||||||
|
store.create("ala", "x", ["interpret"])
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
html = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": "prompt"}).text
|
||||||
|
for slowo in ("uprawnie", "brak dostępu", "nie wolno", "zabronione", "AI", "model"):
|
||||||
|
assert slowo not in html, f"odpowiedź tłumaczy odmowę słowem „{slowo}”"
|
||||||
@@ -15,13 +15,31 @@ import re
|
|||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
|
JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
|
||||||
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||||
|
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||||
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
|
# pilnuje osobny test pokrycia.
|
||||||
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
|
for f in sorted((APP / "static").glob("*.css")))
|
||||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_script_is_loaded_globally():
|
def test_script_is_loaded_only_where_there_is_a_wheel():
|
||||||
"""W base.html, bo koło jest też na zakładce „Skompiluj"."""
|
"""Koło jest na Horoskopie i na Skompiluj — i tylko tam ten skrypt ma prawo
|
||||||
assert "wheeltip.js" in BASE
|
się pojawić.
|
||||||
|
|
||||||
|
Wcześniej ładowany był z base.html, czyli na KAŻDEJ stronie. Konto, które ma
|
||||||
|
wyłącznie Pliki, pobierało go przy zwykłym wejściu na swój jedyny ekran,
|
||||||
|
a jego treść wymienia z nazwy zakładki, o których takie konto nie ma prawa
|
||||||
|
wiedzieć — łącznie z tą, której jeszcze nie ma."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
szablony = Path(__file__).resolve().parents[1] / "app" / "templates"
|
||||||
|
z_kolem, bez_kola = set(), set()
|
||||||
|
for f in szablony.glob("*.html"):
|
||||||
|
(z_kolem if "wheeltip.js" in f.read_text(encoding="utf-8") else bez_kola).add(f.name)
|
||||||
|
assert z_kolem == {"chart.html", "compile.html"}, f"ładowany też przez: {z_kolem}"
|
||||||
|
assert "files.html" in bez_kola and "logowanie.html" in bez_kola
|
||||||
|
|
||||||
|
|
||||||
def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window():
|
def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window():
|
||||||
|
|||||||
@@ -9,13 +9,31 @@ import re
|
|||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
|
JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
|
||||||
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||||
|
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||||
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
|
# pilnuje osobny test pokrycia.
|
||||||
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
|
for f in sorted((APP / "static").glob("*.css")))
|
||||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_script_is_loaded_globally():
|
def test_script_is_loaded_only_where_there_is_a_wheel():
|
||||||
"""Ładowane w base.html, bo koło pojawi się też na zakładce Skompiluj."""
|
"""Koło jest na Horoskopie i na Skompiluj — i tylko tam ten skrypt ma prawo
|
||||||
assert "wheelzoom.js" in BASE
|
się pojawić.
|
||||||
|
|
||||||
|
Wcześniej ładowany był z base.html, czyli na KAŻDEJ stronie. Konto, które ma
|
||||||
|
wyłącznie Pliki, pobierało go przy zwykłym wejściu na swój jedyny ekran,
|
||||||
|
a jego treść wymienia z nazwy zakładki, o których takie konto nie ma prawa
|
||||||
|
wiedzieć — łącznie z tą, której jeszcze nie ma."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
szablony = Path(__file__).resolve().parents[1] / "app" / "templates"
|
||||||
|
z_kolem, bez_kola = set(), set()
|
||||||
|
for f in szablony.glob("*.html"):
|
||||||
|
(z_kolem if "wheelzoom.js" in f.read_text(encoding="utf-8") else bez_kola).add(f.name)
|
||||||
|
assert z_kolem == {"chart.html", "compile.html"}, f"ładowany też przez: {z_kolem}"
|
||||||
|
assert "files.html" in bez_kola and "logowanie.html" in bez_kola
|
||||||
|
|
||||||
|
|
||||||
def test_toggles_on_click():
|
def test_toggles_on_click():
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ from app.compile import RenderError, to_pdf, toolchain_ready
|
|||||||
|
|
||||||
log = logging.getLogger("astrololo.render")
|
log = logging.getLogger("astrololo.render")
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · usługa render (PDF)")
|
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||||
|
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||||
|
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||||
|
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||||
|
app = FastAPI(title="astrololo · usługa render (PDF)",
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
security.install(app, "render") # token międzywarstwowy (LOG-32)
|
security.install(app, "render") # token międzywarstwowy (LOG-32)
|
||||||
# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa
|
# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa
|
||||||
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
|||||||
Reference in New Issue
Block a user