Arag / app /api /white_label.py
AuthorBot
Allow SuperAdmin to proxy author admin growth routes.
9e4dfdc
Raw
History Blame Contribute Delete
5.49 kB
"""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."
),
}