🐛 fix(portals): базовый домен порталов — адрес установки, а не localhost

Суффикс приходил из переменной окружения с умолчанием localhost, а задать её
в коробке негде: у продукта нет .env, и production-манифест её не передаёт.
На живой установке диалог создания портала предлагал help.localhost — адрес,
который не резолвится и не получает сертификат. Источник теперь тот же, что у
IPv4 для A-записи: адрес из мастера первого запуска. Переменная осталась
переопределением для контуров с собственной конфигурацией.

Пока установку открывают по IP, домена нет вовсе: hosted-адрес не собирается,
диалог говорит об этом и не даёт создать портал вместо выдачи мёртвой ссылки.
Порталы, успевшие осесть на .localhost, миграция переводит на домен установки —
такие ссылки всё равно никуда не вели.

Заодно поле адреса в диалоге: без color и background оно красилось системным
белым и светилось в тёмной теме. Скин по базлайну — 36px, текст --n-1, суффикс
на --n-9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-14 00:11:06 +03:00
1 parent 44718142b3
commit 42a468ec97
14 files changed
+305 -36

No files matched your search

@@ -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",
@@ -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": "Выберите файл фото",
@@ -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}")
@@ -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}"
@@ -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,
),
]
@@ -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,
},
}
@@ -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")
@@ -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
@@ -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()
@@ -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())}
/>
<b>.{address.baseDomain}</b>
<b>.{baseDomain}</b>
</span>
<small>{t("portals.lowercase_latin_letters_digits_hyphens")}<b>{address.baseDomain}</b> {t("portals.base_domain_hint")}
<small>{t("portals.lowercase_latin_letters_digits_hyphens")}<b>{baseDomain}</b> {t("portals.base_domain_hint")}
</small>
{fieldErrors.slug && <small className="portal-field-error">{fieldErrors.slug}</small>}
</div>
@@ -48,6 +48,10 @@ export function PortalCreateDialog({
const [error, setError] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const portalKeyValid = validPortalKey(slug.trim());
// Базовый домен — адрес самой установки; пока она известна только по IP,
// размещать портал негде, и честнее сказать это здесь, чем выдать адрес,
// который никуда не ведёт.
const baseDomainKnown = Boolean(address.baseDomain);
async function submit() {
setBusy(true);
@@ -73,24 +77,28 @@ export function PortalCreateDialog({
<div className="portal-dialog-form">
<p>{t("portals.portal_public_help_centre_with")}</p>
<FormField error={fieldErrors.name} label={t("portals.portal_name")} value={name} onChange={setName} placeholder={t("portals.example_help_centre")} wide />
<label className={`portal-address-field${fieldErrors.slug ? " is-invalid" : ""}`}>
<span>{t("portals.portal_address")}</span>
<div>
<input
type="text"
value={slug}
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
placeholder="help"
autoComplete="off"
maxLength={63}
pattern="[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
/>
<b>.{address.baseDomain}</b>
</div>
<small>{t("portals.use_lowercase_latin_letters_digits")}</small>
{fieldErrors.slug && <small className="form-field-error" role="alert">{fieldErrors.slug}</small>}
{slug.trim() && <code>{configuredAddress(address, slug.trim())}</code>}
</label>
{baseDomainKnown ? (
<label className={`portal-address-field${fieldErrors.slug ? " is-invalid" : ""}`}>
<span>{t("portals.portal_address")}</span>
<div>
<input
type="text"
value={slug}
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
placeholder="help"
autoComplete="off"
maxLength={63}
pattern="[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
/>
<b>.{address.baseDomain}</b>
</div>
<small>{t("portals.use_lowercase_latin_letters_digits")}</small>
{fieldErrors.slug && <small className="form-field-error" role="alert">{fieldErrors.slug}</small>}
{slug.trim() && <code>{configuredAddress(address, slug.trim())}</code>}
</label>
) : (
<div className="portal-form-error" role="alert">{t("portals.installation_domain_missing")}</div>
)}
<SelectField
label={t("portals.primary_language")}
value={locale}
@@ -100,7 +108,7 @@ export function PortalCreateDialog({
{error && <div className="portal-form-error">{error}</div>}
<div className="portal-dialog-actions">
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={busy || !name.trim() || !portalKeyValid} onClick={() => void submit()}>
<Button variant="primary" disabled={busy || !baseDomainKnown || !name.trim() || !portalKeyValid} onClick={() => void submit()}>
{busy ? t("portals.creating") : t("portals.create_portal")}
</Button>
</div>
@@ -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;
}
+1
View File
@@ -878,6 +878,7 @@ export const en: Record<MessageKey, Message> = {
"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",
+1
View File
@@ -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",