Update visible examples with visitor IP

This commit is contained in:
StasonJatham committed 2026-05-21 18:51:18 +02:00
1 parent 8de260dfc7
commit 03ef2d9af6
6 files changed
+67 -2

No files matched your search

+6 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from fastapi import APIRouter, Response from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS, VISITOR_IP_JS
from ..web.styles import ADMIN_CSS, BASE_CSS from ..web.styles import ADMIN_CSS, BASE_CSS
ASSET_DIR = Path(__file__).parents[1] / "assets" ASSET_DIR = Path(__file__).parents[1] / "assets"
@@ -107,6 +107,11 @@ def admin_tables_js() -> Response:
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/visitor-ip.js")
def visitor_ip_js() -> Response:
return Response(VISITOR_IP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/app.css") @router.get("/app.css")
def app_css() -> Response: def app_css() -> Response:
return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS) return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
+1
View File
@@ -85,6 +85,7 @@ class DdnsSettings(BaseModel):
"/social-preview.webp", "/social-preview.webp",
"/theme.js", "/theme.js",
"/uninstall.sh", "/uninstall.sh",
"/visitor-ip.js",
} }
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths): if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
raise ValueError("admin_path must not conflict with a public route") raise ValueError("admin_path must not conflict with a public route")
+1
View File
@@ -137,6 +137,7 @@ def _page(
{preload_logo_html} {preload_logo_html}
<script src="/theme.js?v=2"></script> <script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=3" defer></script> <script src="/clipboard.js?v=3" defer></script>
<script src="/visitor-ip.js?v=1" defer></script>
{extra_scripts_html} {extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet"> <link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=13" rel="stylesheet"> <link href="/app.css?v=13" rel="stylesheet">
+52
View File
@@ -190,3 +190,55 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
render(); render();
}); });
""".strip() """.strip()
VISITOR_IP_JS = """
(() => {
const ipv4Placeholder = "203.0.113.10";
const ipv6Placeholder = "2001:db8::10";
const needsIpv4 = () => document.body.textContent.includes(ipv4Placeholder) || document.querySelector(`[data-copy*="${ipv4Placeholder}"]`);
const needsIpv6 = () => document.body.textContent.includes(ipv6Placeholder) || document.querySelector(`[data-copy*="${ipv6Placeholder}"]`);
const fetchIp = async (url) => {
try {
const response = await fetch(url, { cache: "no-store", mode: "cors" });
if (!response.ok) return "";
const payload = await response.json();
return typeof payload.ip === "string" ? payload.ip : "";
} catch (_) {
return "";
}
};
const replaceInTextNodes = (search, replacement) => {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) {
if (walker.currentNode.nodeValue.includes(search)) nodes.push(walker.currentNode);
}
nodes.forEach((node) => {
node.nodeValue = node.nodeValue.split(search).join(replacement);
});
};
const replaceInCopyValues = (search, replacement) => {
document.querySelectorAll("[data-copy]").forEach((element) => {
const value = element.dataset.copy || "";
if (value.includes(search)) element.dataset.copy = value.split(search).join(replacement);
});
};
const replaceEverywhere = (search, replacement) => {
if (!replacement || replacement === search) return;
replaceInTextNodes(search, replacement);
replaceInCopyValues(search, replacement);
};
document.addEventListener("DOMContentLoaded", async () => {
const tasks = [];
if (needsIpv4()) tasks.push(fetchIp("https://api4.ipify.org?format=json").then((ip) => replaceEverywhere(ipv4Placeholder, ip)));
if (needsIpv6()) tasks.push(fetchIp("https://api6.ipify.org?format=json").then((ip) => replaceEverywhere(ipv6Placeholder, ip)));
await Promise.allSettled(tasks);
});
})();
""".strip()
+1 -1
View File
@@ -132,7 +132,7 @@ def content_security_policy(path: str) -> str:
"style-src 'self'; " "style-src 'self'; "
"img-src 'self' data:; " "img-src 'self' data:; "
"font-src 'self'; " "font-src 'self'; "
"connect-src 'self'; " "connect-src 'self' https://api4.ipify.org https://api6.ipify.org; "
"form-action 'self'; " "form-action 'self'; "
"frame-ancestors 'none'" "frame-ancestors 'none'"
) )
+6
View File
@@ -552,6 +552,7 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert response.status_code == 200 assert response.status_code == 200
assert '<script src="/theme.js?v=2"></script>' in response.text assert '<script src="/theme.js?v=2"></script>' in response.text
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
assert '<script src="/visitor-ip.js?v=1" defer></script>' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
assert '<link href="/app.css?v=13" rel="stylesheet">' in response.text assert '<link href="/app.css?v=13" rel="stylesheet">' in response.text
@@ -560,6 +561,7 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert "<style>" not in response.text assert "<style>" not in response.text
assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"] assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"]
assert "style-src 'self'" in response.headers["content-security-policy"] assert "style-src 'self'" in response.headers["content-security-policy"]
assert "connect-src 'self' https://api4.ipify.org https://api6.ipify.org" in response.headers["content-security-policy"]
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"] assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
bootstrap_css = client.get("/bootstrap-subset.css") bootstrap_css = client.get("/bootstrap-subset.css")
assert bootstrap_css.status_code == 200 assert bootstrap_css.status_code == 200
@@ -567,6 +569,10 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert bootstrap_css.headers["cache-control"] == "public, max-age=31536000, immutable" assert bootstrap_css.headers["cache-control"] == "public, max-age=31536000, immutable"
assert client.get("/theme.js").status_code == 200 assert client.get("/theme.js").status_code == 200
assert client.get("/clipboard.js").status_code == 200 assert client.get("/clipboard.js").status_code == 200
visitor_ip_js = client.get("/visitor-ip.js")
assert visitor_ip_js.status_code == 200
assert "api4.ipify.org" in visitor_ip_js.text
assert "api6.ipify.org" in visitor_ip_js.text
assert client.get("/admin-tables.js").status_code == 200 assert client.get("/admin-tables.js").status_code == 200
app_css = client.get("/app.css") app_css = client.get("/app.css")
assert app_css.status_code == 200 assert app_css.status_code == 200