Spaces:
Running
Running
| """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." | |
| ) | |
| 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), | |
| } | |
| 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())} | |
| 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." | |
| ), | |
| } | |