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'" ) return f'{self.safe_value}' @property def reveal_button(self) -> str: if not self.should_mask: return "" return ( f'' ) @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'{escape(sensitivity)}' 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"""
{parts.safe_label} {parts.sensitivity_badge} {parts.value_field} {parts.reveal_button}
""") 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'' ) 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"' ) 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"' ) return trusted(f"""
{_svg_icon("more", "more-icon")}
""") 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"{escape(line)}" for line in commands.splitlines()) copy_value = copy_value if copy_value is not None else _code_copy_value(commands) return trusted(f"""
{_copy_icon_button(copy_label, copy_value)} {code_lines}
""") 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"""

Manual updates

FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.

{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
""") 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"""
Public: Hostname and CNAME target can be shared. Secret: {escape(secret_items)} can update or manage this hostname. Secrets are hidden on screen. Copy still copies the real value.
""") def _message_band(message: str | None) -> SafeHtml: if not message: return trusted("") return trusted(f"""
{escape(message)}
""")