Refactor UI assets and harden quality checks

This commit is contained in:
StasonJatham committed 2026-06-17 23:18:00 +02:00
1 parent 23ab466c52
commit 4ecc8eb874
62 files changed
+3314 -3206

No files matched your search

+45 -50
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from typing import Annotated
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@@ -18,72 +19,67 @@ from ..services.ddns import normalize_hostname
from ..web.components import _copy_row
from ..web.forms import first_form_value, request_form
from ..web.html_utils import SafeHtml
from ..web.templates_admin import _admin_notice, _admin_secret_panel, _render_admin_page
from ..web.templates_admin import _admin_notice, _admin_page_context, _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100
ADMIN_JSON_OFFSET_CAP = 10_000
AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
AdminOffsetQuery = Annotated[int, Query(ge=0)]
CleanupDoneQuery = Annotated[bool, Query()]
CleanupCountQuery = Annotated[int, Query(ge=0)]
def _admin_table_offsets(
accounts_offset: AdminOffsetQuery = 0,
cleanup_runs_offset: AdminOffsetQuery = 0,
update_events_offset: AdminOffsetQuery = 0,
) -> dict[str, int]:
return {
"accounts": accounts_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
}
def _admin_cleanup_notice(
cleanup_done: CleanupDoneQuery = False,
cleanup_unused: CleanupCountQuery = 0,
cleanup_events: CleanupCountQuery = 0,
) -> SafeHtml | None:
if not cleanup_done:
return None
return _admin_notice(
"success",
f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events.",
)
AdminTableOffsetsDep = Annotated[dict[str, int], Depends(_admin_table_offsets)]
AdminCleanupNoticeDep = Annotated[SafeHtml | None, Depends(_admin_cleanup_notice)]
def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter()
def render_admin(
service: ServiceDep,
settings: SettingsDep,
store: StoreDep,
table_offsets: dict[str, int] | None = None,
notice: SafeHtml | None = None,
) -> str:
table_offsets = table_offsets or {}
return _render_admin_page(
service,
settings,
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
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(),
"cleanup_runs": store.count_cleanup_runs(),
"update_events": store.count_update_events(),
},
table_offsets,
ADMIN_TABLE_LIMIT,
notice,
)
context = _admin_page_context(store, table_offsets, ADMIN_TABLE_LIMIT, notice)
return _render_admin_page(settings, context)
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
def admin(
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
accounts_offset: int = Query(0, ge=0),
cleanup_runs_offset: int = Query(0, ge=0),
update_events_offset: int = Query(0, ge=0),
cleanup_done: bool = Query(False),
cleanup_unused: int = Query(0, ge=0),
cleanup_events: int = Query(0, ge=0),
_: AdminAuthDep,
table_offsets: AdminTableOffsetsDep,
notice: AdminCleanupNoticeDep,
) -> str:
notice = (
_admin_notice(
"success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events."
)
if cleanup_done
else None
)
return render_admin(
service,
settings,
store,
{
"accounts": accounts_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
},
notice,
)
return render_admin(settings, store, table_offsets, notice)
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
async def admin_delete_account(
@@ -91,7 +87,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
@@ -118,7 +114,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
@@ -168,7 +164,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
)
else:
raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
page = await run_in_threadpool(render_admin, settings, store, None, notice)
return HTMLResponse(page)
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
@@ -176,8 +172,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
+29 -15
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from starlette.concurrency import run_in_threadpool
@@ -33,6 +35,12 @@ router = APIRouter(prefix="/api/v1")
ADMIN_API_LIMIT_CAP = 500
ADMIN_API_OFFSET_CAP = 10_000
HOSTNAME_HISTORY_LIMIT_CAP = 100
AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
HostnameHistoryLimitQuery = Annotated[int, Query(ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP)]
AdminLimitQuery = Annotated[int, Query(ge=1, le=ADMIN_API_LIMIT_CAP)]
AdminOffsetQuery = Annotated[int, Query(ge=0, le=ADMIN_API_OFFSET_CAP)]
AdminDashboardLimitQuery = Annotated[int, Query(ge=1, le=ADMIN_API_LIMIT_CAP)]
AdminBeforeQuery = Annotated[str | None, Query(max_length=64)]
@router.post(
@@ -52,7 +60,10 @@ async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
"/hostnames/{dashboard_secret}",
response_model=HostnameStatusResponse,
summary="Read a hostname by private dashboard link",
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
description=(
"Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, "
"not this API endpoint."
),
tags=["hostnames"],
responses={404: {"model": ErrorResponse}},
)
@@ -60,7 +71,7 @@ async def read_hostname(
dashboard_secret: ManagementSlugPath,
store: StoreDep,
service: ServiceDep,
history_limit: int = Query(10, ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP),
history_limit: HostnameHistoryLimitQuery = 10,
) -> HostnameStatusResponse:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
@@ -113,7 +124,10 @@ async def rotate_hostname_update_url(
"/hostnames/{dashboard_secret}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by private dashboard link",
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
description=(
"Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, "
"not this API endpoint."
),
tags=["hostnames"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
@@ -140,8 +154,8 @@ async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep,
)
async def read_admin(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(25, ge=1, le=ADMIN_API_LIMIT_CAP),
_: AdminAuthDep,
limit: AdminDashboardLimitQuery = 25,
) -> AdminDashboardResponse:
metrics = await run_in_threadpool(store.get_service_metrics)
accounts = await run_in_threadpool(store.list_accounts, limit, 0)
@@ -175,9 +189,9 @@ async def read_admin(
)
async def list_admin_hostnames(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
_: AdminAuthDep,
limit: AdminLimitQuery = 100,
offset: AdminOffsetQuery = 0,
) -> AdminHostnamesResponse:
accounts = await run_in_threadpool(store.list_accounts, limit, offset)
total = await run_in_threadpool(store.count_accounts)
@@ -200,10 +214,10 @@ async def list_admin_hostnames(
)
async def list_admin_events(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
before: str | None = Query(None, max_length=64),
_: AdminAuthDep,
limit: AdminLimitQuery = 100,
offset: AdminOffsetQuery = 0,
before: AdminBeforeQuery = None,
) -> AdminEventsResponse:
items = (
await run_in_threadpool(store.list_update_events_before, limit, before)
@@ -232,9 +246,9 @@ async def list_admin_events(
)
async def list_admin_records(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
_: AdminAuthDep,
limit: AdminLimitQuery = 100,
offset: AdminOffsetQuery = 0,
) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset)
total = await run_in_threadpool(store.count_records)
+12 -32
View File
@@ -5,31 +5,11 @@ from pathlib import Path
from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse
from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS, VISITOR_IP_JS
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"}
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)
@@ -115,33 +95,33 @@ def site_webmanifest() -> Response:
@router.get("/theme.js")
def theme_js() -> Response:
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
def theme_js() -> FileResponse:
return FileResponse(ASSET_DIR / "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)
def clipboard_js() -> FileResponse:
return FileResponse(ASSET_DIR / "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)
def admin_tables_js() -> FileResponse:
return FileResponse(ASSET_DIR / "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)
def visitor_ip_js() -> FileResponse:
return FileResponse(ASSET_DIR / "visitor-ip.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)
def app_css() -> FileResponse:
return FileResponse(ASSET_DIR / "app.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)
def admin_css() -> FileResponse:
return FileResponse(ASSET_DIR / "admin.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/install.sh")
+78 -45
View File
@@ -1,8 +1,8 @@
from __future__ import annotations
from typing import NoReturn
from typing import Annotated, NoReturn
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
@@ -14,11 +14,13 @@ from ..event_statuses import (
DASHBOARD_UPDATE_URL_ROTATED,
)
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..services.ddns import BadIpError
from ..services.ddns import BadIpError, UpdateIpParams
from ..web.forms import first_form_value, request_form
from ..web.html_utils import trusted
from ..web.layout import _page
from ..web.templates_management import (
ManagementPageContext,
PendingManagementPageContext,
_render_management_page,
_render_management_unlock_page,
_render_pending_management_page,
@@ -36,6 +38,19 @@ from .path_params import ManagementSlugPath
router = APIRouter()
router.include_router(management_passwords.router)
ActivationIpQuery = Annotated[str | None, Query()]
def _activation_ip_params_from_query(
myip: ActivationIpQuery = None,
ipaddr: ActivationIpQuery = None,
myipv6: ActivationIpQuery = None,
ip6addr: ActivationIpQuery = None,
) -> UpdateIpParams:
return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
ActivationIpParamsDep = Annotated[UpdateIpParams, Depends(_activation_ip_params_from_query)]
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
@@ -43,27 +58,34 @@ def manage_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
activated: bool = False,
) -> str:
account = get_active_management_account(store, management_slug)
account = get_active_management_account(service.store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account)
if not management_account_is_accepted(account):
return _render_pending_management_page(
service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings)
service,
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=csrf_token,
request_ip=client_ip(request, settings),
),
)
if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None
return _render_management_page(
service,
settings,
management_slug,
account,
csrf_token,
message=message,
request_ip=client_ip(request, settings),
ManagementPageContext(
management_slug=management_slug,
account=account,
csrf_token=csrf_token,
message=message,
request_ip=client_ip(request, settings),
),
)
@@ -72,30 +94,36 @@ async def activate_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
ip_params: ActivationIpParamsDep,
) -> RedirectResponse | HTMLResponse:
account = await get_active_management_account_async(store, management_slug)
account = await get_active_management_account_async(service.store, management_slug)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if not management_account_is_accepted(account):
try:
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
await run_in_threadpool(
service.publish_initial_dns,
request,
hostname,
ip_params,
)
except BadIpError:
return HTMLResponse(
_render_pending_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"The supplied IP address is invalid. Leave IP fields empty and let the router send the first update.",
client_ip(request, settings),
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message=(
"The supplied IP address is invalid. Leave IP fields empty and let the router "
"send the first update."
),
request_ip=client_ip(request, settings),
),
),
status_code=400,
)
@@ -103,20 +131,22 @@ async def activate_dashboard(
return HTMLResponse(
_render_pending_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"DNS registration failed. The hostname was not registered.",
client_ip(request, settings),
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message="DNS registration failed. The hostname was not registered.",
request_ip=client_ip(request, settings),
),
),
status_code=500,
)
activated_account = await run_in_threadpool(lambda: store.activate_account(hostname))
activated_account = await run_in_threadpool(lambda: service.store.activate_account(hostname))
if not activated_account:
return _not_found()
await run_in_threadpool(
store.log_update_event,
service.store.log_update_event,
hostname,
None,
None,
@@ -140,12 +170,14 @@ async def rotate_dashboard_secrets(
return HTMLResponse(
_render_pending_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"Register this hostname first.",
client_ip(request, settings),
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message="Register this hostname first.",
request_ip=client_ip(request, settings),
),
),
status_code=400,
)
@@ -171,12 +203,13 @@ async def rotate_dashboard_secrets(
)
return await render_management_page_async(
service,
settings,
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Update URL changed. Update your router now.",
request_ip=request_ip,
ManagementPageContext(
management_slug=management_slug,
account=rotated,
csrf_token=dashboard_csrf_token(management_slug, rotated),
message="Update URL changed. Update your router now.",
request_ip=request_ip,
),
)
raise _bad_rotation_action()
+84 -40
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
@@ -7,9 +10,11 @@ from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..store import DdnsStore
from ..web.forms import first_form_value, request_form
from ..web.templates_management import _render_management_unlock_page
from ..web.templates_management import ManagementPageContext, _render_management_unlock_page
from .management_shared import (
ManagementAccount,
delete_unlock_cookie,
get_active_management_account_async,
has_management_access,
@@ -23,6 +28,15 @@ from .path_params import ManagementSlugPath
router = APIRouter()
@dataclass(frozen=True, slots=True)
class _DashboardPasswordErrorContext:
management_slug: str
account: ManagementAccount
form: dict[str, list[str]]
request_ip: str
current_password_error: str | None
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_dashboard(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep
@@ -70,45 +84,21 @@ async def set_dashboard_password(
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if account.get("dashboard_password_hash"):
is_current = await run_in_threadpool(
store.verify_dashboard_password,
hostname,
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(
service,
settings,
management_slug,
account,
"Current password is incorrect.",
client_ip(request, settings),
)
current_password_error = await _verify_current_dashboard_password(store, hostname, account, form)
request_ip = client_ip(request, settings)
error_response = await _dashboard_password_error_response(
service,
_DashboardPasswordErrorContext(
management_slug=management_slug,
account=account,
form=form,
request_ip=request_ip,
current_password_error=current_password_error,
),
)
if error_response:
return error_response
password = first_form_value(form, "dashboard_password")
confirm = first_form_value(form, "dashboard_password_confirm")
if len(password) < 8:
page = await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Password must be at least 8 characters.",
request_ip=client_ip(request, settings),
)
return HTMLResponse(page, status_code=400)
if password != confirm:
page = await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="The passwords do not match.",
request_ip=client_ip(request, settings),
)
return HTMLResponse(page, status_code=400)
had_password = bool(account.get("dashboard_password_hash"))
await run_in_threadpool(store.set_dashboard_password, hostname, password)
account = await get_active_management_account_async(store, management_slug)
@@ -126,6 +116,61 @@ async def set_dashboard_password(
return response
def _dashboard_password_error(password: str, confirm: str) -> str | None:
if len(password) < 8:
return "Password must be at least 8 characters."
if password != confirm:
return "The passwords do not match."
return None
async def _dashboard_password_error_response(
service: ServiceDep,
context: _DashboardPasswordErrorContext,
) -> HTMLResponse | None:
if context.current_password_error:
return await management_error_response_async(
service,
context.management_slug,
context.account,
context.current_password_error,
context.request_ip,
)
password_error = _dashboard_password_error(
first_form_value(context.form, "dashboard_password"),
first_form_value(context.form, "dashboard_password_confirm"),
)
if password_error is None:
return None
page = await render_management_page_async(
service,
ManagementPageContext(
management_slug=context.management_slug,
account=context.account,
csrf_token=dashboard_csrf_token(context.management_slug, context.account),
message=password_error,
request_ip=context.request_ip,
),
)
return HTMLResponse(page, status_code=400)
async def _verify_current_dashboard_password(
store: DdnsStore,
hostname: str,
account: Mapping[str, object],
form: dict[str, list[str]],
) -> str | None:
if not account.get("dashboard_password_hash"):
return None
is_current = await run_in_threadpool(
store.verify_dashboard_password,
hostname,
first_form_value(form, "current_dashboard_password"),
)
return None if is_current else "Current password is incorrect."
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
async def remove_dashboard_password(
management_slug: ManagementSlugPath,
@@ -152,7 +197,6 @@ async def remove_dashboard_password(
if not is_current:
return await management_error_response_async(
service,
settings,
management_slug,
account,
"Current password is incorrect.",
+13 -27
View File
@@ -6,6 +6,7 @@ from starlette.concurrency import run_in_threadpool
from ..security import (
DASHBOARD_UNLOCK_COOKIE,
DashboardUnlockIdentity,
dashboard_cookie_signing_secret,
dashboard_csrf_token,
sign_dashboard_unlock_cookie,
@@ -15,7 +16,7 @@ from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from ..store import DdnsStore
from ..store.types import AccountRow
from ..web.templates_management import _render_management_page, _render_management_unlock_page
from ..web.templates_management import ManagementPageContext, _render_management_page, _render_management_unlock_page
ManagementAccount = AccountRow
@@ -59,7 +60,6 @@ def locked_management_response(management_slug: str, account: ManagementAccount)
def management_error_response(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
@@ -68,12 +68,13 @@ def management_error_response(
return HTMLResponse(
_render_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message=message,
request_ip=request_ip,
ManagementPageContext(
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message=message,
request_ip=request_ip,
),
),
status_code=400,
)
@@ -81,35 +82,22 @@ def management_error_response(
async def management_error_response_async(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
request_ip: str | None = None,
) -> HTMLResponse:
return await run_in_threadpool(
management_error_response, service, settings, management_slug, account, message, request_ip
)
return await run_in_threadpool(management_error_response, service, management_slug, account, message, request_ip)
async def render_management_page_async(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
context: ManagementPageContext,
) -> str:
return await run_in_threadpool(
_render_management_page,
service,
settings,
management_slug,
account,
csrf_token,
message,
request_ip,
context,
)
@@ -127,9 +115,7 @@ def set_unlock_cookie(
response.set_cookie(
DASHBOARD_UNLOCK_COOKIE,
sign_dashboard_unlock_cookie(
str(account["hostname"]),
management_slug,
str(password_hash),
DashboardUnlockIdentity(str(account["hostname"]), management_slug, str(password_hash)),
signing_secret,
max_age_seconds,
),
+136 -86
View File
@@ -1,20 +1,36 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Annotated
from urllib.parse import parse_qs
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, StoreDep, apply_update_response
from ..dependencies import ServiceDep, apply_update_response
from ..dns import DnsProviderError
from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED
from ..security import client_ip
from ..services.ddns import BadIpError
from ..services.ddns import BadIpError, DdnsService, UpdateIpParams
from .path_params import ManagementSlugPath, UpdateSlugPath
router = APIRouter()
RouterIpQuery = Annotated[str | None, Query()]
_IpParams = UpdateIpParams
def _ip_params_from_query(
myip: RouterIpQuery = None,
ipaddr: RouterIpQuery = None,
myipv6: RouterIpQuery = None,
ip6addr: RouterIpQuery = None,
) -> UpdateIpParams:
return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
RouterIpParamsDep = Annotated[UpdateIpParams, Depends(_ip_params_from_query)]
@router.get(
@@ -27,14 +43,15 @@ router = APIRouter()
async def register_hostname_get(
dashboard_secret: ManagementSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
ip_params: RouterIpParamsDep,
) -> str | PlainTextResponse:
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
return await _register_hostname(
dashboard_secret,
request,
service,
ip_params,
)
@router.post(
@@ -47,45 +64,76 @@ async def register_hostname_get(
async def register_hostname_post(
dashboard_secret: ManagementSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
ip_params: RouterIpParamsDep,
) -> str | PlainTextResponse:
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
return await _register_hostname(
dashboard_secret,
request,
service,
ip_params,
)
async def _register_hostname(
dashboard_secret: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
ip_params: _IpParams,
) -> str | PlainTextResponse:
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
ip_params = await _ip_params_from_request(request, ip_params)
account = await run_in_threadpool(service.store.get_account_by_management_slug, dashboard_secret)
hostname = _registration_hostname(account, service)
assert account is not None
dns_error = await _publish_initial_registration_dns(request, service, account, hostname, ip_params)
if dns_error is not None:
return dns_error
activated = await run_in_threadpool(service.store.activate_account, hostname)
if not activated:
raise HTTPException(status_code=404, detail="not found")
await _log_registration_activation(request, service, hostname)
return "good registered"
def _registration_hostname(account: Mapping[str, object] | None, service: DdnsService) -> str:
if not account or account.get("disabled"):
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
if not service.hostname_is_managed(hostname):
raise HTTPException(status_code=404, detail="not found")
if not account["accepted_at"]:
try:
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
except BadIpError as exc:
return PlainTextResponse(f"badip {exc}", status_code=400)
except DnsProviderError:
return PlainTextResponse("911 dns registration failed", status_code=500)
activated = await run_in_threadpool(store.activate_account, hostname)
if not activated:
raise HTTPException(status_code=404, detail="not found")
return hostname
async def _publish_initial_registration_dns(
request: Request,
service: DdnsService,
account: Mapping[str, object],
hostname: str,
ip_params: _IpParams,
) -> PlainTextResponse | None:
if account.get("accepted_at"):
return None
try:
await run_in_threadpool(
service.publish_initial_dns,
request,
hostname,
ip_params,
)
except BadIpError as exc:
return PlainTextResponse(f"badip {exc}", status_code=400)
except DnsProviderError:
return PlainTextResponse("911 dns registration failed", status_code=500)
return None
async def _log_registration_activation(
request: Request,
service: DdnsService,
hostname: str,
) -> None:
await run_in_threadpool(
store.log_update_event,
service.store.log_update_event,
hostname,
None,
None,
@@ -93,7 +141,6 @@ async def _register_hostname(
"hostname registered",
client_ip(request, service.settings),
)
return "good registered"
@router.get(
@@ -106,14 +153,15 @@ async def _register_hostname(
async def update_hostname_get(
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
ip_params: RouterIpParamsDep,
) -> str | PlainTextResponse:
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
return await _update_hostname(
update_slug,
request,
service,
ip_params,
)
@router.post(
@@ -126,76 +174,78 @@ async def update_hostname_get(
async def update_hostname_post(
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
ip_params: RouterIpParamsDep,
) -> str | PlainTextResponse:
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
return await _update_hostname(
update_slug,
request,
service,
ip_params,
)
async def _update_hostname(
update_slug: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
ip_params: _IpParams,
) -> str | PlainTextResponse:
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
ip_params = await _ip_params_from_request(request, ip_params)
account = await run_in_threadpool(service.store.get_account_by_slug, update_slug)
if not account or account["disabled"] or not account["accepted_at"]:
raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])):
raise HTTPException(status_code=404, detail="not found")
return await run_in_threadpool(
apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr
apply_update_response,
service,
request,
str(account["hostname"]),
ip_params,
)
async def _ip_params_from_request(
request: Request,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> tuple[str | None, str | None, str | None, str | None]:
async def _ip_params_from_request(request: Request, ip_params: _IpParams) -> _IpParams:
if request.method != "POST":
return myip, ipaddr, myipv6, ip6addr
return ip_params
body_values = await _post_values(request)
return (
myip or body_values.get("myip"),
ipaddr or body_values.get("ipaddr"),
myipv6 or body_values.get("myipv6"),
ip6addr or body_values.get("ip6addr"),
)
return ip_params.with_body_values(body_values)
async def _post_values(request: Request) -> dict[str, str]:
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
body = await request.body()
if not body:
return {}
content_type = _request_content_type(request)
if content_type == "application/json":
try:
data = json.loads(body)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
return {str(key): str(value) for key, value in data.items() if isinstance(value, str)}
return _json_post_values(body)
if content_type == "application/x-www-form-urlencoded":
try:
form_body = body.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {}
try:
parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
except ValueError:
return {}
return {key: values[0] for key, values in parsed.items() if values}
return _form_post_values(body)
return {}
def _request_content_type(request: Request) -> str:
return request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
def _json_post_values(body: bytes) -> dict[str, str]:
try:
data = json.loads(body)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
return {str(key): value for key, value in data.items() if isinstance(value, str)}
def _form_post_values(body: bytes) -> dict[str, str]:
try:
form_body = body.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {}
try:
parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
except ValueError:
return {}
return {key: values[0] for key, values in parsed.items() if values}