This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/karldns/web/components.py
T

284 lines
9.8 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from ipaddress import ip_address
from .html_utils import SafeHtml, attr, escape, trusted
from .icons import _svg_icon
_EVENT_LABELS = {
"admin_hostname_deleted": "hostname deleted by admin",
"admin_management_url_rotated": "dashboard link rotated by admin",
"admin_update_url_rotated": "update URL rotated by admin",
"dashboard_hostname_activated": "hostname registered",
"dashboard_hostname_deleted": "hostname deleted",
"dashboard_password_changed": "dashboard password changed",
"dashboard_password_enabled": "dashboard password enabled",
"dashboard_password_removed": "dashboard password removed",
"dashboard_update_url_rotated": "update URL rotated",
"dns_delete_failed": "DNS delete failed",
"dns_failed": "DNS update failed",
"dns_reconcile_failed": "DNS reconciliation failed",
"unchanged": "unchanged",
"updated": "updated",
}
def _event_label(status: object) -> str:
value = str(status or "-")
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:
parts = _CopyRowParts.from_values(label, value, sensitivity, mask_secret=mask_secret)
return trusted(f"""
<div class="{parts.row_class}">
<div class="copy-pill">
<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="{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>
""")
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
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")}'
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 {button_attrs}>"
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>'
f'{_svg_icon("check", "check-icon")}</button>'
)
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 {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">
<summary aria-label="{safe_label}" title="{safe_label}">
{_svg_icon("more", "more-icon")}
</summary>
<div class="copy-menu-popover" role="menu">
{''.join(buttons)}
</div>
</details>
""")
def _code_window(
commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None
) -> SafeHtml:
safe_class = f" {attr(class_name)}" if class_name else ""
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}" data-slot="code-window">
{_copy_icon_button(copy_label, copy_value)}
{code_lines}
</div>
""")
def _code_copy_value(commands: str) -> str:
lines = commands.splitlines()
prompt_lines = [_code_copy_line(line) for line in lines if line.startswith("$ ")]
if prompt_lines:
return "\n".join(prompt_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]:
ipv4 = "203.0.113.10"
ipv6 = "2001:db8::10"
if not request_ip:
return ipv4, ipv6
try:
parsed = ip_address(request_ip.strip())
except ValueError:
return ipv4, ipv6
if not parsed.is_global:
return ipv4, ipv6
if parsed.version == 4:
return str(parsed), ipv6
return ipv4, str(parsed)
def _manual_update_commands(update_url: str, request_ip: str | None = None) -> str:
update_base = update_url.split("?", 1)[0]
request_version, request_address = _known_request_ip(request_ip)
lines = []
if request_version in {None, 4}:
ipv4, _ = _example_update_ips(request_ip)
lines.append(f"curl -sS '{update_base}?myip={ipv4}'")
if request_version == 6:
lines.append(f"curl -sS '{update_base}?myipv6={request_address}'")
lines.append(f"curl -sS '{update_base}' # use request IP")
return "\n".join(lines)
def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]:
if not request_ip:
return None, ""
try:
parsed = ip_address(request_ip.strip())
except ValueError:
return None, ""
if not parsed.is_global:
return None, ""
return parsed.version, str(parsed)
def _manual_update_panel(commands: str) -> SafeHtml:
return trusted(f"""
<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>
</div>
<div data-slot="card-content">
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
</div>
""")
def _fritzbox_fields(
update_url: str,
hostname: str,
management_url: str | None = None,
) -> str:
lines = [
f"Update-URL: {update_url}",
f"Domainnamen: {hostname}",
f"CNAME target: {hostname}",
]
if management_url:
lines.append(f"Private dashboard: {management_url}")
return "\n".join(lines)
def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
secret_items = (
"Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
)
return trusted(f"""
<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>
""")
def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(f"""
<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>
""")