from __future__ import annotations from pathlib import Path from fastapi import APIRouter, Response from fastapi.responses import FileResponse, RedirectResponse from ..web.styles import ADMIN_CSS, BASE_CSS ASSET_DIR = Path(__file__).parents[1] / "assets" router = APIRouter(include_in_schema=False) ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} 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() def _minify_css(css: str) -> str: return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}") MINIFIED_BASE_CSS = _minify_css(BASE_CSS) MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS) @router.get("/favicon.ico") def favicon() -> FileResponse: return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS) @router.get("/bootstrap-subset.css") def bootstrap_subset_css() -> FileResponse: return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS) @router.get("/favicon-16x16.png") def favicon_16_png() -> FileResponse: return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) @router.get("/favicon-32x32.png") def favicon_32_png() -> FileResponse: return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) @router.get("/apple-touch-icon.png") def apple_touch_icon_png() -> FileResponse: return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) @router.get("/logo.png") def logo_png() -> FileResponse: return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) @router.get("/logo.webp") def logo_webp() -> FileResponse: return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS) @router.get("/logo-nav.webp") def logo_nav_webp() -> FileResponse: return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS) @router.get("/logo-192.png") def logo_192_png() -> FileResponse: return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) @router.get("/logo-512.png") def logo_512_png() -> FileResponse: return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) @router.get("/social-preview.png") def social_preview_png() -> FileResponse: return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) @router.get("/social-preview.webp") def social_preview_webp() -> FileResponse: return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS) @router.get("/site.webmanifest") def site_webmanifest() -> Response: return Response( """ { "name": "KarlDNS", "short_name": "KarlDNS", "description": "Private update links for routers.", "start_url": "/", "scope": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#ffffff", "icons": [ {"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"}, {"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"} ] } """.strip(), media_type="application/manifest+json", headers=ASSET_CACHE_HEADERS, ) @router.get("/theme.js") def theme_js() -> Response: return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) @router.get("/clipboard.js") def clipboard_js() -> Response: return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) @router.get("/admin-tables.js") def admin_tables_js() -> Response: return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) @router.get("/app.css") def app_css() -> Response: return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS) @router.get("/admin.css") def admin_css() -> Response: return Response(MINIFIED_ADMIN_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS) @router.get("/install.sh") def install_sh() -> RedirectResponse: return RedirectResponse( "https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh", status_code=302, ) @router.get("/uninstall.sh") def uninstall_sh() -> RedirectResponse: return RedirectResponse( "https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh", status_code=302, )