Archived
Polish KarlDNS production UI and docs
This commit is contained in:
1 parent
ca92dc211a
commit
5beee0e5c6
61 files changed
+1566
-523
No files matched your search
@@ -19,6 +19,7 @@ from ..web.templates import _copy_row
|
||||
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
|
||||
|
||||
ADMIN_TABLE_LIMIT = 100
|
||||
ADMIN_JSON_OFFSET_CAP = 10_000
|
||||
|
||||
|
||||
def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
@@ -42,6 +43,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
|
||||
store.list_scheduled_tasks(),
|
||||
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
|
||||
store.get_service_metrics(),
|
||||
{
|
||||
"accounts": store.count_accounts(),
|
||||
"domain_challenges": store.count_domain_challenges(),
|
||||
@@ -131,7 +133,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
"router update URL rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
|
||||
notice = _admin_secret_panel("Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
|
||||
elif action == "management":
|
||||
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
|
||||
await run_in_threadpool(
|
||||
@@ -143,7 +145,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
"management URL rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
notice = _admin_secret_panel("Private status page rotated", [_copy_row("Private status page:", service.magic_management_url(rotated))]) if rotated else None
|
||||
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.magic_management_url(rotated))]) if rotated else None
|
||||
elif action == "password":
|
||||
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
||||
await run_in_threadpool(
|
||||
@@ -157,10 +159,10 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
)
|
||||
if rotated:
|
||||
notice = _admin_secret_panel(
|
||||
"Router password rotated",
|
||||
"Router password changed",
|
||||
[
|
||||
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
|
||||
_copy_row("One-box update URL:", service.credentialed_update_url(rotated), "Secret"),
|
||||
_copy_row("Full update URL:", service.credentialed_update_url(rotated), "Secret"),
|
||||
_copy_row("Benutzername (optional):", str(rotated["username"]), "Public"),
|
||||
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
|
||||
],
|
||||
@@ -187,7 +189,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
settings.cleanup_event_history_hours,
|
||||
)
|
||||
notice = _admin_secret_panel(
|
||||
"Cleanup completed",
|
||||
"Cleanup done",
|
||||
[
|
||||
_copy_row("Domain claims removed", str(result["domain_challenges"])),
|
||||
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
|
||||
@@ -202,7 +204,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"items": store.list_records_page(limit=limit, offset=offset),
|
||||
@@ -216,12 +218,16 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
|
||||
before: str | None = Query(None, max_length=64),
|
||||
) -> dict[str, object]:
|
||||
items = store.list_update_events_before(limit=limit, before=before) if before else store.list_update_events_page(limit=limit, offset=offset)
|
||||
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
|
||||
return {
|
||||
"items": store.list_update_events_page(limit=limit, offset=offset),
|
||||
"items": items,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"next_before": next_before,
|
||||
"total": store.count_update_events(),
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from ..schemas import (
|
||||
_credentials_response,
|
||||
_managed_record,
|
||||
)
|
||||
from .path_params import ManagementSlugPath, UpdateSlugPath
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -30,7 +31,7 @@ router = APIRouter(prefix="/api/v1")
|
||||
"/hostnames/magic",
|
||||
response_model=HostnameCredentials,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Generate a provider-owned DynDNS hostname",
|
||||
summary="Create a KarlDNS hostname",
|
||||
tags=["hostnames"],
|
||||
responses={500: {"model": ErrorResponse}},
|
||||
)
|
||||
@@ -42,12 +43,12 @@ async def create_magic_hostname(payload: MagicHostnameRequest, service: ServiceD
|
||||
@router.get(
|
||||
"/management/{management_slug}",
|
||||
response_model=ManagedRecord,
|
||||
summary="Read a hostname by magic management link",
|
||||
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
|
||||
summary="Read a hostname by private dashboard link",
|
||||
description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
|
||||
tags=["management"],
|
||||
responses={404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def read_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> ManagedRecord:
|
||||
async def read_managed_hostname(management_slug: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> ManagedRecord:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
@@ -57,12 +58,12 @@ async def read_managed_hostname(management_slug: str, store: StoreDep, service:
|
||||
@router.delete(
|
||||
"/management/{management_slug}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a hostname by magic management link",
|
||||
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
|
||||
summary="Delete a hostname by private dashboard link",
|
||||
description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
|
||||
tags=["management"],
|
||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def delete_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> Response:
|
||||
async def delete_managed_hostname(management_slug: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> Response:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
@@ -70,7 +71,7 @@ async def delete_managed_hostname(management_slug: str, store: StoreDep, service
|
||||
try:
|
||||
await run_in_threadpool(service.delete_account, hostname, "api")
|
||||
except DnsProviderError as exc:
|
||||
raise HTTPException(status_code=500, detail="DNS delete failed; hostname was not deleted") from exc
|
||||
raise HTTPException(status_code=500, detail="DNS delete failed. Hostname was not deleted.") from exc
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -116,7 +117,7 @@ async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> Ve
|
||||
return VerifyDomainResponse(
|
||||
domain=domain,
|
||||
verified=True,
|
||||
message="domain verified",
|
||||
message="Domain verified",
|
||||
claim_secret=payload.claim_secret,
|
||||
)
|
||||
|
||||
@@ -125,7 +126,7 @@ async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> Ve
|
||||
"/hostnames/custom",
|
||||
response_model=HostnameCredentials,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Generate credentials for a verified custom hostname",
|
||||
summary="Create a hostname for a verified domain",
|
||||
tags=["hostnames"],
|
||||
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
|
||||
)
|
||||
@@ -147,7 +148,7 @@ async def create_custom_hostname(payload: CustomHostnameRequest, service: Servic
|
||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def update_hostname(
|
||||
update_slug: str,
|
||||
update_slug: UpdateSlugPath,
|
||||
request: Request,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
@@ -174,6 +175,6 @@ async def update_hostname(
|
||||
except BadIpError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except DnsProviderError as exc:
|
||||
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
|
||||
raise HTTPException(status_code=500, detail="DNS update failed") from exc
|
||||
result = outcome.result
|
||||
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
|
||||
@@ -5,13 +5,13 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
|
||||
from ..web.styles import APP_CSS
|
||||
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=3600"}
|
||||
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||
|
||||
APP_JS = """
|
||||
THEME_JS = """
|
||||
(() => {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const storageKey = "karldns-theme";
|
||||
@@ -30,8 +30,9 @@ APP_JS = """
|
||||
};
|
||||
const syncToggle = (theme) => {
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||
button.textContent = theme === "dark" ? "Dark" : "Light";
|
||||
button.setAttribute("aria-label", `Switch to ${theme === "dark" ? "light" : "dark"} theme`);
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
button.dataset.themeNext = nextTheme;
|
||||
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`);
|
||||
});
|
||||
};
|
||||
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
|
||||
@@ -54,7 +55,10 @@ APP_JS = """
|
||||
applyTheme(nextTheme);
|
||||
});
|
||||
})();
|
||||
""".strip()
|
||||
|
||||
|
||||
CLIPBOARD_JS = """
|
||||
document.addEventListener("click", async (event) => {
|
||||
const toggle = event.target.closest("[data-secret-toggle]");
|
||||
if (toggle) {
|
||||
@@ -87,7 +91,10 @@ document.addEventListener("click", async (event) => {
|
||||
}, 1200);
|
||||
} catch (_) {}
|
||||
});
|
||||
""".strip()
|
||||
|
||||
|
||||
ADMIN_TABLES_JS = """
|
||||
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
||||
const table = tableShell.querySelector("table");
|
||||
const tbody = tableShell.querySelector("tbody");
|
||||
@@ -176,10 +183,22 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
||||
""".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)
|
||||
@@ -196,6 +215,14 @@ def apple_touch_icon_png() -> FileResponse:
|
||||
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)
|
||||
@@ -204,6 +231,14 @@ def logo_192_png() -> FileResponse:
|
||||
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(
|
||||
@@ -211,7 +246,7 @@ def site_webmanifest() -> Response:
|
||||
{
|
||||
"name": "KarlDNS",
|
||||
"short_name": "KarlDNS",
|
||||
"description": "Accountless Dynamic DNS for routers and home labs.",
|
||||
"description": "Private update links for routers.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
@@ -227,14 +262,29 @@ def site_webmanifest() -> Response:
|
||||
headers=ASSET_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
@router.get("/app.js")
|
||||
def app_js() -> Response:
|
||||
return Response(APP_JS, media_type="application/javascript", 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(APP_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
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")
|
||||
|
||||
@@ -26,6 +26,7 @@ from .management_shared import (
|
||||
locked_management_response,
|
||||
render_management_page_async,
|
||||
)
|
||||
from .path_params import ManagementSlugPath
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(management_passwords.router)
|
||||
@@ -33,24 +34,24 @@ router.include_router(management_domains.router)
|
||||
|
||||
|
||||
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
|
||||
def manage_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
|
||||
def manage_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
|
||||
account = get_active_management_account(store, management_slug)
|
||||
csrf_token = dashboard_csrf_token(management_slug, account)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return _render_management_unlock_page(management_slug, account, csrf_token)
|
||||
return _render_management_page(service, settings, management_slug, account, csrf_token)
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def rotate_magic_credentials(
|
||||
management_slug: str,
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
@@ -75,7 +76,7 @@ async def rotate_magic_credentials(
|
||||
management_slug,
|
||||
rotated,
|
||||
dashboard_csrf_token(management_slug, rotated),
|
||||
message="Update-URL rotated.",
|
||||
message="Update URL changed. Update your router now.",
|
||||
)
|
||||
if action == "password":
|
||||
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
||||
@@ -97,16 +98,16 @@ async def rotate_magic_credentials(
|
||||
management_slug,
|
||||
rotated,
|
||||
dashboard_csrf_token(management_slug, rotated),
|
||||
message="Router credentials rotated. Copy the new password now.",
|
||||
message="Router password changed. Copy it now.",
|
||||
created_account=rotated,
|
||||
)
|
||||
raise _bad_rotation_action()
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/delete", include_in_schema=False)
|
||||
async def delete_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
|
||||
async def delete_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
@@ -114,7 +115,7 @@ async def delete_magic(management_slug: str, request: Request, settings: Setting
|
||||
try:
|
||||
await run_in_threadpool(service.delete_account, hostname, "management-link")
|
||||
except DnsProviderError:
|
||||
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
|
||||
return PlainTextResponse("DNS delete failed. Hostname was not deleted.", status_code=500)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
@@ -126,7 +127,7 @@ async def delete_magic(management_slug: str, request: Request, settings: Setting
|
||||
)
|
||||
return HTMLResponse(
|
||||
_page(
|
||||
"DynDNS deleted",
|
||||
"Hostname deleted",
|
||||
trusted(
|
||||
"""
|
||||
<main>
|
||||
|
||||
@@ -23,12 +23,13 @@ from .management_shared import (
|
||||
locked_management_response,
|
||||
render_management_page_async,
|
||||
)
|
||||
from .path_params import ClaimSecretPath, ManagementSlugPath
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/d/{claim_secret}", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def manage_domain_claim(claim_secret: str, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
|
||||
async def manage_domain_claim(claim_secret: ClaimSecretPath, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
|
||||
challenge = await run_in_threadpool(store.get_domain_challenge_by_secret, claim_secret)
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
@@ -37,14 +38,14 @@ async def manage_domain_claim(claim_secret: str, settings: SettingsDep, store: S
|
||||
|
||||
@router.post("/m/{management_slug}/domains/request", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def request_dashboard_domain(
|
||||
management_slug: str,
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
@@ -71,28 +72,28 @@ async def request_dashboard_domain(
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Domain claim created. Add the TXT record, then verify it.",
|
||||
message="Claim created. Add the TXT record, then verify it.",
|
||||
challenge=challenge,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/domains/verify", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def verify_dashboard_domain(
|
||||
management_slug: str,
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
domain = normalize_domain(first_form_value(form, "domain"))
|
||||
claim_secret = first_form_value(form, "claim_secret")
|
||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, str(account["hostname"]))
|
||||
message = "Domain verified. You can now create a custom hostname." if found else "TXT record not found yet. DNS propagation can take a few minutes."
|
||||
message = "Domain verified. You can create a hostname now." if found else "TXT record not found yet. Try again in a few minutes."
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
str(account["hostname"]),
|
||||
@@ -115,14 +116,14 @@ async def verify_dashboard_domain(
|
||||
|
||||
@router.post("/m/{management_slug}/domains/hostnames", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def create_dashboard_custom_hostname(
|
||||
management_slug: str,
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
@@ -148,6 +149,6 @@ async def create_dashboard_custom_hostname(
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Custom hostname created. Copy these router settings now.",
|
||||
message="Hostname created. Copy these router settings now.",
|
||||
created_account=created,
|
||||
)
|
||||
@@ -18,12 +18,13 @@ from .management_shared import (
|
||||
set_unlock_cookie,
|
||||
unlock_cookie_seconds,
|
||||
)
|
||||
from .path_params import ManagementSlugPath
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
|
||||
async def unlock_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
|
||||
async def unlock_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not account.get("dashboard_password_hash"):
|
||||
return RedirectResponse(f"/m/{management_slug}", status_code=303)
|
||||
@@ -34,7 +35,7 @@ async def unlock_magic(management_slug: str, request: Request, settings: Setting
|
||||
is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password)
|
||||
if not is_valid:
|
||||
return HTMLResponse(
|
||||
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect dashboard password."),
|
||||
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect password."),
|
||||
status_code=401,
|
||||
)
|
||||
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
|
||||
@@ -50,14 +51,14 @@ async def unlock_magic(management_slug: str, request: Request, settings: Setting
|
||||
|
||||
@router.post("/m/{management_slug}/password", include_in_schema=False)
|
||||
async def set_dashboard_password(
|
||||
management_slug: str,
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
@@ -69,7 +70,7 @@ async def set_dashboard_password(
|
||||
first_form_value(form, "current_dashboard_password"),
|
||||
)
|
||||
if not is_current:
|
||||
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
|
||||
return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.")
|
||||
password = first_form_value(form, "dashboard_password")
|
||||
confirm = first_form_value(form, "dashboard_password_confirm")
|
||||
if len(password) < 8:
|
||||
@@ -79,7 +80,7 @@ async def set_dashboard_password(
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Dashboard password must be at least 8 characters.",
|
||||
message="Password must be at least 8 characters.",
|
||||
)
|
||||
return HTMLResponse(page, status_code=400)
|
||||
if password != confirm:
|
||||
@@ -89,7 +90,7 @@ async def set_dashboard_password(
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Dashboard password confirmation did not match.",
|
||||
message="The passwords do not match.",
|
||||
)
|
||||
return HTMLResponse(page, status_code=400)
|
||||
had_password = bool(account.get("dashboard_password_hash"))
|
||||
@@ -111,14 +112,14 @@ async def set_dashboard_password(
|
||||
|
||||
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
|
||||
async def remove_dashboard_password(
|
||||
management_slug: str,
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
@@ -130,7 +131,7 @@ async def remove_dashboard_password(
|
||||
first_form_value(form, "current_dashboard_password"),
|
||||
)
|
||||
if not is_current:
|
||||
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
|
||||
return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.")
|
||||
await run_in_threadpool(store.clear_dashboard_password, hostname)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
|
||||
@@ -7,6 +7,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..ddns_security import (
|
||||
DASHBOARD_UNLOCK_COOKIE,
|
||||
dashboard_cookie_signing_secret,
|
||||
dashboard_csrf_token,
|
||||
sign_dashboard_unlock_cookie,
|
||||
verify_dashboard_unlock_cookie,
|
||||
@@ -30,7 +31,7 @@ async def get_active_management_account_async(store: DdnsStore, management_slug:
|
||||
return await run_in_threadpool(get_active_management_account, store, management_slug)
|
||||
|
||||
|
||||
def has_management_access(request: Request, management_slug: str, account: ManagementAccount) -> bool:
|
||||
def has_management_access(request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount) -> bool:
|
||||
password_hash = account.get("dashboard_password_hash")
|
||||
if not password_hash:
|
||||
return True
|
||||
@@ -38,6 +39,7 @@ def has_management_access(request: Request, management_slug: str, account: Manag
|
||||
str(account["hostname"]),
|
||||
management_slug,
|
||||
str(password_hash),
|
||||
dashboard_cookie_signing_secret(settings),
|
||||
request.cookies.get(DASHBOARD_UNLOCK_COOKIE),
|
||||
)
|
||||
|
||||
@@ -100,11 +102,18 @@ def set_unlock_cookie(
|
||||
max_age_seconds: int,
|
||||
) -> None:
|
||||
password_hash = account.get("dashboard_password_hash")
|
||||
if not password_hash:
|
||||
signing_secret = dashboard_cookie_signing_secret(settings)
|
||||
if not password_hash or not signing_secret:
|
||||
return
|
||||
response.set_cookie(
|
||||
DASHBOARD_UNLOCK_COOKIE,
|
||||
sign_dashboard_unlock_cookie(str(account["hostname"]), management_slug, str(password_hash), max_age_seconds),
|
||||
sign_dashboard_unlock_cookie(
|
||||
str(account["hostname"]),
|
||||
management_slug,
|
||||
str(password_hash),
|
||||
signing_secret,
|
||||
max_age_seconds,
|
||||
),
|
||||
max_age=max_age_seconds,
|
||||
path=f"/m/{management_slug}",
|
||||
httponly=True,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path
|
||||
|
||||
TOKEN_URLSAFE_PATTERN = r"^[A-Za-z0-9_-]+$"
|
||||
TOKEN_MIN_LENGTH = 32
|
||||
TOKEN_MAX_LENGTH = 128
|
||||
|
||||
UpdateSlugPath = Annotated[
|
||||
str,
|
||||
Path(
|
||||
min_length=TOKEN_MIN_LENGTH,
|
||||
max_length=TOKEN_MAX_LENGTH,
|
||||
pattern=TOKEN_URLSAFE_PATTERN,
|
||||
description="Private update URL value.",
|
||||
),
|
||||
]
|
||||
ManagementSlugPath = Annotated[
|
||||
str,
|
||||
Path(
|
||||
min_length=TOKEN_MIN_LENGTH,
|
||||
max_length=TOKEN_MAX_LENGTH,
|
||||
pattern=TOKEN_URLSAFE_PATTERN,
|
||||
description="Private dashboard URL value.",
|
||||
),
|
||||
]
|
||||
ClaimSecretPath = Annotated[
|
||||
str,
|
||||
Path(
|
||||
min_length=TOKEN_MIN_LENGTH,
|
||||
max_length=TOKEN_MAX_LENGTH,
|
||||
pattern=TOKEN_URLSAFE_PATTERN,
|
||||
description="Private domain claim value.",
|
||||
),
|
||||
]
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_security import client_ip
|
||||
@@ -52,9 +52,9 @@ async def verify_domain(request: Request, settings: SettingsDep, service: Servic
|
||||
claim_secret = first_form_value(form, "claim_secret")
|
||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
|
||||
if not found:
|
||||
message = "TXT record not found yet. DNS propagation can take a few minutes."
|
||||
message = "TXT record not found yet. Try again in a few minutes."
|
||||
return _render_public_home(service, settings, None, message, challenge)
|
||||
return _render_public_home(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge)
|
||||
return _render_public_home(service, settings, None, "Domain verified. You can create a hostname now.", challenge)
|
||||
|
||||
|
||||
@router.post("/accounts", response_class=HTMLResponse, include_in_schema=False)
|
||||
@@ -85,5 +85,47 @@ def visible_ip(request: Request, settings: SettingsDep) -> str:
|
||||
|
||||
|
||||
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
|
||||
def privacy() -> str:
|
||||
return _render_privacy_page()
|
||||
def privacy(settings: SettingsDep) -> str:
|
||||
return _render_privacy_page(settings)
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=PlainTextResponse, include_in_schema=False)
|
||||
def robots_txt(settings: SettingsDep) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
admin_path = settings.admin_path.rstrip("/")
|
||||
return "\n".join(
|
||||
[
|
||||
"User-agent: *",
|
||||
f"Disallow: {admin_path}/",
|
||||
"Disallow: /m/",
|
||||
"Disallow: /d/",
|
||||
"Disallow: /u/",
|
||||
"Disallow: /nic/",
|
||||
"Disallow: /api/",
|
||||
"Disallow: /docs",
|
||||
"Disallow: /redoc",
|
||||
"Disallow: /openapi.json",
|
||||
f"Sitemap: {base_url}/sitemap.xml",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sitemap.xml", include_in_schema=False)
|
||||
def sitemap_xml(settings: SettingsDep) -> Response:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
body = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>{base_url}/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{base_url}/privacy</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
"""
|
||||
return Response(body, media_type="application/xml")
|
||||
@@ -6,13 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update
|
||||
from .path_params import UpdateSlugPath
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
|
||||
def slug_update(
|
||||
update_slug: str,
|
||||
update_slug: UpdateSlugPath,
|
||||
request: Request,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
|
||||
Reference in new issue
Block a user