Polish public UI and fix ReDoc CSP

This commit is contained in:
StasonJatham committed 2026-05-20 19:13:25 +02:00
1 parent 7ba7610c41
commit fee0001517
2 files changed
+48 -27

No files matched your search

+28 -27
View File
@@ -610,9 +610,9 @@ def _content_security_policy(path: str) -> str:
return ( return (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https://fastapi.tiangolo.com; " "img-src 'self' data: https://fastapi.tiangolo.com; "
"font-src 'self' https://cdn.jsdelivr.net; " "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
"connect-src 'self'; " "connect-src 'self'; "
"form-action 'self'; " "form-action 'self'; "
"frame-ancestors 'none'" "frame-ancestors 'none'"
@@ -667,9 +667,9 @@ def _render_public_home(
<p class="section-copy">Generate a random hostname, paste the update URL into your router, and keep the private status page. Your own DNS name can point to this hostname with a CNAME.</p> <p class="section-copy">Generate a random hostname, paste the update URL into your router, and keep the private status page. Your own DNS name can point to this hostname with a CNAME.</p>
</div> </div>
<form method="post" action="/magic" class="col-lg-5 card card-body gap-3" aria-label="Create a generated DynDNS hostname"> <form method="post" action="/magic" class="col-lg-5 card card-body gap-3" aria-label="Create a generated DynDNS hostname">
<label>Router username <label>Router login name <span class="text-secondary fw-normal">(optional)</span>
<input class="form-control" name="username" placeholder="optional" autocomplete="off" inputmode="text"> <input class="form-control" name="username" placeholder="optional" autocomplete="off" inputmode="text">
<span class="form-text m-0">Leave empty to generate one automatically.</span> <span class="form-text m-0">This becomes the FRITZ!Box Benutzername. Leave empty to generate one automatically.</span>
</label> </label>
<button type="submit" class="btn btn-primary rounded-pill">Generate hostname</button> <button type="submit" class="btn btn-primary rounded-pill">Generate hostname</button>
</form> </form>
@@ -681,7 +681,6 @@ def _render_public_home(
<div class="container nav justify-content-center gap-3"> <div class="container nav justify-content-center gap-3">
<a class="nav-link p-0 small text-secondary" href="/docs">API docs</a> <a class="nav-link p-0 small text-secondary" href="/docs">API docs</a>
<a class="nav-link p-0 small text-secondary" href="/redoc">ReDoc</a> <a class="nav-link p-0 small text-secondary" href="/redoc">ReDoc</a>
<a class="nav-link p-0 small text-secondary" href="/admin">Admin</a>
</div> </div>
</footer> </footer>
</main> </main>
@@ -751,9 +750,9 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
<label>Verified hostname <label>Verified hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required> <input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label> </label>
<label>Router username <label>Router login name <span class="text-secondary fw-normal">(optional)</span>
<input class="form-control" name="username" placeholder="optional" autocomplete="off"> <input class="form-control" name="username" placeholder="optional" autocomplete="off">
<span class="form-text m-0">Leave empty to generate one automatically.</span> <span class="form-text m-0">This becomes the FRITZ!Box Benutzername. Leave empty to generate one automatically.</span>
</label> </label>
<button type="submit" class="btn btn-primary rounded-pill">Create credentials</button> <button type="submit" class="btn btn-primary rounded-pill">Create credentials</button>
</form> </form>
@@ -1262,21 +1261,22 @@ def _page(title: str, body: str) -> str:
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<style> <style>
:root {{ :root {{
--bs-primary: #0071e3; --bs-primary: #088a5b;
--bs-primary-rgb: 0, 113, 227; --bs-primary-rgb: 8, 138, 91;
--bs-link-color: #0071e3; --bs-link-color: #088a5b;
--bs-link-hover-color: #005bb8; --bs-link-hover-color: #066b47;
--rp-bg: #f5f5f7; --rp-bg: #f6f8f6;
--rp-surface: #ffffff; --rp-surface: #ffffff;
--rp-surface-alt: #fafafc; --rp-surface-alt: #f0f5f2;
--rp-ink: #1d1d1f; --rp-ink: #18211d;
--rp-muted: #6e6e73; --rp-muted: #637067;
--rp-line: rgba(0, 0, 0, 0.1); --rp-line: rgba(0, 0, 0, 0.1);
--rp-soft-line: rgba(0, 0, 0, 0.06); --rp-soft-line: rgba(0, 0, 0, 0.06);
--rp-dark: #161617; --rp-dark: #111816;
--rp-dark-card: #242426; --rp-dark-card: #1a2521;
--rp-danger: #b42318; --rp-danger: #b42318;
--rp-nav-bg: rgba(245, 245, 247, 0.86); --rp-nav-bg: rgba(246, 248, 246, 0.88);
--rp-shadow: 0 18px 48px rgba(24, 33, 29, 0.08);
}} }}
[data-bs-theme="dark"] {{ [data-bs-theme="dark"] {{
--bs-body-bg: #0f1012; --bs-body-bg: #0f1012;
@@ -1286,7 +1286,7 @@ def _page(title: str, body: str) -> str:
--bs-tertiary-bg: #1c1d20; --bs-tertiary-bg: #1c1d20;
--rp-bg: #0f1012; --rp-bg: #0f1012;
--rp-surface: #16171a; --rp-surface: #16171a;
--rp-surface-alt: #1c1d20; --rp-surface-alt: #1b231f;
--rp-ink: #f5f5f7; --rp-ink: #f5f5f7;
--rp-muted: #a1a1aa; --rp-muted: #a1a1aa;
--rp-line: rgba(255, 255, 255, 0.16); --rp-line: rgba(255, 255, 255, 0.16);
@@ -1295,6 +1295,7 @@ def _page(title: str, body: str) -> str:
--rp-dark-card: #16171a; --rp-dark-card: #16171a;
--rp-danger: #ff8a80; --rp-danger: #ff8a80;
--rp-nav-bg: rgba(15, 16, 18, 0.82); --rp-nav-bg: rgba(15, 16, 18, 0.82);
--rp-shadow: none;
}} }}
* {{ box-sizing: border-box; }} * {{ box-sizing: border-box; }}
html {{ scroll-behavior: smooth; }} html {{ scroll-behavior: smooth; }}
@@ -1307,13 +1308,13 @@ def _page(title: str, body: str) -> str:
.container {{ max-width: 1040px; }} .container {{ max-width: 1040px; }}
.skip-link {{ position: fixed; left: 16px; top: 10px; z-index: 40; transform: translateY(-140%); padding: 10px 14px; border-radius: 999px; background: var(--rp-ink); color: var(--rp-bg); text-decoration: none; font-size: 14px; transition: transform .16s ease; }} .skip-link {{ position: fixed; left: 16px; top: 10px; z-index: 40; transform: translateY(-140%); padding: 10px 14px; border-radius: 999px; background: var(--rp-ink); color: var(--rp-bg); text-decoration: none; font-size: 14px; transition: transform .16s ease; }}
.skip-link:focus {{ transform: translateY(0); }} .skip-link:focus {{ transform: translateY(0); }}
.navbar-blur {{ min-height: 52px; background: var(--rp-nav-bg); backdrop-filter: saturate(180%) blur(20px); }} .navbar-blur {{ min-height: 56px; background: var(--rp-nav-bg); backdrop-filter: saturate(180%) blur(20px); }}
.brand {{ display: inline-flex; align-items: center; gap: 9px; color: var(--rp-ink); font-size: 15px; font-weight: 650; letter-spacing: -0.01em; white-space: nowrap; }} .brand {{ display: inline-flex; align-items: center; gap: 9px; color: var(--rp-ink); font-size: 15px; font-weight: 650; letter-spacing: -0.01em; white-space: nowrap; }}
.brand:hover {{ color: var(--rp-ink); }} .brand:hover {{ color: var(--rp-ink); }}
.brand img {{ width: 28px; height: 28px; display: block; }} .brand img {{ width: 28px; height: 28px; display: block; }}
.navbar .nav-link, .navbar-text {{ color: var(--rp-muted); font-size: 13px; }} .navbar .nav-link, .navbar-text {{ color: var(--rp-muted); font-size: 13px; }}
.navbar .nav-link:hover {{ color: var(--rp-ink); }} .navbar .nav-link:hover {{ color: var(--rp-ink); }}
.hero-band {{ min-height: calc(70vh - 52px); display: grid; align-items: center; background: var(--rp-bg); text-align: center; padding: 80px 0 72px; }} .hero-band {{ min-height: calc(70vh - 56px); display: grid; align-items: center; background: var(--rp-bg); text-align: center; padding: 88px 0 76px; }}
.hero-band.compact {{ min-height: 360px; }} .hero-band.compact {{ min-height: 360px; }}
.section {{ padding: 68px 0; background: var(--rp-surface); }} .section {{ padding: 68px 0; background: var(--rp-surface); }}
.section + .section {{ border-top: 1px solid var(--rp-soft-line); }} .section + .section {{ border-top: 1px solid var(--rp-soft-line); }}
@@ -1322,20 +1323,20 @@ def _page(title: str, body: str) -> str:
.danger-section {{ background: var(--rp-surface); border-top: 1px solid var(--rp-soft-line); }} .danger-section {{ background: var(--rp-surface); border-top: 1px solid var(--rp-soft-line); }}
.page-heading, .admin-heading .container {{ max-width: 760px; }} .page-heading, .admin-heading .container {{ max-width: 760px; }}
h1, h2, h3, p {{ margin: 0; }} h1, h2, h3, p {{ margin: 0; }}
h1 {{ max-width: 760px; font-size: 48px; line-height: 1.08; font-weight: 650; letter-spacing: -0.02em; }} h1 {{ max-width: 760px; font-size: 52px; line-height: 1.06; font-weight: 700; letter-spacing: -0.02em; }}
h2 {{ font-size: 28px; line-height: 1.16; font-weight: 600; letter-spacing: -0.015em; }} h2 {{ font-size: 28px; line-height: 1.16; font-weight: 600; letter-spacing: -0.015em; }}
h3 {{ font-size: 18px; line-height: 1.25; font-weight: 600; }} h3 {{ font-size: 18px; line-height: 1.25; font-weight: 600; }}
.eyebrow {{ color: var(--bs-primary); font-size: 13px; font-weight: 600; letter-spacing: 0; }} .eyebrow {{ color: var(--bs-primary); font-size: 13px; font-weight: 700; letter-spacing: .02em; text-transform: uppercase; }}
.lead {{ max-width: 660px; color: var(--rp-muted); font-size: 19px; line-height: 1.47; letter-spacing: -0.01em; }} .lead {{ max-width: 660px; color: var(--rp-muted); font-size: 19px; line-height: 1.47; letter-spacing: -0.01em; }}
.section-dark .lead, .section-dark .section-copy {{ color: #cccccc; }} .section-dark .lead, .section-dark .section-copy {{ color: #cccccc; }}
.section-dark .text-secondary {{ color: #cccccc !important; }} .section-dark .text-secondary {{ color: #cccccc !important; }}
.section-copy, .intro {{ margin-top: 12px; max-width: 560px; color: var(--rp-muted); font-size: 15px; line-height: 1.5; }} .section-copy, .intro {{ margin-top: 12px; max-width: 560px; color: var(--rp-muted); font-size: 15px; line-height: 1.5; }}
.card {{ background: var(--rp-surface); border-color: var(--rp-line); border-radius: .75rem; }} .card {{ background: var(--rp-surface); border-color: var(--rp-line); border-radius: 1rem; box-shadow: var(--rp-shadow); }}
.section-dark .card {{ background: var(--rp-dark-card); border-color: rgba(255, 255, 255, 0.14); color: #f5f5f7; }} .section-dark .card {{ background: var(--rp-dark-card); border-color: rgba(255, 255, 255, 0.14); color: #f5f5f7; }}
label {{ display: grid; gap: 8px; color: var(--rp-muted); font-size: 13px; font-weight: 500; }} label {{ display: grid; gap: 8px; color: var(--rp-muted); font-size: 13px; font-weight: 600; }}
input {{ width: 100%; min-height: 44px; border: 1px solid var(--bs-border-color); border-radius: .5rem; padding: 10px 12px; color: var(--bs-body-color); font: inherit; background: var(--bs-body-bg); outline: none; }} input {{ width: 100%; min-height: 46px; border: 1px solid var(--bs-border-color); border-radius: .75rem; padding: 10px 13px; color: var(--bs-body-color); font: inherit; background: var(--bs-body-bg); outline: none; }}
input:focus {{ border-color: var(--bs-primary); box-shadow: 0 0 0 .25rem rgba(var(--bs-primary-rgb), .16); }} input:focus {{ border-color: var(--bs-primary); box-shadow: 0 0 0 .25rem rgba(var(--bs-primary-rgb), .16); }}
.btn {{ display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 500; transition: background-color .16s ease, border-color .16s ease, transform .16s ease; }} .btn {{ display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 650; transition: background-color .16s ease, border-color .16s ease, transform .16s ease; }}
.btn:not(.btn-sm) {{ min-height: 44px; font-size: 15px; }} .btn:not(.btn-sm) {{ min-height: 44px; font-size: 15px; }}
.btn-sm {{ min-height: 32px; }} .btn-sm {{ min-height: 32px; }}
.btn:active {{ transform: scale(.97); }} .btn:active {{ transform: scale(.97); }}
+20
View File
@@ -466,6 +466,8 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
assert docs.status_code == 200 assert docs.status_code == 200
assert redoc.status_code == 200 assert redoc.status_code == 200
assert "unsafe-inline" in docs.headers["content-security-policy"] assert "unsafe-inline" in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
assert "/api/v1/hostnames/magic" in schema["paths"] assert "/api/v1/hostnames/magic" in schema["paths"]
assert "/api/v1/updates/{update_slug}" in schema["paths"] assert "/api/v1/updates/{update_slug}" in schema["paths"]
assert "/admin" not in schema["paths"] assert "/admin" not in schema["paths"]
@@ -475,6 +477,24 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
assert all(tag["name"] != "admin" for tag in schema["tags"]) assert all(tag["name"] != "admin" for tag in schema["tags"])
def test_public_home_hides_admin_link_and_explains_router_login(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/admin"' not in response.text
assert "Router login name" in response.text
assert "FRITZ!Box Benutzername" in response.text
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None: def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app) client = TestClient(app)