From 0f8f980c87de3ae32d0c544a21ac308da7b11aa1 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 14:08:15 +0200 Subject: [PATCH] Redesign server-rendered UI --- router_dyndns/ddns.py | 798 ++++++++++++++++++++++++++++-------------- 1 file changed, 540 insertions(+), 258 deletions(-) diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index b04c434..be0584d 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -100,7 +100,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: response.headers["Referrer-Policy"] = "same-origin" response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" response.headers["Content-Security-Policy"] = ( - "default-src 'self'; style-src 'unsafe-inline' 'self'; form-action 'self'; frame-ancestors 'none'" + "default-src 'self'; script-src 'unsafe-inline' 'self'; style-src 'unsafe-inline' 'self'; form-action 'self'; frame-ancestors 'none'" ) if settings.public_base_url.startswith("https://"): response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" @@ -154,15 +154,22 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: return PlainTextResponse("911 dns publish failed", status_code=500) @app.get("/", response_class=HTMLResponse) - def index() -> str: - return _render_magic_page(service, settings, None, None) + def index(request: Request) -> str: + user = current_user(request) + if user: + return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, None) + return _render_public_home(service, settings, None, None) + + @app.get("/login", response_class=HTMLResponse) + def login_page() -> str: + return _render_auth_page(settings, None) @app.post("/magic", response_class=HTMLResponse) async def magic_account(request: Request) -> str: form = _parse_form(await request.body()) username = _first_form_value(form, "username") or None account = await run_in_threadpool(service.create_managed_account, username, None) - return _render_magic_page(service, settings, account, None) + return _render_public_home(service, settings, account, None) @app.get("/m/{management_slug}", response_class=HTMLResponse) def manage_magic(management_slug: str) -> str: @@ -184,7 +191,18 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: return HTMLResponse( _page( "DynDNS deleted", - '

Hostname deleted

The update URL no longer works.

', + """ +
+
+
+

Deleted

+

Hostname deleted.

+

The update URL no longer works.

+ Home +
+
+
+ """, ) ) @@ -198,7 +216,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: challenge = await run_in_threadpool(service.create_domain_challenge, _first_form_value(form, "domain"), int(user["id"])) except sqlite3.IntegrityError as exc: raise HTTPException(status_code=409, detail="domain is already verified") from exc - return _render_magic_page(service, settings, None, None, challenge) + return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, challenge) @app.post("/verify-domain", response_class=HTMLResponse) async def verify_domain(request: Request) -> str: @@ -210,8 +228,16 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: claim_secret = _first_form_value(form, "claim_secret") found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, int(user["id"])) if not found: - return _render_magic_page(service, settings, None, "TXT record not found yet. DNS propagation can take a few minutes.", challenge) - return _render_magic_page(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge) + return _render_user_home( + service, + settings, + user, + store.list_accounts(int(user["id"])), + None, + "TXT record not found yet. DNS propagation can take a few minutes.", + challenge, + ) + return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, "Domain verified. You can now create a hostname under it.", challenge) @app.post("/accounts", response_class=HTMLResponse) async def self_service_account(request: Request) -> str: @@ -232,7 +258,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: username, int(user["id"]), ) - return _render_magic_page(service, settings, account, None) + return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), account, None, None) @app.post("/register") async def register(request: Request) -> Response: @@ -241,12 +267,12 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: password = _first_form_value(form, "password") invite = _first_form_value(form, "invite") if not email or len(password) < 12: - return HTMLResponse(_render_public_page(service, settings, None, [], None, "Use a valid email and a password with at least 12 characters.")) + return HTMLResponse(_render_auth_page(settings, "Use a valid email and a password with at least 12 characters.")) try: user = await run_in_threadpool(store.create_user, email, password, invite, settings.require_invite) except (sqlite3.IntegrityError, ValueError): return HTMLResponse( - _render_public_page(service, settings, None, [], None, "Registration failed. Check the invite code and email address."), + _render_auth_page(settings, "Registration failed. Check the invite code and email address."), status_code=400, ) response = RedirectResponse("/", status_code=303) @@ -258,7 +284,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: form = _parse_form(await request.body()) user = await run_in_threadpool(store.authenticate_user, normalize_email(_first_form_value(form, "email")), _first_form_value(form, "password")) if not user: - return HTMLResponse(_render_public_page(service, settings, None, [], None, "Login failed."), status_code=401) + return HTMLResponse(_render_auth_page(settings, "Login failed."), status_code=401) response = RedirectResponse("/", status_code=303) set_session_cookie(response, settings, int(user["id"])) return response @@ -378,93 +404,182 @@ def _render_magic_page( message: str | None, challenge: dict[str, str | None] | None = None, ) -> str: - suffix = html.escape(settings.hostname_suffix or "your DynDNS domain") - created_html = _created_account_panel(service, created_account) - challenge_html = _challenge_panel(service, challenge, message) - message_html = f'

{html.escape(message)}

' if message and not challenge else "" - managed_html = "" - if settings.hostname_suffix: - managed_html = """ -
-

Free DynDNS hostname

- -

No account needed. You will receive a random hostname, a router update URL, and a separate magic management link. Keep the management link private.

-
- """ + return _render_public_home(service, settings, created_account, message, challenge) + +def _render_public_home( + service: DdnsService, + settings: DdnsSettings, + created_account: dict[str, str] | None, + message: str | None, + challenge: dict[str, str | None] | None = None, +) -> str: + suffix = html.escape(settings.hostname_suffix or "your DynDNS domain") + created_html = _credentials_panel(service, created_account) + message_html = _message_band(message) if message and not challenge else "" return _page( - "DynDNS", + "router-dyndns", f""" -
-
-
+
+
+

FRITZ!Box compatible

-

Free DynDNS for {suffix}

-

Generate cryptographically random URLs, paste the update URL into your router, and keep the management link somewhere safe.

+

DynDNS without the account ceremony.

+

Create a secure update endpoint under {suffix}. Provider hostnames are anonymous; custom domains require DNS proof.

+
- Admin
{message_html} {created_html} - {managed_html} - {challenge_html} +
+
+
+

Free DynDNS hostname

+

Create a router update URL

+

Generate a random hostname, paste the update URL into your router, and keep the management link private.

+
+
+ + +
+
+
+
+
+
+

Custom domains

+

Use your own domain after TXT verification.

+

Sign in to prove ownership and create hostnames below verified DNS zones.

+
+ Sign in or register +
+
+
""", ) -def _render_management_page(service: DdnsService, settings: DdnsSettings, management_slug: str, account: dict[str, str | int | None]) -> str: - account_for_url = { - "hostname": str(account["hostname"]), - "username": str(account["username"]), - "password": "", - "update_slug": str(account["update_slug"]), - "management_slug": management_slug, - } - update_url = html.escape(service.fritz_update_url(account_for_url)) - hostname = html.escape(str(account["hostname"])) - username = html.escape(str(account["username"])) - ipv4 = html.escape(str(account.get("ipv4") or "-")) - ipv6 = html.escape(str(account.get("ipv6") or "-")) - updated = html.escape(str(account.get("updated_at") or "Never")) +def _render_user_home( + service: DdnsService, + settings: DdnsSettings, + user: dict[str, str | int], + accounts: list[dict[str, str | int | None]], + created_account: dict[str, str] | None, + message: str | None, + challenge: dict[str, str | None] | None = None, +) -> str: + created_html = _credentials_panel(service, created_account) + message_html = _message_band(message) if message and not challenge else "" + account_rows = "\n".join(_account_row(account, show_owner=False) for account in accounts) or """ + No hostnames yet. + """ return _page( - "DynDNS management", + "router-dyndns dashboard", f""" -
-
-
-

Magic management

-

{hostname}

-

Anyone with this link can manage this hostname. Keep it private.

-
- Home -
-
-

FRITZ!Box fields

-
- - - - +
+ -
-

Current IP

- - - - - - -
IPv4{ipv4}
IPv6{ipv6}
Updated{updated}
-
- -
+
+
+

Dashboard

+

Your router hostnames

+

Create provider hostnames immediately or verify a custom domain with DNS before issuing credentials.

+
+
+ {message_html} + {created_html} +
+
+
+

Provider hostname

+

Create a random hostname

+

Best for simple FRITZ!Box setups. The hostname and update link are generated for you.

+
+
+ + + +
+
+
+ {_custom_domain_flow(service, challenge, message)} +
+
+
+
+

Inventory

+

Hostnames

+
+
+
+ + + {account_rows} +
DomainUserIPv4IPv6Updated
+
+
+
+
+ """, + ) + + +def _render_auth_page(settings: DdnsSettings, message: str | None) -> str: + invite_field = """ + + """ if settings.require_invite else "" + return _page( + "Sign in", + f""" +
+
+
+

Custom domains

+

Sign in to verify domains.

+

Provider-owned hostnames are anonymous. Custom domains need an account so TXT claims and quotas stay scoped to you.

+
+
+ {_message_band(message) if message else ""} +
+
+
+

Login

+
+ + + +
+
+
+

Register

+
+ + + {invite_field} + +
+
+
""", @@ -480,68 +595,27 @@ def _render_public_page( message: str | None, created_account: dict[str, str] | None = None, ) -> str: - suffix = html.escape(settings.hostname_suffix or "your domain") - message_html = f'

{html.escape(message)}

' if message else "" - if not user: - invite_field = """ - - """ if settings.require_invite else "" - return _page( - "DynDNS", - f""" -
-
-
-

FRITZ!Box compatible

-

Managed DynDNS for {suffix}

-

Create a secure update URL for your FRITZ!Box or router. Sign in to generate provider hostnames or verify your own domain with DNS.

-
- Admin -
- {message_html} -
-
-

Login

-
- - - -
-
-
-

Register

-
- - - {invite_field} - -
-
-
-
- """, - ) - return _render_magic_page(service, settings, created_account, message, challenge) + if user: + return _render_user_home(service, settings, user, accounts, created_account, message, challenge) + return _render_auth_page(settings, message) -def _challenge_panel(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> str: +def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> str: challenge_html = "" - claim_secret = "" + create_form = "" if challenge: domain = html.escape(str(challenge["domain"])) - token = html.escape(str(challenge["token"])) + token = str(challenge["token"]) claim_secret = html.escape(str(challenge.get("claim_secret") or "")) - verification_name = html.escape(service.verification_name(str(challenge["domain"]))) + verification_name = service.verification_name(str(challenge["domain"])) status = html.escape(message or "Add this TXT record, then verify.") challenge_html = f""" -
+
+ 2 +

Add TXT record

{status}

-
- - -
+ {_copy_row("TXT name", verification_name)} + {_copy_row("TXT value", token)}
@@ -549,59 +623,191 @@ def _challenge_panel(service: DdnsService, challenge: dict[str, str | None] | No
""" + if challenge.get("verified_at"): + create_form = f""" +
+ 3 +

Create hostname

+
+ + + + + +
+
+ """ + else: + create_form = """ +
+ 3 +

Create hostname

+

Credentials unlock after the TXT record verifies.

+
+ """ return f""" -
-

Custom domain

- - {challenge_html} - -

The hostname must be inside a domain that has already passed TXT verification.

+
+
+
+
+

Custom domain

+

Verify ownership before issuing credentials.

+

The hostname must be inside a DNS zone this service can publish.

+
+
+
+
+ 1 +

Add domain

+
+ + +
+
+ {challenge_html} + {create_form} +
+
+
+ """ + + +def _challenge_panel(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> str: + return _custom_domain_flow(service, challenge, message) + + +def _credentials_panel(service: DdnsService, created_account: dict[str, str] | None) -> str: + if not created_account: + return "" + hostname = created_account["hostname"] + username = created_account["username"] + password = created_account["password"] + update_url = service.fritz_update_url(created_account) + management_url = service.magic_management_url(created_account) + return f""" +
+
+

Account generated

+

{html.escape(hostname)}

+

Save this now. The password and management link are only shown on this screen.

+
+ {_copy_row("Update-URL:", update_url)} + {_copy_row("Domainnamen:", hostname)} + {_copy_row("Benutzername:", username)} + {_copy_row("Kennwort:", password)} + {_copy_row("Magic management link:", management_url)} +
+
""" def _created_account_panel(service: DdnsService, created_account: dict[str, str] | None) -> str: - if not created_account: - return "" - hostname = html.escape(created_account["hostname"]) - username = html.escape(created_account["username"]) - password = html.escape(created_account["password"]) - update_url = html.escape(service.fritz_update_url(created_account)) - management_url = html.escape(service.magic_management_url(created_account)) + return _credentials_panel(service, created_account) + + +def _copy_row(label: str, value: str) -> str: + safe_label = html.escape(label) + safe_value = html.escape(value) return f""" -
-
-

Account generated

-

{hostname}

-
-
- - - - - -
-

Save this now. The password and management link are only shown on this screen. The Update-URL and management link are separate cryptographically random bearer URLs.

+
+ {safe_label} + {safe_value} + +
+ """ + + +def _message_band(message: str | None) -> str: + if not message: + return "" + return f""" +
+

{html.escape(message)}

""" +def _render_management_page(service: DdnsService, settings: DdnsSettings, management_slug: str, account: dict[str, str | int | None]) -> str: + account_for_url = { + "hostname": str(account["hostname"]), + "username": str(account["username"]), + "password": "", + "update_slug": str(account["update_slug"]), + "management_slug": management_slug, + } + update_url = service.fritz_update_url(account_for_url) + hostname = html.escape(str(account["hostname"])) + username = html.escape(str(account["username"])) + ipv4 = html.escape(str(account.get("ipv4") or "-")) + ipv6 = html.escape(str(account.get("ipv6") or "-")) + updated = html.escape(str(account.get("updated_at") or "Never")) + return _page( + "DynDNS management", + f""" +
+
+
+

Magic management

+

{hostname}

+

Anyone with this link can manage or delete this hostname. Keep it private.

+ Home +
+
+
+
+
+

Router settings

+

FRITZ!Box fields

+

Use these values in the custom DynDNS provider fields.

+
+
+ {_copy_row("Update-URL:", update_url)} + {_copy_row("Domainnamen:", hostname)} + {_copy_row("Benutzername:", username)} + {_copy_row("Kennwort:", "unchanged; only shown when generated")} +
+
+
+
+
+
+

Current address

+

Status

+
+ + + + + + +
IPv4{ipv4}
IPv6{ipv6}
Updated{updated}
+
+
+
+
+
+

Danger zone

+

Delete hostname

+

This removes the account and DNS records. The router update URL will stop working.

+
+
+ +
+
+
+
+ """, + ) + + def _render_admin_page( service: DdnsService, settings: DdnsSettings, @@ -624,59 +830,84 @@ def _render_admin_page( return _page( "DynDNS Admin", f""" -
-
-
+
+ +
+

DynDNS aktiv

FRITZ!Box accounts

-
-
- JSON records - Update events +

Create, inspect, and retire router update credentials.

{_created_account_panel(service, created_account)} -
-

Invites

-
- - -
-
- - - {invite_rows} -
CodeCreatedUsed
+
+
+
+

Generate account

+

Router credentials

+

Generated accounts produce the FRITZ!Box fields: Update-URL, Domainnamen, Benutzername, Kennwort.

+

{suffix_help}

+
+
+ + + + +
-
-

Generate account

-

Geben Sie die Anmeldedaten für Ihren DynDNS-Anbieter an. Generated accounts produce the FRITZ!Box fields: Update-URL, Domainnamen, Benutzername, Kennwort.

- -

{suffix_help}

+
+
+
+
+

Invites

+

Registration codes

+
+
+ + +
+
+
+ + + {invite_rows} +
CodeCreatedUsed
+
+
-
-

Accounts

-
- - - - - {rows} -
DomainUserOwnerIPv4IPv6Updated
+
+
+
+
+

Accounts

+

Active credentials

+
+
+
+ + + + + {rows} +
DomainUserOwnerIPv4IPv6Updated
+
@@ -723,61 +954,112 @@ def _page(title: str, body: str) -> str: + {body}