Archived
Refactor UI assets and harden quality checks
This commit is contained in:
1 parent
23ab466c52
commit
4ecc8eb874
62 files changed
+3314
-3206
No files matched your search
+45
-50
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
Reference in new issue
Block a user