feat(bezpieczeństwo): konta z uprawnieniami do zakładek i funkcji (PRE-27)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m31s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m32s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 19s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s

Ekran „Konta" dla administratora: zakładanie, kasowanie i nadawanie uprawnień.
Zestaw funkcji zależy od konta, a konto ograniczone widzi program KOMPLETNY —
tylko mniejszy.

PODZIAŁ NA GRUPY. Ekrany to zakładki (7), bo zakładka jest naturalną jednostką —
to ją widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI wewnątrz ekranów:
porównanie systemów domów, wykresy dodatkowe, obliczenia zaawansowane, generowanie
tekstu przez model (kosztuje pieniądze) i eksport plików. Konto bez porównania
domów dostaje horoskop w Whole Sign i nie wie, że systemów jest trzynaście.

NIC NIE ZDRADZA, ŻE JEST WIĘCEJ:
- brak pozycji w menu zamiast pozycji wyszarzonej,
- 404 zamiast 403 — odmowa z powodem sama mówi, że coś tam jest,
- rysunki bez uprawnienia w OGÓLE NIE POWSTAJĄ, więc nie ma ich nawet w źródle,
- automatyczna dokumentacja API wyłączona. /docs, /redoc i /openapi.json wypisują
  komplet tras, czyli spis wszystkich funkcji programu — ochrona zakładek nic by
  nie dała, gdyby obok leżał ich katalog. Znalezione TESTEM przechodzącym po
  trasach aplikacji, nie przeglądem kodu.

KONTO ADMINISTRACYJNE zostaje w APP_USER/APP_PASSWORD, jak było. Nie leży w pliku
kont, więc nie da się go skasować ani ograniczyć z ekranu. Konto założone w pliku
o tym samym loginie NIE przesłoni administracyjnego — kolejność sprawdzania jest
odwrotna, inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać.
Uprawnienia administracyjnego nie da się też nadać z formularza: odsiewamy je
w normalise(), a nie w handlerze, więc żadne spreparowane żądanie tam nie sięgnie.

GRANICA JEST W HANDLERZE, NIE W SZABLONIE. Ukrycie pola chroni przed przypadkiem,
nie przed kimś, kto zna nazwy pól — _limit_options() ścina opcje po stronie
serwera i test wysyła spreparowane żądanie, żeby to potwierdzić.

MAPA TRASA→UPRAWNIENIE JEST JEDNA (features.ROUTES). Rozproszenie jej po
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
jest niewidoczna, dopóki ktoś jej nie znajdzie. Trasa bez wpisu wymaga
administratora: przeoczenie ma ZAMYKAĆ, nie otwierać. Test idzie po trasach
APLIKACJI, nie po wpisach mapy — inaczej potwierdzałby tylko sam siebie.

Konta w pliku JSON na własnym podkatalogu NFS (nie tam, gdzie bazy — zamontowanie
całego udziału obeszłoby bokiem DAN-25). Hasła wyłącznie jako hash scrypt, tym
samym mechanizmem co APP_USERS. Zapis atomowy, bo przerwanie zapisu na NFS
obcięłoby plik, czyli skasowało wszystkie konta naraz.

Przy okazji przepisane trzy testy, które greppowały nawigację i main.py: menu
powstaje teraz z katalogu funkcji, więc szukanie sztywnych linków w base.html
niczego już nie sprawdzało.

Wymaga wolumenu na konta — osobny PR w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 21:45:23 +02:00
parent 8b6ecc727d
commit 73af7205d1
22 changed files with 1086 additions and 56 deletions
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
# Konta i uprawnienia (PRE-27)
Rozszerzenie kont imiennych z [PRE-17](konta-i-audyt.md): konta zakłada się
**z aplikacji**, a każde dostaje własny zestaw funkcji.
## Dwie zasady, z których wynika reszta
**1. Konto ograniczone widzi program KOMPLETNY — tylko mniejszy.**
Nic nie może zdradzać, że istnieje coś więcej. Żadnych wyszarzonych zakładek,
żadnego „brak uprawnień", żadnego 403 — bo **403 samo w sobie jest informacją**,
że pod tym adresem coś jest. Ścieżka bez uprawnienia odpowiada **404**, tak samo
jak adres, którego nie ma.
Z tej zasady wynikło też wyłączenie `/docs`, `/redoc` i `/openapi.json`.
Automatyczna dokumentacja FastAPI wypisuje komplet tras — czyli spis wszystkich
funkcji programu. Ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog.
(Znalezione testem, nie przeglądem kodu.)
**2. Konto administracyjne pochodzi WYŁĄCZNIE ze środowiska.**
`APP_USER` / `APP_PASSWORD` (albo `APP_USERS`) — jak dotąd. To konto ma wszystkie
uprawnienia i jako jedyne zarządza pozostałymi. **Nie leży w pliku kont**, więc
nie da się go skasować ani ograniczyć z ekranu — nawet przez pomyłkę, nawet
spreparowanym żądaniem. Konto założone w pliku o tym samym loginie **nie
przesłoni** administracyjnego (kolejność sprawdzania jest odwrotna).
## Podział funkcji
**Ekrany** — zakładki widoczne w nawigacji:
| klucz | zakładka |
|---|---|
| `chart` | Horoskop |
| `interpret` | Interpretacje |
| `timeline` | Kalendarz |
| `synastry` | Synastria |
| `significators` | Sygnifikatory |
| `compile` | Skompiluj |
| `settings` | Ustawienia |
**Rozszerzenia** — poziomy złożoności wewnątrz ekranów:
| klucz | co daje |
|---|---|
| `houses_compare` | wybór systemu domów, zestawienie kilku obok siebie, obrót koła |
| `extra_charts` | aspektarian, wykres deklinacji, oś antyscji |
| `advanced_calc` | stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne |
| `ai` | horoskopy pisane przez model językowy (**każde użycie kosztuje**) |
| `export` | pobieranie PDF i Excela |
Konto bez `houses_compare` dostaje horoskop w Whole Sign i **nie widzi**, że
systemów jest trzynaście. Konto bez `ai` nie zobaczy przycisku generowania ani
nie wywoła go z pominięciem interfejsu.
## Gdzie leży granica
W handlerze, nie w szablonie. Ukrycie pola w formularzu chroni przed przypadkiem,
ale nie przed kimś, kto zna nazwy pól — dlatego `_limit_options()` ścina opcje
**po stronie serwera**, a rysunki dodatkowe bez uprawnienia w ogóle nie powstają
(nie ma ich nawet w źródle strony).
Mapa `trasa → uprawnienie` jest **jedna**, w `app/features.py`. Rozproszenie jej
po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wymaga
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
## Gdzie leżą konta
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
na NFS — **własny podkatalog prezentacji**, nie katalog z bazami: zamontowanie
tutaj całego udziału obeszłoby bokiem zamknięcie dostępu z DAN-25.
Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co `APP_USERS` — jedna
implementacja, więc nie ma czego rozjechać. Zapis jest **atomowy** (plik
tymczasowy + `os.replace` w tym samym katalogu): przerwanie zapisu nie obetnie
pliku, czyli nie skasuje wszystkich kont naraz.
## Czego ten mechanizm NIE robi
Nie zastępuje ochrony baz na poziomie sieci ani NFS (DAN-25). Ktoś z dostępem do
udziału albo do warstwy danych nadal je odczyta — uprawnienia w aplikacji
ograniczają to, co widać **przez aplikację**, i tyle.
+142
View File
@@ -0,0 +1,142 @@
"""Magazyn kont zarządzanych z aplikacji (PRE-27).
KONTO ADMINISTRACYJNE NIE LEŻY TUTAJ. Pochodzi z konfiguracji środowiska
(APP_USER/APP_PASSWORD albo APP_USERS) i dlatego NIE DA SIĘ go skasować ani
ograniczyć z poziomu ekranu — nawet przez pomyłkę, nawet spreparowanym żądaniem.
Ekran zarządzania kontami operuje wyłącznie na tym pliku.
Format: JSON `{"users": {"login": {"secret": "scrypt$...", "features": [...],
"note": "...", "created": "ISO"}}}`. Hasła WYŁĄCZNIE jako hash scrypt — ten sam
mechanizm, co dla kont z APP_USERS (security.hash_password), więc nie ma tu
drugiej implementacji do rozjechania.
Zapis jest ATOMOWY (plik tymczasowy + os.replace) i pod blokadą procesu. Plik
leży na NFS, dzieli go kilka replik, a zwykły zapis „w miejscu" po przerwaniu
zostawiłby plik obcięty — czyli utratę WSZYSTKICH kont naraz. os.replace jest
w obrębie jednego katalogu operacją niepodzielną.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
from datetime import datetime, timezone
from app import features
_lock = threading.Lock()
def store_path() -> str:
"""Ścieżka pliku kont.
Domyślnie WŁASNY podkatalog prezentacji na NFS, nie katalog z bazami:
prezentacja nie ma powodu widzieć baz interpretacyjnych, a zamontowanie ich
tutaj obeszłoby bokiem zamknięcie dostępu z DAN-25."""
return os.getenv("ACCOUNTS_FILE", "/app/state/accounts.json")
def _read() -> dict:
try:
with open(store_path(), encoding="utf-8") as fh:
data = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError):
return {"users": {}}
users = data.get("users")
return {"users": users if isinstance(users, dict) else {}}
def _write(data: dict) -> None:
path = store_path()
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
# Plik tymczasowy MUSI powstać w tym samym katalogu — os.replace jest
# niepodzielne tylko w obrębie jednego systemu plików.
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", 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:
try:
os.unlink(tmp)
except OSError:
pass
raise
def all_users() -> dict[str, dict]:
"""Konta zarządzane: login → {features, note, created}. BEZ sekretów.
Sekret nie wychodzi z tego modułu — ekran nigdy go nie potrzebuje, a to, czego
nie ma w kontekście szablonu, nie wycieknie do HTML-a przez pomyłkę."""
out = {}
for login, row in _read()["users"].items():
out[login] = {
"features": sorted(features.normalise(row.get("features"))),
"note": str(row.get("note") or ""),
"created": str(row.get("created") or ""),
}
return out
def secret_of(login: str) -> str | None:
return (_read()["users"].get(login) or {}).get("secret")
def permissions_of(login: str) -> frozenset[str]:
return features.normalise((_read()["users"].get(login) or {}).get("features"))
def exists(login: str) -> bool:
return login in _read()["users"]
def create(login: str, password: str, granted, note: str = "") -> None:
"""Zakłada konto. Podnosi ValueError, gdy login zajęty albo dane puste."""
from app.security import hash_password
login = (login or "").strip()
if not login:
raise ValueError("Login nie może być pusty.")
if not (password or "").strip():
raise ValueError("Hasło nie może być puste.")
with _lock:
data = _read()
if login in data["users"]:
raise ValueError(f"Konto „{login}” już istnieje.")
data["users"][login] = {
"secret": hash_password(password),
"features": sorted(features.normalise(granted)),
"note": (note or "").strip(),
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
_write(data)
def update(login: str, granted=None, password: str = "", note: str | None = None) -> None:
"""Zmiana uprawnień, hasła albo opisu. Puste hasło = bez zmiany hasła."""
from app.security import hash_password
with _lock:
data = _read()
row = data["users"].get(login)
if row is None:
raise ValueError(f"Nie ma konta „{login}”.")
if granted is not None:
row["features"] = sorted(features.normalise(granted))
if (password or "").strip():
row["secret"] = hash_password(password)
if note is not None:
row["note"] = note.strip()
_write(data)
def delete(login: str) -> None:
with _lock:
data = _read()
if data["users"].pop(login, None) is None:
raise ValueError(f"Nie ma konta „{login}”.")
_write(data)
+123
View File
@@ -0,0 +1,123 @@
"""Katalog funkcji i uprawnień (PRE-27).
DWIE ZASADY, KTÓRE RZĄDZĄ TYM MODUŁEM
1. Konto ograniczone ma widzieć program KOMPLETNY — tylko mniejszy. Nic nie może
sugerować, że istnieje coś więcej: żadnych wyszarzonych pozycji, żadnego
„brak uprawnień", żadnego 403. Ścieżka bez uprawnienia zwraca **404**, bo 403
jest samo w sobie informacją, że coś tam jest.
2. Mapa trasa → uprawnienie jest JEDNA i leży tutaj. Rozproszenie jej po
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka
dziura jest niewidoczna, dopóki ktoś jej nie znajdzie. Test przechodzi po
wszystkich trasach aplikacji i wymaga, żeby każda była tu wymieniona.
PODZIAŁ NA GRUPY. Ekrany odpowiadają zakładkom — to naturalna jednostka, bo
zakładka jest tym, co widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI
wewnątrz ekranów: ktoś może liczyć horoskopy, nie mając dostępu do porównywania
systemów domów ani do generowania tekstu przez model (co kosztuje pieniądze).
"""
from __future__ import annotations
from dataclasses import dataclass
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
@dataclass(frozen=True)
class Feature:
key: str
label: str
hint: str
group: str
href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki
# ── ekrany (zakładki) ────────────────────────────────────────────────────
SCREENS: tuple[Feature, ...] = (
Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.", "Ekrany", "/"),
Feature("interpret", "Interpretacje", "Interpretacja natalna z baz i od modelu.", "Ekrany", "/interpret"),
Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.", "Ekrany", "/timeline"),
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
)
# ── rozszerzenia (poziomy złożoności wewnątrz ekranów) ───────────────────
EXTRAS: tuple[Feature, ...] = (
Feature("houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia"),
Feature("extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia"),
Feature("advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia"),
Feature("ai", "Generowanie tekstu przez model",
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
"Rozszerzenia"),
Feature("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
)
ALL: tuple[Feature, ...] = SCREENS + EXTRAS
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
ROUTES: dict[tuple[str, str], str | None] = {
("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart",
("GET", "/interpret"): "interpret",
("POST", "/interpret"): "interpret",
("GET", "/timeline"): "timeline",
("POST", "/timeline"): "timeline",
("GET", "/synastry"): "synastry",
("POST", "/synastry"): "synastry",
("GET", "/significators"): "significators",
("POST", "/significators"): "significators",
("GET", "/compile"): "compile",
("POST", "/compile"): "compile",
("POST", "/compile/pdf"): "export",
("GET", "/settings"): "settings",
("POST", "/horoscope/stream"): "ai",
("GET", "/accounts"): ADMIN,
("POST", "/accounts/create"): ADMIN,
("POST", "/accounts/update"): ADMIN,
("POST", "/accounts/delete"): ADMIN,
("GET", "/geocode"): None,
("GET", "/reverse"): None,
("GET", "/timezone"): None,
("GET", "/health"): None,
}
def required(method: str, path: str) -> str | None:
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
return ROUTES.get((method.upper(), path), ADMIN)
def can(perms: frozenset[str], feature: str | None) -> bool:
return feature is None or feature in perms
def screens_for(perms: frozenset[str]) -> list[Feature]:
"""Zakładki do pokazania w nawigacji — w stałej kolejności katalogu."""
return [f for f in SCREENS if f.key in perms]
def normalise(keys) -> frozenset[str]:
"""Zbiór uprawnień z wejścia formularza: tylko znane klucze, bez ADMIN-a.
ADMIN odsiewamy TUTAJ, a nie w handlerze: konto administracyjne pochodzi
wyłącznie z konfiguracji środowiska, więc żadne wysłanie formularza — nawet
spreparowane — nie może go nadać."""
return frozenset(k for k in (keys or ()) if k in GRANTABLE)
+142 -17
View File
@@ -18,25 +18,32 @@ from pathlib import Path
import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app import accounts as accounts_store
from app import chartwheel as chartwheel_mod
from app import features
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
from app import geocode, security
from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form
app = FastAPI(title="astrololo · warstwa prezentacji")
# Automatyczna dokumentacja WYŁĄCZONA. /docs, /redoc i /openapi.json wypisują
# KOMPLET tras aplikacji — czyli spis wszystkich funkcji, łącznie z tymi, o których
# konto ograniczone nie ma prawa wiedzieć (PRE-27). Ochrona zakładek nic by nie dała,
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
app = FastAPI(title="astrololo · warstwa prezentacji",
docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
# ── cache-busting statyki (PRE-26) ─────────────────────────────────────────
# ── cache-busting statyki (PRE-27) ─────────────────────────────────────────
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
@@ -64,6 +71,45 @@ templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
def _perms(request: Request) -> frozenset[str]:
"""Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to."""
who = getattr(request.state, "principal", None)
return who.permissions if who is not None else frozenset()
# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost.
# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
# nikt już nie pamięta.
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
templates.env.globals["can"] = lambda request, key: key in _perms(request)
def _limit_options(request: Request, opts: dict) -> dict:
"""Ścina opcje, których konto nie ma prawa użyć — PO STRONIE SERWERA.
Ukrycie pola w formularzu chroni tylko przed przypadkiem. Kto zna nazwy pól,
wyśle je wprost — a wtedy policzyłby funkcję, której mu nie przyznano, i to
jeszcze zobaczyłby ją w wyniku. Granicą jest ta funkcja, nie szablon.
Wartości zastępcze są DOMYŚLNE, nie puste: konto ograniczone ma dostać
działający horoskop w podstawowej postaci, a nie komunikat o braku czegoś.
"""
perms = _perms(request)
if "advanced_calc" not in perms:
opts.update(stations=False, tables=False, aspect_minor=False, zodiac="tropical")
if "houses_compare" not in perms:
opts.update(house_system="whole_sign", house_systems=[],
wheel_orientation=chartwheel_mod.ASC_LEFT)
return opts
def _landing(request: Request) -> str | None:
"""Pierwszy ekran dostępny dla konta — dokąd odesłać z korzenia."""
allowed = features.screens_for(_perms(request))
return allowed[0].href if allowed else None
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
@@ -108,6 +154,16 @@ def _logic_error(e: Exception) -> str:
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
if "chart" not in _perms(request):
target = _landing(request)
if target:
return RedirectResponse(target, status_code=303)
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>"
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
status_code=200)
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
@@ -140,22 +196,27 @@ def chart_compute(
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables,
"wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, house_systems=house_systems,
house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=aspect_minor,
stations=stations, zodiac=zodiac, tables=tables,
aspect_minor=form["aspect_minor"],
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
)
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
orientation=form["wheel_orientation"])
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
if "extra_charts" in _perms(request):
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
@@ -206,22 +267,25 @@ def compile_build(
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables,
"wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, house_systems=house_systems,
house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=aspect_minor,
stations=stations, zodiac=zodiac, tables=tables,
aspect_minor=form["aspect_minor"],
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
)
from app import chartwheel
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
orientation=form["wheel_orientation"])
if "extra_charts" in _perms(request):
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
@@ -608,6 +672,67 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00")
return res
# ---------------- Konta i uprawnienia (PRE-27) ----------------
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
return {
"users": accounts_store.all_users(),
"catalog": features.ALL,
"screens": features.SCREENS,
"extras": features.EXTRAS,
"admin_login": security.app_user(),
"store_path": accounts_store.store_path(),
"error": error, "done": done,
}
@app.get("/accounts", response_class=HTMLResponse)
def accounts_view(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "accounts.html",
_accounts_context(request, error, done))
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
powtórzyć zakładania ani kasowania konta."""
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
@app.post("/accounts/create")
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.create(login, password, granted, note)
except ValueError as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
@app.post("/accounts/update")
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.update(login, granted=granted, password=password, note=note)
except ValueError as e:
return _accounts_redirect(error=str(e))
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
@app.post("/accounts/delete")
def accounts_delete(request: Request, login: str = Form(...)):
try:
accounts_store.delete(login)
except ValueError as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
+67 -10
View File
@@ -42,6 +42,7 @@ import os
import secrets
import time
from collections import deque
from dataclasses import dataclass
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse
@@ -131,12 +132,36 @@ def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
def authenticate(header: str | None) -> str | None:
"""Nagłówek Basic → LOGIN zalogowanej osoby albo None.
@dataclass(frozen=True)
class Principal:
"""Kto się zalogował i co wolno mu zobaczyć."""
Zwracamy login (nie bool), bo dziennik audytowy musi wiedzieć KTO — to sedno
PRE-17. Nieznany login i złe hasło traktujemy tak samo (bez podpowiadania,
które konto istnieje)."""
login: str
permissions: frozenset[str]
is_admin: bool
def _admin_permissions() -> frozenset[str]:
"""Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie.
Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania
o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja
byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać."""
from app import features
return frozenset(features.GRANTABLE) | {features.ADMIN}
def principal(header: str | None) -> Principal | None:
"""Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują.
Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne
uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej
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
istnieje."""
if not header or not header.lower().startswith("basic "):
return None
try:
@@ -144,10 +169,26 @@ def authenticate(header: str | None) -> str | None:
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
return None
spec = accounts().get(user)
if spec is None:
if spec is not None:
return (Principal(user, _admin_permissions(), True)
if _secret_matches(spec, password) else None)
from app import accounts as store
managed = store.secret_of(user)
if managed is None:
return None
return user if _secret_matches(spec, password) else None
if not _secret_matches(managed, password):
return None
return Principal(user, store.permissions_of(user), False)
def authenticate(header: str | None) -> str | None:
"""Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz."""
who = principal(header)
return who.login if who else None
def client_ip(request: Request) -> str:
@@ -264,16 +305,32 @@ def install(app) -> None:
status_code=429, headers={"Retry-After": "60"},
)
user = ""
from app import features
user, who = "", None
if auth_enabled():
user = authenticate(request.headers.get("authorization")) or ""
if not user:
who = principal(request.headers.get("authorization"))
if who is None:
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
)
user = who.login
else:
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
who = Principal("", _admin_permissions(), True)
request.state.user = user
request.state.principal = who
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
need = features.required(request.method, request.url.path)
if not features.can(who.permissions, need):
_audit(request, user, 404, (time.monotonic() - started) * 1000)
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
response = await call_next(request)
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
return response
@@ -214,3 +214,22 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
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);
}
@@ -1,12 +1,15 @@
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik,
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
{% if can(request, 'advanced_calc') %}
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label>
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
</div>
{% endif %}
{% if can(request, 'houses_compare') %}
<div class="opts systems" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
{% set chosen = form.house_systems or [] %}
<span class="muted small opts-label">Porównaj systemy domów
@@ -16,6 +19,8 @@
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
{% endfor %}
</div>
{% endif %}
{% if can(request, 'houses_compare') %}
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
{% set wo = form.wheel_orientation or 'asc' %}
<span class="muted small">Kosmogram — po lewej stronie:</span>
@@ -23,8 +28,11 @@
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
{% endfor %}
</div>
{% endif %}
{% if can(request, 'advanced_calc') %}
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
</div>
{% endif %}
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% set nav_active = "accounts" %}
{% block title %}Konta{% endblock %}
{% block content %}
<p class="muted">
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
pochodzi z konfiguracji środowiska, ma wszystkie uprawnienia i jest jedynym,
które może zarządzać pozostałymi — dlatego nie ma go na tej liście i nie da się
go stąd skasować.
</p>
<p class="muted small">
Osoba na koncie ograniczonym widzi wyłącznie przyznane jej zakładki i opcje.
Nic w programie nie pokazuje jej, że istnieje coś więcej — niedostępne adresy
odpowiadają tak samo jak nieistniejące.
</p>
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Konta ({{ users | length }})</div>
{% if not users %}
<p class="muted">Nie ma jeszcze żadnego konta poza administracyjnym.</p>
{% endif %}
{% for login, row in users | dictsort %}
<form method="post" action="/accounts/update" class="account-card">
<input type="hidden" name="login" value="{{ login }}">
<div class="account-head">
<strong>{{ login }}</strong>
<span class="muted small">{{ row.created[:10] }}</span>
</div>
<label class="account-note">Opis
<input type="text" name="note" value="{{ row.note }}" placeholder="np. do czego to konto służy">
</label>
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
<div class="opts systems" title="{{ 'Zakładki widoczne w nawigacji.' if group == 'Ekrany' else 'Poziomy złożoności wewnątrz ekranów.' }}">
<span class="muted small opts-label">{{ group }}</span>
{% for f in items %}
<label title="{{ f.hint }}">
<input type="checkbox" name="granted" value="{{ f.key }}"
{{ 'checked' if f.key in row.features else '' }}> {{ f.label }}
</label>
{% endfor %}
</div>
{% endfor %}
<div class="opts">
<label>Nowe hasło (puste = bez zmiany)
<input type="password" name="password" autocomplete="new-password" placeholder="••••••">
</label>
</div>
<div class="actions">
<button type="submit">Zapisz</button>
<button type="submit" formaction="/accounts/delete" class="danger"
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
Skasuj konto
</button>
</div>
</form>
{% endfor %}
<div class="meta">Nowe konto</div>
<form method="post" action="/accounts/create" class="account-card">
<div class="grid">
<label>Login <input type="text" name="login" required autocomplete="off"></label>
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
<label>Opis <input type="text" name="note" placeholder="np. do czego to konto służy"></label>
</div>
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
<div class="opts systems">
<span class="muted small opts-label">{{ group }}</span>
{% for f in items %}
<label title="{{ f.hint }}"><input type="checkbox" name="granted" value="{{ f.key }}"> {{ f.label }}</label>
{% endfor %}
</div>
{% endfor %}
<div class="actions"><button type="submit">Załóż konto</button></div>
</form>
<details class="loc">
<summary>Gdzie to jest zapisane</summary>
<p class="muted small">
Konta trzymamy w pliku <code>{{ store_path }}</code> — hasła wyłącznie jako
hash scrypt, nigdy jawnie. Zapis jest atomowy, więc przerwanie w trakcie nie
obetnie pliku i nie skasuje wszystkich kont naraz.
</p>
</details>
{% endblock %}
@@ -18,14 +18,16 @@
<main>
<header class="topbar">
<h1>astrololo</h1>
{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych —
ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że
program umie więcej, niż pokazuje (PRE-26). #}
<nav>
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
{% for tab in nav_screens(request) %}
<a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
{% endfor %}
{% if can(request, 'admin') %}
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
{% endif %}
</nav>
</header>
{% block content %}{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Horoskop{% endblock %}
{% block nav_chart %}active{% endblock %}
{% set nav_active = "chart" %}
{% block content %}
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
@@ -31,6 +31,7 @@
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
{% if can(request, 'houses_compare') %}
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
@@ -39,6 +40,8 @@
{% endfor %}
</select>
</label>
{% endif %}
{% if can(request, 'advanced_calc') %}
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
@@ -49,6 +52,7 @@
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
{% endif %}
</div>
{% include "_location_picker.html" %}
{% include "_form_options.html" %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Skompiluj{% endblock %}
{% block nav_compile %}active{% endblock %}
{% set nav_active = "compile" %}
{% block content %}
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
@@ -36,6 +36,7 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
{% if can(request, 'houses_compare') %}
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
@@ -44,6 +45,8 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
{% endfor %}
</select>
</label>
{% endif %}
{% if can(request, 'advanced_calc') %}
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
@@ -54,15 +57,16 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
{% endif %}
</div>
{# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między
zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #}
{% include "_form_options.html" %}
<div class="actions">
<button type="submit">Złóż podsumowanie</button>
<button type="button" id="pdfBtn" class="ghost"
{% if can(request, 'export') %}<button type="button" id="pdfBtn" class="ghost"
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
<span id="pdfNote" class="muted small"></span>
<span id="pdfNote" class="muted small"></span>{% endif %}
</div>
</form>
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Interpretacje{% endblock %}
{% block nav_interp %}active{% endblock %}
{% set nav_active = "interpret" %}
{% block content %}
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
@@ -40,7 +40,7 @@
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
<span id="geoNote" class="muted small"></span>
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Ustawienia{% endblock %}
{% block nav_settings %}active{% endblock %}
{% set nav_active = "settings" %}
{% block content %}
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Sygnifikatory{% endblock %}
{% block nav_sig %}active{% endblock %}
{% set nav_active = "significators" %}
{% block content %}
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Synastria{% endblock %}
{% block nav_synastry %}active{% endblock %}
{% set nav_active = "synastry" %}
{% block content %}
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Kalendarz{% endblock %}
{% block nav_timeline %}active{% endblock %}
{% set nav_active = "timeline" %}
{% block content %}
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
+29 -6
View File
@@ -31,7 +31,16 @@ def _client():
# ───────────────────────────────── nawigacja i trasa ─────────────────────
def test_tab_is_in_the_menu():
assert 'href="/compile"' in BASE and "Skompiluj" in BASE
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["compile"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_get_renders_empty_form():
@@ -203,11 +212,25 @@ def test_compile_form_carries_the_options():
assert f in opts, f"brak opcji we wspólnym pliku: {f}"
def test_compile_handler_recomputes_with_all_options():
assert "house_systems: list[str] = Form([])" in MAIN
assert "stations: bool = Form(False)" in MAIN
assert "house_systems=house_systems" in MAIN # przekazane do logiki
assert "stations=stations, zodiac=zodiac, tables=tables" in MAIN
def test_compile_handler_recomputes_with_all_options(monkeypatch):
"""„Skompiluj" przelicza horoskop z KOMPLETEM opcji z formularza.
To jest ta regresja z 2026-07-28: podsumowanie liczyło się z okrojonym
zestawem i miało braki względem tego, co widać na „Horoskopie". Sprawdzamy
przez wywołanie trasy, nie przez szukanie napisów w main.py."""
seen = {}
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _pdf_sample_chart())[1])
r = _client().post("/compile", data={
"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94", "house_system": "koch",
"house_systems": ["koch", "equal"], "stations": "true", "tables": "true",
"zodiac": "sidereal_lahiri", "aspect_minor": "true"})
assert r.status_code == 200, r.text[:300]
assert seen["house_systems"] == ["koch", "equal"]
assert seen["stations"] is True and seen["tables"] is True
assert seen["zodiac"] == "sidereal_lahiri" and seen["aspect_minor"] is True
def test_compile_pdf_recomputes_with_all_options():
@@ -34,7 +34,12 @@ def _render(name: str, **ctx) -> str:
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
house_label=house_label, static=lambda p: p)
house_label=house_label, static=lambda p: p,
# Domyślnie renderujemy jak dla konta z PEŁNYMI uprawnieniami —
# inaczej testy opcji sprawdzałyby pusty formularz. Ograniczenia
# mają własne testy w test_kontrola_dostepu.py.
can=lambda request, key: ctx.pop("_denied", None) != key,
nav_screens=lambda request: [])
return env.get_template(name).render(**ctx)
@@ -57,9 +62,26 @@ def test_form_marks_systems_with_a_domain_limit():
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
def test_handler_accepts_and_passes_house_systems():
assert "house_systems: list[str] = Form([])" in MAIN
assert "house_systems=house_systems" in MAIN # przekazane do logiki
def test_handler_accepts_and_passes_house_systems(monkeypatch):
"""Zaznaczone systemy DOCHODZĄ do warstwy logicznej.
Poprzednia wersja szukała napisu `house_systems=house_systems` w main.py
i pękała, gdy argument zaczął przechodzić przez słownik opcji — mimo że
zachowanie zostało poprawne. Sprawdzamy więc, co naprawdę wyszło na dół."""
seen = {}
def spy(**kw):
seen.update(kw)
return _minimal_chart()
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
from app.main import logic
monkeypatch.setattr(logic, "positions", spy)
r = c.post("/", data={**FORM, "house_systems": ["koch", "campanus"]})
assert r.status_code == 200, r.text[:300]
assert seen["house_systems"] == ["koch", "campanus"]
assert seen["house_system"] == "placidus"
def test_client_forwards_house_systems_to_logic():
@@ -0,0 +1,311 @@
"""Kontrola dostępu: konta, uprawnienia i niewidzialność funkcji (PRE-27).
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
1. Konto widzi dokładnie to, co mu przyznano — ani mniej, ani więcej. Sprawdzane
przez PRAWDZIWE żądania, nie przez czytanie szablonów: ukrycie pola w formularzu
nie chroni przed kimś, kto zna nazwy pól.
2. Konto ograniczone nie ma SKĄD wiedzieć, że program umie więcej. To własność
negatywna — łatwo ją zepsuć przez dobre intencje („dodajmy czytelny komunikat
o braku uprawnień"), więc testy są tu wprost o tym: 404 zamiast 403, brak
pozycji w menu, brak rysunków w źródle strony.
"""
import json
import pathlib
import pytest
from app import accounts as store
from app import features, security
@pytest.fixture()
def env(tmp_path, monkeypatch):
"""Świeży plik kont + konto administracyjne ze środowiska."""
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania
return tmp_path
def _client(monkeypatch, chart=None):
from starlette.testclient import TestClient
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: chart or _chart())
return TestClient(app)
def _auth(user, password):
import base64
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
def _chart():
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
"sign_glyph": ""} for i in range(12)]
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
"sign_glyph": ""} for k in ("Asc", "MC", "Dsc", "IC")}
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
"sign_glyphs": [{"sign": "Aries", "glyph": ""}], "house_system": "whole_sign",
"aspects": [], "house_systems": []}
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94"}
# ── katalog i mapa tras ──────────────────────────────────────────────────
def test_every_route_declares_the_permission_it_needs():
"""Trasa bez wpisu w ROUTES to dziura, której nie widać.
Idziemy po TRASACH APLIKACJI, nie po wpisach mapy — inaczej test
potwierdzałby tylko sam siebie i nie zauważyłby nowej, nieopisanej trasy."""
from app.main import app
missing = []
for route in app.routes:
path = getattr(route, "path", None)
methods = getattr(route, "methods", None) or set()
if not path or path.startswith("/static"):
continue
for method in methods & {"GET", "POST"}:
if (method, path) not in features.ROUTES:
missing.append(f"{method} {path}")
assert not missing, f"trasy bez zadeklarowanego uprawnienia: {missing}"
def test_unknown_route_defaults_to_the_narrowest_permission():
"""Przeoczenie ma ZAMYKAĆ, nie otwierać."""
assert features.required("GET", "/cokolwiek-nowego") == features.ADMIN
def test_admin_permission_cannot_be_granted_from_a_form():
"""Nawet spreparowane żądanie nie nada uprawnień administracyjnych —
konto administracyjne pochodzi wyłącznie ze środowiska."""
assert features.ADMIN not in features.normalise([features.ADMIN, "chart"])
assert features.normalise(["chart", "wymyślone"]) == frozenset({"chart"})
# ── magazyn kont ─────────────────────────────────────────────────────────
def test_password_is_stored_only_as_a_hash(env):
store.create("ala", "hasło-ali", ["chart"])
raw = pathlib.Path(store.store_path()).read_text(encoding="utf-8")
assert "hasło-ali" not in raw
assert json.loads(raw)["users"]["ala"]["secret"].startswith("scrypt$")
def test_listing_accounts_never_exposes_secrets(env):
store.create("ala", "hasło-ali", ["chart"])
assert "secret" not in store.all_users()["ala"]
def test_create_update_delete(env):
store.create("ala", "x", ["chart"], note="do testów")
assert store.permissions_of("ala") == frozenset({"chart"})
store.update("ala", granted=["chart", "ai"])
assert store.permissions_of("ala") == frozenset({"chart", "ai"})
with pytest.raises(ValueError):
store.create("ala", "y", []) # login zajęty
store.delete("ala")
assert not store.exists("ala")
def test_empty_password_on_update_keeps_the_old_one(env):
store.create("ala", "stare", ["chart"])
before = store.secret_of("ala")
store.update("ala", granted=["chart"], password="")
assert store.secret_of("ala") == before
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
def test_environment_account_is_the_administrator(env):
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
assert who and who.is_admin
assert features.ADMIN in who.permissions
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
def test_managed_account_gets_exactly_its_own_permissions(env):
store.create("ala", "hasło-ali", ["chart", "ai"])
who = security.principal(_auth("ala", "hasło-ali")["Authorization"])
assert who and not who.is_admin
assert who.permissions == frozenset({"chart", "ai"})
def test_managed_account_cannot_shadow_the_administrator(env):
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
store.create("szef", "podszywam-się", [])
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
assert who and who.is_admin and features.ADMIN in who.permissions
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
store.create("ala", "hasło-ali", ["chart"])
assert security.principal(_auth("ala", "złe")["Authorization"]) is None
assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None
# ── niewidzialność funkcji ───────────────────────────────────────────────
def test_forbidden_screen_answers_404_not_403(env, monkeypatch):
"""403 samo w sobie mówi „tu coś jest". Ma być nie do odróżnienia od
adresu, którego nie ma."""
store.create("ala", "x", ["chart"])
c = _client(monkeypatch)
for path in ("/significators", "/interpret", "/timeline", "/synastry",
"/compile", "/settings", "/accounts"):
r = c.get(path, headers=_auth("ala", "x"))
assert r.status_code == 404, f"{path}{r.status_code}"
assert c.get("/nie-ma-takiego-adresu", headers=_auth("ala", "x")).status_code == 404
def test_menu_shows_only_granted_screens(env, monkeypatch):
store.create("ala", "x", ["chart", "significators"])
c = _client(monkeypatch)
html = c.get("/", headers=_auth("ala", "x")).text
assert 'href="/significators"' in html
for absent in ('href="/interpret"', 'href="/timeline"', 'href="/synastry"',
'href="/compile"', 'href="/settings"', 'href="/accounts"'):
assert absent not in html, f"menu zdradza {absent}"
for word in ("Interpretacje", "Kalendarz", "Synastria", "Skompiluj", "Konta"):
assert word not in html, f"nazwa „{word}” nie powinna paść"
def test_administrator_sees_everything_including_the_accounts_tab(env, monkeypatch):
c = _client(monkeypatch)
html = c.get("/", headers=_auth("szef", "tajne-szefa")).text
for tab in features.SCREENS:
assert f'href="{tab.href}"' in html, tab.key
assert 'href="/accounts"' in html
def test_root_lands_on_the_first_granted_screen(env, monkeypatch):
"""Konto bez „Horoskopu" nie może zobaczyć 404 pod adresem głównym —
wyglądałoby to na zepsuty program, a nie na węższy zestaw funkcji."""
store.create("ala", "x", ["significators"])
c = _client(monkeypatch)
r = c.get("/", headers=_auth("ala", "x"), follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/significators"
# ── ograniczenia opcji egzekwowane NA SERWERZE ──────────────────────────
def test_crafted_request_cannot_buy_options_the_account_lacks(env, monkeypatch):
"""Ukrycie pola w formularzu chroni przed przypadkiem, nie przed kimś,
kto zna nazwy pól. Granicą jest handler."""
store.create("ala", "x", ["chart"])
seen = {}
from app.main import app, logic
from starlette.testclient import TestClient
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _chart())[1])
r = TestClient(app).post("/", headers=_auth("ala", "x"), data={
**FORM, "stations": "true", "tables": "true", "aspect_minor": "true",
"zodiac": "sidereal_lahiri", "house_system": "koch",
"house_systems": ["koch", "campanus"]})
assert r.status_code == 200, r.text[:300]
assert seen["stations"] is False and seen["tables"] is False
assert seen["aspect_minor"] is False and seen["zodiac"] == "tropical"
assert seen["house_system"] == "whole_sign" and seen["house_systems"] == []
def test_extra_charts_are_not_even_in_the_page_source(env, monkeypatch):
"""Nie chodzi o ukrycie rysunków stylem — nie mają w ogóle powstać."""
store.create("ala", "x", ["chart"])
c = _client(monkeypatch)
html = c.post("/", headers=_auth("ala", "x"), data=FORM).text
for word in ("Aspektarian", "deklinacj", "ntyscj"):
assert word not in html, f"źródło strony zdradza „{word}"
def test_the_same_account_with_the_extra_gets_them(env, monkeypatch):
"""Kontrola pozytywna: bez niej powyższy test przechodziłby też wtedy,
gdyby rysunki były zepsute dla wszystkich."""
store.create("ola", "x", ["chart", "extra_charts"])
c = _client(monkeypatch, chart=_chart_with_objects())
html = c.post("/", headers=_auth("ola", "x"), data=FORM).text
assert "Aspektarian" in html
def _chart_with_objects():
"""Horoskop na tyle bogaty, żeby rysunki dodatkowe w ogóle powstały."""
base = _chart()
base["positions"] = [
{"name": n, "glyph": g, "sign": "Aries", "sign_glyph": "", "in_sign": "0",
"decimal": float(i * 30), "direction": "D", "speed": 1.0,
"declination": 10.0, "house": i + 1}
for i, (n, g) in enumerate((("Sun", ""), ("Moon", ""), ("Mars", "")))]
base["aspects"] = [{"obj1": "Sun", "obj2": "Moon", "aspect": "trine",
"orb": 1.0, "allowed": 8.0}]
return base
def test_only_the_administrator_can_manage_accounts(env, monkeypatch):
store.create("ala", "x", ["chart", "ai", "export", "extra_charts"])
c = _client(monkeypatch)
for method, path in (("get", "/accounts"), ("post", "/accounts/create"),
("post", "/accounts/update"), ("post", "/accounts/delete")):
kwargs = {"headers": _auth("ala", "x")}
if method == "post":
kwargs["data"] = {"login": "ktoś"}
r = getattr(c, method)(path, **kwargs)
assert r.status_code == 404, f"{path}{r.status_code}"
assert c.get("/accounts", headers=_auth("szef", "tajne-szefa")).status_code == 200
# ── pełny obieg: założenie konta z ekranu i zalogowanie się na nie ───────
def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypatch):
"""Nagłówek całej funkcji: administrator zakłada konto z ekranu, a osoba na
tym koncie loguje się i dostaje DOKŁADNIE przyznany zestaw — bez restartu
aplikacji i bez dotykania konfiguracji środowiska."""
c = _client(monkeypatch)
admin = _auth("szef", "tajne-szefa")
r = c.post("/accounts/create", headers=admin, follow_redirects=False, data={
"login": "nowa", "password": "jej-hasło", "note": "praktykantka",
"granted": ["chart", "significators", "extra_charts"]})
assert r.status_code == 303
listing = c.get("/accounts", headers=admin).text
assert "nowa" in listing and "praktykantka" in listing
assert "jej-hasło" not in listing, "hasło nie ma prawa trafić na ekran"
her = _auth("nowa", "jej-hasło")
html = c.get("/", headers=her).text
assert 'href="/significators"' in html
assert 'href="/accounts"' not in html and 'href="/compile"' not in html
assert c.get("/compile", headers=her).status_code == 404
# odebranie uprawnienia działa od razu
c.post("/accounts/update", headers=admin, follow_redirects=False,
data={"login": "nowa", "granted": ["chart"]})
assert c.get("/significators", headers=her).status_code == 404
# skasowanie konta odcina logowanie
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
assert c.get("/", headers=her).status_code == 401
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
"""Konto administracyjne nie leży w pliku, więc nie ma czego skasować —
ale próba nie może też wywalić aplikacji ani skasować czegoś innego."""
c = _client(monkeypatch)
admin = _auth("szef", "tajne-szefa")
store.create("ala", "x", ["chart"])
r = c.post("/accounts/delete", headers=admin, follow_redirects=False,
data={"login": "szef"})
assert r.status_code == 303
assert store.exists("ala"), "kasowanie nieistniejącego konta ruszyło inne"
assert c.get("/", headers=admin).status_code == 200
@@ -9,7 +9,16 @@ CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def test_tab_is_in_the_menu():
assert 'href="/settings"' in BASE and "Ustawienia" in BASE
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["settings"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_route_and_client_exist():
+10 -1
View File
@@ -20,7 +20,16 @@ def _client():
def test_tab_is_in_the_menu():
assert 'href="/synastry"' in BASE and "Synastria" in BASE
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["synastry"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_routes_exist():