from __future__ import annotations import secrets import sqlite3 from datetime import UTC, datetime from ..ddns_security import hash_lookup_token from .types import DomainChallengeRow class DomainsMixin: def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> DomainChallengeRow: token = f"ff-ddns-{secrets.token_urlsafe(24)}" claim_secret = secrets.token_urlsafe(32) claim_hash = hash_lookup_token(claim_secret) now = datetime.now(UTC).isoformat() with self.connect() as conn: existing = conn.execute( "SELECT verified_at, owner_hostname FROM domain_challenges WHERE domain = ?", (domain,), ).fetchone() if existing and existing[0]: raise sqlite3.IntegrityError("domain is already verified") if existing and existing[1] and existing[1] != owner_hostname: raise sqlite3.IntegrityError("domain claim belongs to another dashboard") conn.execute( """ INSERT INTO domain_challenges(domain, token, claim_hash, owner_hostname, created_at, verified_at) VALUES (?, ?, ?, ?, ?, NULL) ON CONFLICT(domain) DO UPDATE SET token=excluded.token, claim_hash=excluded.claim_hash, owner_hostname=excluded.owner_hostname, created_at=excluded.created_at, verified_at=NULL """, (domain, token, claim_hash, owner_hostname, now), ) return {"domain": domain, "token": token, "claim_secret": claim_secret} def verify_domain_challenge( self, domain: str, claim_secret: str, owner_hostname: str | None = None, ) -> DomainChallengeRow | None: now = datetime.now(UTC).isoformat() claim_hash = hash_lookup_token(claim_secret) with self.connect() as conn: sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ? AND claim_hash = ?" params = [domain, claim_hash] if owner_hostname is not None: sql += " AND owner_hostname = ?" params.append(owner_hostname) challenge = conn.execute(sql, tuple(params)).fetchone() if not challenge: return None update_sql = "UPDATE domain_challenges SET verified_at = ? WHERE domain = ? AND claim_hash = ?" update_params = [now, domain, claim_hash] if owner_hostname is not None: update_sql += " AND owner_hostname = ?" update_params.append(owner_hostname) cursor = conn.execute(update_sql, tuple(update_params)) if cursor.rowcount != 1: return None result = dict(challenge) result["verified_at"] = now result["claim_secret"] = claim_secret return result def get_domain_challenge( self, domain: str, claim_secret: str | None = None, owner_hostname: str | None = None, ) -> DomainChallengeRow | None: with self.connect() as conn: sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ?" params = [domain] if claim_secret is not None: sql += " AND claim_hash = ?" params.append(hash_lookup_token(claim_secret)) if owner_hostname is not None: sql += " AND owner_hostname = ?" params.append(owner_hostname) challenge = conn.execute(sql, tuple(params)).fetchone() return dict(challenge) if challenge else None def get_domain_challenge_by_secret(self, claim_secret: str) -> DomainChallengeRow | None: with self.connect() as conn: challenge = conn.execute( "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE claim_hash = ?", (hash_lookup_token(claim_secret),), ).fetchone() if not challenge: return None result = dict(challenge) result["claim_secret"] = claim_secret return result def list_domain_challenges(self, limit: int = 100, offset: int = 0) -> list[DomainChallengeRow]: with self.connect() as conn: rows = conn.execute( """ SELECT domain, owner_hostname, created_at, verified_at FROM domain_challenges ORDER BY created_at DESC LIMIT ? OFFSET ? """, (limit, offset), ).fetchall() return [dict(row) for row in rows] def count_domain_challenges(self) -> int: with self.connect() as conn: row = conn.execute("SELECT COUNT(*) FROM domain_challenges").fetchone() return int(row[0]) def is_domain_verified( self, domain: str, claim_secret: str | None = None, owner_hostname: str | None = None, ) -> bool: with self.connect() as conn: sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?" params = [domain] if claim_secret is not None: sql += " AND claim_hash = ?" params.append(hash_lookup_token(claim_secret)) if owner_hostname is not None: sql += " AND owner_hostname = ?" params.append(owner_hostname) verified_at = conn.execute(sql, tuple(params)).fetchone() return bool(verified_at and verified_at[0])