From 49b9e3a7ca3512fa3ecc17feef99e09cb792bd26 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 20:23:32 +0200 Subject: [PATCH] Add footer and privacy page --- router_dyndns/ddns.py | 70 ++++++++++++++++++++++++++++++++++++++----- tests/test_ddns.py | 21 +++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index 23340af..ddf0b8a 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -515,6 +515,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: def healthz() -> str: return "ok" + @app.get("/privacy", response_class=HTMLResponse, include_in_schema=False) + def privacy() -> str: + return _render_privacy_page() + @app.get("/favicon.ico", include_in_schema=False) def favicon() -> FileResponse: return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon") @@ -757,12 +761,6 @@ def _render_public_home( {challenge_html} - """, ) @@ -1353,6 +1351,62 @@ def _user_event_row(event: dict[str, str | None]) -> str: return f"{created}{status}{ipv4}{ipv6}{detail}" +def _render_privacy_page() -> str: + return _page( + "Legal and Privacy", + f""" +
+ {_top_nav()} +
+
+

Legal and privacy

+

KarlDNS keeps accountless DynDNS simple.

+

This page explains the basic data handled by this self-hosted service. Configure your own imprint, controller details, and jurisdiction-specific terms before offering it publicly.

+
+
+
+
+
+

Data processed

+

KarlDNS stores generated hostnames, public DNS target names, update slugs, hashed management tokens, hashed router passwords, DNS TXT verification challenges, IP update history, timestamps, and operational events needed to run Dynamic DNS.

+
+
+

Secrets

+

Private management links, update URLs, router passwords, and one-box update URLs are bearer secrets. Anyone with them can update or manage the related hostname. Passwords and management lookup values are not stored in recoverable plaintext.

+
+
+

Logs and retention

+

The service keeps recent update events for troubleshooting and runs cleanup jobs for abandoned setup flows and unused hostnames. Server and reverse-proxy logs may also contain IP addresses and request metadata depending on your deployment.

+
+
+

Contact

+

For the public KarlDNS instance, contact Karl through karl.fail or karlcom.de.

+
+
+
+
+ """, + ) + + +def _site_footer() -> str: + return """ + + """ + + def _page(title: str, body: str) -> str: return f""" @@ -1480,6 +1534,8 @@ def _page(title: str, body: str) -> str: .admin-table-scroll {{ max-height: 560px; overflow: auto; }} .admin-table-scroll thead th {{ position: sticky; top: 0; z-index: 1; background: var(--rp-surface); }} .table-page-size {{ width: auto; min-width: 76px; }} + .site-footer {{ padding: 24px 0; background: var(--rp-bg); }} + .site-footer .nav-link:hover {{ color: var(--rp-ink) !important; }} table {{ width: 100%; border-collapse: collapse; font-size: 14px; font-variant-numeric: tabular-nums; color: var(--rp-ink); }} th, td {{ border-bottom: 1px solid var(--rp-soft-line); padding: 14px 10px; text-align: left; white-space: nowrap; }} tr:last-child th, tr:last-child td {{ border-bottom: 0; }} @@ -1507,6 +1563,6 @@ def _page(title: str, body: str) -> str: }} - {body} + {body}{_site_footer()} """ diff --git a/tests/test_ddns.py b/tests/test_ddns.py index 8e777e0..c611a79 100644 --- a/tests/test_ddns.py +++ b/tests/test_ddns.py @@ -609,6 +609,27 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None: assert client.get("/app.js").status_code == 200 +def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None: + app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) + client = TestClient(app) + + response = client.get("/") + + assert response.status_code == 200 + assert "Made with ❤️ by Karl" in response.text + assert 'href="https://github.com/StasonJatham/router-dyndns"' in response.text + assert 'href="https://karl.fail"' in response.text + assert 'href="https://karlcom.de"' in response.text + assert 'href="/privacy"' in response.text + + privacy = client.get("/privacy") + assert privacy.status_code == 200 + assert "Legal and privacy" in privacy.text + assert "Data processed" in privacy.text + assert "hashed router passwords" in privacy.text + assert "Made with ❤️ by Karl" in privacy.text + + def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None: app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) client = TestClient(app)