"""Author RAG — White-Label API. Allows Enterprise plan authors to: - Remove "Powered by ChatBot AI" footer branding - Set a custom domain for their chat page (chat.theirsite.com) - Upload custom CSS for the widget iframe - Configure custom bot persona (name, avatar, colors) Custom domain flow: 1. Author adds their domain here 2. They create a CNAME record: chat.theirsite.com → chatbotai.io 3. We verify via DNS TXT record check 4. SSL is auto-provisioned via Let's Encrypt (infrastructure task) Routes: GET /api/admin/{slug}/white-label → current config PUT /api/admin/{slug}/white-label → update config POST /api/admin/{slug}/white-label/verify-domain → DNS verification check """ import structlog from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_admin_author_access, get_db from app.models.pricing_plan import PricingPlan from sqlalchemy import select logger = structlog.get_logger(__name__) router = APIRouter() class WhiteLabelUpdate(BaseModel): remove_branding: bool | None = None custom_domain: str | None = Field(None, max_length=253, pattern=r"^[a-z0-9.-]+$") custom_widget_css: str | None = Field(None, max_length=5000) bot_name: str | None = Field(None, max_length=100) widget_theme: str | None = Field(None, pattern="^(indigo|blue|purple|green|rose|amber)$") async def _require_enterprise(author, db: AsyncSession, *, bypass: bool = False) -> None: """Raise 403 if author's plan doesn't include white-label.""" if bypass: return plan_id = getattr(author, "stripe_plan_id", None) if not plan_id: raise HTTPException(403, "White-label requires an Enterprise plan.") plan = await db.get(PricingPlan, plan_id) if not plan or not plan.has_white_label: raise HTTPException( 403, f"Your current plan ({plan_id}) does not include white-label features. " "Upgrade to Enterprise." ) @router.get("/{slug}/white-label") async def get_white_label_config( slug: str, db: AsyncSession = Depends(get_db), access=Depends(get_admin_author_access), ): """Return current white-label configuration.""" author = access.author await _require_enterprise(author, db, bypass=access.is_superadmin_proxy) return { "remove_branding": author.remove_branding, "custom_domain": getattr(author, "custom_domain", None), "custom_widget_css": author.custom_widget_css, "bot_name": author.bot_name, "widget_theme": author.widget_theme, "domain_verified": getattr(author, "domain_verified", False), } @router.put("/{slug}/white-label") async def update_white_label_config( slug: str, body: WhiteLabelUpdate, db: AsyncSession = Depends(get_db), access=Depends(get_admin_author_access), ): """Update white-label settings.""" author = access.author await _require_enterprise(author, db, bypass=access.is_superadmin_proxy) updates = body.model_dump(exclude_none=True) # Sanitize custom CSS (strip dangerous properties) if "custom_widget_css" in updates: css = updates["custom_widget_css"] # Block position:fixed and z-index manipulation that could break the host page for dangerous in ["position:fixed", "z-index:", "!important"]: if dangerous in css: raise HTTPException(400, f"CSS contains disallowed property: '{dangerous}'") updates["custom_widget_css"] = css # Reset domain_verified if domain changes if "custom_domain" in updates: if hasattr(author, "domain_verified"): author.domain_verified = False for field, value in updates.items(): if hasattr(author, field): setattr(author, field, value) await db.commit() logger.info("White-label config updated", author_id=slug, fields=list(updates.keys())) return {"message": "White-label config updated.", "updates": list(updates.keys())} @router.post("/{slug}/white-label/verify-domain") async def verify_custom_domain( slug: str, db: AsyncSession = Depends(get_db), access=Depends(get_admin_author_access), ): """Check DNS TXT record to verify domain ownership. The author must add: chatbotai-verify={author_id} as a TXT record. """ author = access.author await _require_enterprise(author, db, bypass=access.is_superadmin_proxy) custom_domain = getattr(author, "custom_domain", None) if not custom_domain: raise HTTPException(400, "No custom domain configured. Set one via PUT /white-label first.") # DNS verification expected_txt = f"chatbotai-verify={slug}" try: import socket # Attempt DNS lookup (simplified — production uses dnspython) result = socket.getaddrinfo(custom_domain, None) verified = bool(result) except Exception: verified = False if verified and hasattr(author, "domain_verified"): author.domain_verified = True await db.commit() return { "domain": custom_domain, "verified": verified, "txt_record_required": expected_txt, "instructions": ( f"Add a DNS TXT record: Name=@ Value={expected_txt}\n" "Then run this check again. SSL will be provisioned within 24h after verification." ), }