2026-05-20 23:55:27 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Response
|
|
|
|
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
from ..web.styles import ADMIN_CSS, BASE_CSS
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
ASSET_DIR = Path(__file__).parents[1] / "assets"
|
|
|
|
|
router = APIRouter(include_in_schema=False)
|
2026-05-21 10:05:40 +02:00
|
|
|
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
2026-05-20 23:55:27 +02:00
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
THEME_JS = """
|
2026-05-20 23:55:27 +02:00
|
|
|
(() => {
|
|
|
|
|
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) => {
|
2026-05-21 10:05:40 +02:00
|
|
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
|
|
|
|
button.dataset.themeNext = nextTheme;
|
|
|
|
|
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`);
|
2026-05-20 23:55:27 +02:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
})();
|
2026-05-21 10:05:40 +02:00
|
|
|
""".strip()
|
2026-05-20 23:55:27 +02:00
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
|
|
|
|
|
CLIPBOARD_JS = """
|
2026-05-20 23:55:27 +02:00
|
|
|
document.addEventListener("click", async (event) => {
|
2026-05-21 14:18:39 +02:00
|
|
|
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
|
|
|
|
if (!menu.contains(event.target)) menu.removeAttribute("open");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
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 || "");
|
2026-05-21 14:18:39 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-20 23:55:27 +02:00
|
|
|
setTimeout(() => {
|
2026-05-21 14:18:39 +02:00
|
|
|
button.classList.remove("is-copied");
|
|
|
|
|
if (label) label.textContent = oldLabel;
|
2026-05-20 23:55:27 +02:00
|
|
|
}, 1200);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
});
|
2026-05-21 14:18:39 +02:00
|
|
|
|
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
|
|
|
if (event.key !== "Escape") return;
|
|
|
|
|
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
|
|
|
|
menu.removeAttribute("open");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-21 10:05:40 +02:00
|
|
|
""".strip()
|
2026-05-20 23:55:27 +02:00
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
|
|
|
|
|
ADMIN_TABLES_JS = """
|
2026-05-20 23:55:27 +02:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
@router.get("/favicon.ico")
|
|
|
|
|
def favicon() -> FileResponse:
|
|
|
|
|
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
|
|
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
@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)
|
|
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
@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)
|
|
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
@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)
|
|
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
@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)
|
|
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
@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)
|
|
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
@router.get("/site.webmanifest")
|
|
|
|
|
def site_webmanifest() -> Response:
|
|
|
|
|
return Response(
|
|
|
|
|
"""
|
|
|
|
|
{
|
|
|
|
|
"name": "KarlDNS",
|
|
|
|
|
"short_name": "KarlDNS",
|
2026-05-21 10:05:40 +02:00
|
|
|
"description": "Private update links for routers.",
|
2026-05-20 23:55:27 +02:00
|
|
|
"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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
@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)
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/app.css")
|
|
|
|
|
def app_css() -> Response:
|
2026-05-21 10:05:40 +02:00
|
|
|
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)
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
|
|
|
|
)
|