f0d07ee8c3
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build / build (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m18s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Basic nie miał wylogowania i nie dało się tego obejść: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Poprzednia próba (LOG-32) opierała się na nakłonieniu przeglądarki, żeby porzuciła
zapamiętane dane — zachowaniu powszechnym, ale nigdzie nie zapisanym. Teraz to
serwer decyduje, czy dana przeglądarka jest w środku, i może to cofnąć.
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
1. wylogowanie = skasowanie ciasteczka. Natychmiastowe, bez magazynu.
2. zmiana hasła albo skasowanie konta = odcisk poświadczenia wpisany
w ciasteczko przestaje pasować. Dzieje się SAMO, bez pamiętania o tym.
Bez tego odebranie komuś dostępu nie odbierałoby dostępu aż do wygaśnięcia.
3. „zamknij sesje" z ekranu kont = licznik pokolenia. Jedyny wymagający zapisu,
więc jedyny opcjonalny: gdy licznika nie ma, poziomy 1 i 2 nadal działają.
KONTO ADMINISTRACYJNE ODSEPAROWANE. Sprawdzane pierwsze i BEZ DOTYKANIA pliku
kont, co daje dwie rzeczy naraz: konto z pliku o tym samym loginie nie przesłoni
administratora, a administrator zaloguje się także wtedy, gdy plik jest uszkodzony
— czyli w jedynej sytuacji, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie
jego stanu w tym samym pliku dawałoby zakleszczenie: nie da się naprawić, bo nie
da się wejść. Jego odpowiednikiem „wyloguj zewsząd" jest zmiana APP_PASSWORD.
KLUCZ WYMAGANY, FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie
odróżniłaby ważnej sesji od podrobionej, więc nie wstaje — i lepiej przy starcie
niż przy pierwszym logowaniu człowieka. Losowanie klucza byłoby wygodne, ale
wylogowywałoby wszystkich przy każdym restarcie poda: wygląda jak awaria i uczy
ludzi ignorować ekran logowania.
Ciasteczko HttpOnly (jeden wstrzyknięty skrypt inaczej wynosi sesję) i
SameSite=Strict (obca strona nie zadziała w imieniu zalogowanego). Wylogowanie
POST-em, nie odsyłaczem: pod adresem GET wystarczyłby obrazek na obcej stronie.
Adres powrotu po zalogowaniu przechodzi przez sito — bez tego `?dokad=https://obcy`
zamieniłby nasz ekran logowania w narzędzie do wyłudzania haseł.
Kres bezczynności 8 h i twardy 30 dni. Znacznik aktywności odświeżany z progiem,
inaczej Set-Cookie leciałby przy każdym obrazku i arkuszu stylów.
Zdarzenia logowania w dzienniku (PRE-17). Nieudane próby są tam ważniejsze od
udanych: pojedyncza nic nie znaczy, ale seria pod jednym adresem to jedyny
widoczny ślad zgadywania haseł. Test pilnuje, że hasło tam nie trafia.
Sprawdzone w przeglądarce: po zalogowaniu ciasteczko jest NIEWIDOCZNE dla
JavaScriptu, a po wylogowaniu wejście na chronioną stronę ląduje na ekranie
logowania — czyli dokładnie to, czego Basic nie potrafił.
Testy: 21 na rdzeń podpisywania (w tym podrabianie ładunku, podpisu i klucza,
ciasteczko z przyszłości, śmieci na wejściu), reszta przepisana z Basic na sesje.
Prezentacja 338 zielonych.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
968 lines
43 KiB
Python
968 lines
43 KiB
Python
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
|
||
|
||
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
||
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
|
||
|
||
Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
|
||
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
|
||
„/significators".
|
||
"""
|
||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
from datetime import datetime, timedelta, timezone
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
|
||
import base64
|
||
|
||
from app import accounts as accounts_store
|
||
from app import chartwheel as chartwheel_mod
|
||
from app import features
|
||
from app import session
|
||
from app import files_state
|
||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||
|
||
from app import geocode, security
|
||
from app.clients.logic_client import LogicClient
|
||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||
|
||
# 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-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).
|
||
# Hash liczony raz na proces (lru_cache) — nowy pod po deployu = świeży hash.
|
||
_STATIC_DIR = Path("app/static")
|
||
|
||
|
||
@lru_cache(maxsize=None)
|
||
def _asset_version(name: str) -> str:
|
||
try:
|
||
return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
|
||
except OSError:
|
||
return "0" # brak pliku nie może wywrócić strony
|
||
|
||
|
||
def static_url(name: str) -> str:
|
||
return f"/static/{name}?v={_asset_version(name)}"
|
||
|
||
|
||
templates.env.globals["static"] = static_url
|
||
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
|
||
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
||
templates.env.globals["house_label"] = house_label
|
||
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
||
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
|
||
|
||
|
||
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))
|
||
# Kto jest zalogowany i czy logowanie w ogóle działa — pod przycisk wyjścia.
|
||
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
|
||
templates.env.globals["auth_on"] = security.auth_enabled
|
||
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
||
|
||
|
||
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.
|
||
|
||
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny − offset.
|
||
"""
|
||
local = datetime.fromisoformat(f"{date}T{time}")
|
||
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
|
||
label = utc.strftime("%Y-%m-%d %H:%M UTC")
|
||
return utc.isoformat(), label
|
||
|
||
|
||
def _llm_catalog() -> dict:
|
||
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||
try:
|
||
return logic.llm_models()
|
||
except httpx.HTTPError:
|
||
return {"providers": {}, "defaults": {}}
|
||
|
||
|
||
def _report_records(report: dict | None) -> int:
|
||
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
|
||
|
||
Liczymy próbki we wszystkich fasetach: to one są treścią z baz. Sama liczba,
|
||
nigdy treść — dziennik ma pokazywać SKALĘ dostępu, nie powielać danych."""
|
||
total = 0
|
||
for o in (report or {}).get("objects") or []:
|
||
for f in o.get("facets") or []:
|
||
total += len(f.get("samples") or [])
|
||
return total
|
||
|
||
|
||
def _logic_error(e: Exception) -> str:
|
||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||
return (
|
||
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
|
||
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
|
||
)
|
||
return f"Warstwa logiczna niedostępna: {e}"
|
||
|
||
|
||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def chart_form(request: Request):
|
||
# 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,
|
||
"llm_catalog": _llm_catalog()},
|
||
)
|
||
|
||
|
||
@app.post("/", response_class=HTMLResponse)
|
||
def chart_compute(
|
||
request: Request,
|
||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
house_system: str = Form("whole_sign"),
|
||
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
|
||
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
|
||
aspect_luminary_bonus: float = Form(2.0),
|
||
aspect_minor: bool = Form(False),
|
||
stations: bool = Form(False),
|
||
zodiac: str = Form("tropical"),
|
||
tables: bool = Form(False),
|
||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
|
||
):
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "house_system": house_system,
|
||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||
"wheel_orientation": wheel_orientation}
|
||
form = _limit_options(request, form)
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||
try:
|
||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||
ctx["moment"] = label
|
||
ctx["result"] = logic.positions(
|
||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||
aspect_minor=form["aspect_minor"],
|
||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||
)
|
||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||
orientation=form["wheel_orientation"])
|
||
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
|
||
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
|
||
if "extra_charts" in _perms(request):
|
||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||
except (httpx.HTTPError,) as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "chart.html", ctx)
|
||
|
||
|
||
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
|
||
@app.get("/compile", response_class=HTMLResponse)
|
||
def compile_form(request: Request):
|
||
return templates.TemplateResponse(
|
||
request, "compile.html",
|
||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||
)
|
||
|
||
|
||
@app.post("/compile", response_class=HTMLResponse)
|
||
def compile_build(
|
||
request: Request,
|
||
person: str = Form(""),
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
house_system: str = Form("whole_sign"),
|
||
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
|
||
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
|
||
aspect_luminary_bonus: float = Form(2.0),
|
||
aspect_minor: bool = Form(False),
|
||
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
||
zodiac: str = Form("tropical"),
|
||
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
|
||
):
|
||
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
||
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||
|
||
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
|
||
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
|
||
wybrano przy horoskopie (stacje, tabele, porównanie domów) — inaczej
|
||
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
|
||
zakładkami przez formsync.
|
||
"""
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "house_system": house_system,
|
||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||
"wheel_orientation": wheel_orientation}
|
||
form = _limit_options(request, form)
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||
try:
|
||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||
ctx["moment"] = label
|
||
ctx["result"] = logic.positions(
|
||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||
aspect_minor=form["aspect_minor"],
|
||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||
)
|
||
from app import chartwheel
|
||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||
orientation=form["wheel_orientation"])
|
||
if "extra_charts" in _perms(request):
|
||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||
except (httpx.HTTPError,) as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "compile.html", ctx)
|
||
|
||
|
||
@app.post("/compile/pdf")
|
||
def compile_pdf(payload: dict):
|
||
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
|
||
|
||
Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje)
|
||
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
|
||
zawierał dokładnie to, co widać na stronie.
|
||
|
||
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna
|
||
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
|
||
"""
|
||
from fastapi.responses import Response
|
||
|
||
from app.clients.render_client import RenderClient
|
||
|
||
data = payload.get("data") or {}
|
||
try:
|
||
iso_utc, label = _build_utc(
|
||
str(data.get("date") or ""), str(data.get("time") or ""),
|
||
float(data.get("tz_offset") or 0.0),
|
||
)
|
||
except (ValueError, TypeError) as e:
|
||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||
|
||
figures: list[dict] = []
|
||
warnings: list[str] = []
|
||
try:
|
||
chart = logic.positions(
|
||
when_utc_iso=iso_utc,
|
||
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
|
||
house_system=str(data.get("house_system") or "whole_sign"),
|
||
house_systems=[s for s in (data.get("house_systems") or []) if s],
|
||
aspect_orb=float(data.get("aspect_orb") or 8.0),
|
||
aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
|
||
aspect_minor=bool(data.get("aspect_minor")),
|
||
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
|
||
zodiac=str(data.get("zodiac") or "tropical"),
|
||
)
|
||
# Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
|
||
# nie da się poznać, że podział jest z innego systemu, niż zamówiono.
|
||
warnings = [w for w in (chart.get("house_warnings") or []) if w]
|
||
|
||
from app import chartwheel
|
||
|
||
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
|
||
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
|
||
# zmienne CSS i font glifów muszą być wprost w rysunku.
|
||
for svg, caption in (
|
||
(chartwheel.render(chart, theme="print",
|
||
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
|
||
"Kosmogram"),
|
||
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
|
||
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
|
||
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
|
||
):
|
||
if svg:
|
||
figures.append({"svg": svg, "caption": caption})
|
||
except httpx.HTTPError as e:
|
||
# Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
|
||
data = {**data, "wheel_error": _logic_error(e)}
|
||
|
||
report = {
|
||
"person": payload.get("person") or "",
|
||
"data": {**data, "moment_utc": label},
|
||
"figures": figures,
|
||
"warnings": warnings,
|
||
"natal": payload.get("natal") or {},
|
||
"predictions": payload.get("predictions") or [],
|
||
}
|
||
try:
|
||
pdf = RenderClient().pdf(report)
|
||
except httpx.HTTPError as e:
|
||
return JSONResponse(
|
||
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
|
||
status_code=502,
|
||
)
|
||
return Response(content=pdf, media_type="application/pdf",
|
||
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
|
||
|
||
|
||
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
|
||
@app.get("/settings", response_class=HTMLResponse)
|
||
def settings_view(request: Request):
|
||
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
|
||
|
||
Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są
|
||
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
|
||
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
|
||
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
|
||
ctx: dict = {"result": None, "error": None}
|
||
try:
|
||
ctx["result"] = logic.bases()
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
return templates.TemplateResponse(request, "settings.html", ctx)
|
||
|
||
|
||
# ---------------- Synastria (technika relacyjna) ----------------
|
||
@app.get("/synastry", response_class=HTMLResponse)
|
||
def synastry_form(request: Request):
|
||
# domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce)
|
||
form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0,
|
||
"aspect_orb": 8, "aspect_luminary_bonus": 2}
|
||
return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form})
|
||
|
||
|
||
@app.post("/synastry", response_class=HTMLResponse)
|
||
def synastry_run(
|
||
request: Request,
|
||
a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...),
|
||
a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0),
|
||
b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...),
|
||
b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0),
|
||
zodiac: str = Form("tropical"),
|
||
aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0),
|
||
aspect_minor: bool = Form(False),
|
||
):
|
||
"""Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami."""
|
||
form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset,
|
||
"a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date,
|
||
"b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon,
|
||
"zodiac": zodiac, "aspect_orb": aspect_orb,
|
||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor}
|
||
ctx: dict = {"form": form, "result": None, "error": None}
|
||
try:
|
||
iso_a, _ = _build_utc(a_date, a_time, a_tz_offset)
|
||
iso_b, _ = _build_utc(b_date, b_time, b_tz_offset)
|
||
ctx["result"] = logic.synastry(
|
||
{"when_utc": iso_a, "lat": a_lat, "lon": a_lon},
|
||
{"when_utc": iso_b, "lat": b_lat, "lon": b_lon},
|
||
zodiac=zodiac, aspect_orb=aspect_orb,
|
||
aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor,
|
||
)
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "synastry.html", ctx)
|
||
|
||
|
||
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
|
||
@app.get("/significators", response_class=HTMLResponse)
|
||
def significators_form(request: Request):
|
||
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
|
||
|
||
|
||
@app.post("/significators", response_class=HTMLResponse)
|
||
def significators_search(
|
||
request: Request,
|
||
query: str = Form(...),
|
||
field: str = Form("name"),
|
||
exact: bool = Form(False),
|
||
limit: int = Form(25),
|
||
):
|
||
form = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||
ctx: dict = {"form": form, "result": None, "error": None}
|
||
try:
|
||
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
|
||
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
return templates.TemplateResponse(request, "significators.html", ctx)
|
||
|
||
|
||
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||
@app.get("/interpret", response_class=HTMLResponse)
|
||
def interpret_form(request: Request):
|
||
return templates.TemplateResponse(
|
||
request, "interpret.html",
|
||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||
"llm_catalog": _llm_catalog()},
|
||
)
|
||
|
||
|
||
@app.post("/interpret", response_class=HTMLResponse)
|
||
def interpret_run(
|
||
request: Request,
|
||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
group: bool = Form(False),
|
||
action: str = Form("report"),
|
||
prompt_budget: str = Form("medium"),
|
||
llm_provider: str = Form("local"),
|
||
llm_model: str = Form(""),
|
||
):
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||
"llm_catalog": _llm_catalog()}
|
||
try:
|
||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||
ctx["moment"] = label
|
||
if action == "export":
|
||
# Tabela robocza do Excela (DAN-23/PRE-10) — pobranie pliku, nie strona.
|
||
from fastapi.responses import Response
|
||
|
||
from app.report_export import report_to_xlsx
|
||
|
||
report = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||
# Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać
|
||
# ślad w dzienniku (PRE-17): sama liczba rekordów, nigdy treść.
|
||
security.audit_records(request, _report_records(report))
|
||
return Response(
|
||
content=report_to_xlsx(report),
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
|
||
)
|
||
if action == "prompt":
|
||
ctx["prompt_result"] = logic.prompt(
|
||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||
provider=llm_provider, model=llm_model,
|
||
)
|
||
elif action == "horoscope":
|
||
ctx["prompt_result"] = logic.horoscope(
|
||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||
)
|
||
else:
|
||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||
security.audit_records(request, _report_records(ctx["result"]))
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "interpret.html", ctx)
|
||
|
||
|
||
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
|
||
@app.get("/timeline", response_class=HTMLResponse)
|
||
def timeline_form(request: Request):
|
||
return templates.TemplateResponse(
|
||
request, "timeline.html",
|
||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||
"llm_catalog": _llm_catalog()},
|
||
)
|
||
|
||
|
||
@app.post("/timeline", response_class=HTMLResponse)
|
||
def timeline_run(
|
||
request: Request,
|
||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
from_date: str = Form(...),
|
||
to_date: str = Form(...),
|
||
action: str = Form("timeline"),
|
||
prompt_budget: str = Form("medium"),
|
||
llm_provider: str = Form("local"),
|
||
llm_model: str = Form(""),
|
||
):
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||
"llm_catalog": _llm_catalog()}
|
||
try:
|
||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||
ctx["moment"] = label
|
||
if action == "prompt":
|
||
ctx["prompt_result"] = logic.prompt(
|
||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||
provider=llm_provider, model=llm_model,
|
||
)
|
||
elif action == "horoscope":
|
||
ctx["prompt_result"] = logic.horoscope(
|
||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||
from_date=from_date, to_date=to_date,
|
||
)
|
||
else:
|
||
ctx["result"] = logic.timeline(
|
||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
from_date=from_date, to_date=to_date, interpret=True,
|
||
)
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||
|
||
|
||
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
|
||
@app.post("/horoscope/stream")
|
||
def horoscope_stream(
|
||
profile: str = Form("natal"),
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
prompt_budget: str = Form("medium"),
|
||
llm_provider: str = Form("local"),
|
||
llm_model: str = Form(""),
|
||
from_date: str = Form(""),
|
||
to_date: str = Form(""),
|
||
):
|
||
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
|
||
|
||
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
|
||
przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
|
||
"""
|
||
from fastapi.responses import StreamingResponse
|
||
|
||
try:
|
||
iso_utc, _ = _build_utc(date, time, tz_offset)
|
||
except ValueError as e:
|
||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||
|
||
payload: dict = {
|
||
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
|
||
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
|
||
}
|
||
if profile == "period" and from_date and to_date:
|
||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||
|
||
def relay():
|
||
try:
|
||
for raw in logic.horoscope_stream(payload):
|
||
try:
|
||
event = json.loads(raw)
|
||
except ValueError:
|
||
continue
|
||
if event.get("type") == "result":
|
||
html = templates.get_template("_prompt_result.html").render(
|
||
prompt_result=event.get("result") or {}
|
||
)
|
||
event["html"] = html
|
||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||
except httpx.HTTPError as e:
|
||
yield json.dumps({"type": "error", "message": _logic_error(e)},
|
||
ensure_ascii=False) + "\n"
|
||
|
||
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
||
|
||
|
||
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||
@app.get("/geocode")
|
||
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
|
||
try:
|
||
return {"results": geocode.search(q)}
|
||
except httpx.HTTPError as e:
|
||
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||
|
||
|
||
@app.get("/reverse")
|
||
def geocode_reverse(lat: float, lon: float):
|
||
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
|
||
try:
|
||
return geocode.reverse(lat, lon)
|
||
except httpx.HTTPError as e:
|
||
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||
|
||
|
||
@app.get("/timezone")
|
||
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
|
||
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
|
||
|
||
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
|
||
gdy strefy nie da się ustalić — front zostawia wtedy ręczny offset."""
|
||
from app import timezone as tzmod
|
||
|
||
res = tzmod.resolve(lat, lon, date, time)
|
||
if not res:
|
||
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
|
||
return res
|
||
|
||
|
||
# ---------------- Pliki baz (DAN-27) ----------------
|
||
# Trzy poziomy dostępu, opisane w features.ROUTES:
|
||
# „files" — widzi listę i decyduje, z czego program korzysta,
|
||
# „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony
|
||
# ze znacznikiem czasu, znika tylko z użytku),
|
||
# administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
|
||
#
|
||
# Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE
|
||
# DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby
|
||
# takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać
|
||
# reguły — a te ma znać wyłącznie administrator.
|
||
|
||
def _files_context(request: Request, error: str = "", done: str = "") -> dict:
|
||
is_admin = features.ADMIN in _perms(request)
|
||
try:
|
||
data = logic.files_list(for_admin=is_admin)
|
||
except httpx.HTTPError as e:
|
||
return {"files": [], "rules": {}, "is_admin": is_admin,
|
||
"error": _logic_error(e), "done": ""}
|
||
return {"files": data.get("files") or [], "rules": data.get("rules") or {},
|
||
"is_admin": is_admin, "error": error, "done": done}
|
||
|
||
|
||
@app.get("/files", response_class=HTMLResponse)
|
||
def files_view(request: Request, error: str = "", done: str = ""):
|
||
return templates.TemplateResponse(request, "files.html",
|
||
_files_context(request, error, done))
|
||
|
||
|
||
def _files_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||
from urllib.parse import urlencode
|
||
|
||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||
return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303)
|
||
|
||
|
||
def _who(request: Request) -> str:
|
||
return getattr(request.state, "user", "") or "-"
|
||
|
||
|
||
@app.post("/files/use")
|
||
def files_use(request: Request, path: str = Form(...), use: str = Form("")):
|
||
"""Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji
|
||
w warstwie danych — odmowa wraca BEZ POWODU, bo powód zdradzałby reguły."""
|
||
want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \
|
||
else files_state.READY
|
||
try:
|
||
logic.files_status(path, want, by=_who(request))
|
||
except httpx.HTTPStatusError as e:
|
||
detail = _http_detail(e)
|
||
if features.ADMIN not in _perms(request):
|
||
detail = "Tego pliku nie da się teraz włączyć do użytku."
|
||
return _files_redirect(error=detail)
|
||
except httpx.HTTPError as e:
|
||
return _files_redirect(error=_logic_error(e))
|
||
return _files_redirect(done="Zapisano." if want == files_state.READY
|
||
else "Baza włączona do użytku.")
|
||
|
||
|
||
@app.post("/files/upload")
|
||
async def files_upload(request: Request, upload: UploadFile = File(...)):
|
||
"""Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji —
|
||
nie tracimy niczego, co ktoś wgrał."""
|
||
raw = await upload.read()
|
||
if not raw:
|
||
return _files_redirect(error="Pusty plik.")
|
||
try:
|
||
out = logic.files_upload(upload.filename or "plik.xlsx",
|
||
base64.b64encode(raw).decode("ascii"), by=_who(request))
|
||
except httpx.HTTPError as e:
|
||
return _files_redirect(error=_logic_error(e))
|
||
if out.get("accepted"):
|
||
return _files_redirect(done=f"Wgrano „{out.get('name')}”. Można ją włączyć do użytku.")
|
||
# Bez powodu i bez słowa „walidacja" — poza administratorem nikt nie ma
|
||
# skąd wiedzieć, że taki mechanizm istnieje.
|
||
return _files_redirect(done=f"Wgrano „{out.get('name')}”. "
|
||
f"Zanim trafi do użytku, musi ją zatwierdzić administrator.")
|
||
|
||
|
||
@app.post("/files/archive")
|
||
def files_archive(request: Request, path: str = Form(...)):
|
||
"""Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko
|
||
z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane."""
|
||
try:
|
||
logic.files_status(path, files_state.ARCHIVED, by=_who(request))
|
||
except httpx.HTTPError as e:
|
||
return _files_redirect(error=_logic_error(e))
|
||
return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.")
|
||
|
||
|
||
@app.post("/files/restore")
|
||
def files_restore(request: Request, path: str = Form(...)):
|
||
try:
|
||
logic.files_status(path, files_state.READY, by=_who(request))
|
||
except httpx.HTTPError as e:
|
||
return _files_redirect(error=_logic_error(e))
|
||
return _files_redirect(done=f"Przywrócono „{path}” z archiwum.")
|
||
|
||
|
||
@app.post("/files/delete")
|
||
def files_delete(request: Request, path: str = Form(...)):
|
||
try:
|
||
logic.files_delete(path)
|
||
except httpx.HTTPError as e:
|
||
return _files_redirect(error=_logic_error(e))
|
||
return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.")
|
||
|
||
|
||
@app.post("/files/rules")
|
||
def files_rules(request: Request, extensions: str = Form(".xlsx"),
|
||
max_size_mb: float = Form(50), min_rows: int = Form(1),
|
||
required_columns: str = Form(""),
|
||
reject_duplicate_content: str = Form("")):
|
||
rules = {
|
||
"extensions": [e.strip() for e in extensions.split(",") if e.strip()],
|
||
"max_size_mb": max_size_mb,
|
||
"min_rows": min_rows,
|
||
"required_columns": [c.strip() for c in required_columns.split(",") if c.strip()],
|
||
"reject_duplicate_content": reject_duplicate_content.strip().lower()
|
||
in {"1", "true", "on", "tak"},
|
||
}
|
||
try:
|
||
logic.files_rules(rules)
|
||
except httpx.HTTPError as e:
|
||
return _files_redirect(error=_logic_error(e))
|
||
return _files_redirect(done="Zapisano reguły walidacji.")
|
||
|
||
|
||
def _http_detail(e: httpx.HTTPStatusError) -> str:
|
||
try:
|
||
return str(e.response.json().get("detail") or e)
|
||
except Exception: # noqa: BLE001
|
||
return str(e)
|
||
|
||
|
||
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
||
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
||
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
||
|
||
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
|
||
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
|
||
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
|
||
# jest nie tak.
|
||
try:
|
||
users = accounts_store.all_users()
|
||
except accounts_store.AccountsUnavailable as e:
|
||
users, error = {}, str(e)
|
||
return {
|
||
"users": users,
|
||
"catalog": features.ALL,
|
||
"screens": features.SCREENS,
|
||
"extras": features.EXTRAS,
|
||
"admin_login": security.app_user(),
|
||
"store_path": accounts_store.store_path(),
|
||
"error": error, "done": done,
|
||
}
|
||
|
||
|
||
@app.get("/accounts", response_class=HTMLResponse)
|
||
def accounts_view(request: Request, error: str = "", done: str = ""):
|
||
return templates.TemplateResponse(request, "accounts.html",
|
||
_accounts_context(request, error, done))
|
||
|
||
|
||
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||
powtórzyć zakładania ani kasowania konta."""
|
||
from urllib.parse import urlencode
|
||
|
||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
|
||
|
||
|
||
@app.post("/accounts/create")
|
||
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
|
||
note: str = Form(""), granted: list[str] = Form([])):
|
||
try:
|
||
accounts_store.create(login, password, granted, note)
|
||
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||
return _accounts_redirect(error=str(e))
|
||
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
|
||
|
||
|
||
@app.post("/accounts/update")
|
||
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
|
||
note: str = Form(""), granted: list[str] = Form([])):
|
||
try:
|
||
accounts_store.update(login, granted=granted, password=password, note=note)
|
||
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||
return _accounts_redirect(error=str(e))
|
||
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
|
||
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
|
||
|
||
|
||
@app.post("/accounts/sesje")
|
||
def accounts_sesje(request: Request, login: str = Form(...)):
|
||
"""Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu.
|
||
|
||
Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek
|
||
„wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany
|
||
komputer i nie chce wpisywać nowego hasła wszędzie."""
|
||
try:
|
||
accounts_store.bump_session(login)
|
||
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||
return _accounts_redirect(error=str(e))
|
||
return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.")
|
||
|
||
|
||
@app.post("/accounts/delete")
|
||
def accounts_delete(request: Request, login: str = Form(...)):
|
||
try:
|
||
accounts_store.delete(login)
|
||
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||
return _accounts_redirect(error=str(e))
|
||
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
|
||
|
||
|
||
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
|
||
# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
|
||
# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
|
||
# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
|
||
|
||
def _bezpieczne_dokad(dokad: str) -> str:
|
||
"""Adres powrotu po zalogowaniu, przepuszczony przez sito.
|
||
|
||
Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
|
||
do przekierowywania ludzi gdzie indziej — klasyczna droga do wyłudzenia hasła,
|
||
bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
|
||
wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
|
||
zacząć adres obcego serwera."""
|
||
dokad = (dokad or "").strip()
|
||
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
|
||
return "/"
|
||
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
|
||
|
||
|
||
@app.get("/logowanie", response_class=HTMLResponse)
|
||
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
|
||
# Zalogowanego nie ma po co pytać o hasło jeszcze raz.
|
||
if security.auth_enabled() and security.principal(request) is not None:
|
||
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
|
||
return templates.TemplateResponse(request, "logowanie.html",
|
||
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
|
||
|
||
|
||
@app.post("/logowanie")
|
||
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
|
||
dokad: str = Form("/")):
|
||
cel = _bezpieczne_dokad(dokad)
|
||
who = security.verify(login.strip(), haslo)
|
||
if who is None:
|
||
# JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby,
|
||
# które konta istnieją — a to informacja warta czegoś dla zgadującego.
|
||
security.audit_login(request, login.strip(), udane=False)
|
||
return templates.TemplateResponse(
|
||
request, "logowanie.html",
|
||
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
|
||
status_code=401)
|
||
|
||
security.audit_login(request, who.login, udane=True)
|
||
odpowiedz = RedirectResponse(cel, status_code=303)
|
||
odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login),
|
||
max_age=session.max_age(),
|
||
**session.cookie_params(secure=security.cookies_secure()))
|
||
return odpowiedz
|
||
|
||
|
||
@app.post("/wyloguj")
|
||
def wyloguj(request: Request):
|
||
"""Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe
|
||
i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać.
|
||
|
||
POST, nie GET: pod adresem GET wystarczyłby obrazek na obcej stronie, żeby
|
||
wylogować kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa
|
||
do uniknięcia."""
|
||
security.audit_logout(request)
|
||
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
|
||
odpowiedz.delete_cookie(session.COOKIE, **session.cookie_params(
|
||
secure=security.cookies_secure()))
|
||
return odpowiedz
|
||
|
||
|
||
@app.get("/health")
|
||
def health() -> dict:
|
||
return {"status": "ok", "layer": "presentation"}
|