Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified backend/backend/__pycache__/__init__.cpython-314.pyc
Binary file not shown.
Binary file modified backend/backend/__pycache__/celery.cpython-314.pyc
Binary file not shown.
Binary file modified backend/backend/__pycache__/settings.cpython-314.pyc
Binary file not shown.
Binary file modified backend/backend/__pycache__/urls.cpython-314.pyc
Binary file not shown.
75 changes: 75 additions & 0 deletions backend/backend/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import logging
from django.utils.deprecation import MiddlewareMixin
from django.http import HttpResponseNotFound
from django.conf import settings
from urllib.parse import urlparse
from tenants.models import Organization
from tenants.middleware import _thread_locals

logger = logging.getLogger(__name__)

class CustomDomainMiddleware(MiddlewareMixin):
"""
Middleware that intercepts requests with custom domains in the Host header,
identifies the associated Organization, and restricts access to ONLY
the tracking endpoints, rewriting/routing them to existing handlers.
"""

TRACKING_ENDPOINTS = (
'/api/v1/clicks/track/',
'/api/v1/webhooks/email/',
'/api/v1/unsubscribe/',
)

def process_request(self, request):
host = request.get_host().split(':')[0].lower()
base_url = getattr(settings, 'BACKEND_BASE_URL', 'http://localhost:8000')
parsed_base = urlparse(base_url)
default_host = (parsed_base.hostname or 'localhost').lower()

# If the request comes via the default host or localhost, skip custom domain processing
if host == default_host or host == '127.0.0.1' or host == 'localhost':
return None

# Check if the host matches a custom tracking domain
from django.core.cache import cache
from django.core.exceptions import DisallowedHost
cache_key = f"org_for_host_{host}"
org_id = cache.get(cache_key)

if org_id == "MISSING":
raise DisallowedHost(f"Invalid HTTP_HOST header: {host}")

if org_id:
try:
org = Organization.objects.get(id=org_id)
except Organization.DoesNotExist:
cache.delete(cache_key)
raise DisallowedHost(f"Invalid HTTP_HOST header: {host}")
else:
try:
org = Organization.objects.get(custom_tracking_domain=host)
cache.set(cache_key, org.id, 300)
except Organization.DoesNotExist:
cache.set(cache_key, "MISSING", 300)
# If no matching organization exists, block the request
raise DisallowedHost(f"Invalid HTTP_HOST header: {host}")

# Custom domain matched. Identify the correct tenant.
_thread_locals.tenant = org

# Enforce strict tenant isolation: A custom domain must ONLY access tracking resources.
is_tracking_endpoint = False
for endpoint in self.TRACKING_ENDPOINTS:
if request.path.startswith(endpoint):
is_tracking_endpoint = True
break

if not is_tracking_endpoint:
sanitized_path = request.path.replace('\n', '').replace('\r', '')
logger.warning(f"Blocked non-tracking access on custom domain {host} for path {sanitized_path}")
return HttpResponseNotFound("Not Found")

# The request will naturally proceed to the existing tracking handlers
# preserving the request method and query parameters.
return None
26 changes: 25 additions & 1 deletion backend/backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,30 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str:
'MAILBOX_CREDENTIALS_ENCRYPTION_KEY',
'fallback-insecure-key-for-local-dev-and-testing' if (DEBUG or TESTING) else '',
)
ALLOWED_HOSTS = ['*']
class DynamicAllowedHosts(list):
def __contains__(self, host):
from urllib.parse import urlparse
import os

canonical_host = urlparse(os.getenv('BACKEND_BASE_URL', 'https://leadorbit.onrender.com')).hostname or 'leadorbit.onrender.com'
if host in ['localhost', '127.0.0.1', '[::1]', canonical_host]:
return True

try:
from django.core.cache import cache
cache_key = f"allowed_host_{host}"
is_allowed = cache.get(cache_key)
if is_allowed is not None:
return is_allowed

from tenants.models import Organization
is_allowed = Organization.objects.filter(custom_tracking_domain=host).exists()
cache.set(cache_key, is_allowed, 300)
return is_allowed
except Exception:
return False
Comment on lines +68 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Blind except Exception fails closed silently, with no logging.

Any transient cache/DB error (connection pool exhaustion, cache backend blip) causes is_allowed resolution to silently return False for every non-canonical host, with no log trace to diagnose why custom-domain traffic suddenly stopped resolving. Ruff also flags this (BLE001).

🩹 Suggested fix
-        except Exception:
-            return False
+        except Exception:
+            logger.exception("Failed to validate dynamic host %r", host)
+            return False
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
from django.core.cache import cache
cache_key = f"allowed_host_{host}"
is_allowed = cache.get(cache_key)
if is_allowed is not None:
return is_allowed
from tenants.models import Organization
is_allowed = Organization.objects.filter(custom_tracking_domain=host).exists()
cache.set(cache_key, is_allowed, 300)
return is_allowed
except Exception:
return False
try:
from django.core.cache import cache
cache_key = f"allowed_host_{host}"
is_allowed = cache.get(cache_key)
if is_allowed is not None:
return is_allowed
from tenants.models import Organization
is_allowed = Organization.objects.filter(custom_tracking_domain=host).exists()
cache.set(cache_key, is_allowed, 300)
return is_allowed
except Exception:
logger.exception("Failed to validate dynamic host %r", host)
return False
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 79-79: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/backend/settings.py` around lines 68 - 80, Update the custom-host
resolution try/except block to replace the broad silent Exception handler with
targeted exception handling for the expected cache and database failures, and
log each handled failure with the relevant host and exception details before
returning False. Preserve the existing successful cache lookup, database query,
and fail-closed behavior; avoid a bare broad catch so Ruff BLE001 is satisfied.

Source: Linters/SAST tools


ALLOWED_HOSTS = DynamicAllowedHosts()
Comment on lines +59 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== backend/backend/settings.py =="
nl -ba backend/backend/settings.py | sed -n '50,130p'

echo
echo "== search for ALLOWED_HOSTS overrides and get_host usage =="
rg -n "override_settings\(ALLOWED_HOSTS|request\.get_host\(|ALLOWED_HOSTS\s*=" backend -g '!**/node_modules/**' || true

echo
echo "== Django host validation implementation (installed package, if available) =="
python3 - <<'PY'
import inspect
try:
    import django
    from django.http.request import HttpRequest, validate_host
    print("django_version:", django.get_version())
    print("\nvalidate_host source:")
    print(inspect.getsource(validate_host))
    print("\nHttpRequest.get_host source:")
    print(inspect.getsource(HttpRequest.get_host))
except Exception as e:
    print("ERROR:", type(e).__name__, e)
PY

Repository: Kuldeeep18/LeadOrbit

Length of output: 2029


🌐 Web query:

Django 5.0 validate_host HttpRequest.get_host source any(pattern == '*' or is_same_domain(host, pattern) for pattern in allowed_hosts)

💡 Result:

In Django 5.0, the validate_host function is used by HttpRequest.get_host to ensure that the request's Host header is safe, preventing HTTP Host header attacks [1][2]. The validation logic is defined in django.http.request as follows [3][4]: return any( pattern == "" or is_same_domain(host, pattern) for pattern in allowed_hosts) When ALLOWED_HOSTS contains the wildcard '', the validation process short-circuits to return True for any provided host header [1][4]. This effectively disables Django's built-in host validation [1][5]. If you use '', you are responsible for implementing your own security validation for the Host header, typically by placing custom middleware at the beginning of the MIDDLEWARE setting [1][5]. When the list does not contain '', Django checks the host against each pattern in ALLOWED_HOSTS using is_same_domain [2][4][6]. Patterns starting with a period (e.g., '.example.com') match the domain and all its subdomains, while other patterns must match exactly [1][6][5]. Note that this security check is only applied when using HttpRequest.get_host [1][5]. If your application accesses the Host header directly from request.META, this protection is bypassed [1][5].

Citations:


Critical: DynamicAllowedHosts never participates in Django host validation

Django iterates ALLOWED_HOSTS in validate_host(), so overriding __contains__ here has no effect. Since ALLOWED_HOSTS = DynamicAllowedHosts() starts empty, request.get_host() will reject every host and break requests as soon as middleware calls it. Use ALLOWED_HOSTS = ['*'] with first-position middleware validation, or make this object actually iterable over the allowed hosts.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 79-79: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/backend/settings.py` around lines 59 - 82, Fix DynamicAllowedHosts so
Django host validation actually consults the dynamic allowlist: either configure
ALLOWED_HOSTS as ['*'] and ensure the first-position middleware performs
equivalent validation, or implement iteration over the current allowed hosts so
validate_host() can match them. Preserve the existing localhost, canonical
BACKEND_BASE_URL, and cached Organization.custom_tracking_domain checks.

CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1:3000",
Expand Down Expand Up @@ -87,6 +110,7 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str:
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'tenants.middleware.TenantMiddleware', # custom tenant isolation
'backend.middleware.CustomDomainMiddleware', # Custom tracking domain routing
'tenants.security.RateLimitMiddleware', # API rate limiting
'tenants.security.SecurityHeadersMiddleware', # security headers
'django.contrib.messages.middleware.MessageMiddleware',
Expand Down
Binary file modified backend/campaigns/__pycache__/__init__.cpython-314.pyc
Binary file not shown.
Binary file modified backend/campaigns/__pycache__/ai.cpython-314.pyc
Binary file not shown.
Binary file modified backend/campaigns/__pycache__/apps.cpython-314.pyc
Binary file not shown.
Binary file modified backend/campaigns/__pycache__/gmail_service.cpython-314.pyc
Binary file not shown.
Binary file modified backend/campaigns/__pycache__/google_auth_views.cpython-314.pyc
Binary file not shown.
Binary file modified backend/campaigns/__pycache__/models.cpython-314.pyc
Binary file not shown.
Binary file modified backend/campaigns/__pycache__/serializers.cpython-314.pyc
Binary file not shown.
Binary file modified backend/campaigns/__pycache__/tasks.cpython-314.pyc
Binary file not shown.
Binary file modified backend/campaigns/__pycache__/views.cpython-314.pyc
Binary file not shown.
13 changes: 10 additions & 3 deletions backend/campaigns/gmail_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,21 @@ def _extract_failed_recipients(message, account_email=None):
return fallback_emails if len(fallback_emails) == 1 else []


def build_unsubscribe_url(lead):
def build_unsubscribe_url(lead, organization=None):
"""
Build a signed unsubscribe URL for a lead using the backend base URL.
Build a signed unsubscribe URL for a lead using the backend base URL or custom tracking domain.
"""
from .utils import generate_unsubscribe_token
from django.conf import settings

token = generate_unsubscribe_token(lead.id)
return f"{settings.BACKEND_BASE_URL}/api/v1/unsubscribe/{lead.id}/{token}/"
if organization and organization.custom_tracking_domain:
from tenants.utils import is_local_tracking_domain
scheme = 'http' if is_local_tracking_domain(organization.custom_tracking_domain) else 'https'
base_url = f"{scheme}://{organization.custom_tracking_domain}"
else:
base_url = settings.BACKEND_BASE_URL.rstrip('/')
return f"{base_url}/api/v1/unsubscribe/{lead.id}/{token}/"
Comment on lines +164 to +178

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Inconsistent BACKEND_BASE_URL access vs. sibling rewrite_email_links.

This falls back to settings.BACKEND_BASE_URL.rstrip('/') with no default, while rewrite_email_links in tasks.py (called on the same clead right before this) uses getattr(django_settings, 'BACKEND_BASE_URL', 'http://127.0.0.1:8000'). If BACKEND_BASE_URL is ever missing in a given environment, this function raises AttributeError and breaks email sending entirely, whereas the other falls back gracefully — worth confirming BACKEND_BASE_URL is guaranteed to always be defined, and aligning the two for consistency/resilience.

🛡️ Suggested fix
     else:
-        base_url = settings.BACKEND_BASE_URL.rstrip('/')
+        base_url = getattr(settings, 'BACKEND_BASE_URL', 'http://127.0.0.1:8000').rstrip('/')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def build_unsubscribe_url(lead, organization=None):
"""
Build a signed unsubscribe URL for a lead using the backend base URL.
Build a signed unsubscribe URL for a lead using the backend base URL or custom tracking domain.
"""
from .utils import generate_unsubscribe_token
from django.conf import settings
token = generate_unsubscribe_token(lead.id)
return f"{settings.BACKEND_BASE_URL}/api/v1/unsubscribe/{lead.id}/{token}/"
if organization and organization.custom_tracking_domain:
from tenants.utils import is_local_tracking_domain
scheme = 'http' if is_local_tracking_domain(organization.custom_tracking_domain) else 'https'
base_url = f"{scheme}://{organization.custom_tracking_domain}"
else:
base_url = settings.BACKEND_BASE_URL.rstrip('/')
return f"{base_url}/api/v1/unsubscribe/{lead.id}/{token}/"
def build_unsubscribe_url(lead, organization=None):
"""
Build a signed unsubscribe URL for a lead using the backend base URL or custom tracking domain.
"""
from .utils import generate_unsubscribe_token
from django.conf import settings
token = generate_unsubscribe_token(lead.id)
if organization and organization.custom_tracking_domain:
from tenants.utils import is_local_tracking_domain
scheme = 'http' if is_local_tracking_domain(organization.custom_tracking_domain) else 'https'
base_url = f"{scheme}://{organization.custom_tracking_domain}"
else:
base_url = getattr(settings, 'BACKEND_BASE_URL', 'http://127.0.0.1:8000').rstrip('/')
return f"{base_url}/api/v1/unsubscribe/{lead.id}/{token}/"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/gmail_service.py` around lines 164 - 178, Update
build_unsubscribe_url to access BACKEND_BASE_URL with the same fallback used by
rewrite_email_links: default to http://127.0.0.1:8000 when the setting is
absent, then continue stripping the trailing slash before constructing the URL.



def send_gmail(account, to_email, subject, body_html, unsubscribe_url=None, thread_id=None):
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
16 changes: 11 additions & 5 deletions backend/campaigns/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ def _execute_call_step(clead, step, now=None):



def rewrite_email_links(html_body, campaign_lead_id, step_id):
def rewrite_email_links(html_body, campaign_lead_id, step_id, organization=None):
"""
Parses the email body, finds all anchor tags, and replaces the href
with our tracking redirect URL.
Expand All @@ -523,7 +523,13 @@ def rewrite_email_links(html_body, campaign_lead_id, step_id):
token_payload = f"{campaign_lead_id}:{step_id}"
signed_token = signer.sign(token_payload)

base_url = getattr(django_settings, 'BACKEND_BASE_URL', 'http://127.0.0.1:8000').rstrip('/')
if organization and organization.custom_tracking_domain:
from tenants.utils import is_local_tracking_domain
scheme = 'http' if is_local_tracking_domain(organization.custom_tracking_domain) else 'https'
base_url = f"{scheme}://{organization.custom_tracking_domain}"
else:
base_url = getattr(django_settings, 'BACKEND_BASE_URL', 'http://127.0.0.1:8000').rstrip('/')

tracking_endpoint = f"{base_url}/api/v1/clicks/track/"

for a_tag in soup.find_all('a', href=True):
Expand Down Expand Up @@ -584,7 +590,7 @@ def send_email_step(campaign_lead_id, step_id):
subject, body = personalize_email(step.template_subject, step.template_body, clead.lead)


body = rewrite_email_links(body, campaign_lead_id, step_id)
body = rewrite_email_links(body, campaign_lead_id, step_id, organization=clead.organization)
# -------------------------------------------

account = clead.campaign.connected_account
Expand All @@ -596,7 +602,7 @@ def send_email_step(campaign_lead_id, step_id):
clead.lead.email,
subject,
body,
unsubscribe_url=build_unsubscribe_url(clead.lead),
unsubscribe_url=build_unsubscribe_url(clead.lead, organization=clead.organization),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
logger.info(f"Gmail SENT to {clead.lead.email} | msg_id={message_id}")
elif account.provider == 'CUSTOM':
Expand All @@ -605,7 +611,7 @@ def send_email_step(campaign_lead_id, step_id):
clead.lead.email,
subject,
body,
unsubscribe_url=build_unsubscribe_url(clead.lead),
unsubscribe_url=build_unsubscribe_url(clead.lead, organization=clead.organization),
)
logger.info(f"SMTP SENT to {clead.lead.email} | msg_id={message_id}")
else:
Expand Down
116 changes: 116 additions & 0 deletions backend/campaigns/test_custom_domains.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from unittest.mock import patch
from django.test import TestCase, override_settings
from django.core.exceptions import ValidationError
from tenants.models import Organization
from django.conf import settings
from rest_framework.test import APIClient
from campaigns.tasks import rewrite_email_links
from campaigns.gmail_service import build_unsubscribe_url

class MockDnsRdata:
def __init__(self, target):
self.target_text = target

@property
def target(self):
class _Target:
def to_text(self_inner):
return self.target_text
return _Target()

@override_settings(BACKEND_BASE_URL='https://leadorbit.onrender.com')
class CustomDomainModelTests(TestCase):
def setUp(self):
self.org = Organization.objects.create(name="Acme Corp")

@patch('dns.resolver.Resolver.resolve')
def test_valid_cname_configuration(self, mock_resolve):
# Mocking dns.resolver to return a valid CNAME pointing to our target
mock_resolve.return_value = [MockDnsRdata('leadorbit.onrender.com.')]

self.org.custom_tracking_domain = 'track.acme.test'
# Should not raise exception
self.org.clean()
self.org.save()
self.assertEqual(self.org.custom_tracking_domain, 'track.acme.test')

@patch('dns.resolver.Resolver.resolve')
def test_invalid_cname_configuration(self, mock_resolve):
mock_resolve.return_value = [MockDnsRdata('wrong.target.com.')]

self.org.custom_tracking_domain = 'track.acme.test'
with self.assertRaises(ValidationError) as ctx:
self.org.clean()
self.assertIn("CNAME record must point to", str(ctx.exception))

@patch('dns.resolver.Resolver.resolve')
def test_dns_validation_failures(self, mock_resolve):
import dns.resolver
mock_resolve.side_effect = dns.resolver.NXDOMAIN

self.org.custom_tracking_domain = 'notexist.acme.test'
with self.assertRaises(ValidationError) as ctx:
self.org.clean()
self.assertIn("Domain does not exist", str(ctx.exception))


from django.test import RequestFactory

@override_settings(DEBUG=False, ALLOWED_HOSTS=['track.acme.test', 'localhost', '127.0.0.1'])
class CustomDomainMiddlewareTests(TestCase):
def setUp(self):
from django.core.cache import cache
cache.clear()
self.factory = RequestFactory()
self.org = Organization.objects.create(name="Acme Corp", custom_tracking_domain='track.acme.test')
from backend.middleware import CustomDomainMiddleware
self.middleware = CustomDomainMiddleware(lambda req: getattr(req, '_fake_response', None))

def test_middleware_hostname_routing_tracking_endpoint(self):
request = self.factory.get('/api/v1/clicks/track/', HTTP_HOST='track.acme.test')
request._fake_response = "OK"
response = self.middleware(request)
self.assertEqual(response, "OK") # Allowed through

def test_tenant_isolation_non_tracking_endpoint(self):
request = self.factory.get('/api/v1/organizations/', HTTP_HOST='track.acme.test')
response = self.middleware(request)
self.assertEqual(response.status_code, 404)

def test_default_tracking_fallback(self):
request = self.factory.get('/api/v1/organizations/', HTTP_HOST='localhost')
request._fake_response = "OK"
response = self.middleware(request)
self.assertEqual(response, "OK") # Allowed through


class CustomDomainURLGenerationTests(TestCase):
def setUp(self):
self.org_custom = Organization.objects.create(name="Custom Org", custom_tracking_domain='track.custom.test')
self.org_default = Organization.objects.create(name="Default Org")

# Need mock campaign/lead to test rewrite_email_links, or just use string checking
from campaigns.models import Campaign, SequenceStep, CampaignLead
from leads.models import Lead

self.campaign = Campaign.objects.create(organization=self.org_custom, name="Test", status="ACTIVE", sent_count=0, bounced_count=0)
self.step = SequenceStep.objects.create(organization=self.org_custom, campaign=self.campaign, step_order=1, channel_type="EMAIL")
self.lead = Lead.objects.create(organization=self.org_custom, email="test@test.com")
self.clead = CampaignLead.objects.create(
organization=self.org_custom, campaign=self.campaign, lead=self.lead, current_step=self.step
)

def test_rewrite_email_links_with_custom_domain(self):
html = '<a href="https://google.com">Google</a>'
rewritten = rewrite_email_links(html, self.clead.id, self.step.id, organization=self.org_custom)
self.assertIn('https://track.custom.test/api/v1/clicks/track/?t=', rewritten)

def test_rewrite_email_links_default_fallback(self):
html = '<a href="https://google.com">Google</a>'
rewritten = rewrite_email_links(html, self.clead.id, self.step.id, organization=self.org_default)
self.assertIn('/api/v1/clicks/track/', rewritten)
self.assertNotIn('track.custom.test', rewritten)

def test_build_unsubscribe_url_with_custom_domain(self):
url = build_unsubscribe_url(self.lead, organization=self.org_custom)
self.assertIn('https://track.custom.test/api/v1/unsubscribe/', url)
Binary file modified backend/leads/__pycache__/__init__.cpython-314.pyc
Binary file not shown.
Binary file modified backend/leads/__pycache__/apps.cpython-314.pyc
Binary file not shown.
Binary file modified backend/leads/__pycache__/models.cpython-314.pyc
Binary file not shown.
Binary file modified backend/leads/__pycache__/serializers.cpython-314.pyc
Binary file not shown.
Binary file modified backend/leads/__pycache__/views.cpython-314.pyc
Binary file not shown.
Binary file not shown.
Binary file modified backend/leads/migrations/__pycache__/__init__.cpython-314.pyc
Binary file not shown.
Binary file modified backend/tenants/__pycache__/__init__.cpython-314.pyc
Binary file not shown.
Binary file modified backend/tenants/__pycache__/admin.cpython-314.pyc
Binary file not shown.
Binary file modified backend/tenants/__pycache__/apps.cpython-314.pyc
Binary file not shown.
Binary file modified backend/tenants/__pycache__/middleware.cpython-314.pyc
Binary file not shown.
Binary file modified backend/tenants/__pycache__/models.cpython-314.pyc
Binary file not shown.
Binary file modified backend/tenants/__pycache__/security.cpython-314.pyc
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.14 on 2026-07-12 15:38

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('tenants', '0002_organization_enable_ai_personalization_and_more'),
]

operations = [
migrations.AddField(
model_name='organization',
name='custom_tracking_domain',
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
),
]
Binary file not shown.
Binary file modified backend/tenants/migrations/__pycache__/__init__.cpython-314.pyc
Binary file not shown.
46 changes: 46 additions & 0 deletions backend/tenants/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,52 @@ class Organization(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
gemini_api_key = models.CharField(max_length=255, blank=True, null=True)
enable_ai_personalization = models.BooleanField(default=True)
custom_tracking_domain = models.CharField(max_length=255, blank=True, null=True, unique=True)

def clean(self):
super().clean()
if self.custom_tracking_domain:
self.custom_tracking_domain = self.custom_tracking_domain.lower().strip()

from django.conf import settings
from django.core.exceptions import ValidationError
import dns.resolver
from urllib.parse import urlparse

from tenants.utils import is_local_tracking_domain

if is_local_tracking_domain(self.custom_tracking_domain):
return

try:
base_url = getattr(settings, 'BACKEND_BASE_URL', 'https://leadorbit.onrender.com')
parsed = urlparse(base_url)
target_domain = parsed.hostname or 'leadorbit.onrender.com'

resolver = dns.resolver.Resolver()
resolver.timeout = 2.0
resolver.lifetime = 5.0
answers = resolver.resolve(self.custom_tracking_domain, 'CNAME')

valid = False
for rdata in answers:
target = rdata.target.to_text().rstrip('.').lower()
if target == target_domain.lower():
valid = True
break
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if not valid:
raise ValidationError({'custom_tracking_domain': f'CNAME record must point to {target_domain}'})
except dns.resolver.NXDOMAIN as e:
raise ValidationError({'custom_tracking_domain': 'Domain does not exist.'}) from e
except dns.resolver.NoAnswer as e:
raise ValidationError({'custom_tracking_domain': 'No CNAME record found for this domain.'}) from e
except dns.resolver.Timeout as e:
raise ValidationError({'custom_tracking_domain': 'DNS query timed out.'}) from e
except ValidationError:
raise
except Exception as e:
raise ValidationError({'custom_tracking_domain': f'DNS validation failed: {e}'}) from e

def __str__(self):
return self.name
Expand Down
10 changes: 10 additions & 0 deletions backend/tenants/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.conf import settings

def is_local_tracking_domain(domain):
"""
Returns True if the domain should be treated as a local tracking domain.
"""
if not getattr(settings, 'DEBUG', False):
return False
domain = domain.lower()
return domain.startswith('localhost') or 'localhost' in domain
Binary file modified backend/users/__pycache__/__init__.cpython-314.pyc
Binary file not shown.
Binary file modified backend/users/__pycache__/apps.cpython-314.pyc
Binary file not shown.
Binary file modified backend/users/__pycache__/jwt.cpython-314.pyc
Binary file not shown.
Binary file modified backend/users/__pycache__/models.cpython-314.pyc
Binary file not shown.
Binary file modified backend/users/__pycache__/serializers.cpython-314.pyc
Binary file not shown.
Binary file modified backend/users/__pycache__/views.cpython-314.pyc
Binary file not shown.
Binary file not shown.
Binary file modified backend/users/migrations/__pycache__/__init__.cpython-314.pyc
Binary file not shown.