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.
- cms_admin/__init__.py +6 -0
- cms_admin/app.py +102 -0
- cms_admin/articles.py +393 -0
- cms_admin/auth.py +186 -0
- cms_admin/dashboard.py +95 -0
- cms_admin/db.py +34 -0
- cms_admin/demo_export.py +167 -0
- cms_admin/media.py +320 -0
- cms_admin/pages.py +565 -0
- cms_admin/publishing.py +167 -0
- cms_admin/py.typed +0 -0
- cms_admin/security.py +33 -0
- cms_admin/settings.py +52 -0
- cms_admin/static/admin.css +93 -0
- cms_admin/static/fonts/OFL.txt +92 -0
- cms_admin/static/fonts/inter-normal-latin.woff2 +0 -0
- cms_admin/static/fonts/inter-normal-latinext.woff2 +0 -0
- cms_admin/static/fonts/newsreader-italic-latin.woff2 +0 -0
- cms_admin/static/fonts/newsreader-italic-latinext.woff2 +0 -0
- cms_admin/static/fonts/newsreader-normal-latin.woff2 +0 -0
- cms_admin/static/fonts/newsreader-normal-latinext.woff2 +0 -0
- cms_admin/static/vendor/adminlte/ADMINLTE-LICENSE.txt +20 -0
- cms_admin/static/vendor/adminlte/adminlte.min.css +7 -0
- cms_admin/templates/_fields.html.j2 +50 -0
- cms_admin/templates/article_edit.html.j2 +68 -0
- cms_admin/templates/article_new.html.j2 +26 -0
- cms_admin/templates/article_translation.html.j2 +42 -0
- cms_admin/templates/articles_list.html.j2 +44 -0
- cms_admin/templates/base.html.j2 +18 -0
- cms_admin/templates/dashboard.html.j2 +104 -0
- cms_admin/templates/login.html.j2 +31 -0
- cms_admin/templates/media_edit.html.j2 +62 -0
- cms_admin/templates/media_list.html.j2 +50 -0
- cms_admin/templates/media_new.html.j2 +27 -0
- cms_admin/templates/page_edit.html.j2 +122 -0
- cms_admin/templates/page_new.html.j2 +26 -0
- cms_admin/templates/page_translation.html.j2 +39 -0
- cms_admin/templates/pages_list.html.j2 +47 -0
- cms_admin/templates/publishing.html.j2 +74 -0
- cms_admin/templates/section_edit.html.j2 +61 -0
- cms_admin/templates/section_translation.html.j2 +51 -0
- cms_admin/templates/shell.html.j2 +75 -0
- cms_admin/workflow.py +69 -0
- sardine_cms_admin-0.1.0.dist-info/METADATA +69 -0
- sardine_cms_admin-0.1.0.dist-info/RECORD +46 -0
- sardine_cms_admin-0.1.0.dist-info/WHEEL +4 -0
cms_admin/__init__.py
ADDED
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
|