"""Sandbox Área Gerente — produção create-only, nunca apaga existentes (Spec 035).""" from __future__ import annotations import os import re import secrets import sqlite3 import uuid from typing import Any import httpx from app import domain_console_sandbox_store, vm112_domains VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090") SANDBOX_DOMAIN_PREFIX = os.getenv("DOMAIN_CONSOLE_SANDBOX_PREFIX", "cenario-") SANDBOX_ALLOWED_SUFFIX = os.getenv( "DOMAIN_CONSOLE_SANDBOX_SUFFIX", ".ops.ligbox.com.br", ) SANDBOX_ENABLED = os.getenv("DOMAIN_CONSOLE_SANDBOX", "1") == "1" PROTECTED_DOMAINS = vm112_domains.PURGE_BLOCKLIST | frozenset( {"ligbox.com.br", "itecnologys.com", "ligbox.com", "myvexx.com"} ) _SCENARIO_DOMAIN_RE = re.compile( rf"^{re.escape(SANDBOX_DOMAIN_PREFIX)}[a-z0-9]([a-z0-9-]{{0,61}}[a-z0-9])?{re.escape(SANDBOX_ALLOWED_SUFFIX)}$", re.I, ) class SandboxError(ValueError): pass class SandboxForbidden(PermissionError): pass def assert_enabled() -> None: if not SANDBOX_ENABLED: raise SandboxForbidden("DOMAIN_CONSOLE_SANDBOX desactivado") def assert_no_delete(operation: str) -> None: raise SandboxForbidden( f"Sandbox create-only: operação «{operation}» proibida — nada existente pode ser apagado" ) def is_scenario_domain(domain: str) -> bool: return bool(_SCENARIO_DOMAIN_RE.match(domain.lower().strip())) def suggest_scenario_domain(slug: str | None = None) -> str: slug = (slug or uuid.uuid4().hex[:8]).lower() slug = re.sub(r"[^a-z0-9-]", "-", slug).strip("-") or uuid.uuid4().hex[:8] return f"{SANDBOX_DOMAIN_PREFIX}{slug}{SANDBOX_ALLOWED_SUFFIX}" def _vm112_onboarding_headers(session_id: str) -> dict[str, str]: return {"X-Onboarding-Session": session_id, "Content-Type": "application/json"} def _extract_account_emails(domain_data: dict[str, Any]) -> list[str]: emails: list[str] = [] for key in ("accounts", "accounts_preview", "emails"): raw = domain_data.get(key) if isinstance(raw, list): for item in raw: if isinstance(item, str) and "@" in item: emails.append(item.lower()) elif isinstance(item, dict): e = item.get("email") or item.get("name") if e and "@" in str(e): emails.append(str(e).lower()) admin = domain_data.get("admin_email") or domain_data.get("portal_admin") if admin and "@" in str(admin): emails.append(str(admin).lower()) return sorted(set(emails)) def fetch_domain_readonly(domain: str) -> dict[str, Any]: domain = domain.lower().strip() try: data = vm112_domains.get_domain(domain) return {"ok": True, "source": "vm112", "domain": domain, "data": data} except httpx.HTTPError as exc: return { "ok": False, "source": "vm112", "domain": domain, "error": str(exc), "data": None, } def list_domains_readonly(q: str = "") -> dict[str, Any]: try: data = vm112_domains.list_domains(q) return {"ok": True, "source": "vm112", "data": data} except httpx.HTTPError as exc: return {"ok": False, "source": "vm112", "error": str(exc), "data": None} def _create_account_vm112( *, domain: str, local_part: str, password: str, display_name: str, ) -> dict[str, Any]: session_id = f"sandbox-{uuid.uuid4().hex}" body = { "domain": domain, "local_part": local_part, "password": password, "display_name": display_name, "use_server_password": False, "send_welcome": False, "dns_mode": "sandbox", } with httpx.Client(timeout=180.0) as client: r = client.post( f"{VM112_API}/api/onboarding/account/create", json=body, headers=_vm112_onboarding_headers(session_id), ) if r.status_code >= 400: try: detail = r.json() except Exception: detail = r.text raise SandboxError(f"VM112 account/create HTTP {r.status_code}: {detail}") return r.json() def create_scenario( conn: sqlite3.Connection, *, label: str, domain: str | None, slug: str | None, created_by: str, plan_mock: str = "Business", admin_password: str | None = None, ) -> dict[str, Any]: assert_enabled() domain = (domain or suggest_scenario_domain(slug)).lower().strip() if domain in PROTECTED_DOMAINS: raise SandboxForbidden(f"Domínio protegido: {domain}") if not is_scenario_domain(domain): raise SandboxForbidden( f"Sandbox só cria domínios novos com prefixo «{SANDBOX_DOMAIN_PREFIX}» " f"e sufixo «{SANDBOX_ALLOWED_SUFFIX}». Sugestão: {suggest_scenario_domain(slug)}" ) existing = fetch_domain_readonly(domain) baseline: list[str] = [] if existing.get("ok") and existing.get("data"): baseline = _extract_account_emails(existing["data"]) if baseline: raise SandboxForbidden( f"Domínio {domain} já tem contas em produção — sandbox não altera existentes. " "Use outro nome de cenário." ) password = admin_password or secrets.token_urlsafe(12) if len(password) < 8: raise SandboxError("Senha admin mínimo 8 caracteres") vm112_result = _create_account_vm112( domain=domain, local_part="admin", password=password, display_name=f"Gerente {label or domain}", ) scenario = domain_console_sandbox_store.create_scenario( conn, domain=domain, label=label or domain, baseline_accounts=baseline, created_by=created_by, plan_mock=plan_mock, ) admin_email = f"admin@{domain}" return { "scenario": scenario, "vm112": vm112_result, "credentials": { "admin_email": admin_email, "password": password, "webmail": f"https://mail.{domain}/", "console": "https://onboard.ligbox.com.br/admin", }, "guards": { "delete_blocked": True, "baseline_protected": baseline, "mode": "create-only", }, } def add_account_to_scenario( conn: sqlite3.Connection, *, scenario_id: str, local_part: str, display_name: str, password: str | None = None, ) -> dict[str, Any]: assert_enabled() scenario = domain_console_sandbox_store.get_scenario(conn, scenario_id) if not scenario: raise SandboxError("Cenário não encontrado") domain = scenario["domain"] local_part = re.sub(r"[^a-z0-9._+-]", "", local_part.lower().strip()) if not local_part: raise SandboxError("local_part inválido") email = f"{local_part}@{domain}" baseline = set(scenario["baseline_accounts"]) if email in baseline: raise SandboxForbidden( f"{email} já existia antes do cenário — sandbox não sobrescreve contas existentes" ) if email in scenario["created_accounts"]: raise SandboxError(f"{email} já foi criada neste cenário") pwd = password or secrets.token_urlsafe(10) if len(pwd) < 8: raise SandboxError("Senha mínimo 8 caracteres") vm112_result = _create_account_vm112( domain=domain, local_part=local_part, password=pwd, display_name=display_name or local_part, ) updated = domain_console_sandbox_store.append_created_account(conn, scenario_id, email) return { "scenario": updated, "account": { "email": email, "password": pwd, "display_name": display_name or local_part, }, "vm112": vm112_result, "guards": {"delete_blocked": True, "mode": "create-only"}, } def build_console_state(conn: sqlite3.Connection, scenario_id: str) -> dict[str, Any]: """Estado UI — merge VM112 read + cenário local.""" scenario = domain_console_sandbox_store.get_scenario(conn, scenario_id) if not scenario: raise SandboxError("Cenário não encontrado") domain = scenario["domain"] live = fetch_domain_readonly(domain) live_emails = _extract_account_emails(live["data"]) if live.get("data") else [] accounts = [] seen = set() for email in live_emails or scenario["created_accounts"]: if email in seen: continue seen.add(email) is_baseline = email in scenario["baseline_accounts"] is_created = email in scenario["created_accounts"] accounts.append( { "email": email, "name": email.split("@")[0].title(), "mail_gb": 30, "files_gb": 200, "nc": True, "active": True, "protected": is_baseline, "sandbox_created": is_created, "can_delete": False, } ) return { "mode": "live-sandbox", "sandbox": True, "delete_blocked": True, "scenario": scenario, "domain": domain, "plan": { "name": scenario["plan_mock"], "price": 549, "maxSeats": 25, "mailGbDefault": 30, "filesGbDefault": 200, }, "billing": {"status": "Sandbox", "nextRenewal": "—"}, "dmarc": {"spf": True, "dkim": True, "dmarc": True, "score": 95}, "dns": { "mx": live.get("ok", False), "aMail": live.get("ok", False), "subdomain": f"intranet.{domain}", }, "accounts": accounts, "vm112_live": live, }