""" Copyright 2024-2026 ChatterMate Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import base64 import hashlib import hmac import secrets import time from typing import List from urllib.parse import urlencode from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from app.core.auth import get_current_organization, require_permissions from app.core.config import settings from app.core.logger import get_logger from app.core.oauth_state import pop_state, store_state from app.crm.base import CrmAuthError, CrmTransientError, basic_auth_header from app.crm.registry import get_adapter from app.database import get_db from app.models.crm import CrmConnectionStatus, CrmProvider from app.models.organization import Organization from app.models.schemas.crm import CrmConnectionOut, CrmTestResult from app.models.user import User from app.repositories.crm import CrmConnectionRepository, CrmSyncJobRepository from app.services.crm_tokens import get_valid_tokens, tokens_from_connection from app.services.feature_gate import check_feature_access router = APIRouter() logger = get_logger(__name__) UPGRADE_MESSAGE = "CRM sync is not available in your current plan." # HubSpot signs webhooks with a timestamp; reject anything older than this. WEBHOOK_TIMESTAMP_MAX_AGE_SECONDS = 300 def _provider_credentials(provider: str) -> tuple: return { CrmProvider.HUBSPOT.value: (settings.HUBSPOT_CLIENT_ID, settings.HUBSPOT_CLIENT_SECRET), CrmProvider.PIPEDRIVE.value: (settings.PIPEDRIVE_CLIENT_ID, settings.PIPEDRIVE_CLIENT_SECRET), }.get(provider, ("", "")) def _require_provider(provider: str) -> None: if get_adapter(provider) is None: raise HTTPException(status_code=404, detail="Unknown CRM provider") def _store_state(state: str, org_id: str, provider: str) -> None: store_state("crm", state, f"{org_id}:{provider}") def _pop_state(state: str) -> tuple: value = pop_state("crm", state) if not value or ":" not in value: return None, None org_id, provider = value.split(":", 1) return org_id, provider def _redirect_uri(provider: str) -> str: return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/crm/{provider}/callback" def _settings_redirect(provider: str, status: str, reason: str = None) -> RedirectResponse: params = {"status": status, "integration": provider} if reason: params["reason"] = reason return RedirectResponse(f"{settings.FRONTEND_URL}/settings/integrations?{urlencode(params)}") def _get_org_connection_or_404(db: Session, organization: Organization, provider: str): connection = CrmConnectionRepository(db).get_by_org_provider(organization.id, provider) if connection is None: raise HTTPException(status_code=404, detail="CRM is not connected") return connection @router.get("/connections", response_model=List[CrmConnectionOut]) async def list_connections( current_user: User = Depends(require_permissions("manage_organization")), organization: Organization = Depends(get_current_organization), db: Session = Depends(get_db), ): """Connection status per provider for the integrations page, with a failed-push count so a quietly broken connection is visible.""" check_feature_access(db, organization.id, "crm_sync", UPGRADE_MESSAGE) job_repo = CrmSyncJobRepository(db) out = [] for connection in CrmConnectionRepository(db).list_by_org(organization.id): item = CrmConnectionOut.model_validate(connection) item.recent_failures = job_repo.count_recent_failures( organization.id, connection.provider) out.append(item) return out @router.get("/{provider}/install") async def install_crm( provider: str, current_user: User = Depends(require_permissions("manage_organization")), organization: Organization = Depends(get_current_organization), db: Session = Depends(get_db), ): """Start the OAuth install: redirect the browser to the CRM's consent page with a CSRF state bound to (organization, provider).""" _require_provider(provider) check_feature_access(db, organization.id, "crm_sync", UPGRADE_MESSAGE) client_id, client_secret = _provider_credentials(provider) if not client_id or not client_secret: raise HTTPException(status_code=400, detail=f"{provider} app credentials are not configured") state = secrets.token_urlsafe(24) _store_state(state, str(organization.id), provider) adapter = get_adapter(provider) return RedirectResponse(adapter.get_authorize_url(state, _redirect_uri(provider))) @router.get("/{provider}/callback") async def crm_oauth_callback( provider: str, code: str = None, state: str = None, error: str = None, db: Session = Depends(get_db), ): """Complete the OAuth install: exchange the code, upsert the connection, and bounce back to the settings page.""" _require_provider(provider) if error: return _settings_redirect( provider, "failure", "cancelled" if error == "access_denied" else error) org_id, state_provider = _pop_state(state or "") if org_id is None or state_provider != provider or not code: return _settings_redirect(provider, "failure", "invalid_state") adapter = get_adapter(provider) try: tokens = await adapter.exchange_code(code, _redirect_uri(provider)) except (CrmAuthError, CrmTransientError) as e: logger.error(f"{provider} OAuth exchange failed: {e}") return _settings_redirect(provider, "failure", "oauth_failed") repo = CrmConnectionRepository(db) existing = repo.get_by_external_id(provider, tokens.external_account_id) if existing is not None and str(existing.organization_id) != org_id: return _settings_redirect(provider, "failure", "account_connected_elsewhere") repo.create_or_update( organization_id=UUID(org_id), provider=provider, external_account_id=tokens.external_account_id, credentials={ "access_token": tokens.access_token, "refresh_token": tokens.refresh_token, "api_domain": tokens.api_domain, }, display_name=tokens.display_name, access_token_expires_at=tokens.expires_at, refresh_token_expires_at=tokens.refresh_token_expires_at, ) # A reconnect is the fix for a dead connection — requeue anything that # terminally failed while it was down so those leads reach the CRM. CrmSyncJobRepository(db).reopen_failed(UUID(org_id), provider) logger.info(f"Connected {provider} ({tokens.display_name}) for org {org_id}") return _settings_redirect(provider, "success") @router.post("/{provider}/test", response_model=CrmTestResult) async def test_crm_connection( provider: str, current_user: User = Depends(require_permissions("manage_organization")), organization: Organization = Depends(get_current_organization), db: Session = Depends(get_db), ): """Read-only connectivity check — never writes records into the CRM.""" _require_provider(provider) check_feature_access(db, organization.id, "crm_sync", UPGRADE_MESSAGE) connection = _get_org_connection_or_404(db, organization, provider) try: tokens = await get_valid_tokens(db, connection) except CrmAuthError: return CrmTestResult(ok=False, error="Connection expired — please reconnect") except CrmTransientError as e: return CrmTestResult(ok=False, error=str(e)) return CrmTestResult(**(await get_adapter(provider).test_connection(tokens))) @router.delete("/{provider}") async def disconnect_crm( provider: str, current_user: User = Depends(require_permissions("manage_organization")), organization: Organization = Depends(get_current_organization), db: Session = Depends(get_db), ): """Disconnect: best-effort token revocation, drop the row, cancel queued pushes. Agents' crm_sync_target stays set — it's simply inert until a reconnect, and the UI warns about the mismatch.""" _require_provider(provider) connection = _get_org_connection_or_404(db, organization, provider) repo = CrmConnectionRepository(db) try: await get_adapter(provider).revoke(tokens_from_connection(repo, connection)) except Exception as e: logger.warning(f"{provider} revoke on disconnect failed (ignored): {e}") CrmSyncJobRepository(db).skip_pending_for_connection( organization.id, provider, "disconnected") repo.delete(connection) return {"status": "disconnected"} # ---- uninstall webhooks (marketplace requirement) -------------------------- @router.post("/hubspot/uninstall") async def hubspot_uninstall_webhook(request: Request, db: Session = Depends(get_db)): """HubSpot app-uninstall events, authenticated by the v3 signature (HMAC-SHA256 over method+url+body+timestamp with the client secret).""" body = await request.body() if not _valid_hubspot_signature(request, body): raise HTTPException(status_code=404) try: events = (await request.json()) or [] except Exception: events = [] for event in events if isinstance(events, list) else [events]: portal_id = str(event.get("portalId", "") or event.get("hub_id", "")) event_kind = f"{event.get('subscriptionType', '')}{event.get('eventType', '')}".lower() if portal_id and "uninstall" in event_kind: _revoke_connection(db, CrmProvider.HUBSPOT.value, portal_id) return {"ok": True} @router.post("/pipedrive/uninstall") async def pipedrive_uninstall_webhook(request: Request, db: Session = Depends(get_db)): """Pipedrive app_uninstalled callback, authenticated with HTTP Basic — the Developer Hub webhook is configured with our client_id:client_secret.""" if not _valid_pipedrive_basic_auth(request.headers.get("Authorization", "")): raise HTTPException(status_code=404) try: payload = await request.json() except Exception: payload = {} company_id = str(payload.get("company_id", "")) if company_id: _revoke_connection(db, CrmProvider.PIPEDRIVE.value, company_id) return {"ok": True} def _revoke_connection(db: Session, provider: str, external_account_id: str) -> None: repo = CrmConnectionRepository(db) connection = repo.get_by_external_id(provider, external_account_id) if connection is None: return repo.set_status(connection, CrmConnectionStatus.REVOKED.value, last_error="App uninstalled from the CRM") CrmSyncJobRepository(db).skip_pending_for_connection( connection.organization_id, provider, "app uninstalled") logger.info(f"{provider} app uninstalled for org {connection.organization_id}") def _valid_hubspot_signature(request: Request, body: bytes) -> bool: """HubSpot v3: base64(HMAC-SHA256(secret, METHOD + full_url + body + ts)), with a freshness window against replay.""" signature = request.headers.get("X-HubSpot-Signature-v3", "") timestamp = request.headers.get("X-HubSpot-Request-Timestamp", "") if not signature or not timestamp or not settings.HUBSPOT_CLIENT_SECRET: return False try: age_ms = time.time() * 1000 - int(timestamp) except ValueError: return False if age_ms > WEBHOOK_TIMESTAMP_MAX_AGE_SECONDS * 1000: return False # HubSpot signs the public https URL; behind the reverse proxy the # reconstructed URL may say http — accept either scheme, nothing else. url = str(request.url) candidate_urls = {url, url.replace("http://", "https://", 1)} for candidate in candidate_urls: message = f"{request.method}{candidate}".encode() + body + timestamp.encode() expected = base64.b64encode( hmac.new(settings.HUBSPOT_CLIENT_SECRET.encode(), message, hashlib.sha256).digest() ).decode() if hmac.compare_digest(expected, signature): return True return False def _valid_pipedrive_basic_auth(header: str) -> bool: client_id, client_secret = _provider_credentials(CrmProvider.PIPEDRIVE.value) if not client_id or not client_secret or not header.startswith("Basic "): return False return hmac.compare_digest(header[6:], basic_auth_header(client_id, client_secret))