diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 17e9f3e..a64ffa6 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -111,6 +111,10 @@ MESSAGES: dict[str, object] = { "portals.invalid_revision": "Invalid revision", "portals.invalid_section": "Invalid section", "portals.not_found": "Portal not found", + "portals.installation_domain_missing": ( + "The installation address has no domain: set one in Settings, " + "otherwise there is nowhere to host the portal" + ), "portals.section_not_found": "Section not found", "profile.accent_hex": "The accent is a HEX colour like #0f9b8e", "profile.choose_photo_file": "Choose a photo file", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 5d9b20b..bb0aecc 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -115,6 +115,10 @@ MESSAGES: dict[str, object] = { "portals.invalid_revision": "Некорректная версия", "portals.invalid_section": "Некорректный раздел", "portals.not_found": "Портал не найден", + "portals.installation_domain_missing": ( + "Адрес установки задан без домена: укажите домен в «Настройках», " + "иначе портал негде разместить" + ), "portals.section_not_found": "Раздел не найден", "profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e", "profile.choose_photo_file": "Выберите файл фото", diff --git a/apps/backend/chatballs/support_portals/addressing.py b/apps/backend/chatballs/support_portals/addressing.py index 4dcb414..3495827 100644 --- a/apps/backend/chatballs/support_portals/addressing.py +++ b/apps/backend/chatballs/support_portals/addressing.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from ipaddress import IPv4Address from django.conf import settings from django.core.exceptions import ValidationError @@ -35,8 +36,60 @@ def _installation_hosts() -> tuple[str, ...]: return () +def _is_ip_literal(value: str) -> bool: + """IPv4 проходит проверку домена по буквам меток, а доменом не является.""" + + try: + IPv4Address(value) + except ValueError: + return ":" in value + return True + + +def base_domain_for(installation_host: str) -> str: + """Базовый домен при таком адресе установки; пусто — домена ещё нет. + + Отдельно от ``help_base_domain`` ради миграции, которая читает адрес через + историческую модель настроек, а не через рантайм-модель. + """ + + configured = normalize_domain( + str(getattr(settings, "CHATBALLS_HELP_BASE_DOMAIN", "") or "") + ) + if configured: + return configured + host = normalize_domain(installation_host) + if not host or _is_ip_literal(host): + return "" + try: + return validate_domain(host) + except ValidationError: + return "" + + +def help_base_domain() -> str: + """Базовый домен порталов помощи; пусто — установка ещё не знает своего домена. + + Штатный источник — адрес самой установки: у коробки нет .env, и переменную + окружения задать негде, а её прежнее значение по умолчанию (``localhost``) + превращало адрес портала на живом домене в ``help.localhost``. Переменная + остаётся переопределением для контуров, которые ведут конфигурацию сами + (dev-стек, staging) — там же, где она задаётся у IPv4 (``public_address``). + """ + + from chatballs.identity.instance_settings import public_host + + try: + host = public_host() + except Exception: # таблицы ещё нет (ранние миграции) + host = "" + return base_domain_for(host) + + def hosted_domain(portal_key: str) -> str: - base_domain = normalize_domain(settings.CHATBALLS_HELP_BASE_DOMAIN) + base_domain = help_base_domain() + if not base_domain: + raise ValidationError(t("portals.installation_domain_missing")) return validate_domain(f"{portal_key}.{base_domain}") diff --git a/apps/backend/chatballs/support_portals/migrations/0004_portal_domains.py b/apps/backend/chatballs/support_portals/migrations/0004_portal_domains.py index 7815966..2640cfa 100644 --- a/apps/backend/chatballs/support_portals/migrations/0004_portal_domains.py +++ b/apps/backend/chatballs/support_portals/migrations/0004_portal_domains.py @@ -5,7 +5,11 @@ from django.db.models import Q def fill_hosted_domains(apps, schema_editor): SupportPortal = apps.get_model("support_portals", "SupportPortal") - base_domain = settings.CHATBALLS_HELP_BASE_DOMAIN.strip().lower().rstrip(".") + # Историческая засыпка: до этой миграции базовый домен приходил только из + # переменной окружения, и её умолчанием был localhost. Оно и остаётся здесь, + # чтобы бэкфилл старых строк не зависел от того, знает ли установка свой + # домен сейчас; актуальные адреса считает addressing.help_base_domain. + base_domain = settings.CHATBALLS_HELP_BASE_DOMAIN.strip().lower().rstrip(".") or "localhost" for portal in SupportPortal.objects.all().only("id", "slug"): SupportPortal.objects.filter(id=portal.id).update( hosted_domain=f"{portal.slug}.{base_domain}" diff --git a/apps/backend/chatballs/support_portals/migrations/0011_hosted_domains_follow_installation.py b/apps/backend/chatballs/support_portals/migrations/0011_hosted_domains_follow_installation.py new file mode 100644 index 0000000..8291e0c --- /dev/null +++ b/apps/backend/chatballs/support_portals/migrations/0011_hosted_domains_follow_installation.py @@ -0,0 +1,54 @@ +"""Порталы, оставшиеся на `.localhost`, переезжают на домен установки. + +До появления `addressing.help_base_domain` базовый домен приходил только из +переменной окружения, а задать её в коробке негде: у продукта нет .env. Поэтому +на реальной установке адрес портала складывался как `help.localhost` — снаружи +он не вёл никуда и сертификата не получал. Теперь домен известен из мастера +первого запуска, и такие адреса пора привести к нему. + +Переписываются только суффиксы `.localhost`: ссылки, которые всё равно не +работали. Всё остальное — в том числе dev-стек и staging с явной переменной — +остаётся как есть: там смена адреса ломала бы живые ссылки на статьи. +""" + +from django.db import migrations + + +def move_hosted_domains_to_installation(apps, schema_editor): + from chatballs.support_portals.addressing import base_domain_for + + InstanceSettings = apps.get_model("identity", "InstanceSettings") + host = ( + InstanceSettings.objects.filter(pk=1) + .values_list("public_host", flat=True) + .first() + or "" + ) + base_domain = base_domain_for(host) + if not base_domain or base_domain == "localhost": + return + + SupportPortal = apps.get_model("support_portals", "SupportPortal") + portals = SupportPortal.objects.filter(hosted_domain__endswith=".localhost") + for portal in portals.only("id", "slug", "hosted_domain"): + moved = f"{portal.slug}.{base_domain}" + taken = SupportPortal.objects.exclude(id=portal.id).filter(hosted_domain=moved) + if taken.exists(): + continue + SupportPortal.objects.filter(id=portal.id).update(hosted_domain=moved) + + +class Migration(migrations.Migration): + dependencies = [ + ("support_portals", "0010_article_files"), + # Домен установки живёт в настройках инстанса: без этой таблицы его + # неоткуда прочитать. + ("identity", "0033_instance_previous_public_host"), + ] + + operations = [ + migrations.RunPython( + move_hosted_domains_to_installation, + migrations.RunPython.noop, + ), + ] diff --git a/apps/backend/chatballs/support_portals/portal_views.py b/apps/backend/chatballs/support_portals/portal_views.py index 12f2c1b..11769a3 100644 --- a/apps/backend/chatballs/support_portals/portal_views.py +++ b/apps/backend/chatballs/support_portals/portal_views.py @@ -9,6 +9,7 @@ from chatballs.api.permissions import HasCapability from chatballs.i18n import t from chatballs.identity.audit import record_audit_event from chatballs.integrations.models import IntegrationProvider, IntegrationStatus +from chatballs.support_portals.addressing import help_base_domain from chatballs.support_portals.api import validation_response from chatballs.support_portals.domain_services import ( set_custom_domain, @@ -147,7 +148,7 @@ class PortalListView(PortalBaseView): }, "address": { "scheme": settings.CHATBALLS_HELP_PUBLIC_SCHEME, - "baseDomain": settings.CHATBALLS_HELP_BASE_DOMAIN, + "baseDomain": help_base_domain(), "port": settings.CHATBALLS_HELP_PUBLIC_PORT or None, }, } diff --git a/apps/backend/chatballs/support_portals/tests/test_base_domain.py b/apps/backend/chatballs/support_portals/tests/test_base_domain.py new file mode 100644 index 0000000..32af84f --- /dev/null +++ b/apps/backend/chatballs/support_portals/tests/test_base_domain.py @@ -0,0 +1,120 @@ +"""Базовый домен порталов берётся из адреса установки. + +Переменную окружения в коробке задавать негде (у продукта нет .env), а её +прежнее умолчание — localhost — оставляло портал на help.localhost даже там, +где установка работает на живом домене: в диалоге создания владелец видел +суффикс, которого у него нет. +""" + +from __future__ import annotations + +from importlib import import_module + +from django.apps import apps as django_apps +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings + +from chatballs.identity.instance_settings import InstanceSettings, invalidate_cache +from chatballs.support_portals.addressing import help_base_domain, hosted_domain +from chatballs.support_portals.models import SupportPortal +from chatballs.support_portals.tests.base import SupportPortalTestCase + + +class HelpBaseDomainTests(TestCase): + def setUp(self) -> None: + invalidate_cache() + self.addCleanup(invalidate_cache) + + def _set_host(self, host: str) -> None: + row = InstanceSettings.load() + row.public_host = host + row.save(update_fields=["public_host", "updated_at"]) + invalidate_cache() + + @override_settings(CHATBALLS_HELP_BASE_DOMAIN="") + def test_installation_domain_becomes_base_domain(self) -> None: + self._set_host("crm.example.test") + + self.assertEqual(help_base_domain(), "crm.example.test") + self.assertEqual(hosted_domain("help"), "help.crm.example.test") + + @override_settings(CHATBALLS_HELP_BASE_DOMAIN="help.example.test") + def test_environment_variable_overrides_installation_address(self) -> None: + self._set_host("crm.example.test") + + self.assertEqual(help_base_domain(), "help.example.test") + + @override_settings(CHATBALLS_HELP_BASE_DOMAIN="") + def test_installation_known_only_by_ip_has_no_base_domain(self) -> None: + self._set_host("203.0.113.10") + + self.assertEqual(help_base_domain(), "") + with self.assertRaises(ValidationError): + hosted_domain("help") + + +class PortalCreationWithoutDomainTests(SupportPortalTestCase): + """Пока установка известна только по IP, размещать портал негде.""" + + def setUp(self) -> None: + super().setUp() + invalidate_cache() + self.addCleanup(invalidate_cache) + row = InstanceSettings.load() + row.public_host = "203.0.113.10" + row.save(update_fields=["public_host", "updated_at"]) + invalidate_cache() + + @override_settings(CHATBALLS_HELP_BASE_DOMAIN="") + def test_list_reports_empty_base_domain(self) -> None: + response = self.client.get("/api/v1/support/portals/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["address"]["baseDomain"], "") + + @override_settings(CHATBALLS_HELP_BASE_DOMAIN="") + def test_creation_is_rejected(self) -> None: + response = self.create_portal() + + self.assertEqual(response.status_code, 400) + + +class HostedDomainMigrationTests(SupportPortalTestCase): + """Порталы, созданные до появления домена, переезжают на него миграцией.""" + + def setUp(self) -> None: + super().setUp() + invalidate_cache() + self.addCleanup(invalidate_cache) + self.migration = import_module( + "chatballs.support_portals.migrations" + ".0011_hosted_domains_follow_installation" + ) + + def _set_host(self, host: str) -> None: + row = InstanceSettings.load() + row.public_host = host + row.save(update_fields=["public_host", "updated_at"]) + invalidate_cache() + + def test_localhost_address_moves_to_the_installation_domain(self) -> None: + self.assertEqual(self.create_portal().status_code, 201) + portal = SupportPortal.objects.get(slug="app-help") + self.assertEqual(portal.hosted_domain, "app-help.localhost") + self._set_host("crm.example.test") + + with override_settings(CHATBALLS_HELP_BASE_DOMAIN=""): + self.migration.move_hosted_domains_to_installation(django_apps, None) + + portal.refresh_from_db() + self.assertEqual(portal.hosted_domain, "app-help.crm.example.test") + + def test_installation_without_domain_leaves_addresses_alone(self) -> None: + self.assertEqual(self.create_portal().status_code, 201) + self._set_host("203.0.113.10") + + with override_settings(CHATBALLS_HELP_BASE_DOMAIN=""): + self.migration.move_hosted_domains_to_installation(django_apps, None) + + portal = SupportPortal.objects.get(slug="app-help") + self.assertEqual(portal.hosted_domain, "app-help.localhost") diff --git a/apps/backend/chatballs_backend/settings_app.py b/apps/backend/chatballs_backend/settings_app.py index 5dd42c3..825eaa8 100644 --- a/apps/backend/chatballs_backend/settings_app.py +++ b/apps/backend/chatballs_backend/settings_app.py @@ -15,9 +15,14 @@ CHATBALLS_APP_PRIMARY_HOSTS = env_list( "CHATBALLS_APP_ALLOWED_HOSTS", env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]), ) -_help_host_pattern = f".{CHATBALLS_HELP_BASE_DOMAIN}" -if _help_host_pattern not in CHATBALLS_APP_PRIMARY_HOSTS: - CHATBALLS_APP_PRIMARY_HOSTS.append(_help_host_pattern) +# Wildcard базового домена — только когда его задали переменной окружения. При +# штатном источнике (адрес установки) домен известен лишь в рантайме, а хосты +# порталов и без списка проходят: их проверяет каталог порталов в +# SupportPortalHostBoundaryMiddleware. +if CHATBALLS_HELP_BASE_DOMAIN: + _help_host_pattern = f".{CHATBALLS_HELP_BASE_DOMAIN}" + if _help_host_pattern not in CHATBALLS_APP_PRIMARY_HOSTS: + CHATBALLS_APP_PRIMARY_HOSTS.append(_help_host_pattern) if TESTING and "testserver" not in CHATBALLS_APP_PRIMARY_HOSTS: CHATBALLS_APP_PRIMARY_HOSTS.append("testserver") # Custom portal domains are checked against the published ingress directory by diff --git a/apps/backend/chatballs_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py index 9686b28..8b33044 100644 --- a/apps/backend/chatballs_backend/settings_base.py +++ b/apps/backend/chatballs_backend/settings_base.py @@ -261,12 +261,18 @@ CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings( # Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений). CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000") -# Публичные порталы поддержки размещаются на отдельных хостах. -# Базовый домен порталов помощи задаёт установщик (CHATBALLS_HELP_BASE_DOMAIN); -# по умолчанию — localhost, никаких зашитых доменов. +# Публичные порталы поддержки размещаются на отдельных хостах. Базовый домен +# берётся из адреса самой установки (support_portals.addressing.help_base_domain): +# у коробки нет .env, задать переменную окружения негде, и её прежнее значение +# по умолчанию — localhost — оставляло порталы на help.localhost даже на живом +# домене. Пустое значение здесь — не «localhost», а «источник штатный»; +# переменная остаётся переопределением для контуров с собственной конфигурацией. +# В тестах адреса установки нет (мастер не проходили), а порталы нужны почти +# каждому набору: там суффикс остаётся localhost, как в dev-стеке. Сам штатный +# путь проверяется отдельно (support_portals/tests/test_base_domain.py). CHATBALLS_HELP_BASE_DOMAIN = os.environ.get( "CHATBALLS_HELP_BASE_DOMAIN", - "localhost", + "localhost" if TESTING else "", ).strip().lower().rstrip(".") CHATBALLS_HELP_PUBLIC_SCHEME = os.environ.get("CHATBALLS_HELP_PUBLIC_SCHEME", "https").strip().lower() CHATBALLS_HELP_PUBLIC_PORT = os.environ.get("CHATBALLS_HELP_PUBLIC_PORT", "").strip() diff --git a/apps/internal-ui/src/features/support-portals/PortalBasicsSettings.tsx b/apps/internal-ui/src/features/support-portals/PortalBasicsSettings.tsx index 42c598d..c61628b 100644 --- a/apps/internal-ui/src/features/support-portals/PortalBasicsSettings.tsx +++ b/apps/internal-ui/src/features/support-portals/PortalBasicsSettings.tsx @@ -41,7 +41,10 @@ export function PortalBasicsSettings({ }, [portal]); const port = address.port ? `:${address.port}` : ""; - const finalUrl = `${address.scheme}://${slug || portal.slug}.${address.baseDomain}${port}/`; + // Если установка сейчас не знает своего домена (её открыли по IP), суффикс + // всё равно есть — он записан в hosted-адресе портала при создании. + const baseDomain = address.baseDomain || portal.hostedDomain.split(".").slice(1).join("."); + const finalUrl = `${address.scheme}://${slug || portal.slug}.${baseDomain}${port}/`; async function save() { setBusy(true); @@ -81,9 +84,9 @@ export function PortalBasicsSettings({ value={slug} onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())} /> - .{address.baseDomain} + .{baseDomain} - {t("portals.lowercase_latin_letters_digits_hyphens")}{address.baseDomain} {t("portals.base_domain_hint")} + {t("portals.lowercase_latin_letters_digits_hyphens")}{baseDomain} {t("portals.base_domain_hint")} {fieldErrors.slug && {fieldErrors.slug}} diff --git a/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx b/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx index 67cb49b..4e8d4d0 100644 --- a/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx +++ b/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx @@ -48,6 +48,10 @@ export function PortalCreateDialog({ const [error, setError] = useState(""); const [fieldErrors, setFieldErrors] = useState>({}); const portalKeyValid = validPortalKey(slug.trim()); + // Базовый домен — адрес самой установки; пока она известна только по IP, + // размещать портал негде, и честнее сказать это здесь, чем выдать адрес, + // который никуда не ведёт. + const baseDomainKnown = Boolean(address.baseDomain); async function submit() { setBusy(true); @@ -73,24 +77,28 @@ export function PortalCreateDialog({

{t("portals.portal_public_help_centre_with")}

- + {baseDomainKnown ? ( + + ) : ( +
{t("portals.installation_domain_missing")}
+ )} {error}
}
-
diff --git a/apps/internal-ui/src/features/support-portals/styles-list.css b/apps/internal-ui/src/features/support-portals/styles-list.css index 9027688..8dbc326 100644 --- a/apps/internal-ui/src/features/support-portals/styles-list.css +++ b/apps/internal-ui/src/features/support-portals/styles-list.css @@ -359,7 +359,7 @@ .portal-address-field > div { display: flex; - height: 40px; + height: 36px; overflow: hidden; border: 1px solid var(--border-input); border-radius: var(--radius-md); @@ -374,7 +374,12 @@ padding: 0 12px; border: 0; outline: 0; + /* Скин поля — из базлайна (bg подложки, текст --n-1): без явных цветов поле + красилось системным белым и в тёмной теме светилось. */ + background: transparent; + color: var(--n-1); font-family: var(--font-mono); + font-size: 13px; } .portal-address-field b { @@ -382,10 +387,10 @@ align-items: center; padding: 0 12px; border-left: 1px solid var(--border-input); - background: var(--n-10); - color: var(--text-secondary); + background: var(--n-9); + color: var(--n-4); font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; font-weight: 500; } diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index 059daa0..bd58696 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -878,6 +878,7 @@ export const en: Record = { "portals.import_articles_from_yaml": "Import articles from YAML", "portals.importing": "Importing…", "portals.insert_image": "Insert an image", + "portals.installation_domain_missing": "The installation address has no domain — there is nowhere to host a portal. Set the installation domain in Settings, then come back.", "portals.insert_into_text": "Insert into the text", "portals.inserted_into_text": "· inserted into the text", "portals.invalid_yaml": "Invalid YAML", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index 2d889db..f14b6b1 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -879,6 +879,7 @@ export const ru = { "portals.import_articles_from_yaml": "Импорт статей из YAML", "portals.importing": "Импорт…", "portals.insert_image": "Вставить изображение", + "portals.installation_domain_missing": "Адрес установки задан без домена — размещать портал негде. Укажите домен установки в «Настройках», затем вернитесь сюда.", "portals.insert_into_text": "Вставить в текст", "portals.inserted_into_text": "· вставлен в текст", "portals.invalid_yaml": "Невалидный YAML",