from __future__ import annotations import sqlite3 from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response from starlette.concurrency import run_in_threadpool from ..ddns_security import client_ip from ..ddns_service import normalize_domain from ..dependencies import ServiceDep, SettingsDep from ..web.forms import first_form_value, request_form from ..web.templates_public import _render_privacy_page, _render_public_home router = APIRouter() @router.get("/", response_class=HTMLResponse, include_in_schema=False) def index(settings: SettingsDep, service: ServiceDep) -> str: return _render_public_home(service, settings, None, None) @router.get("/magic", include_in_schema=False) def magic_form() -> RedirectResponse: return RedirectResponse("/#create", status_code=303) @router.post("/magic", response_class=HTMLResponse, include_in_schema=False) async def magic_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str: await request_form(request, settings) account = await run_in_threadpool(service.create_managed_account, None) return _render_public_home(service, settings, account, None) @router.post("/request-domain", response_class=HTMLResponse, include_in_schema=False) async def request_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str: form = await request_form(request, settings) try: challenge = await run_in_threadpool( service.create_domain_challenge, first_form_value(form, "domain"), ) except sqlite3.IntegrityError as exc: raise HTTPException(status_code=409, detail="domain is already verified") from exc return _render_public_home(service, settings, None, None, challenge) @router.post("/verify-domain", response_class=HTMLResponse, include_in_schema=False) async def verify_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str: form = await request_form(request, settings) 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) if not found: 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 create a hostname now.", challenge) @router.post("/accounts", response_class=HTMLResponse, include_in_schema=False) async def self_service_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str: form = await request_form(request, settings) mode = first_form_value(form, "mode") if mode == "managed": account = await run_in_threadpool(service.create_managed_account, None) else: claim_secret = first_form_value(form, "claim_secret") account = await run_in_threadpool( service.create_custom_account, first_form_value(form, "hostname"), claim_secret, None, ) return _render_public_home(service, settings, account, None) @router.get("/healthz", response_class=PlainTextResponse) def healthz() -> str: return "ok" @router.get("/ip", response_class=PlainTextResponse) def visible_ip(request: Request, settings: SettingsDep) -> str: return client_ip(request, settings) @router.get("/privacy", response_class=HTMLResponse, include_in_schema=False) 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""" {base_url}/ weekly 1.0 {base_url}/privacy monthly 0.3 """ return Response(body, media_type="application/xml")