Archived
Refactor UI assets and harden quality checks
This commit is contained in:
1 parent
23ab466c52
commit
4ecc8eb874
62 files changed
+3314
-3206
No files matched your search
+110
-39
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address
|
||||
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
@@ -28,32 +29,77 @@ def _event_label(status: object) -> str:
|
||||
return _EVENT_LABELS.get(value, value.replace("_", " "))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CopyRowParts:
|
||||
label: str
|
||||
value: str
|
||||
safe_label: str
|
||||
safe_value: str
|
||||
attr_label: str
|
||||
attr_value: str
|
||||
is_secret: bool
|
||||
should_mask: bool
|
||||
sensitivity_badge: str
|
||||
|
||||
@property
|
||||
def row_class(self) -> str:
|
||||
return "copy-field copy-field-secret" if self.is_secret else "copy-field copy-field-public"
|
||||
|
||||
@property
|
||||
def value_field(self) -> str:
|
||||
if self.should_mask:
|
||||
return (
|
||||
f'<input class="copy-pill-value copy-value" type="password" value="{self.attr_value}" '
|
||||
"readonly data-secret-input>"
|
||||
)
|
||||
return f'<code class="copy-pill-value copy-value">{self.safe_value}</code>'
|
||||
|
||||
@property
|
||||
def reveal_button(self) -> str:
|
||||
if not self.should_mask:
|
||||
return ""
|
||||
return (
|
||||
f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" '
|
||||
f'data-secret-toggle aria-label="Show {self.attr_label}">View</button>'
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_values(
|
||||
cls, label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True
|
||||
) -> _CopyRowParts:
|
||||
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
|
||||
return cls(
|
||||
label=label,
|
||||
value=value,
|
||||
safe_label=escape(label),
|
||||
safe_value=escape(value),
|
||||
attr_label=attr(label),
|
||||
attr_value=attr(value),
|
||||
is_secret=is_secret,
|
||||
should_mask=is_secret and mask_secret,
|
||||
sensitivity_badge=_copy_row_sensitivity_badge(sensitivity, is_secret),
|
||||
)
|
||||
|
||||
|
||||
def _copy_row_sensitivity_badge(sensitivity: str | None, is_secret: bool) -> str:
|
||||
if not sensitivity:
|
||||
return ""
|
||||
badge_kind = "secret" if is_secret else "public"
|
||||
return f'<span class="ui-badge ui-badge-{badge_kind}">{escape(sensitivity)}</span>'
|
||||
|
||||
|
||||
def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = escape(value)
|
||||
attr_label = attr(label)
|
||||
attr_value = attr(value)
|
||||
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
|
||||
should_mask = is_secret and mask_secret
|
||||
value_field = (
|
||||
f'<input class="copy-pill-value copy-value" type="password" value="{attr_value}" readonly data-secret-input>'
|
||||
if should_mask
|
||||
else f'<code class="copy-pill-value copy-value">{safe_value}</code>'
|
||||
)
|
||||
reveal_button = (
|
||||
f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" data-secret-toggle aria-label="Show {attr_label}">View</button>'
|
||||
if should_mask
|
||||
else ""
|
||||
)
|
||||
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
|
||||
parts = _CopyRowParts.from_values(label, value, sensitivity, mask_secret=mask_secret)
|
||||
return trusted(f"""
|
||||
<div class="{row_class}">
|
||||
<div class="{parts.row_class}">
|
||||
<div class="copy-pill">
|
||||
<span class="copy-pill-label">{safe_label}</span>
|
||||
{value_field}
|
||||
{reveal_button}
|
||||
<span class="copy-pill-label">{parts.safe_label}</span>
|
||||
{parts.sensitivity_badge}
|
||||
{parts.value_field}
|
||||
{parts.reveal_button}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button" data-copy="{attr_value}" aria-label="Copy {attr_label}">
|
||||
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button"
|
||||
data-copy="{parts.attr_value}" aria-label="Copy {parts.attr_label}">
|
||||
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -66,16 +112,22 @@ def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> S
|
||||
safe_style = attr(style)
|
||||
return trusted(
|
||||
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
|
||||
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}'
|
||||
f'<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
|
||||
|
||||
def _copy_icon_button(label: str, value: str) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = attr(value)
|
||||
button_attrs = (
|
||||
f'type="button" class="copy-icon-button" data-copy="{safe_value}" '
|
||||
f'aria-label="Copy {safe_label}" title="Copy"'
|
||||
)
|
||||
return trusted(
|
||||
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
|
||||
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
f"<button {button_attrs}>"
|
||||
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>'
|
||||
f'{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
|
||||
|
||||
@@ -83,9 +135,14 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
buttons = []
|
||||
for item_label, item_value in items:
|
||||
button_attrs = (
|
||||
f'type="button" class="copy-menu-item" data-copy="{attr(item_value)}" '
|
||||
f'aria-label="Copy {attr(item_label)}"'
|
||||
)
|
||||
buttons.append(
|
||||
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
|
||||
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
f"<button {button_attrs}>"
|
||||
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>'
|
||||
f'{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
return trusted(f"""
|
||||
<details class="copy-menu">
|
||||
@@ -106,7 +163,7 @@ def _code_window(
|
||||
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
|
||||
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
|
||||
return trusted(f"""
|
||||
<div class="terminal-card code-window{safe_class}">
|
||||
<div class="terminal-card code-window{safe_class}" data-slot="code-window">
|
||||
{_copy_icon_button(copy_label, copy_value)}
|
||||
{code_lines}
|
||||
</div>
|
||||
@@ -115,10 +172,14 @@ def _code_window(
|
||||
|
||||
def _code_copy_value(commands: str) -> str:
|
||||
lines = commands.splitlines()
|
||||
prompt_lines = [line[2:] for line in lines if line.startswith("$ ")]
|
||||
prompt_lines = [_code_copy_line(line) for line in lines if line.startswith("$ ")]
|
||||
if prompt_lines:
|
||||
return "\n".join(prompt_lines)
|
||||
return "\n".join(line[2:] if line.startswith("$ ") else line for line in lines)
|
||||
return "\n".join(_code_copy_line(line) for line in lines)
|
||||
|
||||
|
||||
def _code_copy_line(line: str) -> str:
|
||||
return line[2:] if line.startswith("$ ") else line
|
||||
|
||||
|
||||
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
|
||||
@@ -164,12 +225,17 @@ def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]:
|
||||
|
||||
def _manual_update_panel(commands: str) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div>
|
||||
<div class="card card-body gap-3 mt-4" data-slot="card">
|
||||
<div data-slot="card-header">
|
||||
<h3>Manual updates</h3>
|
||||
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
|
||||
<p class="section-copy">
|
||||
FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl.
|
||||
If no IP is sent, KarlDNS uses the request IP.
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="card-content">
|
||||
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</div>
|
||||
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</div>
|
||||
""")
|
||||
|
||||
@@ -194,10 +260,14 @@ def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
|
||||
"Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
|
||||
)
|
||||
return trusted(f"""
|
||||
<div class="alert alert-info my-3 py-2 small" role="note">
|
||||
<strong>Public:</strong> Hostname and CNAME target can be shared.
|
||||
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
|
||||
Secrets are hidden on screen. Copy still copies the real value.
|
||||
<div class="alert alert-info ui-alert my-3 py-2 small" role="note" data-slot="alert">
|
||||
<span class="ui-badge ui-badge-public">Public:</span>
|
||||
<span>Hostname and CNAME target can be shared.</span>
|
||||
<span class="ui-badge ui-badge-secret">Secret:</span>
|
||||
<span>
|
||||
{escape(secret_items)} can update or manage this hostname. Secrets are hidden on screen.
|
||||
Copy still copies the real value.
|
||||
</span>
|
||||
</div>
|
||||
""")
|
||||
|
||||
@@ -206,7 +276,8 @@ def _message_band(message: str | None) -> SafeHtml:
|
||||
if not message:
|
||||
return trusted("")
|
||||
return trusted(f"""
|
||||
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
|
||||
<div class="alert alert-warning ui-alert mb-0 rounded-0 border-start-0 border-end-0"
|
||||
role="status" data-slot="alert">
|
||||
<div class="container">{escape(message)}</div>
|
||||
</div>
|
||||
""")
|
||||
@@ -22,7 +22,8 @@ def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
|
||||
safe_class = attr(f"lucide-icon {css_class}")
|
||||
return trusted(
|
||||
f'<svg class="{safe_class}" aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24" '
|
||||
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{path}</svg>'
|
||||
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
||||
f'stroke-linejoin="round">{path}</svg>'
|
||||
)
|
||||
|
||||
|
||||
|
||||
+107
-65
@@ -1,10 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .icons import _svg_icon
|
||||
|
||||
DEFAULT_DESCRIPTION = (
|
||||
"KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, " "Linux cron jobs, and home networks."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageOptions:
|
||||
extra_styles: SafeHtml = field(default_factory=lambda: trusted(""))
|
||||
extra_scripts: SafeHtml = field(default_factory=lambda: trusted(""))
|
||||
description: str = DEFAULT_DESCRIPTION
|
||||
preload_logo: bool = False
|
||||
canonical_url: str | None = None
|
||||
image_url: str | None = None
|
||||
image_alt: str | None = None
|
||||
structured_data: dict[str, object] | None = None
|
||||
|
||||
|
||||
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
|
||||
account_html = ""
|
||||
@@ -22,8 +39,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
|
||||
<div class="container">
|
||||
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
|
||||
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
|
||||
<a class="navbar-brand brand" href="/">
|
||||
<img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS
|
||||
</a>
|
||||
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end
|
||||
gap-2 gap-sm-3">
|
||||
{links}
|
||||
{account_html}
|
||||
{theme_toggle}
|
||||
@@ -68,60 +88,11 @@ def _site_footer() -> SafeHtml:
|
||||
def _page(
|
||||
title: str,
|
||||
body: SafeHtml,
|
||||
extra_styles: SafeHtml | None = None,
|
||||
extra_scripts: SafeHtml | None = None,
|
||||
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
|
||||
preload_logo: bool = False,
|
||||
canonical_url: str | None = None,
|
||||
image_url: str | None = None,
|
||||
image_alt: str | None = None,
|
||||
structured_data: dict[str, object] | None = None,
|
||||
options: PageOptions | None = None,
|
||||
) -> str:
|
||||
if not isinstance(body, SafeHtml):
|
||||
raise TypeError("body must be a SafeHtml fragment built by a template helper")
|
||||
extra_styles_html = extra_styles or trusted("")
|
||||
extra_scripts_html = extra_scripts or trusted("")
|
||||
preload_logo_html = (
|
||||
trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
|
||||
if preload_logo
|
||||
else trusted("")
|
||||
)
|
||||
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
|
||||
social_url = canonical_url or ""
|
||||
social_image = image_url or ""
|
||||
social_html = trusted("")
|
||||
if social_url:
|
||||
social_card = "summary_large_image" if social_image else "summary"
|
||||
social_image_alt = image_alt or title
|
||||
social_image_html = (
|
||||
f"""
|
||||
<meta property="og:image" content="{attr(social_image)}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:alt" content="{attr(social_image_alt)}">
|
||||
<meta name="twitter:image" content="{attr(social_image)}">
|
||||
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
|
||||
"""
|
||||
if social_image
|
||||
else ""
|
||||
)
|
||||
social_html = trusted(f"""
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{attr(title)}">
|
||||
<meta property="og:description" content="{attr(description)}">
|
||||
<meta property="og:url" content="{attr(social_url)}">
|
||||
{social_image_html}
|
||||
<meta name="twitter:card" content="{attr(social_card)}">
|
||||
<meta name="twitter:title" content="{attr(title)}">
|
||||
<meta name="twitter:description" content="{attr(description)}">
|
||||
""")
|
||||
structured_data_html = trusted("")
|
||||
if structured_data:
|
||||
structured_data_html = trusted(
|
||||
'<script type="application/ld+json">'
|
||||
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
|
||||
+ "</script>"
|
||||
)
|
||||
options = options or PageOptions()
|
||||
return f"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -129,25 +100,96 @@ def _page(
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="description" content="{attr(description)}">
|
||||
{canonical_html}
|
||||
{social_html}
|
||||
<meta name="description" content="{attr(options.description)}">
|
||||
{_canonical_tag(options.canonical_url)}
|
||||
{_social_meta_tags(
|
||||
title=title,
|
||||
description=options.description,
|
||||
canonical_url=options.canonical_url,
|
||||
image_url=options.image_url,
|
||||
image_alt=options.image_alt,
|
||||
)}
|
||||
<title>{escape(title)}</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
{preload_logo_html}
|
||||
<script src="/theme.js?v=2"></script>
|
||||
<script src="/clipboard.js?v=3" defer></script>
|
||||
<script src="/visitor-ip.js?v=1" defer></script>
|
||||
{extra_scripts_html}
|
||||
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
||||
<link href="/app.css?v=13" rel="stylesheet">
|
||||
{extra_styles_html}
|
||||
{structured_data_html}
|
||||
{_logo_preload_tag(options.preload_logo)}
|
||||
{_base_script_tags()}
|
||||
{options.extra_scripts}
|
||||
{_base_style_tags()}
|
||||
{options.extra_styles}
|
||||
{_structured_data_tag(options.structured_data)}
|
||||
</head>
|
||||
<body>{body}{_site_footer()}</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _canonical_tag(canonical_url: str | None) -> SafeHtml:
|
||||
return trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
|
||||
|
||||
|
||||
def _logo_preload_tag(preload_logo: bool) -> SafeHtml:
|
||||
if not preload_logo:
|
||||
return trusted("")
|
||||
return trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
|
||||
|
||||
|
||||
def _base_script_tags() -> SafeHtml:
|
||||
return trusted("""
|
||||
<script src="/theme.js?v=2"></script>
|
||||
<script src="/clipboard.js?v=3" defer></script>
|
||||
<script src="/visitor-ip.js?v=1" defer></script>
|
||||
""")
|
||||
|
||||
|
||||
def _base_style_tags() -> SafeHtml:
|
||||
return trusted("""
|
||||
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
||||
<link href="/app.css?v=14" rel="stylesheet">
|
||||
""")
|
||||
|
||||
|
||||
def _social_meta_tags(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
canonical_url: str | None,
|
||||
image_url: str | None,
|
||||
image_alt: str | None,
|
||||
) -> SafeHtml:
|
||||
if not canonical_url:
|
||||
return trusted("")
|
||||
social_card = "summary_large_image" if image_url else "summary"
|
||||
return trusted(f"""
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{attr(title)}">
|
||||
<meta property="og:description" content="{attr(description)}">
|
||||
<meta property="og:url" content="{attr(canonical_url)}">
|
||||
{_social_image_tags(image_url, image_alt or title)}
|
||||
<meta name="twitter:card" content="{attr(social_card)}">
|
||||
<meta name="twitter:title" content="{attr(title)}">
|
||||
<meta name="twitter:description" content="{attr(description)}">
|
||||
""")
|
||||
|
||||
|
||||
def _social_image_tags(image_url: str | None, image_alt: str) -> SafeHtml:
|
||||
if not image_url:
|
||||
return trusted("")
|
||||
return trusted(f"""
|
||||
<meta property="og:image" content="{attr(image_url)}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:alt" content="{attr(image_alt)}">
|
||||
<meta name="twitter:image" content="{attr(image_url)}">
|
||||
<meta name="twitter:image:alt" content="{attr(image_alt)}">
|
||||
""")
|
||||
|
||||
|
||||
def _structured_data_tag(structured_data: dict[str, object] | None) -> SafeHtml:
|
||||
if not structured_data:
|
||||
return trusted("")
|
||||
payload = json.dumps(structured_data, ensure_ascii=False, separators=(",", ":"))
|
||||
return trusted('<script type="application/ld+json">' + escape(payload) + "</script>")
|
||||
+1
-280
@@ -1,282 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
THEME_JS = """
|
||||
(() => {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const storageKey = "karldns-theme";
|
||||
const systemTheme = () => (media.matches ? "dark" : "light");
|
||||
const storedTheme = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const setStoredTheme = (theme) => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, theme);
|
||||
} catch (_) {}
|
||||
};
|
||||
const syncToggle = (theme) => {
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
button.dataset.themeNext = nextTheme;
|
||||
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`);
|
||||
});
|
||||
};
|
||||
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
|
||||
const theme = requestedTheme === "dark" ? "dark" : "light";
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
syncToggle(theme);
|
||||
return theme;
|
||||
};
|
||||
applyTheme();
|
||||
media.addEventListener("change", () => {
|
||||
if (!storedTheme()) applyTheme();
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", () => syncToggle(document.documentElement.getAttribute("data-bs-theme") || "light"));
|
||||
document.addEventListener("click", (event) => {
|
||||
const toggle = event.target.closest("[data-theme-toggle]");
|
||||
if (!toggle) return;
|
||||
const currentTheme = document.documentElement.getAttribute("data-bs-theme") || systemTheme();
|
||||
const nextTheme = currentTheme === "dark" ? "light" : "dark";
|
||||
setStoredTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
});
|
||||
})();
|
||||
""".strip()
|
||||
|
||||
|
||||
CLIPBOARD_JS = """
|
||||
document.addEventListener("click", async (event) => {
|
||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||
if (!menu.contains(event.target)) menu.removeAttribute("open");
|
||||
});
|
||||
|
||||
const toggle = event.target.closest("[data-secret-toggle]");
|
||||
if (toggle) {
|
||||
const group = toggle.closest(".copy-field");
|
||||
const field = group ? group.querySelector("[data-secret-input], [data-secret-value]") : null;
|
||||
if (!field) return;
|
||||
if (field.matches("[data-secret-input]")) {
|
||||
const masked = field.type === "password";
|
||||
field.type = masked ? "text" : "password";
|
||||
toggle.textContent = masked ? "Hide" : "View";
|
||||
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
|
||||
} else {
|
||||
const masked = field.dataset.secretMasked !== "false";
|
||||
field.dataset.secretMasked = masked ? "false" : "true";
|
||||
field.textContent = masked ? (field.dataset.secretValue || "") : "********";
|
||||
toggle.textContent = masked ? "Hide" : "View";
|
||||
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const button = event.target.closest("[data-copy]");
|
||||
if (!button) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(button.dataset.copy || "");
|
||||
const label = button.querySelector("[data-copy-label]");
|
||||
const oldLabel = label ? label.textContent : "";
|
||||
button.classList.add("is-copied");
|
||||
if (label) label.textContent = "Copied";
|
||||
const menu = button.closest("details.copy-menu");
|
||||
if (menu) {
|
||||
setTimeout(() => {
|
||||
menu.removeAttribute("open");
|
||||
}, 250);
|
||||
}
|
||||
setTimeout(() => {
|
||||
button.classList.remove("is-copied");
|
||||
if (label) label.textContent = oldLabel;
|
||||
}, 1200);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||
menu.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
""".strip()
|
||||
|
||||
|
||||
ADMIN_TABLES_JS = """
|
||||
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
||||
const table = tableShell.querySelector("table");
|
||||
const tbody = tableShell.querySelector("tbody");
|
||||
const search = tableShell.querySelector("[data-table-search]");
|
||||
const pageSize = tableShell.querySelector("[data-table-page-size]");
|
||||
const count = tableShell.querySelector("[data-table-count]");
|
||||
const pagination = tableShell.querySelector("[data-table-pagination]");
|
||||
if (!table || !tbody || !search || !pageSize || !count || !pagination) return;
|
||||
|
||||
const headerCount = table.querySelectorAll("thead th").length || 1;
|
||||
const originalRows = Array.from(tbody.querySelectorAll("tr"));
|
||||
const dataRows = originalRows.filter((row) => !row.classList.contains("empty-row"));
|
||||
let currentPage = 1;
|
||||
|
||||
const emptyRow = (message) => {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "empty-row";
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "empty";
|
||||
cell.colSpan = headerCount;
|
||||
cell.textContent = message;
|
||||
row.append(cell);
|
||||
return row;
|
||||
};
|
||||
|
||||
const visibleRows = () => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
if (!query) return dataRows;
|
||||
return dataRows.filter((row) => row.textContent.toLowerCase().includes(query));
|
||||
};
|
||||
|
||||
const renderButton = (label, page, disabled = false, active = false) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = `page-item${disabled ? " disabled" : ""}${active ? " active" : ""}`;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "page-link";
|
||||
button.textContent = label;
|
||||
button.disabled = disabled;
|
||||
button.addEventListener("click", () => {
|
||||
currentPage = page;
|
||||
render();
|
||||
});
|
||||
item.append(button);
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderPagination = (totalPages) => {
|
||||
pagination.replaceChildren();
|
||||
if (totalPages <= 1) return;
|
||||
pagination.append(renderButton("Previous", Math.max(1, currentPage - 1), currentPage === 1));
|
||||
for (let page = 1; page <= totalPages; page += 1) {
|
||||
if (totalPages > 7 && page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) {
|
||||
if (page === 2 || page === totalPages - 1) pagination.append(renderButton("...", currentPage, true));
|
||||
continue;
|
||||
}
|
||||
pagination.append(renderButton(String(page), page, false, page === currentPage));
|
||||
}
|
||||
pagination.append(renderButton("Next", Math.min(totalPages, currentPage + 1), currentPage === totalPages));
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const rows = visibleRows();
|
||||
const size = Number.parseInt(pageSize.value, 10) || 25;
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / size));
|
||||
currentPage = Math.min(currentPage, totalPages);
|
||||
const start = (currentPage - 1) * size;
|
||||
const pageRows = rows.slice(start, start + size);
|
||||
tbody.replaceChildren(...(pageRows.length ? pageRows : [emptyRow(dataRows.length ? "No matching rows." : "No rows yet.")]));
|
||||
const shownStart = rows.length ? start + 1 : 0;
|
||||
const shownEnd = rows.length ? Math.min(start + size, rows.length) : 0;
|
||||
count.textContent = `${shownStart}-${shownEnd} of ${rows.length}`;
|
||||
renderPagination(totalPages);
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
currentPage = 1;
|
||||
render();
|
||||
});
|
||||
pageSize.addEventListener("change", () => {
|
||||
currentPage = 1;
|
||||
render();
|
||||
});
|
||||
render();
|
||||
});
|
||||
""".strip()
|
||||
|
||||
|
||||
VISITOR_IP_JS = """
|
||||
(() => {
|
||||
const ipv4Placeholder = "203.0.113.10";
|
||||
const ipv6Placeholder = "2001:db8::10";
|
||||
const needsIpv4 = () => document.body.textContent.includes(ipv4Placeholder) || document.querySelector(`[data-copy*="${ipv4Placeholder}"]`);
|
||||
const needsIpv6 = () => document.body.textContent.includes(ipv6Placeholder) || document.querySelector(`[data-copy*="${ipv6Placeholder}"]`);
|
||||
|
||||
const fetchIp = async (url) => {
|
||||
try {
|
||||
const response = await fetch(url, { cache: "no-store", mode: "cors" });
|
||||
if (!response.ok) return "";
|
||||
const payload = await response.json();
|
||||
return typeof payload.ip === "string" ? payload.ip : "";
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const replaceInTextNodes = (search, replacement) => {
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
const nodes = [];
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.nodeValue.includes(search)) nodes.push(walker.currentNode);
|
||||
}
|
||||
nodes.forEach((node) => {
|
||||
node.nodeValue = node.nodeValue.split(search).join(replacement);
|
||||
});
|
||||
};
|
||||
|
||||
const replaceInCopyValues = (search, replacement) => {
|
||||
document.querySelectorAll("[data-copy]").forEach((element) => {
|
||||
const value = element.dataset.copy || "";
|
||||
if (value.includes(search)) element.dataset.copy = value.split(search).join(replacement);
|
||||
});
|
||||
};
|
||||
|
||||
const replaceEverywhere = (search, replacement) => {
|
||||
if (!replacement || replacement === search) return;
|
||||
replaceInTextNodes(search, replacement);
|
||||
replaceInCopyValues(search, replacement);
|
||||
};
|
||||
|
||||
const updateCopyCommand = (button, ipv6Command) => {
|
||||
const value = button.dataset.copy || "";
|
||||
if (!value || value.includes("?myipv6=")) return;
|
||||
const lines = value.split("\\n");
|
||||
const output = [];
|
||||
let inserted = false;
|
||||
lines.forEach((line) => {
|
||||
output.push(line);
|
||||
if (!inserted && line.includes("/api/v1/router/update/") && line.includes("?myip=")) {
|
||||
output.push(ipv6Command);
|
||||
inserted = true;
|
||||
}
|
||||
});
|
||||
if (inserted) button.dataset.copy = output.join("\\n");
|
||||
};
|
||||
|
||||
const insertIpv6Examples = (ip) => {
|
||||
if (!ip) return;
|
||||
document.querySelectorAll(".terminal-card.manual-code").forEach((card) => {
|
||||
if (card.textContent.includes("?myipv6=")) return;
|
||||
const lines = [...card.querySelectorAll("code")];
|
||||
const ipv4Line = lines.find((line) => line.textContent.includes("/api/v1/router/update/") && line.textContent.includes("?myip="));
|
||||
if (!ipv4Line) return;
|
||||
const base = ipv4Line.textContent.split("?myip=", 1)[0].replace(/'$/, "");
|
||||
const ipv6Command = `${base}?myipv6=${ip}'`;
|
||||
const code = document.createElement("code");
|
||||
code.textContent = ipv6Command;
|
||||
ipv4Line.after(code);
|
||||
const copyButton = card.querySelector("[data-copy]");
|
||||
if (copyButton) updateCopyCommand(copyButton, ipv6Command);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const tasks = [];
|
||||
if (needsIpv4()) tasks.push(fetchIp("https://api4.ipify.org?format=json").then((ip) => replaceEverywhere(ipv4Placeholder, ip)));
|
||||
if (needsIpv6() || document.querySelector(".terminal-card.manual-code")) {
|
||||
tasks.push(fetchIp("https://api6.ipify.org?format=json").then((ip) => {
|
||||
replaceEverywhere(ipv6Placeholder, ip);
|
||||
insertIpv6Examples(ip);
|
||||
}));
|
||||
}
|
||||
await Promise.allSettled(tasks);
|
||||
});
|
||||
})();
|
||||
""".strip()
|
||||
"""JavaScript is served from package assets by karldns.routers.assets."""
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..components import _code_window
|
||||
from ..html_utils import SafeHtml, escape, trusted
|
||||
|
||||
|
||||
def _public_home_hero(*, suffix: str, demo_update_url: str) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="hero-band">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80"
|
||||
decoding="async" fetchpriority="high">
|
||||
<h1>Dynamic DNS for home routers.</h1>
|
||||
<div class="install-pill mx-auto" data-ui="hero-command">
|
||||
<code>{escape(demo_update_url)}</code>
|
||||
</div>
|
||||
<p class="lead">
|
||||
KarlDNS gives you a generated DynDNS hostname under {escape(suffix)}.
|
||||
Add the private update URL to your router, or use it from a script.
|
||||
</p>
|
||||
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
|
||||
<form method="post" action="/" class="hero-action-form"
|
||||
aria-label="Create a generated DynDNS hostname">
|
||||
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
|
||||
</form>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _setup_flow_section(*, create_demo: str, create_demo_command: str) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow text-center">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>No account needed.</h2>
|
||||
<p class="section-copy mx-auto">Create a hostname, save the private values, register it, done.</p>
|
||||
<ol class="flow-steps" aria-label="KarlDNS setup flow">
|
||||
<li><strong>Create</strong><span>Random hostname and private links.</span></li>
|
||||
<li><strong>Save</strong><span>Copy the secrets while they are shown.</span></li>
|
||||
<li><strong>Register</strong><span>Activate the hostname when you are ready.</span></li>
|
||||
<li><strong>Update</strong><span>Router, cron, or CNAME can use it.</span></li>
|
||||
</ol>
|
||||
{_code_window(
|
||||
create_demo,
|
||||
copy_label="update example",
|
||||
class_name="mt-4",
|
||||
copy_value=create_demo_command,
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _manual_updates_section(*, manual_demo: str) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="section" id="manual-updates">
|
||||
<div class="container narrow text-center">
|
||||
<h2>Update from a router or script.</h2>
|
||||
<p class="lead mx-auto mt-3">
|
||||
Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense,
|
||||
OPNsense, UniFi, MikroTik, and custom tools.
|
||||
</p>
|
||||
{_code_window(manual_demo, copy_label="manual update examples", class_name="manual-code text-start mt-4")}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
@@ -2,10 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
|
||||
@@ -13,6 +15,12 @@ from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
|
||||
ACCESS_LOGGER = logging.getLogger("karldns.access")
|
||||
SECRET_RESPONSE_PREFIXES = (
|
||||
"/m/",
|
||||
"/api/v1/hostnames",
|
||||
"/api/v1/router/",
|
||||
"/api/v1/admin/",
|
||||
)
|
||||
|
||||
|
||||
class RequestBodyTooLargeError(Exception):
|
||||
@@ -49,7 +57,7 @@ class RequestBodyLimitMiddleware:
|
||||
|
||||
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
|
||||
@app.middleware("http")
|
||||
async def security_middleware(request: Request, call_next):
|
||||
async def security_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
content_length = int(request.headers.get("content-length", "0") or "0")
|
||||
@@ -102,14 +110,12 @@ def is_admin_path(path: str, settings: DdnsSettings) -> bool:
|
||||
|
||||
def is_secret_response(request: Request) -> bool:
|
||||
path = request.url.path
|
||||
settings = request.app.state.settings
|
||||
if is_admin_path(path, settings) or path.startswith("/m/"):
|
||||
return True
|
||||
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
|
||||
return True
|
||||
if path.startswith("/api/v1/admin/"):
|
||||
return True
|
||||
return request.method == "POST" and path == "/"
|
||||
return (
|
||||
is_admin_path(path, request.app.state.settings)
|
||||
or path.startswith(SECRET_RESPONSE_PREFIXES)
|
||||
or request.method == "POST"
|
||||
and path == "/"
|
||||
)
|
||||
|
||||
|
||||
def is_noindex_response(request: Request) -> bool:
|
||||
|
||||
+1
-1718
File diff suppressed because it is too large.
Load diff
+270
-114
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ..security import admin_csrf_token
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .layout import PageOptions, _page, _top_nav
|
||||
from .templates_admin_tables import (
|
||||
_account_row,
|
||||
_admin_table_pagination,
|
||||
@@ -17,6 +18,52 @@ from .templates_admin_tables import (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CleanupSummary:
|
||||
last_run: object
|
||||
next_run: object
|
||||
status: object
|
||||
last_error: object
|
||||
interval_seconds: object
|
||||
accounts_removed: object
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminPageContext:
|
||||
accounts: Sequence[Mapping[str, object]]
|
||||
cleanup_runs: Sequence[Mapping[str, object]]
|
||||
scheduled_tasks: Sequence[Mapping[str, object]]
|
||||
update_events: Sequence[Mapping[str, object]]
|
||||
service_metrics: dict[str, float]
|
||||
table_counts: dict[str, int]
|
||||
table_offsets: dict[str, int]
|
||||
table_limit: int
|
||||
notice_html: SafeHtml | None
|
||||
|
||||
|
||||
def _admin_page_context(
|
||||
store: DdnsStore,
|
||||
table_offsets: dict[str, int],
|
||||
table_limit: int,
|
||||
notice_html: SafeHtml | None,
|
||||
) -> AdminPageContext:
|
||||
return AdminPageContext(
|
||||
accounts=store.list_accounts(limit=table_limit, offset=table_offsets.get("accounts", 0)),
|
||||
cleanup_runs=store.list_cleanup_runs(limit=table_limit, offset=table_offsets.get("cleanup_runs", 0)),
|
||||
scheduled_tasks=store.list_scheduled_tasks(),
|
||||
update_events=store.list_update_events(table_limit, offset=table_offsets.get("update_events", 0)),
|
||||
service_metrics=store.get_service_metrics(),
|
||||
table_counts={
|
||||
"accounts": store.count_accounts(),
|
||||
"cleanup_runs": store.count_cleanup_runs(),
|
||||
"update_events": store.count_update_events(),
|
||||
},
|
||||
table_offsets=table_offsets,
|
||||
table_limit=table_limit,
|
||||
notice_html=notice_html,
|
||||
)
|
||||
|
||||
|
||||
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="section success-section">
|
||||
@@ -33,116 +80,193 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
||||
|
||||
|
||||
def _render_admin_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
accounts: Sequence[Mapping[str, object]],
|
||||
cleanup_runs: Sequence[Mapping[str, object]],
|
||||
scheduled_tasks: Sequence[Mapping[str, object]],
|
||||
update_events: Sequence[Mapping[str, object]],
|
||||
service_metrics: dict[str, float],
|
||||
table_counts: dict[str, int],
|
||||
table_offsets: dict[str, int],
|
||||
table_limit: int,
|
||||
notice_html: SafeHtml | None,
|
||||
context: AdminPageContext,
|
||||
) -> str:
|
||||
csrf = attr(admin_csrf_token(settings))
|
||||
admin_path = settings.admin_path
|
||||
safe_admin_path = attr(admin_path)
|
||||
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
||||
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
|
||||
"""
|
||||
event_rows = "\n".join(_event_row(event) for event in update_events) or """
|
||||
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
|
||||
"""
|
||||
accounts_count = escape(table_counts.get("accounts", len(accounts)))
|
||||
event_count = escape(table_counts.get("update_events", len(update_events)))
|
||||
metrics_html = _metrics_panel(service_metrics)
|
||||
rows = _account_rows(context.accounts, csrf=csrf, admin_path=admin_path)
|
||||
event_rows = _event_rows(context.update_events)
|
||||
accounts_count = escape(context.table_counts.get("accounts", len(context.accounts)))
|
||||
event_count = escape(context.table_counts.get("update_events", len(context.update_events)))
|
||||
metrics_html = _metrics_panel(context.service_metrics)
|
||||
account_pager = _server_page_links(
|
||||
admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0)
|
||||
admin_path,
|
||||
"accounts_offset",
|
||||
context.table_offsets.get("accounts", 0),
|
||||
context.table_limit,
|
||||
context.table_counts.get("accounts", 0),
|
||||
)
|
||||
event_pager = _server_page_links(
|
||||
admin_path,
|
||||
"update_events_offset",
|
||||
table_offsets.get("update_events", 0),
|
||||
table_limit,
|
||||
table_counts.get("update_events", 0),
|
||||
context.table_offsets.get("update_events", 0),
|
||||
context.table_limit,
|
||||
context.table_counts.get("update_events", 0),
|
||||
)
|
||||
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
|
||||
totals_html = _totals_strip(table_counts, service_metrics)
|
||||
cleanup_panel = _cleanup_panel(settings, context.cleanup_runs, context.scheduled_tasks, safe_admin_path, csrf)
|
||||
totals_html = _totals_strip(context.table_counts, context.service_metrics)
|
||||
title_section = _admin_title_section(totals_html)
|
||||
status_section = _admin_status_section(cleanup_panel, metrics_html)
|
||||
hostnames_section = _admin_hostnames_section(context, rows, accounts_count, account_pager)
|
||||
events_section = _admin_events_section(context, event_rows, event_count, event_pager)
|
||||
return _page(
|
||||
"KarlDNS Admin",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
{_top_nav("Operator")}
|
||||
<section class="admin-console">
|
||||
<div class="container">
|
||||
<div class="admin-page-title">
|
||||
<div>
|
||||
<p class="eyebrow">KarlDNS</p>
|
||||
<h1>Operator console</h1>
|
||||
</div>
|
||||
{totals_html}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice_html or ""}
|
||||
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
{cleanup_panel}
|
||||
{metrics_html}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<h2>Hostnames</h2>
|
||||
<p class="section-copy mb-0">{escape(len(accounts))} of {accounts_count} shown.</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("hostnames", "Search hostnames")}
|
||||
<div class="table-responsive border rounded admin-table-scroll">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{account_pager}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<h2>Update events</h2>
|
||||
<p class="section-copy mb-0">{escape(len(update_events))} of {event_count} shown. Unchanged checks are grouped.</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("update-events", "Search update events")}
|
||||
<div class="table-responsive border rounded admin-table-scroll">
|
||||
<table class="table mb-0">
|
||||
<thead><tr><th scope="col">Time</th><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
|
||||
<tbody>{event_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{event_pager}
|
||||
</div>
|
||||
</section>
|
||||
{title_section}
|
||||
{context.notice_html or ""}
|
||||
{status_section}
|
||||
{hostnames_section}
|
||||
{events_section}
|
||||
</main>
|
||||
"""),
|
||||
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
||||
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
|
||||
PageOptions(
|
||||
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
||||
extra_scripts=trusted('<script src="/admin-tables.js?v=3" defer></script>'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _admin_title_section(totals_html: SafeHtml) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="admin-console">
|
||||
<div class="container">
|
||||
<div class="admin-page-title">
|
||||
<div>
|
||||
<p class="eyebrow">KarlDNS</p>
|
||||
<h1>Operator console</h1>
|
||||
</div>
|
||||
{totals_html}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _admin_status_section(cleanup_panel: SafeHtml, metrics_html: SafeHtml) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
{cleanup_panel}
|
||||
{metrics_html}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _admin_hostnames_section(
|
||||
context: AdminPageContext,
|
||||
rows: SafeHtml,
|
||||
accounts_count: str,
|
||||
account_pager: SafeHtml,
|
||||
) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<h2>Hostnames</h2>
|
||||
<p class="section-copy mb-0">{escape(len(context.accounts))} of {accounts_count} shown.</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("hostnames", "Filter visible hostnames")}
|
||||
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Hostname</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">IPv4</th>
|
||||
<th scope="col">IPv6</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Links</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{account_pager}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _admin_events_section(
|
||||
context: AdminPageContext,
|
||||
event_rows: SafeHtml,
|
||||
event_count: str,
|
||||
event_pager: SafeHtml,
|
||||
) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<h2>Update events</h2>
|
||||
<p class="section-copy mb-0">
|
||||
{escape(len(context.update_events))} of {event_count} shown. Unchanged checks are grouped.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("update-events", "Filter visible update events")}
|
||||
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Hostname</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">IPv4</th>
|
||||
<th scope="col">IPv6</th>
|
||||
<th scope="col">Count</th>
|
||||
<th scope="col">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{event_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{event_pager}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _account_rows(accounts: Sequence[Mapping[str, object]], *, csrf: str, admin_path: str) -> SafeHtml:
|
||||
return trusted("\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
||||
<tr class="empty-row">
|
||||
<td colspan="7" class="empty">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">No hostnames yet.</div>
|
||||
<div class="empty-state-description">
|
||||
Generated hostnames appear here after users create or register them.
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
|
||||
def _event_rows(events: Sequence[Mapping[str, object]]) -> SafeHtml:
|
||||
return trusted("\n".join(_event_row(event) for event in events) or """
|
||||
<tr class="empty-row">
|
||||
<td colspan="7" class="empty">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">No update events yet.</div>
|
||||
<div class="empty-state-description">
|
||||
Router update activity and DNS publish results will appear here.
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
|
||||
def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> SafeHtml:
|
||||
items = [
|
||||
("Hostnames", table_counts.get("accounts", 0)),
|
||||
@@ -161,7 +285,7 @@ def _admin_notice(kind: str, message: str) -> SafeHtml:
|
||||
alert_class = "alert-success" if kind == "success" else "alert-info"
|
||||
return trusted(f"""
|
||||
<div class="container">
|
||||
<div class="alert {alert_class} admin-notice" role="status">{escape(message)}</div>
|
||||
<div class="alert {alert_class} ui-alert admin-notice" role="status" data-slot="alert">{escape(message)}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
@@ -180,8 +304,10 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
|
||||
return trusted(f"""
|
||||
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" aria-label="Server-side table pages">
|
||||
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
|
||||
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous</a>
|
||||
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a>
|
||||
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}"
|
||||
aria-disabled="{str(offset <= 0).lower()}">Previous</a>
|
||||
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}"
|
||||
aria-disabled="{str(next_offset >= total).lower()}">Next</a>
|
||||
</nav>
|
||||
""")
|
||||
|
||||
@@ -193,37 +319,31 @@ def _cleanup_panel(
|
||||
safe_admin_path: str,
|
||||
csrf: str,
|
||||
) -> SafeHtml:
|
||||
task = next(
|
||||
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
|
||||
scheduled_tasks[0] if scheduled_tasks else {},
|
||||
summary = _cleanup_summary(
|
||||
cleanup_runs=cleanup_runs,
|
||||
scheduled_tasks=scheduled_tasks,
|
||||
fallback_interval_seconds=settings.cleanup_interval_seconds,
|
||||
)
|
||||
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
|
||||
next_run = task.get("next_run_at")
|
||||
status = task.get("last_status") or "pending"
|
||||
last_error = task.get("last_error") or ""
|
||||
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
|
||||
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
||||
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
|
||||
error_html = (
|
||||
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
|
||||
if last_error
|
||||
trusted(f'<p class="small text-danger mb-0">Last error: {escape(summary.last_error)}</p>')
|
||||
if summary.last_error
|
||||
else trusted("")
|
||||
)
|
||||
last_run_title = attr(str(last_run or "Never"))
|
||||
next_run_title = attr(str(next_run or "Pending"))
|
||||
last_run_display = escape(_format_timestamp(last_run, "Never"))
|
||||
next_run_display = escape(_format_timestamp(next_run, "Pending"))
|
||||
last_run_title = attr(str(summary.last_run or "Never"))
|
||||
next_run_title = attr(str(summary.next_run or "Pending"))
|
||||
last_run_display = escape(_format_timestamp(summary.last_run, "Never"))
|
||||
next_run_display = escape(_format_timestamp(summary.next_run, "Pending"))
|
||||
return trusted(f"""
|
||||
<div class="admin-cleanup-bar">
|
||||
<div class="admin-cleanup-title">
|
||||
<h2>Cleanup</h2>
|
||||
<span>Every {escape(_format_interval(interval))}</span>
|
||||
<span>Every {escape(_format_interval(summary.interval_seconds))}</span>
|
||||
</div>
|
||||
<dl class="admin-cleanup-times">
|
||||
<div><dt>Last run</dt><dd title="{last_run_title}">{last_run_display}</dd></div>
|
||||
<div><dt>Next run</dt><dd title="{next_run_title}">{next_run_display}</dd></div>
|
||||
<div><dt>Status</dt><dd>{escape(str(status))}</dd></div>
|
||||
<div><dt>Removed</dt><dd>{escape(accounts_removed)}</dd></div>
|
||||
<div><dt>Status</dt><dd>{escape(str(summary.status))}</dd></div>
|
||||
<div><dt>Removed</dt><dd>{escape(summary.accounts_removed)}</dd></div>
|
||||
</dl>
|
||||
{error_html}
|
||||
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
||||
@@ -234,6 +354,39 @@ def _cleanup_panel(
|
||||
""")
|
||||
|
||||
|
||||
def _cleanup_task(scheduled_tasks: Sequence[Mapping[str, object]]) -> Mapping[str, object]:
|
||||
return next(
|
||||
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
|
||||
scheduled_tasks[0] if scheduled_tasks else {},
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_summary(
|
||||
*,
|
||||
cleanup_runs: Sequence[Mapping[str, object]],
|
||||
scheduled_tasks: Sequence[Mapping[str, object]],
|
||||
fallback_interval_seconds: int,
|
||||
) -> _CleanupSummary:
|
||||
task = _cleanup_task(scheduled_tasks)
|
||||
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
||||
return _CleanupSummary(
|
||||
last_run=_cleanup_last_run(task, last_cleanup),
|
||||
next_run=task.get("next_run_at"),
|
||||
status=task.get("last_status") or "pending",
|
||||
last_error=task.get("last_error") or "",
|
||||
interval_seconds=task.get("interval_seconds") or fallback_interval_seconds,
|
||||
accounts_removed=_cleanup_accounts_removed(last_cleanup),
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_last_run(task: Mapping[str, object], last_cleanup: Mapping[str, object]) -> object:
|
||||
return task.get("last_finished_at") or last_cleanup.get("created_at")
|
||||
|
||||
|
||||
def _cleanup_accounts_removed(last_cleanup: Mapping[str, object]) -> object:
|
||||
return last_cleanup.get("unused_accounts_deleted", 0)
|
||||
|
||||
|
||||
def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||
samples = metrics.get("update_latency_samples_total", 0)
|
||||
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
|
||||
@@ -247,7 +400,10 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
|
||||
(
|
||||
"Cleanup ok/errors",
|
||||
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}",
|
||||
(
|
||||
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/"
|
||||
f"{_metric_int(metrics.get('cleanup_errors_total', 0))}"
|
||||
),
|
||||
),
|
||||
]
|
||||
cards = "\n".join(f"""
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .components import _event_label
|
||||
from .html_utils import attr, escape
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AdminEventRow:
|
||||
created_raw: str
|
||||
hostname_raw: str
|
||||
detail_raw: str
|
||||
created: str
|
||||
hostname: str
|
||||
status: str
|
||||
ipv4: str
|
||||
ipv6: str
|
||||
count: str
|
||||
detail: str
|
||||
|
||||
|
||||
def _admin_event_row_view(event: Mapping[str, object]) -> _AdminEventRow:
|
||||
created_raw = _event_text(event, "created_at")
|
||||
hostname_raw = _event_text(event, "hostname")
|
||||
detail_raw = _event_text(event, "detail")
|
||||
return _AdminEventRow(
|
||||
created_raw=created_raw,
|
||||
hostname_raw=hostname_raw,
|
||||
detail_raw=detail_raw,
|
||||
created=escape(_short_time(created_raw)),
|
||||
hostname=escape(hostname_raw),
|
||||
status=escape(_event_label(event.get("status"))),
|
||||
ipv4=escape(_event_text(event, "ipv4")),
|
||||
ipv6=escape(_event_text(event, "ipv6")),
|
||||
count=escape(event.get("count") or 1),
|
||||
detail=escape(detail_raw),
|
||||
)
|
||||
|
||||
|
||||
def _event_text(event: Mapping[str, object], key: str) -> str:
|
||||
return str(event.get(key) or "-")
|
||||
|
||||
|
||||
def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
|
||||
raw_hostname = str(account["hostname"])
|
||||
hostname = escape(raw_hostname)
|
||||
@@ -30,15 +67,18 @@ def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str
|
||||
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="admin-action-group">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname_attr}">
|
||||
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill" title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
|
||||
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill" title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
|
||||
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill"
|
||||
title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
|
||||
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill"
|
||||
title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="cell-delete">
|
||||
<form method="post" action="{safe_admin_path}/hostnames/delete">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname_attr}">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname" aria-label="Delete {hostname_attr}">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname"
|
||||
aria-label="Delete {hostname_attr}">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -53,7 +93,8 @@ def _admin_table_toolbar(table_id: str, search_label: str) -> str:
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row mb-3">
|
||||
<label class="w-100 mb-0" for="{safe_id}-search">
|
||||
<span class="visually-hidden">{safe_label}</span>
|
||||
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}" data-table-search autocomplete="off">
|
||||
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}"
|
||||
data-table-search autocomplete="off">
|
||||
</label>
|
||||
<div class="d-flex align-items-center gap-2 flex-shrink-0">
|
||||
<span class="small text-secondary text-nowrap" data-table-count>0-0 of 0</span>
|
||||
@@ -79,21 +120,13 @@ def _admin_table_pagination() -> str:
|
||||
|
||||
|
||||
def _event_row(event: Mapping[str, object]) -> str:
|
||||
created_raw = str(event.get("created_at") or "-")
|
||||
hostname_raw = str(event.get("hostname") or "-")
|
||||
detail_raw = str(event.get("detail") or "-")
|
||||
created = escape(_short_time(created_raw))
|
||||
hostname = escape(hostname_raw)
|
||||
status = escape(_event_label(event.get("status")))
|
||||
ipv4 = escape(event.get("ipv4") or "-")
|
||||
ipv6 = escape(event.get("ipv6") or "-")
|
||||
count = escape(event.get("count") or 1)
|
||||
detail = escape(detail_raw)
|
||||
row = _admin_event_row_view(event)
|
||||
return (
|
||||
f'<tr><td class="cell-time" title="{attr(created_raw)}">{created}</td>'
|
||||
f'<td class="cell-hostname" title="{attr(hostname_raw)}">{hostname}</td>'
|
||||
f'<td class="cell-status">{status}</td><td class="cell-ip">{ipv4}</td><td class="cell-ip">{ipv6}</td>'
|
||||
f'<td class="cell-count">{count}</td><td class="cell-detail" title="{attr(detail_raw)}">{detail}</td></tr>'
|
||||
f'<tr><td class="cell-time" title="{attr(row.created_raw)}">{row.created}</td>'
|
||||
f'<td class="cell-hostname" title="{attr(row.hostname_raw)}">{row.hostname}</td>'
|
||||
f'<td class="cell-status">{row.status}</td><td class="cell-ip">{row.ipv4}</td>'
|
||||
f'<td class="cell-ip">{row.ipv6}</td><td class="cell-count">{row.count}</td>'
|
||||
f'<td class="cell-detail" title="{attr(row.detail_raw)}">{row.detail}</td></tr>'
|
||||
)
|
||||
|
||||
|
||||
|
||||
+402
-209
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .components import (
|
||||
_code_window,
|
||||
_copy_button,
|
||||
@@ -14,229 +15,383 @@ from .components import (
|
||||
_secret_notice,
|
||||
)
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .layout import PageOptions, _page, _top_nav
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..settings import DdnsSettings
|
||||
|
||||
|
||||
def _render_management_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: Mapping[str, object],
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
request_ip: str | None = None,
|
||||
) -> str:
|
||||
account_for_url = {
|
||||
"hostname": str(account["hostname"]),
|
||||
"update_slug": str(account["update_slug"]),
|
||||
"management_slug": management_slug,
|
||||
}
|
||||
update_url = service.fritz_update_url(account_for_url)
|
||||
hostname = escape(account["hostname"])
|
||||
ipv4 = escape(account.get("ipv4") or "-")
|
||||
ipv6 = escape(account.get("ipv6") or "-")
|
||||
updated = escape(account.get("updated_at") or "Never")
|
||||
fritzbox_fields = _fritzbox_fields(update_url, hostname)
|
||||
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
|
||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
||||
history_rows = (
|
||||
"\n".join(
|
||||
_user_event_row(event)
|
||||
for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)
|
||||
)
|
||||
or """
|
||||
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
|
||||
"""
|
||||
@dataclass(frozen=True)
|
||||
class ManagementPageContext:
|
||||
management_slug: str
|
||||
account: Mapping[str, object]
|
||||
csrf_token: str
|
||||
message: str | None = None
|
||||
request_ip: str | None = None
|
||||
|
||||
@property
|
||||
def account_for_url(self) -> dict[str, str]:
|
||||
return {
|
||||
"hostname": str(self.account["hostname"]),
|
||||
"update_slug": str(self.account["update_slug"]),
|
||||
"management_slug": self.management_slug,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ManagementPageView:
|
||||
update_url: str
|
||||
hostname: str
|
||||
ipv4: str
|
||||
ipv6: str
|
||||
updated: str
|
||||
fritzbox_fields: str
|
||||
cname_record: str
|
||||
manual_commands: str
|
||||
|
||||
|
||||
def _management_page_view(update_url: str, context: ManagementPageContext) -> _ManagementPageView:
|
||||
hostname = escape(context.account["hostname"])
|
||||
return _ManagementPageView(
|
||||
update_url=update_url,
|
||||
hostname=hostname,
|
||||
ipv4=escape(context.account.get("ipv4") or "-"),
|
||||
ipv6=escape(context.account.get("ipv6") or "-"),
|
||||
updated=escape(context.account.get("updated_at") or "Never"),
|
||||
fritzbox_fields=_fritzbox_fields(update_url, hostname),
|
||||
cname_record=f"your-subdomain.example.com. CNAME {hostname}.",
|
||||
manual_commands=_manual_update_commands(update_url, context.request_ip),
|
||||
)
|
||||
message_html = _management_message(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingManagementPageContext:
|
||||
settings: DdnsSettings
|
||||
management_slug: str
|
||||
account: Mapping[str, object]
|
||||
csrf_token: str
|
||||
message: str | None = None
|
||||
request_ip: str | None = None
|
||||
|
||||
@property
|
||||
def account_for_url(self) -> dict[str, str]:
|
||||
return {
|
||||
"hostname": str(self.account["hostname"]),
|
||||
"update_slug": str(self.account["update_slug"]),
|
||||
"management_slug": self.management_slug,
|
||||
}
|
||||
|
||||
|
||||
def _render_management_page(service: DdnsService, context: ManagementPageContext) -> str:
|
||||
update_url = service.fritz_update_url(context.account_for_url)
|
||||
view = _management_page_view(update_url, context)
|
||||
message_html = _management_message(context.message)
|
||||
dashboard_password_panel = _dashboard_password_panel(
|
||||
management_slug, csrf_token, bool(account.get("dashboard_password_hash"))
|
||||
context.management_slug,
|
||||
context.csrf_token,
|
||||
bool(context.account.get("dashboard_password_hash")),
|
||||
)
|
||||
dashboard_shell = _management_dashboard_shell(context, view, message_html)
|
||||
history_section = _management_history_section(_management_history_rows(service, context))
|
||||
delete_section = _management_delete_section(context)
|
||||
return _page(
|
||||
"KarlDNS management",
|
||||
trusted(f"""
|
||||
<main id="main" class="dashboard-page">
|
||||
{_top_nav()}
|
||||
<section class="dashboard-shell">
|
||||
<div class="container">
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<p class="eyebrow">Dashboard</p>
|
||||
<h1>{hostname}</h1>
|
||||
<p class="lead">Private management link. Keep it safe.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
|
||||
</div>
|
||||
{message_html}
|
||||
<div class="dashboard-grid">
|
||||
<section class="dashboard-card dashboard-card-main" aria-labelledby="router-settings-title">
|
||||
<div class="dashboard-card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Router settings</p>
|
||||
<h2 id="router-settings-title">Update endpoint</h2>
|
||||
</div>
|
||||
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<input type="hidden" name="action" value="update">
|
||||
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="section-copy">Paste the Update-URL into your router. Use the hostname as Domainnamen, or point your own subdomain at it with a CNAME.</p>
|
||||
{_secret_notice()}
|
||||
<div class="dashboard-copy-stack">
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
</div>
|
||||
<div class="dashboard-actions">
|
||||
{_copy_button("Router fields", fritzbox_fields)}
|
||||
{_copy_button("Copy CNAME", cname_record, style="outline-secondary")}
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-card dashboard-status-card" aria-labelledby="status-title">
|
||||
<p class="eyebrow">Current address</p>
|
||||
<h2 id="status-title">Status</h2>
|
||||
<dl class="dashboard-status-list">
|
||||
<div><dt>IPv4</dt><dd>{ipv4}</dd></div>
|
||||
<div><dt>IPv6</dt><dd>{ipv6}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{updated}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<section class="dashboard-card dashboard-card-compact" aria-labelledby="manual-updates-title">
|
||||
<div class="dashboard-card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Manual updates</p>
|
||||
<h2 id="manual-updates-title">curl examples</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-copy">FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly or omit it and let KarlDNS use the request IP.</p>
|
||||
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
{dashboard_shell}
|
||||
{dashboard_password_panel}
|
||||
<section class="dashboard-section">
|
||||
<div class="container">
|
||||
<div class="dashboard-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">History</p>
|
||||
<h2>Router updates</h2>
|
||||
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive dashboard-table-shell">
|
||||
<table class="table table-hover dashboard-history-table mb-0">
|
||||
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
|
||||
<tbody>{history_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-section dashboard-danger-section">
|
||||
<div class="container">
|
||||
<div class="dashboard-danger-card">
|
||||
<div>
|
||||
<p class="eyebrow">Delete</p>
|
||||
<h2>Delete hostname</h2>
|
||||
<p class="section-copy">Remove this hostname and its DNS records. The update URL will stop working.</p>
|
||||
</div>
|
||||
<form method="post" action="/m/{attr(management_slug)}/delete">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{history_section}
|
||||
{delete_section}
|
||||
</main>
|
||||
"""),
|
||||
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
||||
PageOptions(extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">')),
|
||||
)
|
||||
|
||||
|
||||
def _render_pending_management_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: Mapping[str, object],
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
request_ip: str | None = None,
|
||||
) -> str:
|
||||
raw_hostname = str(account["hostname"])
|
||||
account_for_url = {
|
||||
"hostname": raw_hostname,
|
||||
"update_slug": str(account["update_slug"]),
|
||||
"management_slug": management_slug,
|
||||
}
|
||||
update_url = service.fritz_update_url(account_for_url)
|
||||
dashboard_url = service.dashboard_url({"management_slug": management_slug})
|
||||
def _management_history_rows(service: DdnsService, context: ManagementPageContext) -> SafeHtml:
|
||||
events = service.store.list_update_events_for_hostname(str(context.account["hostname"]), 10)
|
||||
rows = "\n".join(_user_event_row(event) for event in events)
|
||||
return trusted(rows or """
|
||||
<tr class="empty-row">
|
||||
<td colspan="6" class="empty">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">No router updates yet.</div>
|
||||
<div class="empty-state-description">
|
||||
This table fills after the router or a script sends its first address update.
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
|
||||
def _management_dashboard_shell(
|
||||
context: ManagementPageContext,
|
||||
view: _ManagementPageView,
|
||||
message_html: SafeHtml,
|
||||
) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="dashboard-shell">
|
||||
<div class="container">
|
||||
{_management_dashboard_header(view)}
|
||||
{message_html}
|
||||
<div class="dashboard-grid">
|
||||
{_router_settings_card(context, view)}
|
||||
{_status_card(view)}
|
||||
</div>
|
||||
{_manual_updates_card(view)}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _management_dashboard_header(view: _ManagementPageView) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<p class="eyebrow">Dashboard</p>
|
||||
<h1>{view.hostname}</h1>
|
||||
<p class="lead">Private management link. Keep it safe.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _router_settings_card(context: ManagementPageContext, view: _ManagementPageView) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="dashboard-card dashboard-card-main" data-slot="card" aria-labelledby="router-settings-title">
|
||||
<div class="dashboard-card-header" data-slot="card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Router settings</p>
|
||||
<h2 id="router-settings-title">Update endpoint</h2>
|
||||
</div>
|
||||
<form method="post" action="/m/{attr(context.management_slug)}/rotate">
|
||||
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
|
||||
<input type="hidden" name="action" value="update">
|
||||
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
|
||||
</form>
|
||||
</div>
|
||||
<div data-slot="card-content">
|
||||
<p class="section-copy">
|
||||
Paste the Update-URL into your router. Use the hostname as Domainnamen,
|
||||
or point your own subdomain at it with a CNAME.
|
||||
</p>
|
||||
{_secret_notice()}
|
||||
<div class="dashboard-copy-stack">
|
||||
{_copy_row("Update-URL:", view.update_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", view.hostname, "Public")}
|
||||
{_copy_row("CNAME target:", view.hostname, "Public")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-actions" data-slot="card-footer">
|
||||
{_copy_button("Router fields", view.fritzbox_fields)}
|
||||
{_copy_button("Copy CNAME", view.cname_record, style="outline-secondary")}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _status_card(view: _ManagementPageView) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="dashboard-card dashboard-status-card" data-slot="card" aria-labelledby="status-title">
|
||||
<div data-slot="card-header">
|
||||
<p class="eyebrow">Current address</p>
|
||||
<h2 id="status-title">Status</h2>
|
||||
</div>
|
||||
<div data-slot="card-content">
|
||||
<dl class="dashboard-status-list">
|
||||
<div><dt>IPv4</dt><dd>{view.ipv4}</dd></div>
|
||||
<div><dt>IPv6</dt><dd>{view.ipv6}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{view.updated}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _manual_updates_card(view: _ManagementPageView) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="dashboard-card dashboard-card-compact" data-slot="card" aria-labelledby="manual-updates-title">
|
||||
<div class="dashboard-card-header" data-slot="card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Manual updates</p>
|
||||
<h2 id="manual-updates-title">curl examples</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="card-content">
|
||||
<p class="section-copy">
|
||||
FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly
|
||||
or omit it and let KarlDNS use the request IP.
|
||||
</p>
|
||||
{_code_window(view.manual_commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _management_history_section(history_rows: SafeHtml) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="dashboard-section">
|
||||
<div class="container">
|
||||
<div class="dashboard-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">History</p>
|
||||
<h2>Router updates</h2>
|
||||
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive dashboard-table-shell">
|
||||
<table class="table table-hover dashboard-history-table mb-0">
|
||||
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
|
||||
<tbody>{history_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _management_delete_section(context: ManagementPageContext) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="dashboard-section dashboard-danger-section">
|
||||
<div class="container">
|
||||
<div class="dashboard-danger-card" data-slot="card">
|
||||
<div>
|
||||
<p class="eyebrow">Delete</p>
|
||||
<h2>Delete hostname</h2>
|
||||
<p class="section-copy">
|
||||
Remove this hostname and its DNS records. The update URL will stop working.
|
||||
</p>
|
||||
</div>
|
||||
<form method="post" action="/m/{attr(context.management_slug)}/delete">
|
||||
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
|
||||
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _render_pending_management_page(service: DdnsService, context: PendingManagementPageContext) -> str:
|
||||
raw_hostname = str(context.account["hostname"])
|
||||
update_url = service.fritz_update_url(context.account_for_url)
|
||||
dashboard_url = service.dashboard_url({"management_slug": context.management_slug})
|
||||
hostname = escape(raw_hostname)
|
||||
message_html = _management_message(message)
|
||||
cleanup_hours = settings.cleanup_pending_account_hours
|
||||
message_html = _management_message(context.message)
|
||||
cleanup_hours = context.settings.cleanup_pending_account_hours
|
||||
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
|
||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
||||
pending_lead = (
|
||||
"Save these values, then register the hostname. Pending setups are cleaned up after " f"{cleanup_text}."
|
||||
)
|
||||
manual_commands = _manual_update_commands(update_url, context.request_ip)
|
||||
hero = _pending_hero(hostname, pending_lead)
|
||||
values_section = _pending_values_section(context, update_url, raw_hostname, dashboard_url)
|
||||
router_section = _pending_router_section(hostname, update_url, manual_commands)
|
||||
return _page(
|
||||
"KarlDNS setup",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
{_top_nav()}
|
||||
<section class="hero-band compact">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<p class="eyebrow">Pending setup</p>
|
||||
<h1>{hostname}</h1>
|
||||
<p class="lead">Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.</p>
|
||||
</div>
|
||||
</section>
|
||||
{hero}
|
||||
{message_html}
|
||||
<section class="section">
|
||||
<div class="container narrow d-grid gap-4">
|
||||
<div>
|
||||
<p class="eyebrow">Save</p>
|
||||
<h2>Your values.</h2>
|
||||
<p class="section-copy">Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.</p>
|
||||
<p class="section-copy"><strong>Public:</strong> Domainnamen. <strong>Secret:</strong> Update-URL and private dashboard.</p>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
|
||||
{_copy_row("Domainnamen:", raw_hostname, "Public")}
|
||||
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
|
||||
</div>
|
||||
<form method="post" action="/m/{attr(management_slug)}/activate">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow d-grid gap-4">
|
||||
<div>
|
||||
<p class="eyebrow">FRITZ!Box</p>
|
||||
<h2>Router setup.</h2>
|
||||
<p class="section-copy">Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field. Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>. No KarlDNS username or password is needed; if your router requires them, enter any placeholder.</p>
|
||||
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Manual updates.</h2>
|
||||
<p class="section-copy">Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP. Visible examples use your public IP when KarlDNS or your browser can detect it.</p>
|
||||
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Use your own DNS name.</h2>
|
||||
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. This is the same value as Domainnamen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{values_section}
|
||||
{router_section}
|
||||
</main>
|
||||
"""),
|
||||
)
|
||||
|
||||
|
||||
def _pending_hero(hostname: str, pending_lead: str) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="hero-band compact">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<p class="eyebrow">Pending setup</p>
|
||||
<h1>{hostname}</h1>
|
||||
<p class="lead">{escape(pending_lead)}</p>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _pending_values_section(
|
||||
context: PendingManagementPageContext,
|
||||
update_url: str,
|
||||
raw_hostname: str,
|
||||
dashboard_url: str,
|
||||
) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="section">
|
||||
<div class="container narrow d-grid gap-4">
|
||||
<div>
|
||||
<p class="eyebrow">Save</p>
|
||||
<h2>Your values.</h2>
|
||||
<p class="section-copy">
|
||||
Save the update URL and dashboard link before you register. After registration,
|
||||
the dashboard hides secrets by default.
|
||||
</p>
|
||||
<p class="section-copy">
|
||||
<strong>Public:</strong> Domainnamen.
|
||||
<strong>Secret:</strong> Update-URL and private dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
|
||||
{_copy_row("Domainnamen:", raw_hostname, "Public")}
|
||||
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
|
||||
</div>
|
||||
<form method="post" action="/m/{attr(context.management_slug)}/activate">
|
||||
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _pending_router_section(hostname: str, update_url: str, manual_commands: str) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow d-grid gap-4">
|
||||
<div>
|
||||
<p class="eyebrow">FRITZ!Box</p>
|
||||
<h2>Router setup.</h2>
|
||||
<p class="section-copy">
|
||||
Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field.
|
||||
Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>.
|
||||
No KarlDNS username or password is needed; if your router requires them,
|
||||
enter any placeholder.
|
||||
</p>
|
||||
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Manual updates.</h2>
|
||||
<p class="section-copy">
|
||||
Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP.
|
||||
Visible examples use your public IP when KarlDNS or your browser can detect it.
|
||||
</p>
|
||||
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Use your own DNS name.</h2>
|
||||
<p class="section-copy">
|
||||
Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>.
|
||||
This is the same value as Domainnamen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _management_message(message: str | None) -> SafeHtml:
|
||||
if not message:
|
||||
return trusted("")
|
||||
return trusted(f"""
|
||||
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
|
||||
<div class="alert alert-info ui-alert mb-0 rounded-0 border-start-0 border-end-0"
|
||||
role="status" data-slot="alert">
|
||||
<div class="container">{escape(message)}</div>
|
||||
</div>
|
||||
""")
|
||||
@@ -248,10 +403,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
||||
remove_form = ""
|
||||
if password_enabled:
|
||||
remove_form = f"""
|
||||
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password/remove">
|
||||
<form class="dashboard-card dashboard-form-card" data-slot="card" method="post"
|
||||
action="/m/{attr(management_slug)}/password/remove">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
|
||||
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||
<input class="form-control" id="dashboard-remove-current-password"
|
||||
name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
|
||||
</form>
|
||||
"""
|
||||
@@ -260,7 +417,8 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
||||
current_password_field = """
|
||||
<div>
|
||||
<label class="form-label" for="dashboard-current-password">Current password</label>
|
||||
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||
<input class="form-control" id="dashboard-current-password"
|
||||
name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
"""
|
||||
return trusted(f"""
|
||||
@@ -270,19 +428,26 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
||||
<div>
|
||||
<p class="eyebrow">Dashboard password</p>
|
||||
<h2>Password protection</h2>
|
||||
<p class="section-copy">Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.</p>
|
||||
<p class="section-copy">
|
||||
Status: {escape(password_status)}. This password protects only this dashboard.
|
||||
Router updates still work.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-grid gap-3">
|
||||
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password">
|
||||
<form class="dashboard-card dashboard-form-card" data-slot="card" method="post"
|
||||
action="/m/{attr(management_slug)}/password">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
{current_password_field}
|
||||
<div>
|
||||
<label class="form-label" for="dashboard-password">{escape(password_action)}</label>
|
||||
<input class="form-control" id="dashboard-password" name="dashboard_password" type="password" autocomplete="new-password" minlength="8" required>
|
||||
<input class="form-control" id="dashboard-password" name="dashboard_password"
|
||||
type="password" autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
|
||||
<input class="form-control" id="dashboard-password-confirm" name="dashboard_password_confirm" type="password" autocomplete="new-password" minlength="8" required>
|
||||
<input class="form-control" id="dashboard-password-confirm"
|
||||
name="dashboard_password_confirm" type="password" autocomplete="new-password"
|
||||
minlength="8" required>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button>
|
||||
@@ -305,7 +470,9 @@ def _render_management_unlock_page(
|
||||
hostname = escape(account["hostname"])
|
||||
message_html = ""
|
||||
if message:
|
||||
message_html = f'<div class="alert alert-warning" role="alert">{escape(message)}</div>'
|
||||
message_html = (
|
||||
f'<div class="alert alert-warning ui-alert" role="alert" data-slot="alert">{escape(message)}</div>'
|
||||
)
|
||||
return _page(
|
||||
"Unlock KarlDNS management",
|
||||
trusted(f"""
|
||||
@@ -321,11 +488,13 @@ def _render_management_unlock_page(
|
||||
<section class="section">
|
||||
<div class="container narrow">
|
||||
{message_html}
|
||||
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/unlock">
|
||||
<form class="card card-body gap-3" data-slot="card" method="post"
|
||||
action="/m/{attr(management_slug)}/unlock">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<div>
|
||||
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
|
||||
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password" type="password" autocomplete="current-password" required autofocus>
|
||||
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password"
|
||||
type="password" autocomplete="current-password" required autofocus>
|
||||
</div>
|
||||
<label class="form-check d-flex align-items-center gap-2">
|
||||
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
|
||||
@@ -341,15 +510,39 @@ def _render_management_unlock_page(
|
||||
|
||||
|
||||
def _user_event_row(event: Mapping[str, object]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
row = _user_event_row_view(event)
|
||||
return (
|
||||
f'<tr><td>{row.created}</td><td><span class="status-badge {attr(row.badge_class)}">{row.status}</span></td>'
|
||||
f"<td>{row.ipv4}</td><td>{row.ipv6}</td><td>{row.count}</td><td>{row.detail}</td></tr>"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _UserEventRow:
|
||||
created: str
|
||||
status: str
|
||||
badge_class: str
|
||||
ipv4: str
|
||||
ipv6: str
|
||||
count: str
|
||||
detail: str
|
||||
|
||||
|
||||
def _user_event_row_view(event: Mapping[str, object]) -> _UserEventRow:
|
||||
raw_status = str(event.get("status") or "")
|
||||
status = escape(_event_label(raw_status))
|
||||
badge_class = attr(_event_badge_class(raw_status))
|
||||
ipv4 = escape(event.get("ipv4") or "-")
|
||||
ipv6 = escape(event.get("ipv6") or "-")
|
||||
count = escape(event.get("count") or 1)
|
||||
detail = escape(event.get("detail") or "-")
|
||||
return f'<tr><td>{created}</td><td><span class="status-badge {badge_class}">{status}</span></td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>'
|
||||
return _UserEventRow(
|
||||
created=escape(_event_text(event, "created_at")),
|
||||
status=escape(_event_label(raw_status)),
|
||||
badge_class=_event_badge_class(raw_status),
|
||||
ipv4=escape(_event_text(event, "ipv4")),
|
||||
ipv6=escape(_event_text(event, "ipv6")),
|
||||
count=escape(event.get("count") or 1),
|
||||
detail=escape(_event_text(event, "detail")),
|
||||
)
|
||||
|
||||
|
||||
def _event_text(event: Mapping[str, object], key: str) -> str:
|
||||
return str(event.get(key) or "-")
|
||||
|
||||
|
||||
def _event_badge_class(status: str) -> str:
|
||||
|
||||
+223
-145
@@ -1,12 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..settings import DdnsSettings
|
||||
from .components import _code_window, _example_update_ips, _message_band
|
||||
from .html_utils import escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .html_utils import trusted
|
||||
from .layout import PageOptions, _page, _top_nav
|
||||
from .sections.public_home import _manual_updates_section, _public_home_hero, _setup_flow_section
|
||||
from .templates_public_sections import _linux_installer_section
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ApiDocsExamples:
|
||||
create: str
|
||||
register: str
|
||||
update: str
|
||||
status: str
|
||||
rotate: str
|
||||
schema: str
|
||||
|
||||
|
||||
def _render_public_home(
|
||||
settings: DdnsSettings,
|
||||
message: str | None,
|
||||
@@ -14,8 +27,7 @@ def _render_public_home(
|
||||
) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
|
||||
suffix = escape(hostname_suffix)
|
||||
demo_update_url = escape(f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>")
|
||||
demo_update_url = f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>"
|
||||
example_ipv4, _ = _example_update_ips(request_ip)
|
||||
create_demo_command = f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'"
|
||||
create_demo = f"{create_demo_command}\ngood {example_ipv4}"
|
||||
@@ -28,81 +40,55 @@ def _render_public_home(
|
||||
]
|
||||
)
|
||||
message_html = _message_band(message) if message else ""
|
||||
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
|
||||
description = (
|
||||
"KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, "
|
||||
"generated KarlDNS hostnames, Linux cron jobs, and home networks."
|
||||
)
|
||||
return _page(
|
||||
"KarlDNS",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
{_top_nav()}
|
||||
<section class="hero-band">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
|
||||
<h1>Dynamic DNS for home routers.</h1>
|
||||
<div class="install-pill mx-auto">
|
||||
<code>{demo_update_url}</code>
|
||||
</div>
|
||||
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.</p>
|
||||
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
|
||||
<form method="post" action="/" class="hero-action-form" aria-label="Create a generated DynDNS hostname">
|
||||
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
|
||||
</form>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{_public_home_hero(suffix=hostname_suffix, demo_update_url=demo_update_url)}
|
||||
{message_html}
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow text-center">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>No account needed.</h2>
|
||||
<p class="section-copy mx-auto">Create a hostname, save the private values, register it, done.</p>
|
||||
<ol class="flow-steps" aria-label="KarlDNS setup flow">
|
||||
<li><strong>Create</strong><span>Random hostname and private links.</span></li>
|
||||
<li><strong>Save</strong><span>Copy the secrets while they are shown.</span></li>
|
||||
<li><strong>Register</strong><span>Activate the hostname when you are ready.</span></li>
|
||||
<li><strong>Update</strong><span>Router, cron, or CNAME can use it.</span></li>
|
||||
</ol>
|
||||
{_code_window(create_demo, copy_label="update example", class_name="mt-4", copy_value=create_demo_command)}
|
||||
</div>
|
||||
</section>
|
||||
<section class="section" id="manual-updates">
|
||||
<div class="container narrow text-center">
|
||||
<h2>Update from a router or script.</h2>
|
||||
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
|
||||
{_code_window(manual_demo, copy_label="manual update examples", class_name="manual-code text-start mt-4")}
|
||||
</div>
|
||||
</section>
|
||||
{_setup_flow_section(create_demo=create_demo, create_demo_command=create_demo_command)}
|
||||
{_manual_updates_section(manual_demo=manual_demo)}
|
||||
{_linux_installer_section(base_url)}
|
||||
</main>
|
||||
"""),
|
||||
description=description,
|
||||
preload_logo=True,
|
||||
canonical_url=f"{base_url}/",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
|
||||
structured_data={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "KarlDNS",
|
||||
"applicationCategory": "NetworkingApplication",
|
||||
"operatingSystem": "Linux",
|
||||
"url": f"{base_url}/",
|
||||
"description": description,
|
||||
"image": f"{base_url}/social-preview.png",
|
||||
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
|
||||
"featureList": [
|
||||
"Dynamic DNS for FRITZ!Box routers",
|
||||
"Accountless private update URLs",
|
||||
"CNAME target for external DNS names",
|
||||
"Linux cron updater",
|
||||
],
|
||||
},
|
||||
PageOptions(
|
||||
description=description,
|
||||
preload_logo=True,
|
||||
canonical_url=f"{base_url}/",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
|
||||
structured_data={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "KarlDNS",
|
||||
"applicationCategory": "NetworkingApplication",
|
||||
"operatingSystem": "Linux",
|
||||
"url": f"{base_url}/",
|
||||
"description": description,
|
||||
"image": f"{base_url}/social-preview.png",
|
||||
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
|
||||
"featureList": [
|
||||
"Dynamic DNS for FRITZ!Box routers",
|
||||
"Accountless private update URLs",
|
||||
"CNAME target for external DNS names",
|
||||
"Linux cron updater",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _render_privacy_page(settings: DdnsSettings) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
|
||||
description = (
|
||||
"Privacy and legal information for KarlDNS, including stored hostnames, update links, "
|
||||
"IP history, and cleanup behavior."
|
||||
)
|
||||
return _page(
|
||||
"Legal and Privacy",
|
||||
trusted(f"""
|
||||
@@ -119,113 +105,205 @@ def _render_privacy_page(settings: DdnsSettings) -> str:
|
||||
<div class="container narrow d-grid gap-4">
|
||||
<div>
|
||||
<h2>Data processed</h2>
|
||||
<p class="section-copy">KarlDNS stores hostnames, protected dashboard tokens, private update links, recent IP history, timestamps, and service events.</p>
|
||||
<p class="section-copy">
|
||||
KarlDNS stores hostnames, protected dashboard tokens, private update links,
|
||||
recent IP history, timestamps, and service events.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Secrets</h2>
|
||||
<p class="section-copy">Dashboard links, registration URLs, and update URLs are secrets. Anyone with them may update or manage the hostname. Dashboard lookup values are not stored as readable text.</p>
|
||||
<p class="section-copy">
|
||||
Dashboard links, registration URLs, and update URLs are secrets.
|
||||
Anyone with them may update or manage the hostname.
|
||||
Dashboard lookup values are not stored as readable text.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Logs and retention</h2>
|
||||
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.</p>
|
||||
<p class="section-copy">
|
||||
KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames.
|
||||
Server logs may also include IP addresses and request details.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Contact</h2>
|
||||
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
|
||||
<p class="section-copy">
|
||||
For the public KarlDNS instance, contact Karl through
|
||||
<a href="https://karl.fail" rel="noopener">karl.fail</a> or
|
||||
<a href="https://karlcom.de" rel="noopener">karlcom.de</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
description=description,
|
||||
canonical_url=f"{base_url}/privacy",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS legal and privacy information",
|
||||
structured_data={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "KarlDNS Legal and Privacy",
|
||||
"url": f"{base_url}/privacy",
|
||||
"description": description,
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KarlDNS",
|
||||
"url": f"{base_url}/",
|
||||
PageOptions(
|
||||
description=description,
|
||||
canonical_url=f"{base_url}/privacy",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS legal and privacy information",
|
||||
structured_data={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "KarlDNS Legal and Privacy",
|
||||
"url": f"{base_url}/privacy",
|
||||
"description": description,
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KarlDNS",
|
||||
"url": f"{base_url}/",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
example_ipv4, _ = _example_update_ips(request_ip)
|
||||
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
|
||||
register_example = (
|
||||
f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'"
|
||||
examples = _api_docs_examples(base_url, example_ipv4)
|
||||
description = (
|
||||
"Small KarlDNS API guide for creating, activating, updating, reading, and deleting "
|
||||
"generated Dynamic DNS hostnames."
|
||||
)
|
||||
update_example = f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'"
|
||||
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'"
|
||||
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url"
|
||||
schema_example = f"curl -sS {base_url}/openapi.json"
|
||||
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
|
||||
return _page(
|
||||
"KarlDNS API",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
{_top_nav()}
|
||||
<section class="hero-band compact">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<p class="eyebrow">API</p>
|
||||
<h1>Small API surface.</h1>
|
||||
<p class="lead">Create a hostname, register it, update it, and read its status.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow d-grid gap-4">
|
||||
<div class="api-endpoints">
|
||||
<h2>Endpoints</h2>
|
||||
<ol class="simple-steps text-start">
|
||||
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and private setup values. The response includes <code>registration_url</code>.</li>
|
||||
<li><strong>GET or POST /api/v1/router/register/<karl_secret>.</strong> Register the hostname after secrets are saved. Publishes an initial DNS record from a public request IP or explicit <code>myip</code>/<code>myipv6</code> value. If no public IP is visible, publishes a temporary <code>192.0.2.1</code> A record until the first router update.</li>
|
||||
<li><strong>GET or POST /api/v1/router/update/<karl_secret>.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
|
||||
<li><strong>GET /api/v1/hostnames/<karl_secret>.</strong> Read hostname status and recent history.</li>
|
||||
<li><strong>POST /api/v1/hostnames/<karl_secret>/rotate-update-url.</strong> Replace the private router update URL.</li>
|
||||
<li><strong>DELETE /api/v1/hostnames/<karl_secret>.</strong> Delete hostname and DNS records.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Create</h2>
|
||||
{_code_window(create_example, copy_label="create command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Register</h2>
|
||||
{_code_window(register_example, copy_label="register command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Update</h2>
|
||||
{_code_window(update_example, copy_label="update command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Status</h2>
|
||||
<p class="section-copy">Use the private dashboard secret to read current DNS state and recent updates for only that hostname.</p>
|
||||
{_code_window(status_example, copy_label="status command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Rotate update URL</h2>
|
||||
<p class="section-copy">Use the private dashboard secret to replace the router update URL. The old update URL stops working immediately.</p>
|
||||
{_code_window(rotate_example, copy_label="rotate command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Schema</h2>
|
||||
<p class="section-copy">Machine-readable OpenAPI is available for the public hostname and router endpoints.</p>
|
||||
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{_api_docs_hero()}
|
||||
{_api_docs_content(examples)}
|
||||
</main>
|
||||
"""),
|
||||
description=description,
|
||||
canonical_url=f"{base_url}/docs",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS API documentation",
|
||||
PageOptions(
|
||||
description=description,
|
||||
canonical_url=f"{base_url}/docs",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS API documentation",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _api_docs_examples(base_url: str, example_ipv4: str) -> _ApiDocsExamples:
|
||||
return _ApiDocsExamples(
|
||||
create=f"curl -sS -X POST {base_url}/api/v1/hostnames",
|
||||
register=f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'",
|
||||
update=f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'",
|
||||
status=f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'",
|
||||
rotate=f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url",
|
||||
schema=f"curl -sS {base_url}/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
def _api_docs_hero() -> str:
|
||||
return """
|
||||
<section class="hero-band compact">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<p class="eyebrow">API</p>
|
||||
<h1>Small API surface.</h1>
|
||||
<p class="lead">Create a hostname, register it, update it, and read its status.</p>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def _api_docs_content(examples: _ApiDocsExamples) -> str:
|
||||
return f"""
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow d-grid gap-4">
|
||||
{_api_endpoints_overview()}
|
||||
{_api_example_block("Create", examples.create, "create command")}
|
||||
{_api_example_block("Register", examples.register, "register command")}
|
||||
{_api_example_block("Update", examples.update, "update command")}
|
||||
{_api_status_block(examples.status)}
|
||||
{_api_rotate_block(examples.rotate)}
|
||||
{_api_schema_block(examples.schema)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def _api_endpoints_overview() -> str:
|
||||
return """
|
||||
<div class="api-endpoints">
|
||||
<h2>Endpoints</h2>
|
||||
<ol class="simple-steps text-start">
|
||||
<li>
|
||||
<strong>POST /api/v1/hostnames.</strong>
|
||||
Create a pending hostname and private setup values. The response includes
|
||||
<code>registration_url</code>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>GET or POST /api/v1/router/register/<karl_secret>.</strong>
|
||||
Register the hostname after secrets are saved. Publishes an initial DNS record
|
||||
from a public request IP or explicit <code>myip</code>/<code>myipv6</code> value.
|
||||
If no public IP is visible, publishes a temporary <code>192.0.2.1</code> A record
|
||||
until the first router update.
|
||||
</li>
|
||||
<li>
|
||||
<strong>GET or POST /api/v1/router/update/<karl_secret>.</strong>
|
||||
Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>,
|
||||
<code>myipv6</code>, and <code>ip6addr</code>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>GET /api/v1/hostnames/<karl_secret>.</strong>
|
||||
Read hostname status and recent history.
|
||||
</li>
|
||||
<li>
|
||||
<strong>POST /api/v1/hostnames/<karl_secret>/rotate-update-url.</strong>
|
||||
Replace the private router update URL.
|
||||
</li>
|
||||
<li>
|
||||
<strong>DELETE /api/v1/hostnames/<karl_secret>.</strong>
|
||||
Delete hostname and DNS records.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _api_example_block(title: str, command: str, copy_label: str) -> str:
|
||||
return f"""
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{_code_window(command, copy_label=copy_label, class_name="mt-3")}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _api_status_block(command: str) -> str:
|
||||
return f"""
|
||||
<div>
|
||||
<h2>Status</h2>
|
||||
<p class="section-copy">
|
||||
Use the private dashboard secret to read current DNS state and recent updates
|
||||
for only that hostname.
|
||||
</p>
|
||||
{_code_window(command, copy_label="status command", class_name="mt-3")}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _api_rotate_block(command: str) -> str:
|
||||
return f"""
|
||||
<div>
|
||||
<h2>Rotate update URL</h2>
|
||||
<p class="section-copy">
|
||||
Use the private dashboard secret to replace the router update URL.
|
||||
The old update URL stops working immediately.
|
||||
</p>
|
||||
{_code_window(command, copy_label="rotate command", class_name="mt-3")}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _api_schema_block(command: str) -> str:
|
||||
return f"""
|
||||
<div>
|
||||
<h2>Schema</h2>
|
||||
<p class="section-copy">
|
||||
Machine-readable OpenAPI is available for the public hostname and router endpoints.
|
||||
</p>
|
||||
{_code_window(command, copy_label="schema command", class_name="mt-3")}
|
||||
</div>
|
||||
"""
|
||||
@@ -14,7 +14,11 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
|
||||
<div class="text-center mb-4">
|
||||
<p class="eyebrow">Linux cron updater</p>
|
||||
<h2>Linux updater.</h2>
|
||||
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates and registers a hostname, installs the updater, schedules cron, and prints your private dashboard link. Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.</p>
|
||||
<p class="section-copy mx-auto">
|
||||
Run this on a Linux box that is always on. It creates and registers a hostname,
|
||||
installs the updater, schedules cron, and prints your private dashboard link.
|
||||
Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.
|
||||
</p>
|
||||
</div>
|
||||
<div class="installer-commands">
|
||||
<div class="installer-command">
|
||||
|
||||
Reference in new issue
Block a user