pillarz 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pillarz/__init__.py +618 -0
- pillarz/__main__.py +176 -0
- pillarz/_api/__init__.py +15 -0
- pillarz/_api/account.py +66 -0
- pillarz/_api/admin.py +55 -0
- pillarz/_api/admin_links.py +24 -0
- pillarz/_api/admin_log.py +41 -0
- pillarz/_api/admin_sessions.py +43 -0
- pillarz/_api/admin_settings.py +68 -0
- pillarz/_api/admin_themes.py +96 -0
- pillarz/_api/admin_users.py +357 -0
- pillarz/_api/common.py +37 -0
- pillarz/_api/me.py +203 -0
- pillarz/_api/tokens.py +98 -0
- pillarz/_auth/__init__.py +4 -0
- pillarz/_auth/accounts.py +336 -0
- pillarz/_auth/devices.py +51 -0
- pillarz/_auth/flows.py +123 -0
- pillarz/_auth/identity.py +66 -0
- pillarz/_auth/linking.py +253 -0
- pillarz/_auth/oidc.py +482 -0
- pillarz/_auth/oidc_accounts.py +197 -0
- pillarz/_auth/oidc_routes.py +363 -0
- pillarz/_auth/passwords.py +146 -0
- pillarz/_auth/roles.py +64 -0
- pillarz/_auth/routes.py +398 -0
- pillarz/_auth/secretbox.py +52 -0
- pillarz/_auth/sessions.py +514 -0
- pillarz/_auth/setup.py +217 -0
- pillarz/_auth/stepup.py +46 -0
- pillarz/_auth/throttle.py +193 -0
- pillarz/_auth/tokens.py +126 -0
- pillarz/_db/__init__.py +1 -0
- pillarz/_db/clock.py +67 -0
- pillarz/_db/connection.py +338 -0
- pillarz/_db/m0001_initial.py +194 -0
- pillarz/_db/m0002_sessions.py +19 -0
- pillarz/_db/m0003_oidc_origin.py +27 -0
- pillarz/_db/migrations.py +274 -0
- pillarz/_events.py +128 -0
- pillarz/_i18n/__init__.py +11 -0
- pillarz/_i18n/babel-js.cfg +4 -0
- pillarz/_i18n/babel.cfg +8 -0
- pillarz/_i18n/catalogs.py +221 -0
- pillarz/_i18n/jinja.py +41 -0
- pillarz/_i18n/js.py +43 -0
- pillarz/_i18n/request.py +113 -0
- pillarz/_i18n/tools.py +200 -0
- pillarz/_protocol.py +351 -0
- pillarz/_runtime.py +86 -0
- pillarz/_settings.py +272 -0
- pillarz/_themes.py +218 -0
- pillarz/_web/__init__.py +1 -0
- pillarz/_web/app.py +82 -0
- pillarz/_web/context.py +14 -0
- pillarz/_web/csp.py +148 -0
- pillarz/_web/errors.py +169 -0
- pillarz/_web/gate.py +150 -0
- pillarz/_web/guard.py +253 -0
- pillarz/_web/headers.py +63 -0
- pillarz/_web/net.py +48 -0
- pillarz/_web/pages.py +30 -0
- pillarz/_web/render.py +341 -0
- pillarz/_web/static.py +40 -0
- pillarz/accounts.py +106 -0
- pillarz/config.py +377 -0
- pillarz/db.py +375 -0
- pillarz/events.py +228 -0
- pillarz/i18n.py +126 -0
- pillarz/locale/en/LC_MESSAGES/pillarz-js.po +1119 -0
- pillarz/locale/en/LC_MESSAGES/pillarz.po +1894 -0
- pillarz/locale/pillarz-js.pot +1106 -0
- pillarz/locale/pillarz.pot +1816 -0
- pillarz/settings.py +33 -0
- pillarz/static/pillarz/css/base.css +587 -0
- pillarz/static/pillarz/favicon.svg +1 -0
- pillarz/static/pillarz/js/account.js +337 -0
- pillarz/static/pillarz/js/admin-links.js +158 -0
- pillarz/static/pillarz/js/admin.js +627 -0
- pillarz/static/pillarz/js/api.js +96 -0
- pillarz/static/pillarz/js/forms.js +206 -0
- pillarz/static/pillarz/js/i18n.js +158 -0
- pillarz/static/pillarz/js/token.js +148 -0
- pillarz/static/pillarz/js/ui.js +540 -0
- pillarz/static/pillarz/vendor/lucide/LICENSE +43 -0
- pillarz/templates/pillarz/account.html +26 -0
- pillarz/templates/pillarz/admin.html +48 -0
- pillarz/templates/pillarz/bare.html +25 -0
- pillarz/templates/pillarz/base.html +38 -0
- pillarz/templates/pillarz/error.html +15 -0
- pillarz/templates/pillarz/icons.svg +66 -0
- pillarz/templates/pillarz/login.html +39 -0
- pillarz/templates/pillarz/partials/account/delete.html +5 -0
- pillarz/templates/pillarz/partials/account/devices.html +9 -0
- pillarz/templates/pillarz/partials/account/language.html +15 -0
- pillarz/templates/pillarz/partials/account/login.html +56 -0
- pillarz/templates/pillarz/partials/account/look.html +17 -0
- pillarz/templates/pillarz/partials/account/profile.html +17 -0
- pillarz/templates/pillarz/partials/flash.html +8 -0
- pillarz/templates/pillarz/partials/nav.html +34 -0
- pillarz/templates/pillarz/plain.html +16 -0
- pillarz/templates/pillarz/setup.html +51 -0
- pillarz/templates/pillarz/token.html +22 -0
- pillarz/testing/__init__.py +236 -0
- pillarz/testing/_web.py +113 -0
- pillarz/testing/fakeidp.py +362 -0
- pillarz/text.py +81 -0
- pillarz/web.py +246 -0
- pillarz-0.1.0.dist-info/METADATA +71 -0
- pillarz-0.1.0.dist-info/RECORD +113 -0
- pillarz-0.1.0.dist-info/WHEEL +5 -0
- pillarz-0.1.0.dist-info/licenses/LICENSE +661 -0
- pillarz-0.1.0.dist-info/top_level.txt +1 -0
pillarz/__init__.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""pillarz – gemeinsames Fundament für FastAPI-Webservices (ADR 0001).
|
|
2
|
+
|
|
3
|
+
Eine App erzeugt **genau ein** `Pillarz`-Objekt je Prozess, meldet daran ihre Erweiterungen an und baut
|
|
4
|
+
zum Schluss mit `create_app()` die FastAPI-Instanz::
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from pillarz import Pillarz
|
|
8
|
+
from . import __version__, migrations
|
|
9
|
+
|
|
10
|
+
pz = Pillarz(name="labelz", title="Labelz", version=__version__,
|
|
11
|
+
package_dir=Path(__file__).resolve().parent, migrations=migrations.STEPS)
|
|
12
|
+
app = pz.create_app()
|
|
13
|
+
|
|
14
|
+
Der Import von pillarz hat keine Nebenwirkungen: Konfiguration, Datenverzeichnis und Schlüssel
|
|
15
|
+
entstehen erst beim Erzeugen von `Pillarz(...)` (`Config.from_env()`), die Datenbank erst im
|
|
16
|
+
Lifespan bzw. bei `python -m pillarz migrate`.
|
|
17
|
+
"""
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import _runtime
|
|
26
|
+
from .config import Config
|
|
27
|
+
from .events import DeleteInput
|
|
28
|
+
from .i18n import lazy
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = ["Pillarz", "NavItem", "AdminTab", "AccountSection", "SettingsSection", "DeleteInput", "Setting", "Theme"]
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger("pillarz.system")
|
|
35
|
+
|
|
36
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
|
|
37
|
+
_KEY_RE = re.compile(r"^[a-z][a-z0-9_-]{0,39}$")
|
|
38
|
+
DEFAULT_AUTH_METHODS = ("password", "oidc")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------ Datenklassen der Registrierungen
|
|
42
|
+
# Vorerst nur die Hüllen (Meilensteinplan 0.1, Paket A): der Schlüssel steht fest, die übrigen Felder
|
|
43
|
+
# kommen mit den Paketen, die sie nutzen (Architektur §3.3–§3.8).
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class NavItem:
|
|
46
|
+
"""Navigationseintrag der App (Architektur §3.3), nur für Angemeldete sichtbar::
|
|
47
|
+
|
|
48
|
+
pz.nav(NavItem("library", "/", _l("Bibliothek"), icon="library", order=10))
|
|
49
|
+
|
|
50
|
+
- `icon`: Name eines Symbols `i-<icon>` im Sprite (pillarz' `icons.svg` oder `pz.icons()`).
|
|
51
|
+
- `visible(request, user) -> bool`: Eintrag nur zeigen, wenn wahr. `user` ist der angemeldete
|
|
52
|
+
`pillarz.accounts.User` (z. B. `lambda request, user: not user.is_guest`).
|
|
53
|
+
- `badge(request, user) -> int`: Zahl am Eintrag (nur auf Seiten mit Navigation berechnet; 0 = keine),
|
|
54
|
+
`badge_href` Ziel des Badges (Standard `href`), `badge_label(n) -> str` zugänglicher Name.
|
|
55
|
+
"""
|
|
56
|
+
key: str
|
|
57
|
+
href: str = "/"
|
|
58
|
+
label: object = ""
|
|
59
|
+
icon: str | None = None
|
|
60
|
+
order: int = 100
|
|
61
|
+
visible: Callable | None = None
|
|
62
|
+
badge: Callable | None = None
|
|
63
|
+
badge_href: str | None = None
|
|
64
|
+
badge_label: Callable | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class AdminTab:
|
|
69
|
+
"""Eigener Admin-Reiter (Architektur §3.3, §14.10)::
|
|
70
|
+
|
|
71
|
+
pz.admin_tab(AdminTab("sheets", _l("Bogenformate"), order=30, icon="ruler",
|
|
72
|
+
template="admin/sheets.html", module="/static/js/admin-sheets.js"))
|
|
73
|
+
|
|
74
|
+
`template` (App-Template, wird in den Reiter gerendert) und `module` (ES-Modul unter `/static/…`) sind
|
|
75
|
+
optional; das Modul exportiert `init(panel, ctx)` und wird beim ersten Öffnen per `import()` geladen.
|
|
76
|
+
Adresse `/admin#<key>` bzw. `/admin#<key>-<id>`. pillarz-Reiter: Konten (10), Geräte (20),
|
|
77
|
+
authentik-Links (30, nur mit aktivem authentik), Themes (50), Einstellungen (80), Protokoll (90); App-Reiter
|
|
78
|
+
stehen nach `order` dazwischen oder danach. Nur für Admins (`require_admin` inkl. befristeter
|
|
79
|
+
authentik-Admins); `visible(request, user)` blendet aus."""
|
|
80
|
+
key: str
|
|
81
|
+
label: object = ""
|
|
82
|
+
order: int = 100
|
|
83
|
+
icon: str | None = None
|
|
84
|
+
template: str | None = None
|
|
85
|
+
module: str | None = None
|
|
86
|
+
visible: Callable | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class AccountSection:
|
|
91
|
+
"""Eigener Abschnitt der Kontoseite `/account` (Architektur §3.3, §12)::
|
|
92
|
+
|
|
93
|
+
pz.account_section(AccountSection("printing", _l("Druck"), order=50,
|
|
94
|
+
template="account/printing.html", module="/static/js/account-printing.js"))
|
|
95
|
+
|
|
96
|
+
pillarz-Abschnitte (Profil 10, Darstellung 20, Sprache 30, Anmeldung 40, Geräte 60, Konto löschen 90)
|
|
97
|
+
stehen fest; App-Abschnitte nach `order`. Das Modul exportiert `init(section, ctx)`. `visible(request,
|
|
98
|
+
user)` blendet aus (z. B. nicht für Gäste)."""
|
|
99
|
+
key: str
|
|
100
|
+
label: object = ""
|
|
101
|
+
order: int = 100
|
|
102
|
+
template: str | None = None
|
|
103
|
+
module: str | None = None
|
|
104
|
+
visible: Callable | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class SettingsSection:
|
|
109
|
+
"""App-Abschnitt im Admin-Reiter „Einstellungen“ (Architektur §3.8, P-102)::
|
|
110
|
+
|
|
111
|
+
pz.settings_section(SettingsSection("printing", _l("Druck"), order=50,
|
|
112
|
+
keys=("default_font", "default_printer_id"),
|
|
113
|
+
template="admin/settings-printing.html", module="/static/js/settings-printing.js"))
|
|
114
|
+
|
|
115
|
+
`keys` sind registrierte `Setting`s, die der Abschnitt bearbeitet (je Schlüssel höchstens ein Abschnitt,
|
|
116
|
+
sonst Startabbruch). Das Modul exportiert `init(section, ctx)`; gespeichert wird über
|
|
117
|
+
`ctx.save({schlüssel: wert})` (`PATCH /api/pz/admin/settings`, 422 mit `field`)."""
|
|
118
|
+
key: str
|
|
119
|
+
label: object = ""
|
|
120
|
+
order: int = 100
|
|
121
|
+
keys: tuple = ()
|
|
122
|
+
template: str | None = None
|
|
123
|
+
module: str | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Setting:
|
|
128
|
+
"""Registrierte App-Einstellung (Architektur §3.8)::
|
|
129
|
+
|
|
130
|
+
pz.setting(Setting("show_names", default=False, kind="bool", label=_l("Namen zeigen"), ui=True))
|
|
131
|
+
pz.setting(Setting("default_sheet_id", default=None, kind="custom", validate=sheets.check_default))
|
|
132
|
+
|
|
133
|
+
- `kind`: `text` (einzeilig, `max_length`, `required`), `int` (`min`/`max`), `bool`, `choice`
|
|
134
|
+
(`options` = `[(wert, beschriftung), …]` oder Funktion, die das liefert; Werte dürfen auch Zahlen
|
|
135
|
+
sein), `custom` (beliebiges JSON, **`validate` Pflicht**).
|
|
136
|
+
- `validate(value) -> value`: zusätzlicher Prüfer; `ValueError(text)` lehnt mit `text` am Feld ab. Lehnt
|
|
137
|
+
er einen schon gespeicherten Wert ab, gilt der Standard (labelz `effective()`).
|
|
138
|
+
- `ui=True`: erscheint mit `label`/`help` im Reiter „Einstellungen“ (nicht bei `custom` – dafür
|
|
139
|
+
`SettingsSection`)."""
|
|
140
|
+
key: str
|
|
141
|
+
default: object = None
|
|
142
|
+
kind: str = "text"
|
|
143
|
+
label: object = ""
|
|
144
|
+
help: object = None
|
|
145
|
+
ui: bool = False
|
|
146
|
+
validate: Callable | None = None
|
|
147
|
+
options: object = ()
|
|
148
|
+
required: bool = False
|
|
149
|
+
max_length: int = 200
|
|
150
|
+
min: int | None = None
|
|
151
|
+
max: int | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class Theme:
|
|
156
|
+
"""Weiteres eingebautes Theme (Architektur §14.5): vier Grundfarben je Modus als `#rrggbb`::
|
|
157
|
+
|
|
158
|
+
pz.theme(Theme("etikett", _l("Etikett"),
|
|
159
|
+
light={"bg": "#…", "surface": "#…", "ink": "#…", "accent": "#…"}, dark={…}))
|
|
160
|
+
"""
|
|
161
|
+
id: str
|
|
162
|
+
name: object = ""
|
|
163
|
+
light: dict = field(default_factory=dict)
|
|
164
|
+
dark: dict = field(default_factory=dict)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_ADMIN_TABS = ("users", "links", "sessions", "themes", "settings", "log")
|
|
168
|
+
_ACCOUNT_SECTIONS = ("profile", "look", "language", "login", "devices", "delete")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _check_extension(where: str, item, existing, reserved) -> None:
|
|
172
|
+
"""Schlüssel, Template und Modul von Admin-Reitern, Konto- und Einstellungsabschnitten prüfen."""
|
|
173
|
+
if not isinstance(item.key, str) or not _KEY_RE.fullmatch(item.key) or item.key in reserved \
|
|
174
|
+
or any(e.key == item.key for e in existing):
|
|
175
|
+
raise ValueError(f"{where}(): Schlüssel {item.key!r} ist ungültig, von pillarz belegt oder schon vergeben")
|
|
176
|
+
if not isinstance(item.order, int) or isinstance(item.order, bool):
|
|
177
|
+
raise ValueError(f"{where}({item.key!r}): order muss eine ganze Zahl sein")
|
|
178
|
+
module = item.module
|
|
179
|
+
if module is not None and (not isinstance(module, str) or not module.startswith("/static/")
|
|
180
|
+
or module.startswith("/static/pillarz/") or ".." in module
|
|
181
|
+
or not module.endswith(".js")):
|
|
182
|
+
raise ValueError(f"{where}({item.key!r}): module muss ein ES-Modul der App unter /static/…/*.js sein")
|
|
183
|
+
template = item.template
|
|
184
|
+
if template is not None and (not isinstance(template, str) or template.startswith(("/", "pillarz/"))
|
|
185
|
+
or ".." in template):
|
|
186
|
+
raise ValueError(f"{where}({item.key!r}): template muss ein Template der App sein (relativ, nicht pillarz/…)")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------ das App-Objekt
|
|
190
|
+
class Pillarz:
|
|
191
|
+
"""Das eine pillarz-Objekt einer App.
|
|
192
|
+
|
|
193
|
+
- `name`: Kennung (Cookie, Logger, Standard-Dateiname der SQLite-Datenbank `DATA_DIR/<name>.db`),
|
|
194
|
+
klein, `[a-z][a-z0-9_-]*`, höchstens 32 Zeichen; `pillarz` ist reserviert.
|
|
195
|
+
- `title`: Standard-Seitenname, `version`: Version der App.
|
|
196
|
+
- `package_dir`: Verzeichnis der App mit `templates/`, `static/`, `locale/` (je optional).
|
|
197
|
+
- `migrations`: geordnete App-Migrationen `[("0001_initial", step), …]`, `step(conn)` (ADR 0003).
|
|
198
|
+
- `auth_methods`: Vorgabe für die Anmeldemethoden; `AUTH_METHODS` in der Umgebung überschreibt.
|
|
199
|
+
- `weak_passwords`: ergänzt pillarz' Liste verbotener Platzhalter-Passwörter.
|
|
200
|
+
- `self_delete`: Nutzer dürfen ihr eigenes Konto löschen (Kontoseite, ADR 0006; Standard an). `False`
|
|
201
|
+
blendet den Abschnitt aus, `GET/DELETE /api/pz/me/delete` bzw. `/api/pz/me` antworten 404.
|
|
202
|
+
- `environ`: nur für Tests – statt `os.environ`.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self, *, name: str, title: str | None = None, version: str = "0", package_dir=None,
|
|
206
|
+
migrations=(), auth_methods=DEFAULT_AUTH_METHODS, weak_passwords=(), self_delete: bool = True,
|
|
207
|
+
environ=None):
|
|
208
|
+
# TODO(0.2): changelog und weitere Parameter aus Architektur §3.1
|
|
209
|
+
from ._db.migrations import validate
|
|
210
|
+
_runtime.check_free()
|
|
211
|
+
if not isinstance(name, str) or not _NAME_RE.fullmatch(name) or name == "pillarz":
|
|
212
|
+
raise ValueError(f"Pillarz(name=…): {name!r} ist keine gültige Kennung "
|
|
213
|
+
"(klein, [a-z][a-z0-9_-]*, höchstens 32 Zeichen, nicht 'pillarz')")
|
|
214
|
+
self.name = name
|
|
215
|
+
self.title = title or name
|
|
216
|
+
self.version = str(version)
|
|
217
|
+
self.package_dir = Path(package_dir).resolve() if package_dir is not None else None
|
|
218
|
+
self.migrations = validate(migrations, "app")
|
|
219
|
+
self.weak_passwords = frozenset(w.strip().lower() for w in weak_passwords if w and w.strip())
|
|
220
|
+
self.self_delete = bool(self_delete)
|
|
221
|
+
self._registry = _runtime.Registry()
|
|
222
|
+
self._app = None
|
|
223
|
+
self._web = None # _web.app.WebState, ab create_app()
|
|
224
|
+
self._setup_closed = False # Einrichtung (ADR 0015) als „zu“ erkannt – wird nie wieder offen
|
|
225
|
+
self.config: Config = Config.from_env(environ, name=name, auth_methods=tuple(auth_methods))
|
|
226
|
+
_runtime.install(self)
|
|
227
|
+
|
|
228
|
+
def __repr__(self) -> str:
|
|
229
|
+
return f"<Pillarz {self.name!r} {self.version}>"
|
|
230
|
+
|
|
231
|
+
# -------------------------------------------------------------- Registrierungen
|
|
232
|
+
def on_startup(self, fn: Callable[[], None]) -> Callable[[], None]:
|
|
233
|
+
"""Funktion nach den Migrationen und vor dem ersten Request aufrufen (als Dekorator nutzbar)."""
|
|
234
|
+
self._registry.add("startup", fn)
|
|
235
|
+
return fn
|
|
236
|
+
|
|
237
|
+
def on_shutdown(self, fn: Callable[[], None]) -> Callable[[], None]:
|
|
238
|
+
"""Funktion beim Herunterfahren aufrufen (umgekehrte Reihenfolge der Anmeldung)."""
|
|
239
|
+
self._registry.add("shutdown", fn)
|
|
240
|
+
return fn
|
|
241
|
+
|
|
242
|
+
def nav(self, item: NavItem) -> NavItem:
|
|
243
|
+
"""Navigationseintrag anmelden (Architektur §3.3). pillarz hängt rechts Konto, Admin und Abmelden an."""
|
|
244
|
+
if not isinstance(item, NavItem):
|
|
245
|
+
raise TypeError("pz.nav() erwartet ein NavItem")
|
|
246
|
+
if not _KEY_RE.fullmatch(item.key) or any(i.key == item.key for i in self._registry.items("nav")):
|
|
247
|
+
raise ValueError(f"pz.nav(): Schlüssel {item.key!r} ist ungültig oder schon vergeben")
|
|
248
|
+
if not isinstance(item.href, str) or not item.href.startswith("/") or item.href.startswith("//"):
|
|
249
|
+
raise ValueError(f"pz.nav({item.key!r}): href muss ein Pfad dieser Seite sein (/…)")
|
|
250
|
+
self._registry.add("nav", item)
|
|
251
|
+
return item
|
|
252
|
+
|
|
253
|
+
def csp(self, **directives) -> None:
|
|
254
|
+
"""CSP der App ergänzen, z. B. `pz.csp(img_src=["blob:"], worker_src=["'self'"])`. Lockern ist
|
|
255
|
+
unmöglich: `'unsafe-*'` (außer `'wasm-unsafe-eval'`), `*`, reine Schemata in `script-src`, `javascript:`,
|
|
256
|
+
in `worker-src`/`child-src` mehr als `'self'`/`blob:`, Hashes in `style-src`, bei reinem HTTPS
|
|
257
|
+
`http://`/`ws://`-Hosts und jede Änderung an `object-src`/`base-uri`/`frame-ancestors` →
|
|
258
|
+
`ValueError` (Startabbruch, ADR 0004 §5)."""
|
|
259
|
+
from ._web.csp import check
|
|
260
|
+
self._registry.add("csp", check(directives, https_only=self.config.https_only))
|
|
261
|
+
|
|
262
|
+
def upload_limit(self, path, size: int) -> None:
|
|
263
|
+
"""Größere Body-Grenze für einen Upload-Pfad (nur POST, `Content-Length` Pflicht): exakter Pfad,
|
|
264
|
+
Präfix mit `/` am Ende oder `re.compile(…)` (ganzer Pfad). Sonst gilt 1 MiB."""
|
|
265
|
+
from ._web.headers import PathRule
|
|
266
|
+
if not isinstance(size, int) or size <= 0:
|
|
267
|
+
raise ValueError("pz.upload_limit(): Größe in Bytes > 0 erwartet")
|
|
268
|
+
PathRule(path)
|
|
269
|
+
self._registry.add("upload_limit", (path, size))
|
|
270
|
+
|
|
271
|
+
def no_store(self, path) -> None:
|
|
272
|
+
"""Antworten auf diesen Pfad (Präfix mit `/` am Ende, exakter Pfad oder `re.compile`) nie
|
|
273
|
+
zwischenspeichern, nie als Referer weitergeben, nicht indexieren – für Link-/Token-Seiten."""
|
|
274
|
+
from ._web.headers import PathRule
|
|
275
|
+
PathRule(path)
|
|
276
|
+
self._registry.add("no_store", path)
|
|
277
|
+
|
|
278
|
+
def public_mount(self, path: str) -> None:
|
|
279
|
+
"""Eine eingehängte Unter-App (`app.mount(path, …)`) ohne Anmeldung erreichbar machen.
|
|
280
|
+
|
|
281
|
+
Mounts, Starlette-Routen (`app.add_route`) und `Host`-Routen laufen am Gate vorbei (sie kennen
|
|
282
|
+
keine FastAPI-Dependencies). pillarz behandelt sie deshalb als **geschützt**: der Guard weist
|
|
283
|
+
anonyme Anfragen an sie ab (401 bzw. 303 auf `/login`), `testing.route_audit()` meldet sie.
|
|
284
|
+
Nur so freigegebene Mount-Pfade (genau der Pfad aus `app.mount`, z. B. `"/docs-static"`) und
|
|
285
|
+
pillarz' eigene Static-Mounts sind öffentlich (ADR 0004 §2)."""
|
|
286
|
+
if not isinstance(path, str) or not path.startswith("/") or path.startswith("//"):
|
|
287
|
+
raise ValueError(f"pz.public_mount(): {path!r} ist kein Pfad (/…)")
|
|
288
|
+
self._registry.add("public_mount", path.rstrip("/") or "/")
|
|
289
|
+
|
|
290
|
+
def boot(self, fn: Callable) -> Callable:
|
|
291
|
+
"""`fn(request, user) -> dict` ergänzt `boot.app` im `#boot`-Block (nur für Angemeldete; `user` ist
|
|
292
|
+
ein `pillarz.accounts.User`)."""
|
|
293
|
+
self._registry.add("boot", fn)
|
|
294
|
+
return fn
|
|
295
|
+
|
|
296
|
+
def context(self, fn: Callable) -> Callable:
|
|
297
|
+
"""Anfrage-Kontext der App (P-9): `fn(request, user) -> dict` liefert Werte für den Template-Kontext
|
|
298
|
+
jeder Seite und für die Routen (`pillarz.web.request_context(request)`), z. B. Navigations-Badges::
|
|
299
|
+
|
|
300
|
+
@pz.context
|
|
301
|
+
def badges(request, user):
|
|
302
|
+
return {"nav_answers": prints.needing_answer(user.id)}
|
|
303
|
+
|
|
304
|
+
- Nur für Angemeldete (Sitzung oder App-Authenticator); `user` ist ein `pillarz.accounts.User`. Ohne
|
|
305
|
+
Anmeldung wird der Haken nie aufgerufen, der Kontext ist `{}`.
|
|
306
|
+
- **Höchstens einmal je Request und erst bei Bedarf**: beim ersten `render()` bzw.
|
|
307
|
+
`request_context()`; API-Routen, die beides nicht nutzen, zahlen nichts. Das Ergebnis liegt danach
|
|
308
|
+
auch in `request.state.app_context`.
|
|
309
|
+
- Mehrere Haken werden in Anmeldereihenfolge zusammengeführt (spätere überschreiben frühere).
|
|
310
|
+
Schlüssel, die pillarz im Template-Kontext selbst setzt (`user`, `nav`, `boot` …), sind verboten
|
|
311
|
+
(`RuntimeError`); Schlüssel aus `render(**ctx)` haben Vorrang vor dem Haken.
|
|
312
|
+
- Eine Ausnahme im Haken ist ein Fehler der Anfrage: 500 mit pillarz-Fehlerseite und Eintrag im
|
|
313
|
+
Protokoll (nichts wird still verschluckt). Das `User`-Objekt bleibt unverändert."""
|
|
314
|
+
if not callable(fn):
|
|
315
|
+
raise TypeError("pz.context(): erwartet eine Funktion fn(request, user) -> dict")
|
|
316
|
+
self._registry.add("context", fn)
|
|
317
|
+
return fn
|
|
318
|
+
|
|
319
|
+
def secret_user_column(self, name: str) -> None:
|
|
320
|
+
"""Eine (app-eigene oder übernommene) Spalte von `users` als **geheim** markieren (S-N6): sie landet
|
|
321
|
+
nie in pillarz' privater Kontozeile der Anfrage, in Templates oder JSON-Antworten. `password_hash`
|
|
322
|
+
trägt pillarz selbst ein (ab 0.4 auch die TOTP-Spalten). `current_user()` liefert ohnehin nur die
|
|
323
|
+
öffentlichen Spalten (`User`)::
|
|
324
|
+
|
|
325
|
+
pz.secret_user_column("api_token_hash")
|
|
326
|
+
"""
|
|
327
|
+
from ._db.connection import ident
|
|
328
|
+
ident(name)
|
|
329
|
+
if name in self._registry.items("secret_user_column"):
|
|
330
|
+
raise ValueError(f"pz.secret_user_column(): {name!r} ist schon eingetragen")
|
|
331
|
+
self._registry.add("secret_user_column", name)
|
|
332
|
+
|
|
333
|
+
def log_source(self, logger: str, source: str, label) -> None:
|
|
334
|
+
"""Logger der App einem Protokoll-Bereich zuordnen, z. B.
|
|
335
|
+
`pz.log_source("labelz.direct", "direct", _l("Direktdruck"))`. Nur Logger unter `<name>`."""
|
|
336
|
+
if not (logger == self.name or logger.startswith(self.name + ".")):
|
|
337
|
+
raise ValueError(f"pz.log_source(): Logger {logger!r} gehört nicht zur App {self.name!r}")
|
|
338
|
+
if not _KEY_RE.fullmatch(source):
|
|
339
|
+
raise ValueError(f"pz.log_source(): Bereich {source!r} ist ungültig ([a-z][a-z0-9_-]*)")
|
|
340
|
+
self._registry.add("log_source", (logger, source, label))
|
|
341
|
+
|
|
342
|
+
def theme(self, theme: Theme) -> Theme:
|
|
343
|
+
"""Weiteres eingebautes Theme anmelden (nach pillarz' fünf, in Anmeldereihenfolge)."""
|
|
344
|
+
from ._themes import check_registration
|
|
345
|
+
if not isinstance(theme, Theme):
|
|
346
|
+
raise TypeError("pz.theme() erwartet ein Theme")
|
|
347
|
+
check_registration(theme)
|
|
348
|
+
if any(t.id == theme.id for t in self._registry.items("theme")):
|
|
349
|
+
raise ValueError(f"pz.theme(): {theme.id!r} ist schon angemeldet")
|
|
350
|
+
self._registry.add("theme", theme)
|
|
351
|
+
return theme
|
|
352
|
+
|
|
353
|
+
def icons(self, path) -> None:
|
|
354
|
+
"""Weitere `<symbol id="i-…">` aus einer SVG-Datei ins Sprite jeder Seite aufnehmen."""
|
|
355
|
+
path = Path(path)
|
|
356
|
+
if not path.is_file():
|
|
357
|
+
raise ValueError(f"pz.icons(): {path} gibt es nicht")
|
|
358
|
+
self._registry.add("icons", path)
|
|
359
|
+
|
|
360
|
+
# -------------------------------------------------------------- Konten, Events, Verwaltung (Paket E)
|
|
361
|
+
def on(self, event: str, fn: Callable | None = None, *, order: int = 100):
|
|
362
|
+
"""Event-Handler anmelden (ADR 0006, `pillarz.events`), als Dekorator::
|
|
363
|
+
|
|
364
|
+
@pz.on("user.deleting", order=10)
|
|
365
|
+
def transfer(ev): …
|
|
366
|
+
|
|
367
|
+
tx-Events (`user.created`, `user.updated`, `user.deactivating`, `user.reactivated`, `user.deleting`)
|
|
368
|
+
laufen in der pillarz-Transaktion: `ev.conn` bzw. jeder `pillarz.db.connect()` auf diesem Thread ist
|
|
369
|
+
dieselbe Verbindung; **keine Threads starten** (`pillarz.db.TxThreadError`); Datei-Arbeit gehört in
|
|
370
|
+
die Nach-Commit-Events `user.deleted`, `user.signed_in`, `user.signed_out`, deren Fehler nur
|
|
371
|
+
protokolliert werden. Reihenfolge: `order`, dann Anmeldung."""
|
|
372
|
+
from ._events import check_name
|
|
373
|
+
check_name(event)
|
|
374
|
+
if not isinstance(order, int) or isinstance(order, bool):
|
|
375
|
+
raise ValueError("pz.on(): order muss eine ganze Zahl sein")
|
|
376
|
+
|
|
377
|
+
def register(handler: Callable) -> Callable:
|
|
378
|
+
from ._events import handler_name
|
|
379
|
+
if not callable(handler):
|
|
380
|
+
raise TypeError("pz.on() erwartet eine Funktion")
|
|
381
|
+
name = handler_name(handler)
|
|
382
|
+
if any(e == event and handler_name(fn) == name for e, fn, _o in self._registry.items("event")):
|
|
383
|
+
# Rückgaben landen unter diesem Namen in user.deleted.result – doppelt hieße überschrieben (C-L7)
|
|
384
|
+
raise ValueError(f"pz.on({event!r}): Handler {name} ist für dieses Event schon angemeldet")
|
|
385
|
+
self._registry.add("event", (event, handler, order))
|
|
386
|
+
return handler
|
|
387
|
+
|
|
388
|
+
return register(fn) if fn is not None else register
|
|
389
|
+
|
|
390
|
+
def delete_input(self, item: DeleteInput) -> DeleteInput:
|
|
391
|
+
"""Zusatzangabe für den Löschdialog anmelden (ADR 0006, `pillarz.events.DeleteInput`)."""
|
|
392
|
+
if not isinstance(item, DeleteInput):
|
|
393
|
+
raise TypeError("pz.delete_input() erwartet ein DeleteInput")
|
|
394
|
+
if not _KEY_RE.fullmatch(item.key) or any(i.key == item.key for i in self._registry.items("delete_input")):
|
|
395
|
+
raise ValueError(f"pz.delete_input(): Schlüssel {item.key!r} ist ungültig oder schon vergeben")
|
|
396
|
+
if item.kind not in ("user", "choice", "bool", "text"):
|
|
397
|
+
raise ValueError(f"pz.delete_input({item.key!r}): kind muss user, choice, bool oder text sein")
|
|
398
|
+
if item.on_self not in (None, "ask", "default"):
|
|
399
|
+
raise ValueError(f"pz.delete_input({item.key!r}): on_self muss 'ask' oder 'default' sein")
|
|
400
|
+
if item.kind == "user" and item.on_self == "ask":
|
|
401
|
+
raise ValueError(f"pz.delete_input({item.key!r}): kind='user' wird bei Selbstlöschung nie abgefragt "
|
|
402
|
+
"(on_self='default' mit self_default)")
|
|
403
|
+
if item.self_default == "actor":
|
|
404
|
+
raise ValueError(f"pz.delete_input({item.key!r}): self_default='actor' wäre das gelöschte Konto")
|
|
405
|
+
if item.kind == "choice" and not item.options:
|
|
406
|
+
raise ValueError(f"pz.delete_input({item.key!r}): kind='choice' braucht options")
|
|
407
|
+
if item.visible is not None and not callable(item.visible):
|
|
408
|
+
raise ValueError(f"pz.delete_input({item.key!r}): visible muss eine Funktion (user, conn) sein")
|
|
409
|
+
if not isinstance(item.max_length, int) or not 1 <= item.max_length <= 1000:
|
|
410
|
+
raise ValueError(f"pz.delete_input({item.key!r}): max_length 1–1000")
|
|
411
|
+
self._registry.add("delete_input", item)
|
|
412
|
+
return item
|
|
413
|
+
|
|
414
|
+
def request_authenticator(self, name: str, *, max_role: str = "user", methods=("GET", "HEAD")):
|
|
415
|
+
"""Eigenen Anmeldeweg für Spezialfälle anmelden (labelz-Render-Ticket, Architektur §3.6, ADR 0004 §6)::
|
|
416
|
+
|
|
417
|
+
@pz.request_authenticator(name="direct", max_role="user")
|
|
418
|
+
def ticket(request) -> AuthResult | int | None: …
|
|
419
|
+
|
|
420
|
+
Läuft im Guard **vor dem Gate**, nur ohne gültige Sitzung und nur für `methods` (Standard GET/HEAD),
|
|
421
|
+
in Anmeldereihenfolge (erster Treffer gewinnt); sieht Methode, Pfad, Query und Header. Rückgabe
|
|
422
|
+
`None`, Konto-ID oder `pillarz.web.AuthResult(user_id, context)` (`auth_context(request)`). Gesperrte
|
|
423
|
+
Konten gelten nie; die Rolle ist auf `max_role` gedeckelt; pillarz-Seiten und `/api/pz/*` antworten
|
|
424
|
+
403. Keine Nebenwirkungen (keine Sitzung, kein Cookie, kein `last_seen`, kein `user.signed_in`).
|
|
425
|
+
Eine Ausnahme im Authenticator ergibt 500 (nie still anonym)."""
|
|
426
|
+
if not isinstance(name, str) or not _KEY_RE.fullmatch(name) or name == "session":
|
|
427
|
+
raise ValueError(f"pz.request_authenticator(): Name {name!r} ist ungültig")
|
|
428
|
+
if any(a[0] == name for a in self._registry.items("authenticator")):
|
|
429
|
+
raise ValueError(f"pz.request_authenticator(): {name!r} ist schon angemeldet")
|
|
430
|
+
if max_role not in ("admin", "user", "guest"):
|
|
431
|
+
raise ValueError("pz.request_authenticator(): max_role muss admin, user oder guest sein")
|
|
432
|
+
allowed = {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
|
|
433
|
+
methods = tuple(m.upper() for m in methods)
|
|
434
|
+
if not methods or any(m not in allowed for m in methods):
|
|
435
|
+
raise ValueError(f"pz.request_authenticator(): methods {methods!r} ungültig")
|
|
436
|
+
|
|
437
|
+
def register(fn: Callable) -> Callable:
|
|
438
|
+
self._registry.add("authenticator", (name, fn, max_role, frozenset(methods)))
|
|
439
|
+
return fn
|
|
440
|
+
|
|
441
|
+
return register
|
|
442
|
+
|
|
443
|
+
def admin_tab(self, tab: AdminTab) -> AdminTab:
|
|
444
|
+
"""Eigenen Admin-Reiter anmelden (siehe `AdminTab`)."""
|
|
445
|
+
if not isinstance(tab, AdminTab):
|
|
446
|
+
raise TypeError("pz.admin_tab() erwartet ein AdminTab")
|
|
447
|
+
_check_extension("pz.admin_tab", tab, self._registry.items("admin_tab"), _ADMIN_TABS)
|
|
448
|
+
self._registry.add("admin_tab", tab)
|
|
449
|
+
return tab
|
|
450
|
+
|
|
451
|
+
def account_section(self, section: AccountSection) -> AccountSection:
|
|
452
|
+
"""Eigenen Abschnitt der Kontoseite anmelden (siehe `AccountSection`)."""
|
|
453
|
+
if not isinstance(section, AccountSection):
|
|
454
|
+
raise TypeError("pz.account_section() erwartet eine AccountSection")
|
|
455
|
+
_check_extension("pz.account_section", section, self._registry.items("account_section"), _ACCOUNT_SECTIONS)
|
|
456
|
+
self._registry.add("account_section", section)
|
|
457
|
+
return section
|
|
458
|
+
|
|
459
|
+
def settings_section(self, section: SettingsSection) -> SettingsSection:
|
|
460
|
+
"""App-Abschnitt im Reiter „Einstellungen“ anmelden (siehe `SettingsSection`)."""
|
|
461
|
+
if not isinstance(section, SettingsSection):
|
|
462
|
+
raise TypeError("pz.settings_section() erwartet eine SettingsSection")
|
|
463
|
+
_check_extension("pz.settings_section", section, self._registry.items("settings_section"), ())
|
|
464
|
+
if not section.keys or not all(isinstance(k, str) for k in section.keys):
|
|
465
|
+
raise ValueError(f"pz.settings_section({section.key!r}): keys fehlt")
|
|
466
|
+
self._registry.add("settings_section", section)
|
|
467
|
+
return section
|
|
468
|
+
|
|
469
|
+
def setting(self, setting: Setting) -> Setting:
|
|
470
|
+
"""App-Einstellung anmelden (siehe `Setting`, `pillarz.settings`)."""
|
|
471
|
+
from ._settings import KINDS, PILLARZ_KEYS
|
|
472
|
+
if not isinstance(setting, Setting):
|
|
473
|
+
raise TypeError("pz.setting() erwartet ein Setting")
|
|
474
|
+
if not _KEY_RE.fullmatch(setting.key) or setting.key in PILLARZ_KEYS or \
|
|
475
|
+
any(s.key == setting.key for s in self._registry.items("setting")):
|
|
476
|
+
raise ValueError(f"pz.setting(): Schlüssel {setting.key!r} ist ungültig oder schon vergeben")
|
|
477
|
+
if setting.kind not in KINDS:
|
|
478
|
+
raise ValueError(f"pz.setting({setting.key!r}): kind muss {', '.join(KINDS)} sein")
|
|
479
|
+
if setting.kind == "choice" and not setting.options:
|
|
480
|
+
raise ValueError(f"pz.setting({setting.key!r}): kind='choice' braucht options")
|
|
481
|
+
if setting.kind == "custom" and setting.validate is None:
|
|
482
|
+
# Ohne Prüfer nähme PATCH /api/pz/admin/settings beliebiges JSON an (S-N4)
|
|
483
|
+
raise ValueError(f"pz.setting({setting.key!r}): kind='custom' braucht validate")
|
|
484
|
+
if setting.ui and setting.kind == "custom":
|
|
485
|
+
raise ValueError(f"pz.setting({setting.key!r}): kind='custom' hat keine eingebaute Oberfläche – "
|
|
486
|
+
"SettingsSection nutzen")
|
|
487
|
+
self._registry.add("setting", setting)
|
|
488
|
+
return setting
|
|
489
|
+
|
|
490
|
+
def _check_registrations(self) -> None:
|
|
491
|
+
"""Beim `create_app()`: Kombinationen prüfen, die erst mit allen Registrierungen feststehen."""
|
|
492
|
+
if self.self_delete:
|
|
493
|
+
for item in self._registry.items("delete_input"):
|
|
494
|
+
asks = item.asks_self
|
|
495
|
+
if not asks and item.required and item.self_default is None:
|
|
496
|
+
raise ValueError(
|
|
497
|
+
f"pz.delete_input({item.key!r}): Pflichtangabe ohne self_default – bei Selbstlöschung "
|
|
498
|
+
f"wird {'kind=user' if item.kind == 'user' else 'on_self=default'} nicht abgefragt. "
|
|
499
|
+
"self_default setzen, die Selbstlöschung im user.deleting-Handler ablehnen oder "
|
|
500
|
+
"Pillarz(self_delete=False).")
|
|
501
|
+
settings = {s.key for s in self._registry.items("setting")}
|
|
502
|
+
owner: dict[str, str] = {}
|
|
503
|
+
for section in self._registry.items("settings_section"):
|
|
504
|
+
for key in section.keys:
|
|
505
|
+
if key not in settings:
|
|
506
|
+
raise ValueError(f"pz.settings_section({section.key!r}): Einstellung {key!r} ist nicht "
|
|
507
|
+
"mit pz.setting() angemeldet")
|
|
508
|
+
if key in owner:
|
|
509
|
+
raise ValueError(f"pz.settings_section(): Einstellung {key!r} gehört schon zum Abschnitt "
|
|
510
|
+
f"{owner[key]!r}")
|
|
511
|
+
owner[key] = section.key
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def frozen(self) -> bool:
|
|
515
|
+
return self._registry.frozen
|
|
516
|
+
|
|
517
|
+
# -------------------------------------------------------------- Start
|
|
518
|
+
def migrate(self, *, dry_run: bool = False) -> list[str]:
|
|
519
|
+
"""Offene Migrationen (pillarz, dann App) ausführen – wie beim Start. Rückgabe: angewendete
|
|
520
|
+
Schritte als `"komponente/name"`."""
|
|
521
|
+
from ._db.migrations import run
|
|
522
|
+
return run(self, dry_run=dry_run)
|
|
523
|
+
|
|
524
|
+
def create_app(self):
|
|
525
|
+
"""FastAPI-Instanz bauen: Guard, Gate „default deny“, Fehler-Handler, Static, Templates,
|
|
526
|
+
`/healthz`, `/themes.css` (Paket B). Danach sind alle Registrierungen eingefroren."""
|
|
527
|
+
from ._web.app import build
|
|
528
|
+
|
|
529
|
+
if self._app is not None:
|
|
530
|
+
return self._app
|
|
531
|
+
self._check_registrations()
|
|
532
|
+
self._registry.freeze()
|
|
533
|
+
pz = self
|
|
534
|
+
|
|
535
|
+
@asynccontextmanager
|
|
536
|
+
async def lifespan(_app):
|
|
537
|
+
from starlette.concurrency import run_in_threadpool
|
|
538
|
+
for warning in pz.config.warnings:
|
|
539
|
+
log.warning(warning)
|
|
540
|
+
await run_in_threadpool(pz._startup)
|
|
541
|
+
try:
|
|
542
|
+
yield
|
|
543
|
+
finally:
|
|
544
|
+
await run_in_threadpool(pz._shutdown)
|
|
545
|
+
|
|
546
|
+
self._app = build(self, lifespan)
|
|
547
|
+
return self._app
|
|
548
|
+
|
|
549
|
+
def _startup(self) -> None:
|
|
550
|
+
from . import _protocol
|
|
551
|
+
from ._i18n import catalogs
|
|
552
|
+
catalogs.load(self) # kaputte .po stoppen den Start (statt still deutsch anzuzeigen)
|
|
553
|
+
self.migrate()
|
|
554
|
+
_protocol.install(self) # nach den Migrationen (Tabelle pz_log), vor allem anderen
|
|
555
|
+
urls = self.config.public_urls
|
|
556
|
+
# S-N1: Secure/__Host- hängen an PUBLIC_URLS. Leer ist in Tests normal (keine Warnung), gemischt nie.
|
|
557
|
+
if not urls and not _runtime.testing:
|
|
558
|
+
log.warning(lazy("PUBLIC_URLS ist nicht gesetzt – kein HSTS, Anmelde-Cookie ohne Secure-Flag und keine "
|
|
559
|
+
"Links nach außen. Für den Betrieb die öffentliche https://-Adresse setzen."))
|
|
560
|
+
elif not self.config.https_only and any(u.startswith("https://") for u in urls):
|
|
561
|
+
log.warning(lazy("PUBLIC_URLS mischt http:// und https:// – deshalb kein HSTS und das Anmelde-Cookie "
|
|
562
|
+
"wird auch über unverschlüsseltes HTTP gesendet."))
|
|
563
|
+
if "oidc" in self.config.auth_methods and not urls:
|
|
564
|
+
log.warning(lazy("Anmeldung über authentik ist aktiv, aber PUBLIC_URLS ist nicht gesetzt – ohne öffentliche "
|
|
565
|
+
"Adresse gibt es keine Rückleitung von authentik und keine Verknüpfungslinks."))
|
|
566
|
+
self._prune()
|
|
567
|
+
from ._auth.setup import seed_admin
|
|
568
|
+
seed_admin(self) # ADMIN_* bei leerer DB (K-15: schwaches Passwort bricht ab), Warnung /setup
|
|
569
|
+
self._check_admin_reachable()
|
|
570
|
+
self._check_oidc()
|
|
571
|
+
for fn in self._registry.items("startup"):
|
|
572
|
+
fn()
|
|
573
|
+
|
|
574
|
+
def _prune(self) -> None:
|
|
575
|
+
"""Aufräumen beim Start (P-43): abgelaufene/im Leerlauf ungültige Sitzungen, abgelaufene Flows, alte
|
|
576
|
+
Bremsen-Einträge, verbrauchte/zurückgezogene/abgelaufene Einmal-Tokens nach 30 Tagen. Ab 0.5
|
|
577
|
+
zusätzlich regelmäßig im System-Job `pillarz.prune`."""
|
|
578
|
+
from ._auth import flows, sessions, throttle, tokens
|
|
579
|
+
counts = {"sessions": sessions.prune(), "flows": flows.prune(), "throttle": throttle.prune(),
|
|
580
|
+
"tokens": tokens.prune()}
|
|
581
|
+
if any(counts.values()):
|
|
582
|
+
log.info(lazy("Aufgeräumt: %(sessions)d Sitzungen, %(flows)d Anmelde-Zwischenstände, "
|
|
583
|
+
"%(throttle)d Bremsen-Einträge, %(tokens)d Einmal-Tokens"), counts)
|
|
584
|
+
|
|
585
|
+
def _check_admin_reachable(self) -> None:
|
|
586
|
+
"""C-L4: Konten, aber kein aktives Admin-Konto und keine `OIDC_ADMIN_GROUPS` – niemand kommt in
|
|
587
|
+
die Verwaltung (typisch nach der Übernahme einer Datenbank ohne Umbau `is_admin` → `role`).
|
|
588
|
+
Kein Startabbruch, aber ein Fehler im Protokoll. Leere Datenbank = Einrichtung, kein Fehler."""
|
|
589
|
+
from .db import connect
|
|
590
|
+
if self.config.oidc_admin_groups:
|
|
591
|
+
return
|
|
592
|
+
with connect() as conn:
|
|
593
|
+
users = conn.scalar("SELECT COUNT(*) FROM users")
|
|
594
|
+
admins = conn.scalar("SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1")
|
|
595
|
+
if users and not admins:
|
|
596
|
+
log.error(lazy("Kein aktives Admin-Konto (Konten insgesamt: %(users)d) und OIDC_ADMIN_GROUPS ist leer "
|
|
597
|
+
"– niemand kann die Verwaltung öffnen. Ein Konto zum Admin machen (role = 'admin') "
|
|
598
|
+
"oder OIDC_ADMIN_GROUPS setzen."), {"users": users})
|
|
599
|
+
|
|
600
|
+
def _check_oidc(self) -> None:
|
|
601
|
+
"""P-6: Discovery im Hintergrund prüfen – ein falscher `OIDC_ISSUER` steht so schon nach dem Start im Protokoll
|
|
602
|
+
und im Admin-Bereich, nicht erst nach der ersten fehlgeschlagenen Anmeldung. Nicht im Testmodus."""
|
|
603
|
+
from ._auth import oidc
|
|
604
|
+
if oidc.enabled() and not _runtime.testing:
|
|
605
|
+
oidc.check_in_background()
|
|
606
|
+
|
|
607
|
+
def _shutdown(self) -> None:
|
|
608
|
+
from ._db.connection import close_pool
|
|
609
|
+
try:
|
|
610
|
+
for fn in reversed(self._registry.items("shutdown")):
|
|
611
|
+
try:
|
|
612
|
+
fn()
|
|
613
|
+
except Exception: # noqa: BLE001 – weitere Handler und das Schließen des Pools laufen trotzdem
|
|
614
|
+
log.exception(lazy("on_shutdown-Handler %r ist fehlgeschlagen"), fn)
|
|
615
|
+
finally:
|
|
616
|
+
from . import _protocol
|
|
617
|
+
_protocol.uninstall()
|
|
618
|
+
close_pool()
|