sardine-cms-admin 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.
Files changed (46) hide show
  1. cms_admin/__init__.py +6 -0
  2. cms_admin/app.py +102 -0
  3. cms_admin/articles.py +393 -0
  4. cms_admin/auth.py +186 -0
  5. cms_admin/dashboard.py +95 -0
  6. cms_admin/db.py +34 -0
  7. cms_admin/demo_export.py +167 -0
  8. cms_admin/media.py +320 -0
  9. cms_admin/pages.py +565 -0
  10. cms_admin/publishing.py +167 -0
  11. cms_admin/py.typed +0 -0
  12. cms_admin/security.py +33 -0
  13. cms_admin/settings.py +52 -0
  14. cms_admin/static/admin.css +93 -0
  15. cms_admin/static/fonts/OFL.txt +92 -0
  16. cms_admin/static/fonts/inter-normal-latin.woff2 +0 -0
  17. cms_admin/static/fonts/inter-normal-latinext.woff2 +0 -0
  18. cms_admin/static/fonts/newsreader-italic-latin.woff2 +0 -0
  19. cms_admin/static/fonts/newsreader-italic-latinext.woff2 +0 -0
  20. cms_admin/static/fonts/newsreader-normal-latin.woff2 +0 -0
  21. cms_admin/static/fonts/newsreader-normal-latinext.woff2 +0 -0
  22. cms_admin/static/vendor/adminlte/ADMINLTE-LICENSE.txt +20 -0
  23. cms_admin/static/vendor/adminlte/adminlte.min.css +7 -0
  24. cms_admin/templates/_fields.html.j2 +50 -0
  25. cms_admin/templates/article_edit.html.j2 +68 -0
  26. cms_admin/templates/article_new.html.j2 +26 -0
  27. cms_admin/templates/article_translation.html.j2 +42 -0
  28. cms_admin/templates/articles_list.html.j2 +44 -0
  29. cms_admin/templates/base.html.j2 +18 -0
  30. cms_admin/templates/dashboard.html.j2 +104 -0
  31. cms_admin/templates/login.html.j2 +31 -0
  32. cms_admin/templates/media_edit.html.j2 +62 -0
  33. cms_admin/templates/media_list.html.j2 +50 -0
  34. cms_admin/templates/media_new.html.j2 +27 -0
  35. cms_admin/templates/page_edit.html.j2 +122 -0
  36. cms_admin/templates/page_new.html.j2 +26 -0
  37. cms_admin/templates/page_translation.html.j2 +39 -0
  38. cms_admin/templates/pages_list.html.j2 +47 -0
  39. cms_admin/templates/publishing.html.j2 +74 -0
  40. cms_admin/templates/section_edit.html.j2 +61 -0
  41. cms_admin/templates/section_translation.html.j2 +51 -0
  42. cms_admin/templates/shell.html.j2 +75 -0
  43. cms_admin/workflow.py +69 -0
  44. sardine_cms_admin-0.1.0.dist-info/METADATA +69 -0
  45. sardine_cms_admin-0.1.0.dist-info/RECORD +46 -0
  46. sardine_cms_admin-0.1.0.dist-info/WHEEL +4 -0
cms_admin/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Sardine CMS admin panel (ADR-0013): FastAPI API + server-rendered UI."""
2
+
3
+ from cms_admin.app import create_app
4
+ from cms_admin.settings import AdminSettings
5
+
6
+ __all__ = ["AdminSettings", "create_app"]
cms_admin/app.py ADDED
@@ -0,0 +1,102 @@
1
+ """Application factory.
2
+
3
+ One FastAPI process serves the API and the server-rendered UI (ADR-0013).
4
+ Storage is opened once per process (any supported engine, via the
5
+ thread-affine ``StorageExecutor``) and closed on shutdown; handlers reach it
6
+ through ``get_db``.
7
+ """
8
+
9
+ import tempfile
10
+ from collections.abc import AsyncIterator
11
+ from contextlib import asynccontextmanager
12
+ from pathlib import Path
13
+
14
+ from fastapi import FastAPI, Request
15
+ from fastapi.staticfiles import StaticFiles
16
+ from fastapi.templating import Jinja2Templates
17
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
18
+
19
+ from cms_admin.articles import router as articles_router
20
+ from cms_admin.auth import get_db
21
+ from cms_admin.auth import router as auth_router
22
+ from cms_admin.dashboard import router as dashboard_router
23
+ from cms_admin.db import StorageExecutor
24
+ from cms_admin.media import router as media_router
25
+ from cms_admin.pages import router as pages_router
26
+ from cms_admin.publishing import router as publishing_router
27
+ from cms_admin.settings import AdminSettings
28
+
29
+
30
+ @asynccontextmanager
31
+ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
32
+ settings: AdminSettings = app.state.settings
33
+ app.state.db = StorageExecutor(settings.storage_url)
34
+ try:
35
+ yield
36
+ finally:
37
+ app.state.db.close()
38
+
39
+
40
+ # Hardening (SECURITY_STRATEGY, M3 phase 9): the admin ships zero JavaScript,
41
+ # so the CSP allows no script source at all; styles, fonts and images come
42
+ # only from the app itself; nothing may frame it.
43
+ SECURITY_HEADERS = {
44
+ "Content-Security-Policy": (
45
+ "default-src 'none'; style-src 'self'; font-src 'self'; "
46
+ "img-src 'self' data:; form-action 'self'; base-uri 'none'; "
47
+ "frame-ancestors 'none'"
48
+ ),
49
+ "X-Content-Type-Options": "nosniff",
50
+ "X-Frame-Options": "DENY",
51
+ "Referrer-Policy": "strict-origin-when-cross-origin",
52
+ "Permissions-Policy": "geolocation=(), microphone=(), camera=()",
53
+ }
54
+
55
+
56
+ def create_app(settings: AdminSettings | None = None) -> FastAPI:
57
+ from cms_admin.auth import LoginRateLimiter
58
+
59
+ app = FastAPI(title="Sardine CMS admin", lifespan=_lifespan, docs_url=None, redoc_url=None)
60
+
61
+ @app.middleware("http")
62
+ async def _security_headers(request: Request, call_next): # type: ignore[no-untyped-def]
63
+ response = await call_next(request)
64
+ for name, value in SECURITY_HEADERS.items():
65
+ response.headers.setdefault(name, value)
66
+ return response
67
+
68
+ app.state.settings = settings if settings is not None else AdminSettings.from_env()
69
+ # autoescape must be forced on: the stock select_autoescape does not
70
+ # recognize the .html.j2 extension and would render templates unescaped.
71
+ app.state.templates = Jinja2Templates(
72
+ env=Environment(
73
+ loader=FileSystemLoader(Path(__file__).parent / "templates"),
74
+ autoescape=select_autoescape(default=True, default_for_string=True),
75
+ )
76
+ )
77
+ app.state.login_limiter = LoginRateLimiter()
78
+ app.include_router(auth_router)
79
+ app.include_router(dashboard_router)
80
+ app.include_router(articles_router)
81
+ app.include_router(pages_router)
82
+ app.include_router(media_router)
83
+ app.include_router(publishing_router)
84
+ app.state.preview_dir = tempfile.mkdtemp(prefix="sardine-preview-")
85
+ app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
86
+ app.mount(
87
+ "/preview",
88
+ StaticFiles(directory=app.state.preview_dir, html=True, check_dir=False),
89
+ name="preview",
90
+ )
91
+ app.mount(
92
+ "/media-files",
93
+ StaticFiles(directory=app.state.settings.media_dir, check_dir=False),
94
+ name="media-files",
95
+ )
96
+
97
+ @app.get("/healthz")
98
+ async def healthz(request: Request) -> dict[str, str | int]:
99
+ version = await get_db(request).run(lambda storage: storage.schema_version())
100
+ return {"status": "ok", "schema_version": version}
101
+
102
+ return app
cms_admin/articles.py ADDED
@@ -0,0 +1,393 @@
1
+ """Articles: list, create, and the side-by-side translation editor.
2
+
3
+ The EN source is edited on the article page; each translation is edited next
4
+ to a read-only view of the source it translates (the checksum model marks it
5
+ outdated automatically when the source changes afterwards). The preview uses
6
+ the builder's own Markdown renderer, so what an editor sees is exactly what
7
+ the published site will render — raw HTML stays disabled.
8
+ """
9
+
10
+ from datetime import UTC, datetime
11
+
12
+ from cms_build import render_markdown
13
+ from cms_core import (
14
+ AdminSession,
15
+ Article,
16
+ ArticleContent,
17
+ ContentStatus,
18
+ Language,
19
+ Role,
20
+ User,
21
+ new_article,
22
+ )
23
+ from cms_core.languages import SOURCE_LANGUAGE, TARGET_LANGUAGES
24
+ from cms_validation import SiteContent
25
+ from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
26
+ from fastapi.responses import RedirectResponse
27
+ from pydantic import ValidationError
28
+
29
+ from cms_admin.auth import current_session, enforce_csrf, get_db
30
+ from cms_admin.workflow import (
31
+ allowed,
32
+ available_transitions,
33
+ publish_blockers,
34
+ transition_minimum,
35
+ )
36
+
37
+ router = APIRouter(prefix="/articles")
38
+
39
+ HTTP_422 = status.HTTP_422_UNPROCESSABLE_CONTENT
40
+
41
+
42
+ def form_errors(error: ValidationError) -> list[str]:
43
+ return [
44
+ f"{'.'.join(str(part) for part in item['loc']) or 'form'}: {item['msg']}"
45
+ for item in error.errors()
46
+ ]
47
+
48
+
49
+ def parse_tags(raw: str) -> tuple[str, ...]:
50
+ return tuple(part.strip() for part in raw.split(",") if part.strip())
51
+
52
+
53
+ def content_form(content: ArticleContent | None) -> dict[str, str]:
54
+ if content is None:
55
+ return {"title": "", "summary": "", "body_markdown": "", "slug": ""}
56
+ return {
57
+ "title": content.title,
58
+ "summary": content.summary,
59
+ "body_markdown": content.body_markdown,
60
+ "slug": content.slug or "",
61
+ }
62
+
63
+
64
+ def _target_language(code: str) -> Language:
65
+ try:
66
+ language = Language(code)
67
+ except ValueError:
68
+ raise HTTPException(
69
+ status_code=status.HTTP_404_NOT_FOUND, detail="unknown language"
70
+ ) from None
71
+ if language is SOURCE_LANGUAGE:
72
+ raise HTTPException(
73
+ status_code=status.HTTP_404_NOT_FOUND,
74
+ detail="the source language is edited on the article page",
75
+ )
76
+ return language
77
+
78
+
79
+ async def _load_article(request: Request, article_id: str) -> Article:
80
+ article = await get_db(request).run(lambda storage: storage.load_article(article_id))
81
+ if article is None:
82
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown article")
83
+ return article
84
+
85
+
86
+ def _page(
87
+ request: Request, template: str, context: dict[str, object], status_code: int = 200
88
+ ) -> object:
89
+ return request.app.state.templates.TemplateResponse(
90
+ request, template, {"active_section": "articles", **context}, status_code=status_code
91
+ )
92
+
93
+
94
+ @router.get("")
95
+ async def articles_list(
96
+ request: Request,
97
+ user_session: tuple[User, AdminSession] = Depends(current_session),
98
+ ) -> object:
99
+ user, session = user_session
100
+ articles = await get_db(request).run(lambda storage: storage.load_all_articles())
101
+ return _page(
102
+ request,
103
+ "articles_list.html.j2",
104
+ {
105
+ "user": user,
106
+ "csrf_token": session.csrf_token,
107
+ "articles": articles,
108
+ "target_languages": TARGET_LANGUAGES,
109
+ },
110
+ )
111
+
112
+
113
+ @router.get("/new")
114
+ async def article_new_form(
115
+ request: Request,
116
+ user_session: tuple[User, AdminSession] = Depends(current_session),
117
+ ) -> object:
118
+ user, session = user_session
119
+ form = {"id": "", "category": "", "tags": "", "cover": "", **content_form(None)}
120
+ return _page(
121
+ request,
122
+ "article_new.html.j2",
123
+ {"user": user, "csrf_token": session.csrf_token, "errors": [], "form": form},
124
+ )
125
+
126
+
127
+ def _validated_article(base: Article, form: dict[str, str]) -> Article:
128
+ """Rebuild the article from the form and run every model validator once."""
129
+ payload = base.model_dump()
130
+ payload.update(
131
+ source=ArticleContent(
132
+ title=form["title"],
133
+ summary=form["summary"],
134
+ body_markdown=form["body_markdown"],
135
+ slug=form["slug"] or None,
136
+ ).model_dump(),
137
+ category=form["category"] or None,
138
+ cover=form["cover"] or None,
139
+ tags=parse_tags(form["tags"]),
140
+ updated_at=datetime.now(UTC),
141
+ )
142
+ return Article.model_validate(payload)
143
+
144
+
145
+ @router.post("")
146
+ async def article_create(
147
+ request: Request,
148
+ user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
149
+ article_id: str = Form(alias="id"),
150
+ title: str = Form(""),
151
+ summary: str = Form(""),
152
+ body_markdown: str = Form(""),
153
+ slug: str = Form(""),
154
+ category: str = Form(""),
155
+ tags: str = Form(""),
156
+ cover: str = Form(""),
157
+ ) -> object:
158
+ user, session = user_session
159
+ db = get_db(request)
160
+ form = {
161
+ "id": article_id,
162
+ "title": title,
163
+ "summary": summary,
164
+ "body_markdown": body_markdown,
165
+ "slug": slug,
166
+ "category": category,
167
+ "tags": tags,
168
+ "cover": cover,
169
+ }
170
+ try:
171
+ base = new_article(article_id, ArticleContent(title=title or "-"))
172
+ article = _validated_article(base, form)
173
+ except ValidationError as error:
174
+ errors = form_errors(error)
175
+ else:
176
+ existing = await db.run(lambda storage: storage.load_article(article_id))
177
+ if existing is None:
178
+ await db.run(lambda storage: storage.save_article(article))
179
+ return RedirectResponse(
180
+ f"/articles/{article.id}", status_code=status.HTTP_303_SEE_OTHER
181
+ )
182
+ errors = [f"id: an article with id {article_id!r} already exists"]
183
+ return _page(
184
+ request,
185
+ "article_new.html.j2",
186
+ {"user": user, "csrf_token": session.csrf_token, "errors": errors, "form": form},
187
+ status_code=HTTP_422,
188
+ )
189
+
190
+
191
+ def _editor_context(
192
+ article: Article, form: dict[str, str] | None = None, role: Role | None = None
193
+ ) -> dict[str, object]:
194
+ return {
195
+ "article": article,
196
+ "transitions": available_transitions(article.status, role) if role else [],
197
+ "states": article.translation_states(),
198
+ "target_languages": TARGET_LANGUAGES,
199
+ "preview_html": render_markdown(article.source.body_markdown),
200
+ "form": form
201
+ or {
202
+ **content_form(article.source),
203
+ "category": article.category or "",
204
+ "tags": ", ".join(article.tags),
205
+ "cover": article.cover or "",
206
+ },
207
+ }
208
+
209
+
210
+ @router.get("/{article_id}")
211
+ async def article_edit_form(
212
+ request: Request,
213
+ article_id: str,
214
+ user_session: tuple[User, AdminSession] = Depends(current_session),
215
+ ) -> object:
216
+ user, session = user_session
217
+ article = await _load_article(request, article_id)
218
+ return _page(
219
+ request,
220
+ "article_edit.html.j2",
221
+ {
222
+ "user": user,
223
+ "csrf_token": session.csrf_token,
224
+ "errors": [],
225
+ **_editor_context(article, role=user.role),
226
+ },
227
+ )
228
+
229
+
230
+ @router.post("/{article_id}")
231
+ async def article_edit_save(
232
+ request: Request,
233
+ article_id: str,
234
+ user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
235
+ title: str = Form(""),
236
+ summary: str = Form(""),
237
+ body_markdown: str = Form(""),
238
+ slug: str = Form(""),
239
+ category: str = Form(""),
240
+ tags: str = Form(""),
241
+ cover: str = Form(""),
242
+ ) -> object:
243
+ user, session = user_session
244
+ article = await _load_article(request, article_id)
245
+ form = {
246
+ "title": title,
247
+ "summary": summary,
248
+ "body_markdown": body_markdown,
249
+ "slug": slug,
250
+ "category": category,
251
+ "tags": tags,
252
+ "cover": cover,
253
+ }
254
+ try:
255
+ article = _validated_article(article, form)
256
+ except ValidationError as error:
257
+ return _page(
258
+ request,
259
+ "article_edit.html.j2",
260
+ {
261
+ "user": user,
262
+ "csrf_token": session.csrf_token,
263
+ "errors": form_errors(error),
264
+ **_editor_context(article, form, role=user.role),
265
+ },
266
+ status_code=HTTP_422,
267
+ )
268
+ await get_db(request).run(lambda storage: storage.save_article(article))
269
+ return RedirectResponse(f"/articles/{article.id}", status_code=status.HTTP_303_SEE_OTHER)
270
+
271
+
272
+ def _translation_context(
273
+ article: Article, language: Language, form: dict[str, str] | None = None
274
+ ) -> dict[str, object]:
275
+ translation = article.translations.get(language)
276
+ content = translation.content if translation else None
277
+ return {
278
+ "article": article,
279
+ "language": language,
280
+ "state": article.translation_state(language),
281
+ "source_preview_html": render_markdown(article.source.body_markdown),
282
+ "preview_html": render_markdown(content.body_markdown) if content else "",
283
+ "form": form or content_form(content),
284
+ }
285
+
286
+
287
+ @router.get("/{article_id}/translations/{language_code}")
288
+ async def translation_form(
289
+ request: Request,
290
+ article_id: str,
291
+ language_code: str,
292
+ user_session: tuple[User, AdminSession] = Depends(current_session),
293
+ ) -> object:
294
+ user, session = user_session
295
+ language = _target_language(language_code)
296
+ article = await _load_article(request, article_id)
297
+ return _page(
298
+ request,
299
+ "article_translation.html.j2",
300
+ {
301
+ "user": user,
302
+ "csrf_token": session.csrf_token,
303
+ "errors": [],
304
+ **_translation_context(article, language),
305
+ },
306
+ )
307
+
308
+
309
+ @router.post("/{article_id}/translations/{language_code}")
310
+ async def translation_save(
311
+ request: Request,
312
+ article_id: str,
313
+ language_code: str,
314
+ user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
315
+ title: str = Form(""),
316
+ summary: str = Form(""),
317
+ body_markdown: str = Form(""),
318
+ slug: str = Form(""),
319
+ ) -> object:
320
+ user, session = user_session
321
+ language = _target_language(language_code)
322
+ article = await _load_article(request, article_id)
323
+ form = {"title": title, "summary": summary, "body_markdown": body_markdown, "slug": slug}
324
+ try:
325
+ content = ArticleContent(
326
+ title=title, summary=summary, body_markdown=body_markdown, slug=slug or None
327
+ )
328
+ except ValidationError as error:
329
+ return _page(
330
+ request,
331
+ "article_translation.html.j2",
332
+ {
333
+ "user": user,
334
+ "csrf_token": session.csrf_token,
335
+ "errors": form_errors(error),
336
+ **_translation_context(article, language, form),
337
+ },
338
+ status_code=HTTP_422,
339
+ )
340
+ article.set_translation(language, content)
341
+ article.updated_at = datetime.now(UTC)
342
+ await get_db(request).run(lambda storage: storage.save_article(article))
343
+ return RedirectResponse(
344
+ f"/articles/{article.id}/translations/{language.value}",
345
+ status_code=status.HTTP_303_SEE_OTHER,
346
+ )
347
+
348
+
349
+ @router.post("/{article_id}/status")
350
+ async def article_status(
351
+ request: Request,
352
+ article_id: str,
353
+ user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
354
+ to: str = Form(...),
355
+ ) -> object:
356
+ user, session = user_session
357
+ article = await _load_article(request, article_id)
358
+ try:
359
+ target = ContentStatus(to)
360
+ except ValueError:
361
+ raise HTTPException(status_code=400, detail="unknown status") from None
362
+ minimum = transition_minimum(article.status, target)
363
+ if minimum is None:
364
+ raise HTTPException(status_code=400, detail="invalid transition")
365
+ if not allowed(user.role, minimum):
366
+ raise HTTPException(
367
+ status_code=status.HTTP_403_FORBIDDEN,
368
+ detail=f"requires the {minimum.value} role",
369
+ )
370
+ if target is ContentStatus.PUBLISHED and request.app.state.settings.publish_gate:
371
+ db = get_db(request)
372
+ content = SiteContent(
373
+ articles=await db.run(lambda storage: storage.load_all_articles()),
374
+ pages=await db.run(lambda storage: storage.load_all_pages()),
375
+ media=await db.run(lambda storage: storage.load_all_media_assets()),
376
+ )
377
+ blockers = publish_blockers(article, content)
378
+ if blockers:
379
+ return _page(
380
+ request,
381
+ "article_edit.html.j2",
382
+ {
383
+ "user": user,
384
+ "csrf_token": session.csrf_token,
385
+ "errors": blockers,
386
+ **_editor_context(article, role=user.role),
387
+ },
388
+ status_code=HTTP_422,
389
+ )
390
+ article.status = target
391
+ article.updated_at = datetime.now(UTC)
392
+ await get_db(request).run(lambda storage: storage.save_article(article))
393
+ return RedirectResponse(f"/articles/{article.id}", status_code=status.HTTP_303_SEE_OTHER)
cms_admin/auth.py ADDED
@@ -0,0 +1,186 @@
1
+ """Authentication and access control (SECURITY_STRATEGY, admin section).
2
+
3
+ Server-side sessions in the storage database (revocable, engine-agnostic),
4
+ session cookie ``HttpOnly`` + ``SameSite=Strict`` (+ ``Secure`` outside
5
+ local development), synchronizer CSRF tokens on authenticated state-changing
6
+ requests, a double-submit token on the login form itself, and per-process
7
+ rate limiting on failed logins.
8
+ """
9
+
10
+ from collections.abc import Awaitable, Callable
11
+ from dataclasses import dataclass, field
12
+ from datetime import UTC, datetime, timedelta
13
+
14
+ from cms_core.accounts import AdminSession, Role, User
15
+ from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
16
+ from fastapi.responses import RedirectResponse
17
+
18
+ from cms_admin.db import StorageExecutor
19
+ from cms_admin.security import new_token, token_digest, verify_password
20
+
21
+ SESSION_COOKIE = "sardine_session"
22
+ LOGIN_CSRF_COOKIE = "sardine_login_csrf"
23
+
24
+ # Least-privilege ladder (cms_core.accounts.Role docstring).
25
+ ROLE_ORDER = (Role.EDITOR, Role.REVIEWER, Role.PUBLISHER, Role.ADMIN)
26
+
27
+ router = APIRouter()
28
+
29
+
30
+ @dataclass
31
+ class LoginRateLimiter:
32
+ """Per-process failed-login limiter, keyed by username and client host."""
33
+
34
+ max_failures: int = 5
35
+ window: timedelta = timedelta(minutes=15)
36
+ _failures: dict[str, list[datetime]] = field(default_factory=dict)
37
+
38
+ def is_blocked(self, key: str, now: datetime) -> bool:
39
+ recent = [moment for moment in self._failures.get(key, []) if now - moment < self.window]
40
+ if recent:
41
+ self._failures[key] = recent
42
+ else:
43
+ self._failures.pop(key, None)
44
+ return len(recent) >= self.max_failures
45
+
46
+ def record_failure(self, key: str, now: datetime) -> None:
47
+ self._failures.setdefault(key, []).append(now)
48
+
49
+ def reset(self, key: str) -> None:
50
+ self._failures.pop(key, None)
51
+
52
+
53
+ def get_db(request: Request) -> StorageExecutor:
54
+ db: StorageExecutor = request.app.state.db
55
+ return db
56
+
57
+
58
+ def _login_redirect() -> HTTPException:
59
+ return HTTPException(
60
+ status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"}, detail="sign in"
61
+ )
62
+
63
+
64
+ async def current_session(request: Request) -> tuple[User, AdminSession]:
65
+ token = request.cookies.get(SESSION_COOKIE)
66
+ if not token:
67
+ raise _login_redirect()
68
+ digest = token_digest(token)
69
+ now = datetime.now(UTC)
70
+ db = get_db(request)
71
+ session = await db.run(lambda storage: storage.load_session(digest))
72
+ if session is None or session.expires_at <= now:
73
+ raise _login_redirect()
74
+ user = await db.run(lambda storage: storage.load_user(session.username))
75
+ if user is None:
76
+ raise _login_redirect()
77
+ return user, session
78
+
79
+
80
+ def require_at_least(minimum: Role) -> Callable[..., Awaitable[User]]:
81
+ async def dependency(
82
+ user_session: tuple[User, AdminSession] = Depends(current_session),
83
+ ) -> User:
84
+ user, _ = user_session
85
+ if ROLE_ORDER.index(user.role) < ROLE_ORDER.index(minimum):
86
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="insufficient role")
87
+ return user
88
+
89
+ return dependency
90
+
91
+
92
+ async def enforce_csrf(
93
+ request: Request,
94
+ user_session: tuple[User, AdminSession] = Depends(current_session),
95
+ ) -> tuple[User, AdminSession]:
96
+ form = await request.form()
97
+ token = form.get("csrf_token")
98
+ _, session = user_session
99
+ if not isinstance(token, str) or token != session.csrf_token:
100
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid CSRF token")
101
+ return user_session
102
+
103
+
104
+ def _client_key(request: Request, username: str) -> str:
105
+ host = request.client.host if request.client else "unknown"
106
+ return f"{username.lower()}|{host}"
107
+
108
+
109
+ @router.get("/login")
110
+ async def login_form(request: Request) -> object:
111
+ csrf = new_token()
112
+ response = request.app.state.templates.TemplateResponse(
113
+ request, "login.html.j2", {"error": None, "login_csrf": csrf}
114
+ )
115
+ response.set_cookie(
116
+ LOGIN_CSRF_COOKIE,
117
+ csrf,
118
+ httponly=True,
119
+ samesite="strict",
120
+ secure=request.app.state.settings.cookie_secure,
121
+ )
122
+ return response
123
+
124
+
125
+ @router.post("/login")
126
+ async def login_submit(
127
+ request: Request,
128
+ username: str = Form(...),
129
+ password: str = Form(...),
130
+ login_csrf: str = Form(...),
131
+ ) -> object:
132
+ settings = request.app.state.settings
133
+ if request.cookies.get(LOGIN_CSRF_COOKIE) != login_csrf:
134
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid CSRF token")
135
+ now = datetime.now(UTC)
136
+ limiter: LoginRateLimiter = request.app.state.login_limiter
137
+ key = _client_key(request, username)
138
+ if limiter.is_blocked(key, now):
139
+ raise HTTPException(
140
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
141
+ detail="too many failed attempts; try again later",
142
+ )
143
+ db = get_db(request)
144
+ user = await db.run(lambda storage: storage.load_user(username))
145
+ if user is None or not verify_password(user.password_hash, password):
146
+ limiter.record_failure(key, now)
147
+ response = request.app.state.templates.TemplateResponse(
148
+ request,
149
+ "login.html.j2",
150
+ {"error": "Wrong username or password.", "login_csrf": login_csrf},
151
+ status_code=status.HTTP_401_UNAUTHORIZED,
152
+ )
153
+ return response
154
+ limiter.reset(key)
155
+ token = new_token()
156
+ session = AdminSession(
157
+ token_hash=token_digest(token),
158
+ username=user.username,
159
+ csrf_token=new_token(),
160
+ expires_at=now + settings.session_ttl,
161
+ )
162
+ await db.run(lambda storage: storage.save_session(session))
163
+ await db.run(lambda storage: storage.delete_expired_sessions(now))
164
+ response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
165
+ response.set_cookie(
166
+ SESSION_COOKIE,
167
+ token,
168
+ max_age=int(settings.session_ttl.total_seconds()),
169
+ httponly=True,
170
+ samesite="strict",
171
+ secure=settings.cookie_secure,
172
+ )
173
+ response.delete_cookie(LOGIN_CSRF_COOKIE)
174
+ return response
175
+
176
+
177
+ @router.post("/logout")
178
+ async def logout(
179
+ request: Request,
180
+ user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
181
+ ) -> RedirectResponse:
182
+ _, session = user_session
183
+ await get_db(request).run(lambda storage: storage.delete_session(session.token_hash))
184
+ response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
185
+ response.delete_cookie(SESSION_COOKIE)
186
+ return response