Archived
Add formatting and type checks
This commit is contained in:
1 parent
c0d82eaf26
commit
24747866bc
38 files changed
+800
-265
No files matched your search
+18
-26
@@ -46,8 +46,7 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_se
|
||||
else ""
|
||||
)
|
||||
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<div class="{row_class}">
|
||||
<div class="copy-pill">
|
||||
<span class="copy-pill-label">{safe_label}</span>
|
||||
@@ -58,8 +57,7 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_se
|
||||
{_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:
|
||||
@@ -89,8 +87,7 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
|
||||
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>'
|
||||
)
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<details class="copy-menu">
|
||||
<summary aria-label="{safe_label}" title="{safe_label}">
|
||||
{_svg_icon("more", "more-icon")}
|
||||
@@ -99,22 +96,21 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
|
||||
{''.join(buttons)}
|
||||
</div>
|
||||
</details>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None) -> SafeHtml:
|
||||
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"""
|
||||
return trusted(f"""
|
||||
<div class="terminal-card code-window{safe_class}">
|
||||
{_copy_icon_button(copy_label, copy_value)}
|
||||
{code_lines}
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _code_copy_value(commands: str) -> str:
|
||||
@@ -167,8 +163,7 @@ def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]:
|
||||
|
||||
|
||||
def _manual_update_panel(commands: str) -> SafeHtml:
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div>
|
||||
<h3>Manual updates</h3>
|
||||
@@ -176,8 +171,7 @@ def _manual_update_panel(commands: str) -> SafeHtml:
|
||||
</div>
|
||||
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _fritzbox_fields(
|
||||
@@ -196,25 +190,23 @@ def _fritzbox_fields(
|
||||
|
||||
|
||||
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"""
|
||||
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 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>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _message_band(message: str | None) -> SafeHtml:
|
||||
if not message:
|
||||
return trusted("")
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
|
||||
<div class="container">{escape(message)}</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
@@ -17,7 +17,9 @@ def parse_form(body: bytes) -> dict[str, list[str]]:
|
||||
try:
|
||||
return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields") from exc
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields"
|
||||
) from exc
|
||||
|
||||
|
||||
async def request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]:
|
||||
|
||||
+15
-11
@@ -18,8 +18,7 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
|
||||
{_svg_icon("sun", "theme-icon theme-icon-sun")}
|
||||
</button>
|
||||
"""
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<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">
|
||||
@@ -31,13 +30,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _site_footer() -> SafeHtml:
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<footer class="site-footer border-top">
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
|
||||
@@ -65,8 +62,7 @@ def _site_footer() -> SafeHtml:
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _page(
|
||||
@@ -85,7 +81,11 @@ def _page(
|
||||
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("")
|
||||
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 ""
|
||||
@@ -93,14 +93,18 @@ def _page(
|
||||
if social_url:
|
||||
social_card = "summary_large_image" if social_image else "summary"
|
||||
social_image_alt = image_alt or title
|
||||
social_image_html = f"""
|
||||
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 ""
|
||||
"""
|
||||
if social_image
|
||||
else ""
|
||||
)
|
||||
social_html = trusted(f"""
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{attr(title)}">
|
||||
|
||||
@@ -47,7 +47,6 @@ class RequestBodyLimitMiddleware:
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
|
||||
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
|
||||
@app.middleware("http")
|
||||
async def security_middleware(request: Request, call_next):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -30,13 +31,14 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _render_admin_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
accounts: list[dict[str, str | int | None]],
|
||||
cleanup_runs: list[dict[str, str | int]],
|
||||
scheduled_tasks: list[dict[str, str | int | None]],
|
||||
update_events: list[dict[str, str | int | None]],
|
||||
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],
|
||||
@@ -55,8 +57,16 @@ def _render_admin_page(
|
||||
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)
|
||||
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, 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))
|
||||
account_pager = _server_page_links(
|
||||
admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, 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),
|
||||
)
|
||||
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
|
||||
totals_html = _totals_strip(table_counts, service_metrics)
|
||||
return _page(
|
||||
@@ -142,10 +152,7 @@ def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> Sa
|
||||
]
|
||||
return trusted(
|
||||
'<div class="admin-total-strip" aria-label="Service totals">'
|
||||
+ "".join(
|
||||
f'<span><strong>{escape(value)}</strong>{escape(label)}</span>'
|
||||
for label, value in items
|
||||
)
|
||||
+ "".join(f"<span><strong>{escape(value)}</strong>{escape(label)}</span>" for label, value in items)
|
||||
+ "</div>"
|
||||
)
|
||||
|
||||
@@ -181,12 +188,15 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
|
||||
|
||||
def _cleanup_panel(
|
||||
settings: DdnsSettings,
|
||||
cleanup_runs: list[dict[str, str | int]],
|
||||
scheduled_tasks: list[dict[str, str | int | None]],
|
||||
cleanup_runs: Sequence[Mapping[str, object]],
|
||||
scheduled_tasks: Sequence[Mapping[str, object]],
|
||||
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 {})
|
||||
task = next(
|
||||
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
|
||||
scheduled_tasks[0] if scheduled_tasks else {},
|
||||
)
|
||||
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"
|
||||
@@ -235,14 +245,14 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||
("Blocked requests", _metric_int(metrics.get("rate_limit_hits_total", 0))),
|
||||
("Average update time", f"{average_latency:.1f} ms"),
|
||||
("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))}"),
|
||||
(
|
||||
"Cleanup ok/errors",
|
||||
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}",
|
||||
),
|
||||
]
|
||||
cards = "\n".join(
|
||||
f"""
|
||||
cards = "\n".join(f"""
|
||||
<span><strong>{escape(value)}</strong>{escape(label)}</span>
|
||||
"""
|
||||
for label, value in items
|
||||
)
|
||||
""" for label, value in items)
|
||||
return trusted(f"""
|
||||
<div class="admin-metric-strip" aria-label="Service metrics">
|
||||
{cards}
|
||||
@@ -250,13 +260,16 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||
""")
|
||||
|
||||
|
||||
def _metric_int(value: float) -> str:
|
||||
return str(int(value))
|
||||
def _metric_int(value: object) -> str:
|
||||
try:
|
||||
return str(int(float(str(value))))
|
||||
except ValueError:
|
||||
return "0"
|
||||
|
||||
|
||||
def _format_interval(seconds: object) -> str:
|
||||
try:
|
||||
value = int(seconds)
|
||||
value = int(str(seconds))
|
||||
except (TypeError, ValueError):
|
||||
return str(seconds)
|
||||
if value % 3600 == 0:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
|
||||
from .components import _event_label
|
||||
from .html_utils import attr, escape
|
||||
|
||||
|
||||
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
|
||||
def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
|
||||
raw_hostname = str(account["hostname"])
|
||||
hostname = escape(raw_hostname)
|
||||
hostname_attr = attr(raw_hostname)
|
||||
@@ -77,7 +78,7 @@ def _admin_table_pagination() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def _event_row(event: dict[str, str | int | None]) -> 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 "-")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .components import (
|
||||
@@ -19,7 +21,7 @@ def _render_management_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: dict[str, str | int | None],
|
||||
account: Mapping[str, object],
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
request_ip: str | None = None,
|
||||
@@ -37,11 +39,19 @@ def _render_management_page(
|
||||
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 """
|
||||
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>
|
||||
"""
|
||||
)
|
||||
message_html = _management_message(message)
|
||||
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
|
||||
dashboard_password_panel = _dashboard_password_panel(
|
||||
management_slug, csrf_token, bool(account.get("dashboard_password_hash"))
|
||||
)
|
||||
return _page(
|
||||
"KarlDNS management",
|
||||
trusted(f"""
|
||||
@@ -143,11 +153,12 @@ def _render_management_page(
|
||||
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
||||
)
|
||||
|
||||
|
||||
def _render_pending_management_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: dict[str, str | int | None],
|
||||
account: Mapping[str, object],
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
request_ip: str | None = None,
|
||||
@@ -224,13 +235,11 @@ def _render_pending_management_page(
|
||||
def _management_message(message: str | None) -> SafeHtml:
|
||||
if not message:
|
||||
return trusted("")
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
|
||||
<div class="container">{escape(message)}</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
|
||||
@@ -254,8 +263,7 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
||||
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
"""
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<section class="dashboard-section">
|
||||
<div class="container">
|
||||
<div class="dashboard-settings-grid">
|
||||
@@ -285,13 +293,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _render_management_unlock_page(
|
||||
management_slug: str,
|
||||
account: dict[str, str | int | None],
|
||||
account: Mapping[str, object],
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
) -> str:
|
||||
@@ -333,7 +340,7 @@ def _render_management_unlock_page(
|
||||
)
|
||||
|
||||
|
||||
def _user_event_row(event: dict[str, str | int | None]) -> str:
|
||||
def _user_event_row(event: Mapping[str, object]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
raw_status = str(event.get("status") or "")
|
||||
status = escape(_event_label(raw_status))
|
||||
|
||||
@@ -99,6 +99,7 @@ def _render_public_home(
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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."
|
||||
@@ -159,7 +160,9 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
|
||||
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}'"
|
||||
register_example = (
|
||||
f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'"
|
||||
)
|
||||
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"
|
||||
|
||||
@@ -8,8 +8,7 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
|
||||
base_url = base_url.rstrip("/")
|
||||
system_install = f"curl -fsSL {base_url}/install.sh | sudo sh"
|
||||
system_uninstall = f"curl -fsSL {base_url}/uninstall.sh | sudo sh"
|
||||
return trusted(
|
||||
f"""
|
||||
return trusted(f"""
|
||||
<section class="section" id="linux-installer">
|
||||
<div class="container narrow">
|
||||
<div class="text-center mb-4">
|
||||
@@ -31,5 +30,4 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
)
|
||||
""")
|
||||
Reference in new issue
Block a user