feat(admin-ui): SRI + CDN fallback for CodeMirror, 301→302 on /install, error sanitization

- Add integrity= + crossorigin= to all 4 cdnjs tags in admin_welcome.html
  and admin_setup_banner.html (I-1)
- Add graceful CDN fallback: when CodeMirror is undefined (SRI mismatch or
  CDN down), degrade to styled plain textarea with polyfill editor interface
  so save/reset/preview still work (I-1)
- Replace fixed 480px editor height with calc(100vh - 320px) for
  viewport-relative sizing; add min-height: 480px to .welcome-editor-col (M-8)
- Change /install redirect from 301 to 302 to prevent indefinite browser
  caching (I-5)
- Sanitize Jinja2 error detail in /api/welcome 500 response: log full error
  server-side, return generic detail pointing at /admin/welcome (M-7)
- Hoist build_context import to module level in app/api/welcome.py (M-11)
This commit is contained in:
ZdenekSrotyr 2026-05-02 20:55:03 +02:00
parent 8ec194cbe4
commit b0ec842804
4 changed files with 109 additions and 34 deletions

View file

@ -7,6 +7,7 @@
"""
import datetime
import logging
from typing import Optional
import duckdb
@ -17,7 +18,9 @@ from pydantic import BaseModel, Field
from app.auth.access import require_admin
from app.auth.dependencies import _get_db, get_current_user
from src.repositories.welcome_template import WelcomeTemplateRepository
from src.welcome_template import _load_default_template, render_welcome
from src.welcome_template import _load_default_template, build_context, render_welcome
logger = logging.getLogger(__name__)
router = APIRouter(tags=["welcome"])
@ -75,9 +78,10 @@ async def get_welcome(
try:
rendered = render_welcome(conn, user=user, server_url=server_url)
except TemplateError as e:
logger.warning("Welcome render failed: %s", e, exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Welcome template render failed: {e}. An admin can reset it via /admin/welcome.",
detail="Welcome template render failed. An admin can fix it at /admin/welcome.",
)
return WelcomeResponse(content=rendered)
@ -133,8 +137,6 @@ async def admin_preview_template(
"""Render arbitrary template content against the live context for the
calling admin, without persisting. Used by the /admin/welcome editor's
Preview button so admins can see their edits before saving."""
from src.welcome_template import build_context
env = Environment(undefined=StrictUndefined, autoescape=False)
try:
template = env.from_string(payload.content)

View file

@ -744,8 +744,13 @@ async def setup_page(
@router.get("/install", response_class=HTMLResponse)
async def install_redirect(request: Request):
"""Backwards-compat redirect: /install → /setup (301)."""
return RedirectResponse(url="/setup", status_code=301)
"""Backwards-compat redirect: /install → /setup (302).
Using 302 (temporary) rather than 301 (permanent) so browsers/proxies
don't cache indefinitely — if the path ever changes again, cached 301s
require manual cache clearing to recover.
"""
return RedirectResponse(url="/setup", status_code=302)
@router.get("/admin/tables", response_class=HTMLResponse)

View file

@ -2,10 +2,18 @@
{% block title %}Setup Banner — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"
integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ=="
crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"
integrity="sha512-2OhXH4Il3n2tHKwLLSDPhrkgnLBC+6lHGGQzSFi3chgVB6DJ/v6+nbx+XYO9CugQyHVF/8D/0k3Hx1eaUK2K9g=="
crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"
integrity="sha512-OeZ4Yrb/W7d2W4rAMOO0HQ9Ro/aWLtpW9BUSR2UOWnSV2hprXLkkYnnCGc9NeLUxxE4ZG7zN16UuT1Elqq8Opg=="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"
integrity="sha512-Z4le1RxwhD8lDCrspbBxjTLLP2HGC1+mKb9KHR2N/sEx8uOe2vre5XQo8YMPAz8FQTo43HjefjlDtjY4LtfaaQ=="
crossorigin="anonymous"></script>
<style>
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
@ -102,6 +110,9 @@
flex: 1 1 50%;
min-width: 0;
}
.welcome-editor-col {
min-height: 480px;
}
.welcome-preview-col {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
@ -267,16 +278,39 @@
<script>
const API = "/api/admin/setup-banner";
// ── CodeMirror editor ─────────────────────────────────────────────────
const editor = CodeMirror.fromTextArea(document.getElementById("content"), {
mode: "jinja2",
lineNumbers: true,
lineWrapping: true,
theme: "material-darker",
indentUnit: 2,
tabSize: 2,
});
editor.setSize("100%", 480);
// ── CodeMirror editor (with graceful CDN fallback) ────────────────────
const ta = document.getElementById("content");
if (typeof CodeMirror === "undefined") {
// CDN unreachable or SRI mismatch — degrade to plain textarea + warn.
ta.style.display = "block";
ta.style.width = "100%";
ta.style.minHeight = "480px";
ta.style.fontFamily = "var(--font-mono)";
ta.style.fontSize = "13px";
ta.style.padding = "9px 12px";
ta.style.border = "1px solid var(--border)";
ta.style.borderRadius = "8px";
// Polyfill the `editor` interface so save/remove/preview still work.
window.editor = {
getValue: () => ta.value,
setValue: (v) => { ta.value = v; },
on: () => {},
setSize: () => {},
};
// toast is defined below; defer so the DOM is ready and toast stack exists.
setTimeout(() => toast("Code editor failed to load — using plain textarea. Check network/CSP.", "error"), 0);
} else {
// Normal path
window.editor = CodeMirror.fromTextArea(ta, {
mode: "jinja2",
lineNumbers: true,
lineWrapping: true,
theme: "material-darker",
indentUnit: 2,
tabSize: 2,
});
editor.setSize("100%", "calc(100vh - 320px)");
}
// ── Live preview (debounced) ──────────────────────────────────────────
let previewTimer = null;

View file

@ -2,10 +2,18 @@
{% block title %}Welcome Prompt — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"
integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ=="
crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"
integrity="sha512-2OhXH4Il3n2tHKwLLSDPhrkgnLBC+6lHGGQzSFi3chgVB6DJ/v6+nbx+XYO9CugQyHVF/8D/0k3Hx1eaUK2K9g=="
crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"
integrity="sha512-OeZ4Yrb/W7d2W4rAMOO0HQ9Ro/aWLtpW9BUSR2UOWnSV2hprXLkkYnnCGc9NeLUxxE4ZG7zN16UuT1Elqq8Opg=="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"
integrity="sha512-Z4le1RxwhD8lDCrspbBxjTLLP2HGC1+mKb9KHR2N/sEx8uOe2vre5XQo8YMPAz8FQTo43HjefjlDtjY4LtfaaQ=="
crossorigin="anonymous"></script>
<style>
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
@ -102,6 +110,9 @@
flex: 1 1 50%;
min-width: 0;
}
.welcome-editor-col {
min-height: 480px;
}
.welcome-preview-col {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
@ -268,16 +279,39 @@
<script>
const API = "/api/admin/welcome-template";
// ── CodeMirror editor ─────────────────────────────────────────────────
const editor = CodeMirror.fromTextArea(document.getElementById("content"), {
mode: "jinja2",
lineNumbers: true,
lineWrapping: true,
theme: "material-darker",
indentUnit: 2,
tabSize: 2,
});
editor.setSize("100%", 480);
// ── CodeMirror editor (with graceful CDN fallback) ────────────────────
const ta = document.getElementById("content");
if (typeof CodeMirror === "undefined") {
// CDN unreachable or SRI mismatch — degrade to plain textarea + warn.
ta.style.display = "block";
ta.style.width = "100%";
ta.style.minHeight = "480px";
ta.style.fontFamily = "var(--font-mono)";
ta.style.fontSize = "13px";
ta.style.padding = "9px 12px";
ta.style.border = "1px solid var(--border)";
ta.style.borderRadius = "8px";
// Polyfill the `editor` interface so save/reset/preview still work.
window.editor = {
getValue: () => ta.value,
setValue: (v) => { ta.value = v; },
on: () => {},
setSize: () => {},
};
// toast is defined below; defer so the DOM is ready and toast stack exists.
setTimeout(() => toast("Code editor failed to load — using plain textarea. Check network/CSP.", "error"), 0);
} else {
// Normal path
window.editor = CodeMirror.fromTextArea(ta, {
mode: "jinja2",
lineNumbers: true,
lineWrapping: true,
theme: "material-darker",
indentUnit: 2,
tabSize: 2,
});
editor.setSize("100%", "calc(100vh - 320px)");
}
// ── Live preview (debounced) ──────────────────────────────────────────
let previewTimer = null;