Files
chatballs/apps/backend/chatballs_backend/settings_base.py
T
AndreyandClaude Opus 5 42a468ec97 🐛 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>
2026-09-14 00:11:06 +03:00

407 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import sys
from ipaddress import IPv4Address
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
from chatballs.i18n.languages import DEFAULT_LANGUAGE
from chatballs.i18n.languages import LANGUAGES as CHATBALLS_LANGUAGES
from chatballs_backend.settings_database import build_databases
from chatballs_backend.settings_env import env_bool, env_list, env_secret
from chatballs_backend.settings_storage import build_storage_settings
BASE_DIR = Path(__file__).resolve().parent.parent
INSECURE_SECRET_KEY = "local-development-only"
# Автоопределение тестового прогона, чтобы manage.py test / pytest работали
# без ручного выставления production-окружения.
TESTING = "test" in sys.argv or "pytest" in sys.modules
# Ключ подписи не задаётся человеком: его генерирует первый старт стека в том
# с секретами (deploy/secrets). Переменная окружения остаётся как переопределение
# для установок, которые ведут конфигурацию сами.
SECRET_KEY = env_secret("CHATBALLS_SECRET_KEY", "secret_key", INSECURE_SECRET_KEY)
DEBUG = env_bool("CHATBALLS_DEBUG")
# Режим поставки — свойство установки, а не переменной окружения: коробку
# ставят self-hosted, облако выставляет режим явно.
_delivery_mode = os.environ.get("CHATBALLS_DELIVERY_MODE", "").strip().upper()
if not _delivery_mode:
_delivery_mode = "CLOUD" if (DEBUG or TESTING) else "SELF_HOSTED"
if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
raise ImproperlyConfigured(
"CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
)
CHATBALLS_DELIVERY_MODE = _delivery_mode
# Версия установки: CI передаёт её в образ при сборке релиза; в сборке из
# исходников остаётся «dev», и тогда обновлений из интерфейса нет — сравнивать
# не с чем.
CHATBALLS_VERSION = os.environ.get("CHATBALLS_VERSION", "dev").strip() or "dev"
# Канал релизов и обмен с сервисом updater (ADR-CHATBALLS-0049).
CHATBALLS_UPDATE_REPO = os.environ.get("CHATBALLS_UPDATE_REPO", "dartdavros/chatballs").strip()
CHATBALLS_UPDATES_DIR = os.environ.get("CHATBALLS_UPDATES_DIR", "/run/chatballs/updates")
CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS = int(
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(15 * 60))
)
ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
if TESTING:
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
CSRF_TRUSTED_ORIGINS = env_list("CHATBALLS_CSRF_TRUSTED_ORIGINS", [])
# Слабый ключ подписи в production недопустим. При пустом томе секретов это
# означает сломанную установку, а не забытую человеком переменную.
if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}:
raise ImproperlyConfigured(
"Не удалось прочитать ключ подписи инстанса: том с секретами пуст или "
"недоступен (deploy/secrets/generate-instance-secrets.sh)"
)
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"chatballs.identity",
"chatballs.tenancy",
"chatballs.platform",
"chatballs.ai",
"chatballs.integrations",
"chatballs.channels",
"chatballs.conversations",
"chatballs.notifications",
"chatballs.webchat",
"chatballs.health",
"chatballs.events",
"chatballs.support_portals",
"chatballs.calls",
"chatballs.updates",
# django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels»
# конфликтует с доменным chatballs.channels, а без runserver-оверрайда
# (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS.
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"chatballs.http.middleware.TlsAwareCookieMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"chatballs.http.middleware.ContentSecurityPolicyMiddleware",
"chatballs.http.middleware.LocalCorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"chatballs.identity.middleware.SessionActivityMiddleware",
"chatballs.events.middleware.CorrelationIdMiddleware",
"chatballs.tenancy.middleware.TenantContextMiddleware",
# Последней в цепочке — ближе всех к вьюхе: язык организации известен
# только после того, как TenantContextMiddleware её определил.
"chatballs.i18n.middleware.LanguageMiddleware",
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
AUTH_USER_MODEL = "identity.HumanUser"
# Tests use the disposable cluster owner to create/drop the test database. The
# dedicated RLS suite explicitly SET ROLEs into the non-owner runtime roles.
DATABASES = build_databases(debug=DEBUG, testing=TESTING)
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://redis:6379/0"),
}
}
# Signaling звонков: Redis только fan-out/presence, source of truth lifecycle —
# PostgreSQL (SPEC-CHATBALLS-0013 §9). В тестах — InMemory layer.
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
}
if TESTING
else {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [os.environ.get("REDIS_URL", "redis://redis:6379/0")]},
}
}
DATABASE_ROUTERS = ["chatballs.tenancy.routing.ForcedAliasRouter"]
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 10},
},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
{"NAME": "chatballs.identity.password_validation.PasswordComplexityValidator"},
]
# Язык — настройка продукта, а не окружения: его выбирает владелец в
# «Настройках» и каждый сотрудник в профиле, а LANGUAGE_CODE остаётся только
# последним рубежом для кода, который выполняется вне запроса (команды,
# фоновые задачи). Список языков общий с каталогом chatballs.i18n, чтобы
# добавление языка не требовало править настройки в двух местах.
LANGUAGE_CODE = DEFAULT_LANGUAGE
LANGUAGES = CHATBALLS_LANGUAGES
TIME_ZONE = "Europe/Moscow"
USE_I18N = True
USE_TZ = True
# Email (env-driven; console backend — безопасный локальный дефолт, SMTP задаётся установщиком).
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
EMAIL_HOST = os.environ.get("EMAIL_HOST", "")
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587"))
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "true").lower() == "true"
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Chatballs <no-reply@localhost>")
# Base URL of the internal UI, used to build links inside transactional emails.
INTERNAL_UI_BASE_URL = os.environ.get("INTERNAL_UI_BASE_URL", "http://localhost:5173")
# Ключ шифрования секретов в БД (Fernet). Как и остальные секреты инстанса —
# из тома, куда его кладёт первый старт стека; человек его не вводит. Пустое
# значение означает установку, до которой файл ещё не доехал: тогда ключ
# выводится из SECRET_KEY тем же способом (см. chatballs.identity.crypto).
CHATBALLS_FIELD_ENCRYPTION_KEY = env_secret(
"CHATBALLS_FIELD_ENCRYPTION_KEY", "field_encryption_key", ""
)
# AI provider runtime. The local adapter is explicit and test-only.
CHATBALLS_AI_PROVIDER = os.environ.get("CHATBALLS_AI_PROVIDER", "")
if TESTING and not CHATBALLS_AI_PROVIDER:
CHATBALLS_AI_PROVIDER = "test"
CHATBALLS_OPENROUTER_BASE_URL = os.environ.get("CHATBALLS_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
CHATBALLS_AI_REQUEST_TIMEOUT = float(os.environ.get("CHATBALLS_AI_REQUEST_TIMEOUT", "30"))
CHATBALLS_AI_MAX_RETRIES = int(os.environ.get("CHATBALLS_AI_MAX_RETRIES", "2"))
CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small")
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
# Managed-провайдер CustoAI удалён (ADR-CHATBALLS-0042 §3): AI — только через
# интеграцию организации (BYOK).
# Long-poll hold-time мессенджеров (сек). Держим малым: единый воркер выполняет
# и inbound-поллинг, и outbox-диспатч в одном потоке — при большом hold-time
# getUpdates/updates блокирует цикл и outbox (приглашения звонков, уведомления,
# ответы AI) уходит с задержкой в размер long-poll на каждое подключение.
CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS", "2"))
# Срок жизни анонимной сессии виджета: отсчёт от последней активности, а не от
# выдачи, — посетитель, который переписывается неделями, историю не теряет.
# Токен лежит в localStorage браузера, поэтому бессрочным он быть не должен:
# на общем компьютере он открывал бы чужую переписку сколько угодно долго.
CHATBALLS_WEBCHAT_SESSION_IDLE_SECONDS = int(
os.environ.get("CHATBALLS_WEBCHAT_SESSION_IDLE_SECONDS", str(30 * 24 * 60 * 60))
)
if CHATBALLS_WEBCHAT_SESSION_IDLE_SECONDS <= 0:
raise ImproperlyConfigured("CHATBALLS_WEBCHAT_SESSION_IDLE_SECONDS must be positive")
# Password reset link lifetime. UI обещает 30 минут (default_token_generator uses this setting).
PASSWORD_RESET_TIMEOUT = int(os.environ.get("PASSWORD_RESET_TIMEOUT", str(30 * 60)))
# Транспорт и cookie.
#
# Коробку ставят одной командой и первый раз открывают по http — по адресу
# сервера, когда домена и сертификата ещё нет. Поэтому жёсткость транспорта
# не включается настройкой «вне DEBUG»: редирект на https делает шлюз, когда
# у него реально есть сертификат, а Secure-cookie и префикс __Host- ставит
# TlsAwareCookieMiddleware по факту TLS у конкретного запроса. Так установка
# работает сразу и ужесточается сама, как только перед ней появляется TLS.
_secure_default = not DEBUG and not TESTING
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_CONTENT_TYPE_NOSNIFF = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = os.environ.get("CHATBALLS_COOKIE_SAMESITE", "Lax")
CSRF_COOKIE_SAMESITE = SESSION_COOKIE_SAMESITE
SESSION_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", False)
CSRF_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", False)
SECURE_SSL_REDIRECT = env_bool("CHATBALLS_SSL_REDIRECT", False)
# HSTS Django отдаёт только на запросах, пришедших по TLS, поэтому установка
# на голом http его не получает и не «залипает» на несуществующий https.
SECURE_HSTS_SECONDS = int(
os.environ.get("CHATBALLS_HSTS_SECONDS", str(60 * 60 * 24 * 365) if _secure_default else "0")
)
SECURE_HSTS_INCLUDE_SUBDOMAINS = SECURE_HSTS_SECONDS > 0
SECURE_HSTS_PRELOAD = SECURE_HSTS_SECONDS > 0
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
# Файловые вложения знаний (ADR-CHATBALLS-0023). Файлы отдаются только через
# download-endpoint (FileResponse), прямого статик-роутинга MEDIA нет.
MEDIA_URL = "media/"
CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
base_dir=BASE_DIR,
debug=DEBUG,
testing=TESTING,
)
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
# Публичные порталы поддержки размещаются на отдельных хостах. Базовый домен
# берётся из адреса самой установки (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" 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()
# Адрес, на который владелец направляет A-запись домена портала. Штатный
# источник — сам адрес установки (его знает только она сама, см.
# chatballs.support_portals.public_address); переменные ниже остаются
# переопределением для контуров, которые ведут конфигурацию сами. Пустое
# значение — не ошибка установки: до мастера первого запуска адреса просто
# ещё нет, а порталов с доменами тем более.
_configured_help_ipv4 = os.environ.get("CHATBALLS_HELP_PUBLIC_IPV4", "").strip()
if not _configured_help_ipv4:
_listening_ip = os.environ.get("CHATBALLS_WEB_LISTENING_IP", "").strip()
if _listening_ip not in {"", "0.0.0.0", "::"}:
_configured_help_ipv4 = _listening_ip
CHATBALLS_HELP_PUBLIC_IPV4 = _configured_help_ipv4
if CHATBALLS_HELP_PUBLIC_IPV4:
try:
IPv4Address(CHATBALLS_HELP_PUBLIC_IPV4)
except ValueError as error:
raise ImproperlyConfigured(
"CHATBALLS_HELP_PUBLIC_IPV4 must be a valid IPv4 address"
) from error
# P2P calls: opaque invitation lifetime and short-lived signaling/media access.
CHATBALLS_CALL_INVITE_TTL_SECONDS = int(os.environ.get("CHATBALLS_CALL_INVITE_TTL_SECONDS", str(5 * 60)))
CHATBALLS_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("CHATBALLS_CALL_ACCESS_TTL_SECONDS", str(60 * 60)))
# Grace period: принятый звонок без установленного соединения закрывается FAILED.
CHATBALLS_CALL_CONNECT_GRACE_SECONDS = int(os.environ.get("CHATBALLS_CALL_CONNECT_GRACE_SECONDS", str(2 * 60)))
# Grace period восстановления активного звонка после обрыва участника.
CHATBALLS_CALL_RECONNECT_GRACE_SECONDS = int(os.environ.get("CHATBALLS_CALL_RECONNECT_GRACE_SECONDS", str(60)))
# C07 concurrent quota: lease TTL for a p2p-call slot reservation. A crashed
# session is released by the reservation sweep once the lease lapses.
CHATBALLS_CONCURRENT_CALL_LEASE_SECONDS = int(
os.environ.get("CHATBALLS_CONCURRENT_CALL_LEASE_SECONDS", str(2 * 60 * 60))
)
if (
CHATBALLS_CALL_INVITE_TTL_SECONDS <= 0
or CHATBALLS_CALL_ACCESS_TTL_SECONDS <= 0
or CHATBALLS_CALL_CONNECT_GRACE_SECONDS <= 0
or CHATBALLS_CALL_RECONNECT_GRACE_SECONDS <= 0
):
raise ImproperlyConfigured("HUB call token TTL values must be positive")
# ICE-серверы для WebRTC (SPEC-CHATBALLS-0013 §10): direct-first через STUN, TURN как
# fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp).
CHATBALLS_CALL_STUN_URLS = env_list("CHATBALLS_CALL_STUN_URLS", [])
# TURN (Coturn, SPEC-CHATBALLS-0013 §11): backend выдаёт краткоживущие REST-credentials
# по общему static-auth-secret. Пусто локально -> только STUN/direct ICE.
# Адреса TURN владелец задаёт в «Настройках» (там же, где адрес установки);
# переменная остаётся переопределением для установок, ведущих конфигурацию сами.
CHATBALLS_CALL_TURN_URLS = env_list("CHATBALLS_CALL_TURN_URLS", [])
# Общий с coturn секрет генерирует первый старт стека — человек его не вводит.
CHATBALLS_CALL_TURN_SECRET = env_secret("CHATBALLS_CALL_TURN_SECRET", "turn_secret", "")
CHATBALLS_CALL_TURN_TTL_SECONDS = int(os.environ.get("CHATBALLS_CALL_TURN_TTL_SECONDS", str(60 * 60)))
if CHATBALLS_CALL_TURN_TTL_SECONDS <= 0:
raise ImproperlyConfigured("CHATBALLS_CALL_TURN_TTL_SECONDS must be positive")
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Лимиты на чувствительные эндпоинты (брутфорс/злоупотребление). В тестах отключены.
_THROTTLE_RATES = {
"login": "10/min",
"password_reset": "5/min",
"totp": "10/min",
"call_invite": "30/min",
# Страница звонка поллит состояние по access token — лимит с запасом.
"call_access": "120/min",
"help_feedback": "20/hour",
# Публичный виджет (chatballs.webchat.throttling): аноним заводит сессию,
# шлёт сообщения и файлы. Первые четыре ставки считаются по адресу клиента,
# остальные — по токену сессии.
#
# По адресу потолки нарочно высокие: за одним адресом мобильного оператора
# (CGNAT) сидят тысячи посетителей сайта, и жёсткий лимит выключил бы виджет
# живым людям, а не скрипту. Их дело — потолок на вал, точный счёт ведут
# ставки по сессии. Виджет тянет config на каждую загрузку страницы и
# опрашивает ленту раз в 2.5 с (~24 запроса в минуту на сессию).
"webchat_config": "600/min",
"webchat_session": "120/hour",
"webchat_read": "1200/min",
"webchat_write": "300/min",
"webchat_session_read": "120/min",
# Каждое сообщение — ход AI по ключу организации, каждый файл — место в её
# хранилище. Здесь считается конкретный собеседник, поэтому строго: живой
# человек в чате не пишет по двадцать реплик в минуту.
"webchat_session_write": "20/min",
"webchat_session_upload": "10/min",
}
if TESTING:
_THROTTLE_RATES = {scope: None for scope in _THROTTLE_RATES}
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
"DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"],
"DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"EXCEPTION_HANDLER": "chatballs.api.exceptions.api_exception_handler",
"DEFAULT_THROTTLE_RATES": _THROTTLE_RATES,
}
CORS_ALLOWED_ORIGINS = env_list(
"CHATBALLS_CORS_ALLOWED_ORIGINS",
["http://localhost:5173", "http://localhost:5174", "http://localhost:5175"],
)
# Конкретное значение задаёт surface settings. Middleware не добавляет header,
# если policy пуста (например, в узком техническом тесте).
CHATBALLS_CONTENT_SECURITY_POLICY = ""
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"structured": {
"format": (
"%(asctime)s %(levelname)s %(name)s %(message)s "
"correlation_id=%(correlation_id)s"
)
}
},
"filters": {
"correlation_id": {"()": "chatballs.events.logging.CorrelationIdLogFilter"}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "structured",
"filters": ["correlation_id"],
}
},
"root": {"handlers": ["console"], "level": "INFO"},
}