diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 7b5e5ee..6d44efe 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -146,6 +146,7 @@ MESSAGES: dict[str, object] = { "settings.http_or_https": "http or https", "settings.integration_not_found": "Integration not found", "settings.invalid_address": "Invalid address", + "settings.invalid_port": "Port must be a number from 1 to 65535", "settings.items_list_expected": "A list of items is expected", "settings.language_unsupported": "This language is not supported", "settings.migration_running": "A migration is already running", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 42f8f0f..1852f9f 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -150,6 +150,7 @@ MESSAGES: dict[str, object] = { "settings.http_or_https": "http или https", "settings.integration_not_found": "Интеграция не найдена", "settings.invalid_address": "Некорректный адрес", + "settings.invalid_port": "Порт — число от 1 до 65535", "settings.items_list_expected": "Ожидается список items", "settings.language_unsupported": "Этот язык не поддерживается", "settings.migration_running": "Перенос уже идёт", diff --git a/apps/backend/chatballs/identity/instance_settings.py b/apps/backend/chatballs/identity/instance_settings.py index b73875b..5c51582 100644 --- a/apps/backend/chatballs/identity/instance_settings.py +++ b/apps/backend/chatballs/identity/instance_settings.py @@ -22,8 +22,14 @@ from chatballs.support_portals.addressing import normalize_domain class InstanceSettings(models.Model): SINGLETON_PK = 1 - # Хост без схемы и порта: «crm.example.com» или «203.0.113.10». + # Хост без схемы и порта: «crm.example.com» или «203.0.113.10». Именно + # хост, а не адрес: по нему проверяются входящие Host, строятся домены + # порталов и адреса TURN — порт им чужой. public_host = models.CharField(max_length=253, blank=True, default="") + # Порт, если установку открывают не на стандартном для схемы: шлюз + # опубликован как 8081, а 80-й занят панелью или другим сервисом. Пусто — + # порт схемы (80/443), и в ссылках его нет. + public_port = models.PositiveIntegerField(null=True, blank=True) # Предыдущий адрес: остаётся принятым, чтобы смена адреса не выбрасывала # того, кто её делает. Владелец меняет адрес заранее — до того, как домен # начал резолвиться и получил сертификат, — и сидит при этом на старом. @@ -179,18 +185,49 @@ def remember_default_language(language: str) -> None: row.save(update_fields=["default_language", "updated_at"]) -def remember_public_host(raw_host: str, scheme: str = "http") -> None: - """Запомнить адрес, на котором прошли мастер, если он ещё не задан.""" +_DEFAULT_PORTS = {"http": 80, "https": 443} - host = normalize_domain(raw_host.partition(":")[0]) - if not host: + +def split_address(raw: str, scheme: str) -> tuple[str, int | None] | None: + """Хост и порт из «host[:port]»; None — адрес не разобрать. + + Порт схемы (80 у http, 443 у https) отбрасывается: в ссылке он лишний, а + хранить его значило бы различать два одинаковых адреса. + """ + + host, separator, port_text = raw.strip().rpartition(":") + if not separator: + return normalize_domain(port_text), None + host = normalize_domain(host) + if not port_text.isdigit() or not 1 <= int(port_text) <= 65535: + return None + port = int(port_text) + return host, None if port == _DEFAULT_PORTS.get(scheme) else port + + +def format_address(host: str, port: int | None) -> str: + """Адрес для ссылок и для поля в «Настройках»: хост и порт, если он не схемы.""" + + return f"{host}:{port}" if host and port else host + + +def remember_public_host(raw_host: str, scheme: str = "http") -> None: + """Запомнить адрес, на котором прошли мастер, если он ещё не задан. + + Порт запоминается вместе с хостом: установку, открытую на ``ip:8081``, + дальше открывают там же, и ссылки без порта вели бы в пустоту. + """ + + scheme = "https" if scheme == "https" else "http" + parsed = split_address(raw_host, scheme) + if parsed is None or not parsed[0]: return row = InstanceSettings.load() if row.public_host: return - row.public_host = host - row.public_scheme = "https" if scheme == "https" else "http" - row.save(update_fields=["public_host", "public_scheme", "updated_at"]) + row.public_host, row.public_port = parsed + row.public_scheme = scheme + row.save(update_fields=["public_host", "public_port", "public_scheme", "updated_at"]) def public_base_url() -> str: @@ -215,7 +252,7 @@ def public_base_url() -> str: row = None if row is not None and row.public_host: scheme = row.public_scheme or "http" - return f"{scheme}://{row.public_host}" + return f"{scheme}://{format_address(row.public_host, row.public_port)}" return str(settings.CHATBALLS_PUBLIC_BASE_URL).rstrip("/") diff --git a/apps/backend/chatballs/identity/instance_views.py b/apps/backend/chatballs/identity/instance_views.py index c51e2c7..90a9421 100644 --- a/apps/backend/chatballs/identity/instance_views.py +++ b/apps/backend/chatballs/identity/instance_views.py @@ -20,17 +20,20 @@ from chatballs.identity.instance_settings import ( default_turn_urls, email_connection, email_from_address, + format_address, invalidate_cache, public_base_url, + split_address, ) -from chatballs.support_portals.addressing import normalize_domain, validate_domain +from chatballs.support_portals.addressing import validate_domain SCHEMES = ("http", "https") def instance_payload(row: InstanceSettings) -> dict: return { - "publicHost": row.public_host, + # Адрес целиком, с портом, если он не схемы: так его и вписывают. + "publicHost": format_address(row.public_host, row.public_port), "publicScheme": row.public_scheme or "http", "publicUrl": public_base_url(), # Язык экранов, где организации ещё нет: логин, сброс пароля, мастер. @@ -77,12 +80,22 @@ class InstanceAddressView(APIView): body = request.data if isinstance(request.data, dict) else {} errors: dict[str, str] = {} - # Владелец может вставить и целый URL из адресной строки — берём хост. - raw_host = str(body.get("publicHost", row.public_host)).strip() + scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower() + if scheme not in SCHEMES: + errors["publicScheme"] = t("settings.http_or_https") + + # Владелец может вставить и целый URL из адресной строки — берём хост + # и порт, путь отбрасываем. + raw_host = str( + body.get("publicHost", format_address(row.public_host, row.public_port)) + ).strip() if "//" in raw_host: raw_host = raw_host.split("//", 1)[1] - host = normalize_domain(raw_host.split("/", 1)[0].split(":", 1)[0]) - if not host: + parsed = split_address(raw_host.split("/", 1)[0], scheme) + host, port = parsed if parsed is not None else ("", None) + if parsed is None: + errors["publicHost"] = t("settings.invalid_port") + elif not host: errors["publicHost"] = t("settings.address_required") else: try: @@ -92,10 +105,6 @@ class InstanceAddressView(APIView): except ValidationError: errors["publicHost"] = t("settings.invalid_address") - scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower() - if scheme not in SCHEMES: - errors["publicScheme"] = t("settings.http_or_https") - raw_language = str(body.get("defaultLanguage", row.default_language)).strip() language = normalize_language(raw_language) if raw_language and not language: @@ -108,6 +117,7 @@ class InstanceAddressView(APIView): fields = [ "public_host", + "public_port", "public_scheme", "previous_public_host", "default_language", @@ -118,6 +128,7 @@ class InstanceAddressView(APIView): # сидя на старом, и не должен выпасть из установки в тот же миг. row.previous_public_host = row.public_host row.public_host = host + row.public_port = port row.public_scheme = scheme row.default_language = language or DEFAULT_LANGUAGE diff --git a/apps/backend/chatballs/identity/migrations/0040_instance_public_port.py b/apps/backend/chatballs/identity/migrations/0040_instance_public_port.py new file mode 100644 index 0000000..220c866 --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0040_instance_public_port.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("identity", "0039_remove_organization_currency"), + ] + + operations = [ + migrations.AddField( + model_name="instancesettings", + name="public_port", + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/apps/backend/chatballs/identity/test_instance_address.py b/apps/backend/chatballs/identity/test_instance_address.py index 2842294..033248a 100644 --- a/apps/backend/chatballs/identity/test_instance_address.py +++ b/apps/backend/chatballs/identity/test_instance_address.py @@ -88,6 +88,33 @@ class InstanceAddressChangeTests(TestCase): self.assertEqual(set(accepted_hosts()), {"crm.example.test", "203.0.113.10"}) + def test_port_is_kept_apart_from_the_host(self) -> None: + """Шлюз на 8081: порт идёт в ссылки, но не в хост — по хосту + проверяются входящие Host и строятся домены порталов.""" + response = self._patch("http://203.0.113.10:8081/login", scheme="http") + + self.assertEqual(response.status_code, 200, response.content) + payload = response.json()["instance"] + self.assertEqual(payload["publicHost"], "203.0.113.10:8081") + self.assertEqual(payload["publicUrl"], "http://203.0.113.10:8081") + row = InstanceSettings.load() + self.assertEqual((row.public_host, row.public_port), ("203.0.113.10", 8081)) + # Смена одного порта — не смена адреса: прежний хост не сдвигается. + self.assertEqual(row.previous_public_host, "") + + def test_scheme_port_is_dropped(self) -> None: + self._patch("crm.example.test:8081") + response = self._patch("crm.example.test:443") + + self.assertEqual(response.json()["instance"]["publicUrl"], "https://crm.example.test") + self.assertIsNone(InstanceSettings.load().public_port) + + def test_bad_port_is_rejected(self) -> None: + for address in ("203.0.113.10:0", "203.0.113.10:70000", "203.0.113.10:web", "203.0.113.10:"): + with self.subTest(address=address): + self.assertEqual(self._patch(address, scheme="http").status_code, 400) + self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10") + class PortalDomainCollisionTests(TestCase): def setUp(self) -> None: diff --git a/apps/backend/chatballs/identity/test_setup.py b/apps/backend/chatballs/identity/test_setup.py index 7e27f6b..2b26fe1 100644 --- a/apps/backend/chatballs/identity/test_setup.py +++ b/apps/backend/chatballs/identity/test_setup.py @@ -54,6 +54,25 @@ class SetupWizardTests(TestCase): self.assertEqual(response.status_code, 201, response.content) self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10") + def test_setup_on_a_custom_port_keeps_the_port_for_links(self) -> None: + """Шлюз опубликован на 8081: ссылки без порта вели бы в пустоту.""" + invalidate_cache() + + response = self.complete(HTTP_HOST="203.0.113.10:8081") + + self.assertEqual(response.status_code, 201, response.content) + row = InstanceSettings.load() + self.assertEqual((row.public_host, row.public_port), ("203.0.113.10", 8081)) + invalidate_cache() + self.assertEqual(public_base_url(), "http://203.0.113.10:8081") + + def test_setup_on_the_scheme_port_stores_no_port(self) -> None: + invalidate_cache() + + self.complete(HTTP_HOST="crm.example.test:80") + + self.assertIsNone(InstanceSettings.load().public_port) + def test_remembered_address_keeps_working_after_setup(self) -> None: self.complete(HTTP_HOST="crm.example.test") invalidate_cache() diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index 6df0d07..9cb6014 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -1515,7 +1515,7 @@ export const en: Record = { "settings.demo_provider_no_key": "Demo provider (no key)", "settings.demo_records_count": "{count} records", "settings.disabled": "Disabled", - "settings.domain_or_ip": "Domain or IP", + "settings.domain_or_ip": "Domain or IP, with a port if needed", "settings.e_g_openrouter_primary": "e.g. OpenRouter · primary", "settings.e_g_support_mailbox": "e.g. Support mailbox", "settings.edit_integration": "Edit the integration", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index 5660eaf..ff4adb8 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -1516,7 +1516,7 @@ export const ru = { "settings.demo_provider_no_key": "Демо-провайдер (без ключа)", "settings.demo_records_count": "{count} записей", "settings.disabled": "Отключено", - "settings.domain_or_ip": "Домен или IP", + "settings.domain_or_ip": "Домен или IP, при необходимости с портом", "settings.e_g_openrouter_primary": "например, OpenRouter · основной", "settings.e_g_support_mailbox": "например, Почта поддержки", "settings.edit_integration": "Изменить интеграцию",