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

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 was merged in pull request #68.
This commit is contained in:
2026-08-06 21:45:23 +02:00
committed by gitea
parent baf4e0e38a
commit a833965909
22 changed files with 1086 additions and 56 deletions
+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
@@ -228,3 +228,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 %}
@@ -19,14 +19,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>