diff --git a/.dockerignore b/.dockerignore index 77c400c..fb26f53 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ .env .env.* -!.env.example !.env.production.example .git .venv diff --git a/.env.example b/.env.example deleted file mode 100644 index 783540d..0000000 --- a/.env.example +++ /dev/null @@ -1,84 +0,0 @@ -COMPOSE_PROJECT_NAME=chatballs - -CHATBALLS_APP_DOMAIN=app.localhost -CHATBALLS_PLATFORM_DOMAIN=platform.localhost -CHATBALLS_HELP_BASE_DOMAIN=localhost -CHATBALLS_HELP_PUBLIC_SCHEME=http -CHATBALLS_HELP_PUBLIC_PORT=8080 -CHATBALLS_ACME_EMAIL=local@example.invalid -CHATBALLS_ADMIN_PORT=18001 - -CHATBALLS_ENV=local -CHATBALLS_DEBUG=true -CHATBALLS_DELIVERY_MODE=CLOUD -CHATBALLS_SECRET_KEY=change-me-only-for-local-development -CHATBALLS_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost -CHATBALLS_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175 -CHATBALLS_PLATFORM_ALLOWED_HOSTS=localhost,127.0.0.1,platform.localhost -CHATBALLS_PLATFORM_CSRF_TRUSTED_ORIGINS=http://platform.localhost,http://localhost:8011 -CHATBALLS_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175 - -# Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из -# CHATBALLS_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key(). -CHATBALLS_FIELD_ENCRYPTION_KEY= - -# AI-провайдер. Локально по умолчанию тестовый адаптер; в production задайте ключ. -# CHATBALLS_AI_PROVIDER=openrouter -CHATBALLS_OPENROUTER_API_KEY= -# CHATBALLS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 -# CHATBALLS_AI_REQUEST_TIMEOUT=30 -# CHATBALLS_AI_MAX_RETRIES=2 -# CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0 - -# Транспортная безопасность. Вне CHATBALLS_DEBUG включается автоматически; здесь — для переопределения. -# CHATBALLS_COOKIE_SECURE=true -# CHATBALLS_SSL_REDIRECT=true -# CHATBALLS_HSTS_SECONDS=31536000 -# CHATBALLS_COOKIE_SAMESITE=Lax - -# Email (по умолчанию console backend; для реальной отправки задайте SMTP). -# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend -# EMAIL_HOST= -# EMAIL_HOST_USER= -# EMAIL_HOST_PASSWORD= -# DEFAULT_FROM_EMAIL=Chatballs -INTERNAL_UI_BASE_URL=http://localhost:5173 - -POSTGRES_DB=chatballs -POSTGRES_USER=chatballs_bootstrap -POSTGRES_PASSWORD=chatballs_bootstrap -POSTGRES_APP_USER=chatballs_app -POSTGRES_APP_PASSWORD=chatballs_app -POSTGRES_PLATFORM_USER=chatballs_platform -POSTGRES_PLATFORM_PASSWORD=chatballs_platform -POSTGRES_MIGRATION_USER=chatballs_migration -POSTGRES_MIGRATION_PASSWORD=chatballs_migration -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 - -REDIS_URL=redis://redis:6379/0 - -# Хранилище файлов: по умолчанию локальный диск (data/media). Внешнее S3 -# включается администратором в «Настройки → Хранилище файлов», не здесь. - -# P2P calls: invite — 5 минут, access token — 1 час. -CHATBALLS_CALL_INVITE_TTL_SECONDS=300 -CHATBALLS_CALL_ACCESS_TTL_SECONDS=3600 -CHATBALLS_CALL_CONNECT_GRACE_SECONDS=120 -CHATBALLS_CALL_RECONNECT_GRACE_SECONDS=60 -# STUN для WebRTC (через запятую). Локально обычно пусто: direct ICE на localhost. -CHATBALLS_CALL_STUN_URLS= -# TURN (Coturn) — production-контур; локально пусто (direct/STUN достаточно). -CHATBALLS_CALL_TURN_URLS= -CHATBALLS_CALL_TURN_SECRET= -CHATBALLS_CALL_TURN_TTL_SECONDS=3600 - -INTERNAL_UI_PORT=5173 -WEB_CHAT_PORT=5175 -BACKEND_APP_PORT=8010 -BACKEND_PLATFORM_PORT=8011 - -VITE_API_BASE_URL=http://app.localhost/api/v1 - -# Публичный домен для сниппета Web-виджета в рантайме выводится от текущего origin -# (ADR-HUB-0028 §10); build-time аргумент не нужен. diff --git a/Caddyfile b/Caddyfile index 3e09424..9ed796c 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,54 +1,61 @@ # Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway). # Подставляется в release bundle и монтируется в контейнер gateway. -# Caddy: TLS termination + ACME, HTTP->HTTPS redirect, WebSocket upgrade (native). -# App-маршрутизация делегирована frontend-контейнеру, platform API идёт в -# отдельный backend. Admin принципиально отсутствует в public gateway. +# +# Свежая установка не знает своего домена: человек поднимает докер на сервере и +# открывает её по IP. Поэтому здесь нет блоков с зашитым именем хоста — они бы +# заставили Caddy выпускать сертификат и уводить http на https до того, как +# домен вообще появился. Вместо них: :80 отвечает по http на любом адресе, а +# https обслуживает любой хост, который одобрил ask-эндпоинт (домены порталов и +# домен установки, заданный в UI). Сертификаты выписываются по мере надобности, +# без перезапуска шлюза. { - email {$CHATBALLS_ACME_EMAIL:} + # ACME-контакт не задаётся при установке: пока домена нет, нет и выпуска + # сертификатов. Адрес для уведомлений добавляется вместе с доменом в UI. # Caddy admin API не публикуется наружу (default localhost:2019). on_demand_tls { ask http://backend-platform:8000/api/v1/gateway/help-domain/ } } -# Published hosted and verified custom Help Center domains. Certificate -# issuance is authorized against the platform ingress directory above. +(surfaces) { + # Платформенная поверхность живёт на своём домене; пока он не задан, + # матчер намеренно не совпадает ни с чем. + @platform host {$CHATBALLS_PLATFORM_DOMAIN:platform.invalid} + handle @platform { + reverse_proxy backend-platform:8000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + } + + # Всё остальное — приложение и публичные порталы: маршрутизация внутри + # frontend-контейнера. + handle { + reverse_proxy frontend:80 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + } +} + +# Обычный http на любом адресе: IP сервера, localhost, ещё не подтверждённый +# домен. Редиректа на https здесь нет намеренно — его делает блок ниже, когда +# для хоста реально выписан сертификат. +:80 { + encode gzip zstd + import surfaces +} + +# Любой хост, которому ask-эндпоинт разрешил сертификат. https:// { encode gzip zstd tls { on_demand } - - reverse_proxy frontend:80 { - header_up Host {host} - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} - } -} - -# Tenant-facing application surface. -{$CHATBALLS_APP_DOMAIN} { - encode gzip zstd - - reverse_proxy frontend:80 { - header_up Host {host} - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} - } -} - -# Platform surface. На C01 опубликован только health endpoint; API -# управления tenant'ами появится на C06. -{$CHATBALLS_PLATFORM_DOMAIN} { - encode gzip zstd - - reverse_proxy backend-platform:8000 { - header_up Host {host} - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} - } + import surfaces } diff --git a/README.md b/README.md index 9912dff..60466a4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ Canonical implementation workspace for Chatballs. ## Быстрый старт (одна минута) Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose -на Linux). Никаких параметров заранее задавать не нужно — всё спросит браузер. +на Linux). Ни одной переменной задавать не нужно и негде: у продукта нет `.env`. +Организацию, владельца, домены, интеграции, почту и хранилище человек настраивает +в интерфейсе. Windows (PowerShell): @@ -23,14 +25,29 @@ cd chatballs/code/chatballs ./scripts/start.sh ``` -Скрипт копирует `.env.example` в `.env` (если его нет), собирает образы и -поднимает стек. Когда в логах появится готовность, откройте +Настраивать нечего: файла `.env` у продукта нет, секреты инстанса (ключ +подписи, пароли ролей БД) генерирует сам первый старт и держит их в томе +`chatballs-secrets`. + +Если положить рядом со скриптом `release.env` со страницы релиза, образы +скачаются из реестра по digest и сборки не будет — это самый быстрый путь. +Без `release.env` стек собирается из исходников: так работают те, кто правит +код. Когда в логах появится готовность, откройте **http://localhost** — вместо входа система покажет **мастер первого запуска**: название организации, ваше имя, e-mail и пароль владельца, переключатель «Установить демо-данные». После кнопки «Начать» вы сразу в приложении под владельцем. Мастер доступен только пока в системе нет ни одной организации; после создания владельца он закрывается навсегда. +### Доступ по http и переход на TLS + +Свежая установка отвечает по обычному http — по адресу сервера, пока домена и +сертификата ещё нет. Продукт не уводит себя на https принудительно: этим +занимается шлюз, когда у него появляется настоящий домен и сертификат. +Жёсткость транспорта включается сама по факту TLS: запрос пришёл по https — +cookie получают префикс `__Host-`, флаг `Secure` и HSTS; по http — обычные +имена без `Secure`. Настраивать для этого нечего. + ### Демо-данные Демо — вымышленное ателье «Норд» (дизайн-базлайн v2): сотрудники и группы, @@ -75,8 +92,8 @@ CHATBALLS_INSTANCE_DIR=/opt/chatballs/instance ./deploy/migrate/rename-to-chatba ```bash # dev-стек из каталога репозитория -CHATBALLS_INSTANCE_DIR="$PWD" CHATBALLS_COMPOSE_ARGS="-f compose.dev.yaml --env-file .env.example" deploy/migrate/rename-to-chatballs.sh -docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up -d +CHATBALLS_INSTANCE_DIR="$PWD" CHATBALLS_COMPOSE_ARGS="-f compose.dev.yaml" deploy/migrate/rename-to-chatballs.sh +docker compose -f compose.yaml -f compose.dev.yaml up -d ``` Скрипт останавливает старый compose-проект, переписывает `.env` (резервная diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py index 63ed5e3..02679c7 100644 --- a/apps/backend/chatballs/ai/models.py +++ b/apps/backend/chatballs/ai/models.py @@ -97,11 +97,12 @@ class KnowledgeAttachment(TenantRelationModel): def public_url(self) -> str: # Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому # строится от публичного адреса Hub, а не от request. - from django.conf import settings from django.urls import reverse + from chatballs.identity.instance_settings import public_base_url + path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id}) - return settings.CHATBALLS_PUBLIC_BASE_URL.rstrip("/") + path + return public_base_url() + path class KnowledgeFragment(TenantRelationModel): diff --git a/apps/backend/chatballs/calls/event_handlers.py b/apps/backend/chatballs/calls/event_handlers.py index 3ed7dc9..2d921fa 100644 --- a/apps/backend/chatballs/calls/event_handlers.py +++ b/apps/backend/chatballs/calls/event_handlers.py @@ -7,7 +7,6 @@ import logging -from django.conf import settings from django.db import transaction from chatballs.calls.lifecycle import transition_call @@ -16,6 +15,7 @@ from chatballs.calls.services import CALL_INVITE_SEND from chatballs.calls.tokens import issue_invite_token from chatballs.conversations import transports from chatballs.events.handlers import register +from chatballs.identity.instance_settings import public_base_url from chatballs.tenancy.context import TenantContext logger = logging.getLogger(__name__) @@ -51,7 +51,7 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non invite.save(update_fields=["token_hash"]) call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок" invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку." - url = f"{settings.CHATBALLS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}?kind={call.kind}" + url = f"{public_base_url()}/calls/{token}?kind={call.kind}" sent = transports.send_call_invite( call.delivery_connection, chat_id=call.conversation.external_chat_id, diff --git a/apps/backend/chatballs/calls/serializers.py b/apps/backend/chatballs/calls/serializers.py index b7a717b..e23053d 100644 --- a/apps/backend/chatballs/calls/serializers.py +++ b/apps/backend/chatballs/calls/serializers.py @@ -2,6 +2,7 @@ from django.conf import settings from chatballs.calls.models import CallSession from chatballs.calls.turn import turn_credentials +from chatballs.identity.instance_settings import turn_config def _iso(value): @@ -68,11 +69,12 @@ def ice_servers_payload() -> list[dict]: servers: list[dict] = [] if settings.CHATBALLS_CALL_STUN_URLS: servers.append({"urls": list(settings.CHATBALLS_CALL_STUN_URLS)}) - if settings.CHATBALLS_CALL_TURN_URLS and settings.CHATBALLS_CALL_TURN_SECRET: - username, credential = turn_credentials() + turn_urls, ttl = turn_config() + if turn_urls and settings.CHATBALLS_CALL_TURN_SECRET: + username, credential = turn_credentials(ttl_seconds=ttl) servers.append( { - "urls": list(settings.CHATBALLS_CALL_TURN_URLS), + "urls": turn_urls, "username": username, "credential": credential, } diff --git a/apps/backend/chatballs/calls/turn.py b/apps/backend/chatballs/calls/turn.py index 46376bb..1dbc445 100644 --- a/apps/backend/chatballs/calls/turn.py +++ b/apps/backend/chatballs/calls/turn.py @@ -8,7 +8,9 @@ import time from django.conf import settings -def turn_credentials(*, label: str = "hub", now: int | None = None) -> tuple[str, str]: +def turn_credentials( + *, label: str = "hub", now: int | None = None, ttl_seconds: int | None = None +) -> tuple[str, str]: """Краткоживущие TURN REST credentials для Coturn (SPEC-HUB-0013 §11). Схема coturn `use-auth-secret`: @@ -20,7 +22,7 @@ def turn_credentials(*, label: str = "hub", now: int | None = None) -> tuple[str coturn и наружу не отдаётся — клиент получает только производные credentials. """ moment = int(time.time()) if now is None else int(now) - expiry = moment + settings.CHATBALLS_CALL_TURN_TTL_SECONDS + expiry = moment + (ttl_seconds or settings.CHATBALLS_CALL_TURN_TTL_SECONDS) username = f"{expiry}:{label}" digest = hmac.new( settings.CHATBALLS_CALL_TURN_SECRET.encode("utf-8"), diff --git a/apps/backend/chatballs/http/middleware.py b/apps/backend/chatballs/http/middleware.py index b65e8f2..fb1d543 100644 --- a/apps/backend/chatballs/http/middleware.py +++ b/apps/backend/chatballs/http/middleware.py @@ -39,3 +39,61 @@ class ContentSecurityPolicyMiddleware: if policy and not response.has_header("Content-Security-Policy"): response["Content-Security-Policy"] = policy return response + +class TlsAwareCookieMiddleware: + """Жёсткость cookie по факту TLS, а не по переменной окружения. + + Коробку ставят одной командой и сначала открывают по http — по адресу + сервера, до того как заведён домен и выписан сертификат. Если бы Secure- + cookie и префикс ``__Host-`` включались настройкой, такая установка не + смогла бы даже завести владельца в мастере. Поэтому решение принимается на + каждый запрос: пришли по https — отдаём ``__Host-`` + Secure, пришли по + http — обычное имя без Secure. Настраивать нечего, а установка сама + ужесточается в тот момент, когда перед ней появляется TLS. + + Фронтенд выбирает имя cookie по протоколу страницы тем же правилом + (``api/client.ts``), поэтому стороны всегда сходятся. + """ + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def _pairs(self) -> tuple[tuple[str, str], ...]: + """Пары «имя по http» → «имя по https» из настроек поверхности. + + Имена заданы явно, а не собираются префиксом: их знает и фронтенд + (api/client.ts), и они не должны разъезжаться. + """ + mapping = getattr(settings, "CHATBALLS_TLS_COOKIE_NAMES", {}) + return tuple( + (plain, hardened) + for plain, hardened in mapping.items() + if plain != hardened + ) + + def __call__(self, request: HttpRequest) -> HttpResponse: + secure = request.is_secure() + pairs = self._pairs() + if secure: + # Браузер прислал защищённые имена — отдаём их Django под обычными. + for plain, hardened in pairs: + if hardened in request.COOKIES and plain not in request.COOKIES: + request.COOKIES[plain] = request.COOKIES[hardened] + response = self.get_response(request) + if not secure: + return response + for plain, hardened in pairs: + cookie = response.cookies.get(plain) + if cookie is None: + continue + response.cookies[hardened] = cookie.value + target = response.cookies[hardened] + for key, value in cookie.items(): + if value != "": + target[key] = value + # Требования префикса __Host-: Secure, Path=/ и без Domain. + target["secure"] = True + target["path"] = "/" + target["domain"] = "" + del response.cookies[plain] + return response diff --git a/apps/backend/chatballs/http/tests.py b/apps/backend/chatballs/http/tests.py index 04968e0..8920d2f 100644 --- a/apps/backend/chatballs/http/tests.py +++ b/apps/backend/chatballs/http/tests.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.test import TestCase, override_settings @@ -33,3 +34,44 @@ class ContentSecurityPolicyMiddlewareTests(TestCase): response = self.client.get("/api/v1/health/live/") self.assertFalse(response.has_header("Content-Security-Policy")) + + +class TlsAwareCookieMiddlewareTests(TestCase): + """Коробку сначала открывают по http, а TLS появляется позже (см. middleware).""" + + def test_plain_http_gets_usable_cookies_without_secure(self) -> None: + # Без этого мастер первого запуска не смог бы завести владельца: + # Secure-cookie по http браузер не сохраняет. + response = self.client.get("/api/v1/auth/session/") + + cookie = response.cookies.get(settings.CSRF_COOKIE_NAME) + self.assertIsNotNone(cookie) + self.assertFalse(cookie["secure"]) + self.assertNotIn(settings.CHATBALLS_TLS_COOKIE_NAMES[settings.CSRF_COOKIE_NAME], response.cookies) + + def test_https_request_gets_host_prefixed_secure_cookies(self) -> None: + response = self.client.get("/api/v1/auth/session/", secure=True) + + hardened = response.cookies.get(settings.CHATBALLS_TLS_COOKIE_NAMES[settings.CSRF_COOKIE_NAME]) + self.assertIsNotNone(hardened) + self.assertTrue(hardened["secure"]) + self.assertEqual(hardened["path"], "/") + self.assertEqual(hardened["domain"], "") + self.assertNotIn(settings.CSRF_COOKIE_NAME, response.cookies) + + def test_https_request_reads_back_host_prefixed_cookie(self) -> None: + first = self.client.get("/api/v1/auth/session/", secure=True) + token = first.cookies[settings.CHATBALLS_TLS_COOKIE_NAMES[settings.CSRF_COOKIE_NAME]].value + self.client.cookies.clear() + self.client.cookies[settings.CHATBALLS_TLS_COOKIE_NAMES[settings.CSRF_COOKIE_NAME]] = token + + response = self.client.get("/api/v1/auth/session/", secure=True) + + self.assertEqual(response.status_code, 200) + + def test_plain_http_is_not_redirected_to_https(self) -> None: + # Установка без домена обязана отвечать по http, а не уводить браузер + # на несуществующий https (иначе редирект залипает в кеше). + response = self.client.get("/api/v1/health/live/") + + self.assertEqual(response.status_code, 200) diff --git a/apps/backend/chatballs/identity/company_urls.py b/apps/backend/chatballs/identity/company_urls.py index 021e44e..71885fe 100644 --- a/apps/backend/chatballs/identity/company_urls.py +++ b/apps/backend/chatballs/identity/company_urls.py @@ -1,6 +1,11 @@ from django.urls import path -from chatballs.identity import administration_views, demo_views, group_views +from chatballs.identity import ( + administration_views, + demo_views, + group_views, + instance_views, +) from chatballs.integrations import feature_views from chatballs.tenancy import storage_views @@ -12,6 +17,16 @@ urlpatterns = [ administration_views.OrganizationSettingsView.as_view(), name="organization-settings", ), + path( + "administration/instance/", + instance_views.InstanceAddressView.as_view(), + name="instance-address", + ), + path( + "administration/instance/email-check/", + instance_views.InstanceEmailCheckView.as_view(), + name="instance-email-check", + ), path( "administration/logo/", administration_views.OrganizationLogoView.as_view(), diff --git a/apps/backend/chatballs/identity/demo_seed/data/support.json b/apps/backend/chatballs/identity/demo_seed/data/support.json index e7c2ab3..10ede0c 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/support.json +++ b/apps/backend/chatballs/identity/demo_seed/data/support.json @@ -86,7 +86,8 @@ "status": "PUBLISHED", "helpfulVotes": 14, "unhelpfulVotes": 1, - "publishedDaysAgo": 27 + "publishedDaysAgo": 27, + "files": ["katalog-tkanej-2026.pdf"] }, { "slug": "sroki-dostavki", diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/support.py b/apps/backend/chatballs/identity/demo_seed/loaders/support.py index d027e76..aee6dd9 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/support.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/support.py @@ -5,6 +5,8 @@ from __future__ import annotations from datetime import timedelta +from django.core.files.base import ContentFile + from chatballs.identity.demo_seed import manifest from chatballs.identity.demo_seed.loaders.common import backdate, now from chatballs.identity.demo_seed.refs import DemoRefs @@ -21,7 +23,11 @@ from chatballs.support_portals.content_services import ( publish_revision, record_feedback, ) -from chatballs.support_portals.models import PortalCategory, SupportPortal +from chatballs.support_portals.models import ( + PortalArticleFile, + PortalCategory, + SupportPortal, +) from chatballs.support_portals.portal_services import ( PortalInput, create_portal, @@ -30,6 +36,7 @@ from chatballs.support_portals.portal_services import ( ) from chatballs.support_portals.statuses import ArticleStatus, PortalStatus from chatballs.tenancy.context import TenantContext +from chatballs.tenancy.storage import adjust_storage_usage def load(context: TenantContext, refs: DemoRefs) -> None: @@ -130,7 +137,9 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N categories[item["slug"]] = category for item in portal_data.get("articles", []): - _ensure_article(context, refs, portal, categories, item, current) + article = _ensure_article(context, refs, portal, categories, item, current) + for spec in item.get("files", []): + _attach_article_file(context, refs, article, spec) target_status = portal_data.get("status", PortalStatus.PUBLISHED) if portal.status != target_status: @@ -138,7 +147,14 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N refs.portal = portal -def _ensure_article(context: TenantContext, refs: DemoRefs, portal: SupportPortal, categories, item: dict, current) -> None: +def _ensure_article( + context: TenantContext, + refs: DemoRefs, + portal: SupportPortal, + categories, + item: dict, + current, +): article = portal.articles.filter(slug=item["slug"]).first() if article is None: article = create_article( @@ -183,6 +199,7 @@ def _ensure_article(context: TenantContext, refs: DemoRefs, portal: SupportPorta if status == ArticleStatus.ARCHIVED: archive_article(article) refs.portal_articles[item["slug"]] = article + return article def _link_agent_articles(refs: DemoRefs) -> None: @@ -194,3 +211,35 @@ def _link_agent_articles(refs: DemoRefs) -> None: article = refs.portal_articles.get(slug) if article is not None and article.status == ArticleStatus.PUBLISHED: agent.portal_articles.add(article) + + +def _attach_article_file(context: TenantContext, refs: DemoRefs, article, spec) -> None: + """Файл статьи портала: картинка или документ рядом с текстом + (кадр PT7 дизайн-базлайна). + """ + + if isinstance(spec, str): + spec = {"file": spec} + filename = spec["file"] + if article.files.filter(original_name=filename).exists(): + return + payload = manifest.media_bytes(filename) + suffix = filename.rsplit(".", 1)[-1].lower() + content_type = spec.get("contentType") or { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "webp": "image/webp", + "pdf": "application/pdf", + "md": "text/markdown", + "txt": "text/plain", + }.get(suffix, "application/octet-stream") + article_file = PortalArticleFile( + organization=refs.organization, + article=article, + original_name=filename, + content_type=content_type, + size=len(payload), + ) + article_file.file.save(filename, ContentFile(payload), save=True) + adjust_storage_usage(context=context, delta_bytes=len(payload)) diff --git a/apps/backend/chatballs/identity/emails.py b/apps/backend/chatballs/identity/emails.py index 5844bbf..62c3062 100644 --- a/apps/backend/chatballs/identity/emails.py +++ b/apps/backend/chatballs/identity/emails.py @@ -1,16 +1,22 @@ -from django.conf import settings from django.contrib.auth.tokens import default_token_generator from django.core.mail import send_mail from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode +from chatballs.identity.instance_settings import ( + email_connection, + email_from_address, + public_base_url, +) from chatballs.identity.models import HumanUser def _password_setup_url(user: HumanUser) -> str: uid = urlsafe_base64_encode(force_bytes(user.pk)) token = default_token_generator.make_token(user) - return f"{settings.INTERNAL_UI_BASE_URL.rstrip('/')}/reset-password?uid={uid}&token={token}" + # Ссылка уходит человеку в почту, поэтому строится от адреса установки, + # который знает сама система, а не от адреса dev-сервера в переменной. + return f"{public_base_url()}/reset-password?uid={uid}&token={token}" def send_initial_access_email(user: HumanUser) -> None: @@ -24,8 +30,9 @@ def send_initial_access_email(user: HumanUser) -> None: f"{setup_url}\n\n" "Ссылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации." ), - from_email=settings.DEFAULT_FROM_EMAIL, + from_email=email_from_address(), recipient_list=[user.email], + connection=email_connection(), ) @@ -39,6 +46,7 @@ def send_password_reset_email(user: HumanUser) -> None: f"{reset_url}\n\n" "Ссылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо." ), - from_email=settings.DEFAULT_FROM_EMAIL, + from_email=email_from_address(), recipient_list=[user.email], + connection=email_connection(), ) diff --git a/apps/backend/chatballs/identity/instance_settings.py b/apps/backend/chatballs/identity/instance_settings.py new file mode 100644 index 0000000..ecd43b4 --- /dev/null +++ b/apps/backend/chatballs/identity/instance_settings.py @@ -0,0 +1,191 @@ +"""Настройки самой установки: одна строка на инсталляцию, без RLS. + +Адрес, по которому открывают установку, продукт узнаёт не из переменной +окружения, а от человека: он поднимает докер на сервере, открывает его по IP +или по своему домену и проходит мастер первого запуска. Адрес из мастера +запоминается здесь, и дальше именно он считается публичным адресом установки — +на нём строятся ссылки и по нему проверяются входящие Host. +""" + +from __future__ import annotations + +import os +import threading +import time + +from django.db import models + +from chatballs.identity.crypto import EncryptedCharField +from chatballs.support_portals.addressing import normalize_domain + + +class InstanceSettings(models.Model): + SINGLETON_PK = 1 + + # Хост без схемы и порта: «crm.example.com» или «203.0.113.10». + public_host = models.CharField(max_length=253, blank=True, default="") + # Схема, по которой установку открывают снаружи. Меняется вместе с + # адресом, когда перед установкой появляется домен и сертификат. + public_scheme = models.CharField(max_length=5, blank=True, default="") + + # Почта установки: через неё уходят приглашения сотрудникам и сброс + # пароля. Пока не задана, письма пишутся в лог — приглашать некого. + email_host = models.CharField(max_length=253, blank=True, default="") + email_port = models.PositiveIntegerField(default=587) + email_user = models.CharField(max_length=255, blank=True, default="") + email_password = EncryptedCharField(max_length=512, blank=True, default="") + email_use_tls = models.BooleanField(default=True) + email_from = models.CharField(max_length=255, blank=True, default="") + + # Адреса TURN-серверов для звонков через relay. Секрет сюда не пишется: + # он общий с coturn и живёт в томе секретов, чтобы не вводить его дважды. + turn_urls = models.TextField(blank=True, default="") + turn_ttl_seconds = models.PositiveIntegerField(default=3600) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Настройки установки" + + def __str__(self) -> str: + return f"instance:{self.public_host or 'не задан'}" + + def save(self, *args, **kwargs): + self.pk = self.SINGLETON_PK + super().save(*args, **kwargs) + invalidate_cache() + + @classmethod + def load(cls) -> InstanceSettings: + obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK) + return obj + + +_CACHE_TTL_SECONDS = 10.0 +_lock = threading.Lock() +_cached: tuple[float, str] | None = None + + +def invalidate_cache() -> None: + global _cached + with _lock: + _cached = None + + +def public_host() -> str: + """Адрес установки, запомненный мастером, или пустая строка.""" + + global _cached + now = time.monotonic() + with _lock: + if _cached is not None and now - _cached[0] < _CACHE_TTL_SECONDS: + return _cached[1] + try: + row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first() + value = row.public_host if row is not None else "" + except Exception: # таблицы ещё нет (первые миграции) + return "" + with _lock: + _cached = (now, value) + return value + + +def remember_public_host(raw_host: str, scheme: str = "http") -> None: + """Запомнить адрес, на котором прошли мастер, если он ещё не задан.""" + + host = normalize_domain(raw_host.partition(":")[0]) + if not host: + 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"]) + + +def public_base_url() -> str: + """Адрес установки для абсолютных ссылок, уходящих наружу. + + Такие ссылки агент отдаёт клиенту в мессенджер, поэтому «localhost» + здесь недопустим. Источник — адрес, на котором прошли мастер (и который + владелец может поменять в «Настройках»); переменная окружения остаётся + переопределением для установок, ведущих конфигурацию сами. + """ + + from django.conf import settings + + configured = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "").strip() + if configured: + return configured.rstrip("/") + try: + row = InstanceSettings.objects.filter( + pk=InstanceSettings.SINGLETON_PK + ).first() + except Exception: + row = None + if row is not None and row.public_host: + scheme = row.public_scheme or "http" + return f"{scheme}://{row.public_host}" + return str(settings.CHATBALLS_PUBLIC_BASE_URL).rstrip("/") + + +def email_is_configured() -> bool: + row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first() + return bool(row and row.email_host) + + +def email_connection(): + """Соединение с почтовым сервером из настроек установки. + + Пока владелец не задал SMTP, возвращается None — письма уходят в бэкенд по + умолчанию (в коробке это консоль), и в «Настройках» видно, что почта не + настроена. + """ + + from django.core.mail import get_connection + + row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first() + if row is None or not row.email_host: + return None + return get_connection( + backend="django.core.mail.backends.smtp.EmailBackend", + host=row.email_host, + port=row.email_port, + username=row.email_user or None, + password=row.email_password or None, + use_tls=row.email_use_tls, + ) + + +def email_from_address() -> str: + """Адрес отправителя: из настроек установки, иначе — общий дефолт.""" + + from django.conf import settings + + row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first() + if row is not None and row.email_from: + return row.email_from + return str(settings.DEFAULT_FROM_EMAIL) + + +def turn_config() -> tuple[list[str], int]: + """Адреса TURN и время жизни credentials из настроек установки. + + Пустой список означает «relay не настроен»: звонки идут напрямую и через + STUN. Переменная окружения, если задана, побеждает. + """ + + from django.conf import settings + + if settings.CHATBALLS_CALL_TURN_URLS: + return list(settings.CHATBALLS_CALL_TURN_URLS), settings.CHATBALLS_CALL_TURN_TTL_SECONDS + try: + row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first() + except Exception: + # Нет БД (юнит-тест без базы, ранние миграции) — берём то, что в + # настройках процесса. + return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS + if row is None or not row.turn_urls.strip(): + return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS + urls = [line.strip() for line in row.turn_urls.splitlines() if line.strip()] + return urls, row.turn_ttl_seconds or settings.CHATBALLS_CALL_TURN_TTL_SECONDS diff --git a/apps/backend/chatballs/identity/instance_views.py b/apps/backend/chatballs/identity/instance_views.py new file mode 100644 index 0000000..4b263c2 --- /dev/null +++ b/apps/backend/chatballs/identity/instance_views.py @@ -0,0 +1,185 @@ +"""Адрес установки в «Настройках». + +Мастер первого запуска запоминает адрес, на котором его открыли. Дальше владелец +меняет его здесь — когда завёл домен и поставил перед установкой TLS. Никаких +переменных окружения: адрес живёт в настройках инсталляции и из него строятся +внешние ссылки (вложения знаний, файлы статей, приглашения на звонок). +""" + +from django.conf import settings +from django.core.exceptions import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from chatballs.api.permissions import HasCapability +from chatballs.identity.instance_settings import ( + InstanceSettings, + email_connection, + email_from_address, + invalidate_cache, + public_base_url, +) +from chatballs.support_portals.addressing import normalize_domain, validate_domain + +SCHEMES = ("http", "https") + + +def instance_payload(row: InstanceSettings) -> dict: + return { + "publicHost": row.public_host, + "publicScheme": row.public_scheme or "http", + "publicUrl": public_base_url(), + "updatedAt": row.updated_at, + "email": { + "host": row.email_host, + "port": row.email_port, + "user": row.email_user, + # Пароль наружу не возвращается: пустое поле при сохранении + # означает «оставить прежний». + "hasPassword": bool(row.email_password), + "useTls": row.email_use_tls, + "from": row.email_from, + "configured": bool(row.email_host), + }, + "turn": { + # Секрет общий с coturn и лежит в томе секретов: наружу не отдаём + # и в настройках не показываем — вводить его человеку не нужно. + "urls": [line for line in row.turn_urls.splitlines() if line.strip()], + "ttlSeconds": row.turn_ttl_seconds, + "secretReady": bool(settings.CHATBALLS_CALL_TURN_SECRET), + }, + } + + +class InstanceAddressView(APIView): + permission_classes = [HasCapability] + required_capabilities = {"GET": "settings.view", "PATCH": "company.manage"} + + def get(self, request: Request) -> Response: + return Response({"instance": instance_payload(InstanceSettings.load())}) + + def patch(self, request: Request) -> Response: + row = InstanceSettings.load() + body = request.data if isinstance(request.data, dict) else {} + errors: dict[str, str] = {} + + # Владелец может вставить и целый URL из адресной строки — берём хост. + raw_host = str(body.get("publicHost", row.public_host)).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: + errors["publicHost"] = "Укажите адрес установки" + else: + try: + # IP-адрес — обычный случай коробки: домена может не быть вовсе. + if not _looks_like_ipv4(host): + validate_domain(host) + except ValidationError: + errors["publicHost"] = "Некорректный адрес" + + scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower() + if scheme not in SCHEMES: + errors["publicScheme"] = "http или https" + + if errors: + return Response( + {"detail": next(iter(errors.values())), "errors": errors}, status=400 + ) + + fields = ["public_host", "public_scheme", "updated_at"] + row.public_host = host + row.public_scheme = scheme + + email = body.get("email") + if isinstance(email, dict): + row.email_host = str(email.get("host", row.email_host)).strip() + row.email_user = str(email.get("user", row.email_user)).strip() + row.email_from = str(email.get("from", row.email_from)).strip() + row.email_use_tls = bool(email.get("useTls", row.email_use_tls)) + try: + row.email_port = int(email.get("port", row.email_port)) + except (TypeError, ValueError): + return Response( + {"detail": "Порт — число", "errors": {"emailPort": "Порт — число"}}, + status=400, + ) + # Пустой пароль означает «оставить прежний»: наружу он не отдаётся. + password = str(email.get("password", "")) + if password: + row.email_password = password + fields += [ + "email_host", + "email_port", + "email_user", + "email_password", + "email_use_tls", + "email_from", + ] + + turn = body.get("turn") + if isinstance(turn, dict): + urls = turn.get("urls", []) + if isinstance(urls, list): + row.turn_urls = sep_join(urls) + try: + row.turn_ttl_seconds = max(60, int(turn.get("ttlSeconds", row.turn_ttl_seconds))) + except (TypeError, ValueError): + return Response( + {"detail": "Время жизни — число секунд", + "errors": {"turnTtlSeconds": "Число секунд"}}, + status=400, + ) + fields += ["turn_urls", "turn_ttl_seconds"] + + row.save(update_fields=fields) + invalidate_cache() + return Response({"instance": instance_payload(row)}) + + +def _looks_like_ipv4(value: str) -> bool: + parts = value.split(".") + return len(parts) == 4 and all(part.isdigit() and 0 <= int(part) <= 255 for part in parts) + + +class InstanceEmailCheckView(APIView): + """Проверка почты: отправить письмо себе и увидеть ошибку сразу. + + Без этого владелец узнаёт о неверном SMTP только тогда, когда сотрудник не + получил приглашение. + """ + + permission_classes = [HasCapability] + required_capability = "company.manage" + + def post(self, request: Request) -> Response: + from django.core.mail import send_mail + + connection = email_connection() + if connection is None: + return Response( + {"detail": "Сначала укажите сервер исходящей почты"}, status=400 + ) + recipient = str(request.data.get("email", "")).strip() or request.user.email + try: + send_mail( + subject="Проверка почты Chatballs", + message=( + "Это тестовое письмо из «Настроек». " + "Если вы его читаете, почта установки работает." + ), + from_email=email_from_address(), + recipient_list=[recipient], + connection=connection, + ) + except Exception as error: # ошибки SMTP разнообразны — показываем текст + return Response({"detail": str(error)[:300]}, status=400) + return Response({"sent": recipient}) + + +def sep_join(urls) -> str: + """Адреса TURN хранятся строками: по одному на строку.""" + + lines = [str(item).strip() for item in urls if str(item).strip()] + return chr(10).join(lines) diff --git a/apps/backend/chatballs/identity/migrations/0027_instance_settings.py b/apps/backend/chatballs/identity/migrations/0027_instance_settings.py new file mode 100644 index 0000000..17907ec --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0027_instance_settings.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.15 on 2026-09-07 00:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0026_humanuser_password_changed_at'), + ] + + operations = [ + migrations.CreateModel( + name='InstanceSettings', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('public_host', models.CharField(blank=True, default='', max_length=253)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Настройки установки', + }, + ), + ] diff --git a/apps/backend/chatballs/identity/migrations/0028_instance_public_scheme.py b/apps/backend/chatballs/identity/migrations/0028_instance_public_scheme.py new file mode 100644 index 0000000..aff3c3f --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0028_instance_public_scheme.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-09-07 00:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0027_instance_settings'), + ] + + operations = [ + migrations.AddField( + model_name='instancesettings', + name='public_scheme', + field=models.CharField(blank=True, default='', max_length=5), + ), + ] diff --git a/apps/backend/chatballs/identity/migrations/0029_instance_email.py b/apps/backend/chatballs/identity/migrations/0029_instance_email.py new file mode 100644 index 0000000..74576f4 --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0029_instance_email.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.15 on 2026-09-07 01:55 + +import chatballs.identity.crypto +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0028_instance_public_scheme'), + ] + + operations = [ + migrations.AddField( + model_name='instancesettings', + name='email_from', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='instancesettings', + name='email_host', + field=models.CharField(blank=True, default='', max_length=253), + ), + migrations.AddField( + model_name='instancesettings', + name='email_password', + field=chatballs.identity.crypto.EncryptedCharField(blank=True, default='', max_length=512), + ), + migrations.AddField( + model_name='instancesettings', + name='email_port', + field=models.PositiveIntegerField(default=587), + ), + migrations.AddField( + model_name='instancesettings', + name='email_use_tls', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='instancesettings', + name='email_user', + field=models.CharField(blank=True, default='', max_length=255), + ), + ] diff --git a/apps/backend/chatballs/identity/migrations/0030_instance_turn.py b/apps/backend/chatballs/identity/migrations/0030_instance_turn.py new file mode 100644 index 0000000..b253f83 --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0030_instance_turn.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-09-07 02:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0029_instance_email'), + ] + + operations = [ + migrations.AddField( + model_name='instancesettings', + name='turn_ttl_seconds', + field=models.PositiveIntegerField(default=3600), + ), + migrations.AddField( + model_name='instancesettings', + name='turn_urls', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/apps/backend/chatballs/identity/models.py b/apps/backend/chatballs/identity/models.py index a3565da..9872a59 100644 --- a/apps/backend/chatballs/identity/models.py +++ b/apps/backend/chatballs/identity/models.py @@ -53,7 +53,7 @@ def user_storage(): return storages["users"] -def user_avatar_upload_path(instance: "HumanUser", filename: str) -> str: +def user_avatar_upload_path(instance: HumanUser, filename: str) -> str: suffix = Path(filename).suffix.lower() return f"users/{instance.id}/avatar-{uuid.uuid4()}{suffix}" @@ -100,7 +100,7 @@ class OrganizationStatus(models.TextChoices): PENDING_OWNER = "PENDING_OWNER", "Pending owner" -def organization_logo_upload_path(instance: "Organization", filename: str) -> str: +def organization_logo_upload_path(instance: Organization, filename: str) -> str: suffix = Path(filename).suffix.lower() return ( f"organizations/{instance.public_id}/branding/" @@ -252,14 +252,17 @@ class AuditEvent(models.Model): # Django imports only models.py by convention. Re-export related models after the core # identity entities are defined so they are registered without growing this file. -from chatballs.identity.group_models import ( # noqa: E402, F401 - EmployeeGroup, - EmployeeGroupMember, -) -from chatballs.identity.invitation_models import ( # noqa: E402, F401 - OrganizationInvitation, -) from chatballs.identity.demo_models import ( # noqa: E402, F401 DemoDataset, DemoRecord, ) +from chatballs.identity.group_models import ( # noqa: E402, F401 + EmployeeGroup, + EmployeeGroupMember, +) +from chatballs.identity.instance_settings import ( # noqa: E402, F401 + InstanceSettings, +) +from chatballs.identity.invitation_models import ( # noqa: E402, F401 + OrganizationInvitation, +) diff --git a/apps/backend/chatballs/identity/setup.py b/apps/backend/chatballs/identity/setup.py index 86c040e..00c2a5c 100644 --- a/apps/backend/chatballs/identity/setup.py +++ b/apps/backend/chatballs/identity/setup.py @@ -23,6 +23,7 @@ from django.utils.text import slugify from chatballs.ai.knowledge_categories import ensure_uncategorized_category from chatballs.identity.audit import record_audit_event +from chatballs.identity.instance_settings import remember_public_host from chatballs.identity.models import ( EmployeeRole, HumanUser, @@ -109,9 +110,15 @@ def _unique_slug(name: str) -> str: return candidate -def complete_setup(data: SetupInput) -> SetupResult: +def complete_setup( + data: SetupInput, public_host: str = "", public_scheme: str = "http" +) -> SetupResult: clean = _clean(data) with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS): + # Адрес, на котором человек прошёл мастер, и есть публичный адрес + # установки: другого источника у коробки нет. + if public_host: + remember_public_host(public_host, public_scheme) # Блокировка от гонки двух вкладок: второй запрос дождётся первого и # увидит созданную организацию. with connections[INSTANCE_DB_ALIAS].cursor() as cursor: diff --git a/apps/backend/chatballs/identity/setup_views.py b/apps/backend/chatballs/identity/setup_views.py index 5e4b85a..0c185ee 100644 --- a/apps/backend/chatballs/identity/setup_views.py +++ b/apps/backend/chatballs/identity/setup_views.py @@ -63,7 +63,9 @@ class SetupView(APIView): email=str(body.get("email", "")), password=str(body.get("password", "")), install_demo=bool(body.get("installDemo", False)), - ) + ), + public_host=request.get_host(), + public_scheme=request.scheme, ) except SetupAlreadyCompleted: return Response(SETUP_CLOSED, status=409) diff --git a/apps/backend/chatballs/identity/test_administration_api.py b/apps/backend/chatballs/identity/test_administration_api.py index 6d8104c..26a4a31 100644 --- a/apps/backend/chatballs/identity/test_administration_api.py +++ b/apps/backend/chatballs/identity/test_administration_api.py @@ -6,6 +6,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings from chatballs.identity.audit import record_audit_event +from chatballs.identity.bootstrap import bootstrap_owner from chatballs.identity.models import ( EmployeeRole, HumanUser, @@ -193,3 +194,104 @@ class AdministrationApiTests(TestCase): event = response.json()["items"][0] self.assertEqual(event["action"], "Добавлен продукт") self.assertNotIn("products.", event["action"]) + + +class InstanceAddressTests(TestCase): + """Адрес установки правится в «Настройках», а не в переменных окружения.""" + + def setUp(self) -> None: + result = bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = result.organization + self.client = TenantAPIClient() + self.client.force_authenticate(result.owner) + + def test_owner_sets_domain_and_scheme(self) -> None: + response = self.client.patch( + "/api/v1/company/administration/instance/", + {"publicHost": "crm.example.com", "publicScheme": "https"}, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + body = response.json()["instance"] + self.assertEqual(body["publicHost"], "crm.example.com") + self.assertEqual(body["publicUrl"], "https://crm.example.com") + + def test_address_may_be_a_bare_ip(self) -> None: + # Коробку часто так и оставляют: сервер по IP, без домена. + response = self.client.patch( + "/api/v1/company/administration/instance/", + {"publicHost": "203.0.113.10", "publicScheme": "http"}, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["instance"]["publicUrl"], "http://203.0.113.10") + + def test_url_is_accepted_and_trimmed_to_host(self) -> None: + response = self.client.patch( + "/api/v1/company/administration/instance/", + {"publicHost": "https://crm.example.com/settings", "publicScheme": "https"}, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["instance"]["publicHost"], "crm.example.com") + + def test_garbage_is_rejected(self) -> None: + response = self.client.patch( + "/api/v1/company/administration/instance/", + {"publicHost": "не адрес!", "publicScheme": "ftp"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + +class InstanceEmailTests(TestCase): + """Почта установки задаётся в «Настройках»: без неё некого приглашать.""" + + def setUp(self) -> None: + result = bootstrap_owner(email="owner@example.com", password="temporary-password") + self.client = TenantAPIClient() + self.client.force_authenticate(result.owner) + + def patch(self, **email): + return self.client.patch( + "/api/v1/company/administration/instance/", + {"publicHost": "crm.example.com", "publicScheme": "https", "email": email}, + format="json", + ) + + def test_smtp_is_saved_and_password_is_not_returned(self) -> None: + response = self.patch( + host="smtp.example.com", + port=465, + user="robot@example.com", + password="s3cret", + useTls=True, + **{"from": "Chatballs "}, + ) + + self.assertEqual(response.status_code, 200, response.content) + email = response.json()["instance"]["email"] + self.assertEqual(email["host"], "smtp.example.com") + self.assertEqual(email["port"], 465) + self.assertTrue(email["configured"]) + self.assertTrue(email["hasPassword"]) + self.assertNotIn("password", email) + + def test_empty_password_keeps_the_stored_one(self) -> None: + self.patch(host="smtp.example.com", password="s3cret") + + response = self.patch(host="smtp.example.com", password="") + + self.assertTrue(response.json()["instance"]["email"]["hasPassword"]) + + def test_check_without_smtp_explains_itself(self) -> None: + response = self.client.post( + "/api/v1/company/administration/instance/email-check/", {}, format="json" + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("сервер исходящей почты", response.json()["detail"]) diff --git a/apps/backend/chatballs/identity/test_seed_demo.py b/apps/backend/chatballs/identity/test_seed_demo.py index 7292c89..3c62423 100644 --- a/apps/backend/chatballs/identity/test_seed_demo.py +++ b/apps/backend/chatballs/identity/test_seed_demo.py @@ -45,6 +45,9 @@ COVERAGE_EXEMPT = { ("identity", "demorecord"), # Настройки хранилища — одна строка на инстанс, не данные организации. ("tenancy", "storagesettings"), + # Настройки установки (адрес, по которому её открывают) — тоже одна + # строка на инстанс: их пишет мастер первого запуска, а не демо. + ("identity", "instancesettings"), } COVERAGE_EXEMPT_APPS = {"platform", "events"} diff --git a/apps/backend/chatballs/identity/test_setup.py b/apps/backend/chatballs/identity/test_setup.py index 61bbea5..62f8eed 100644 --- a/apps/backend/chatballs/identity/test_setup.py +++ b/apps/backend/chatballs/identity/test_setup.py @@ -1,9 +1,13 @@ import json from django.test import TestCase -from chatballs.testing import TenantAPIClient as APIClient from chatballs.ai.models import KnowledgeCategory +from chatballs.identity.instance_settings import ( + InstanceSettings, + invalidate_cache, + public_base_url, +) from chatballs.identity.models import ( AuditEvent, EmployeeRole, @@ -11,6 +15,7 @@ from chatballs.identity.models import ( Organization, OrganizationMembership, ) +from chatballs.testing import TenantAPIClient as APIClient VALID = { "organizationName": "Ателье Норд", @@ -27,11 +32,50 @@ class SetupWizardTests(TestCase): self.client = APIClient() def complete(self, **overrides): + extra = {key: overrides.pop(key) for key in list(overrides) if key.startswith("HTTP_")} body = {**VALID, **overrides} return self.client.post( - "/api/v1/setup/complete/", data=json.dumps(body), content_type="application/json" + "/api/v1/setup/complete/", + data=json.dumps(body), + content_type="application/json", + **extra, ) + def test_setup_remembers_the_address_it_was_opened_on(self) -> None: + """Коробку открывают по IP сервера: этот адрес и есть её публичный. + + Другого источника адреса у установки нет — в .env его никто не задаёт, + а без него сразу после мастера все запросы упёрлись бы в проверку хоста. + """ + invalidate_cache() + + response = self.complete(HTTP_HOST="203.0.113.10") + + self.assertEqual(response.status_code, 201, response.content) + self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10") + + def test_remembered_address_keeps_working_after_setup(self) -> None: + self.complete(HTTP_HOST="crm.example.test") + invalidate_cache() + + response = self.client.get("/api/v1/setup/", HTTP_HOST="crm.example.test") + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["needsSetup"]) + + def test_external_links_use_the_address_from_the_wizard(self) -> None: + """Ссылки на файлы уходят клиенту в мессенджер: «localhost» там мёртв. + + Адрес берётся из мастера, а не из переменной окружения, которой на + свежей установке никто не задаёт. + """ + invalidate_cache() + + self.complete(HTTP_HOST="crm.example.test") + invalidate_cache() + + self.assertEqual(public_base_url(), "http://crm.example.test") + def test_fresh_instance_needs_setup(self) -> None: response = self.client.get("/api/v1/setup/") self.assertEqual(response.status_code, 200) diff --git a/apps/backend/chatballs/notifications/delivery.py b/apps/backend/chatballs/notifications/delivery.py index ce70ccb..4da29f3 100644 --- a/apps/backend/chatballs/notifications/delivery.py +++ b/apps/backend/chatballs/notifications/delivery.py @@ -12,6 +12,7 @@ import logging from django.conf import settings +from chatballs.identity.instance_settings import public_base_url from chatballs.conversations import transports from chatballs.identity.models import OrganizationMembership from chatballs.notifications.models import ( @@ -55,7 +56,7 @@ def _message_text(notification: Notification) -> str: lines = [f"{mark} {notification.title}"] if notification.body: lines.append(notification.body) - base_url = getattr(settings, "INTERNAL_UI_BASE_URL", "") or "" + base_url = public_base_url() if base_url: lines.append(base_url) return "\n".join(lines) diff --git a/apps/backend/chatballs/support_portals/content_markdown.py b/apps/backend/chatballs/support_portals/content_markdown.py new file mode 100644 index 0000000..b41e6a6 --- /dev/null +++ b/apps/backend/chatballs/support_portals/content_markdown.py @@ -0,0 +1,24 @@ +"""Ссылки на файлы статьи внутри Markdown. + +Портал отдаётся на своём домене (``help.example.com``), а строгий CSP +документа разрешает картинки только с ``'self'``. Абсолютная ссылка на файл — +пусть даже на ту же установку, но с другим хостом — браузером режется, и +изображение в статье просто не появляется. Поэтому ссылки на файлы статьи +приводятся к относительному виду: и при записи ревизии, и при отдаче — чтобы +вылечился текст, написанный до этого правила. +""" + +from __future__ import annotations + +import re + +# Абсолютный адрес до пути файла статьи: схема и хост отбрасываются, путь +# остаётся. Хост не проверяется — файл всегда отдаётся тем же сайтом. +_ABSOLUTE_FILE_LINK = re.compile( + r"https?://[^\s)\]<>\"']*?(/api/v1/help/files/[0-9a-fA-F-]{36}/)" +) + + +def normalize_file_links(content: str) -> str: + """Сделать ссылки на файлы статьи относительными.""" + return _ABSOLUTE_FILE_LINK.sub(r"\1", content) diff --git a/apps/backend/chatballs/support_portals/content_services.py b/apps/backend/chatballs/support_portals/content_services.py index f920f9d..a92094f 100644 --- a/apps/backend/chatballs/support_portals/content_services.py +++ b/apps/backend/chatballs/support_portals/content_services.py @@ -1,19 +1,32 @@ from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.core.files.uploadedfile import UploadedFile from django.db import transaction from django.db.models import Max from django.utils import timezone from django.utils.text import slugify from chatballs.ai.indexing import reindex_portal_article +from chatballs.support_portals.content_markdown import normalize_file_links from chatballs.support_portals.models import ( PortalArticle, PortalArticleFeedback, + PortalArticleFile, PortalArticleRevision, PortalCategory, SupportPortal, ) from chatballs.support_portals.statuses import ArticleStatus, PortalStatus from chatballs.tenancy.context import TenantContext +from chatballs.tenancy.storage import adjust_storage_usage +from chatballs.tenancy.storage_quota import ( + finalize_storage, + release_storage, + reserve_storage, +) + +# Лимит на файл статьи — как у вложений знаний и подпись в drop-зоне редактора. +MAX_ARTICLE_FILE_BYTES = 25 * 1024 * 1024 def create_category( @@ -97,7 +110,7 @@ def create_article( def add_revision( - *, context: TenantContext, article: PortalArticle, data: dict + *, context: TenantContext, article: PortalArticle, data: dict, author=None ) -> PortalArticleRevision: ensure_portal_editable(article.portal) if article.status == ArticleStatus.ARCHIVED: @@ -109,7 +122,8 @@ def add_revision( revision=last + 1, title=str(data.get("title", "")).strip(), summary=str(data.get("summary", "")).strip(), - content=str(data.get("content", "")), + content=normalize_file_links(str(data.get("content", ""))), + created_by=author, ) revision.full_clean() revision.save() @@ -186,3 +200,62 @@ def ensure_portal_editable(portal: SupportPortal) -> None: raise ValidationError( {"portal": "Восстановите портал, чтобы изменить его содержимое"} ) + + +def add_article_file( + *, + context: TenantContext, + article: PortalArticle, + upload: UploadedFile, + author=None, +) -> PortalArticleFile: + """Загрузка файла статьи: одноимённый файл заменяется, квота учитывается.""" + + ensure_portal_editable(article.portal) + if article.status == ArticleStatus.ARCHIVED: + raise ValidationError({"article": "Архивную статью нельзя изменять"}) + original_name = (upload.name or "").strip() + if not original_name: + raise ValidationError({"file": "Имя файла обязательно"}) + if upload.size and upload.size > MAX_ARTICLE_FILE_BYTES: + raise ValidationError({"file": "Файл больше 25 МБ"}) + existing = article.files.filter(original_name=original_name).first() + existing_size = existing.size if existing is not None else 0 + data = upload.read() + reservation_key = f"portal-article-file:{article.id}:{original_name}" + reserve_storage( + context=context, expected_bytes=len(data), idempotency_key=reservation_key + ) + try: + if existing is not None: + existing.file.delete(save=False) + existing.delete() + if existing_size: + adjust_storage_usage(context=context, delta_bytes=-existing_size) + article_file = PortalArticleFile( + organization=context.organization, + article=article, + original_name=original_name, + content_type=upload.content_type or "", + size=len(data), + uploaded_by=author, + ) + article_file.file.save(original_name, ContentFile(data), save=True) + except Exception: + release_storage(context=context, idempotency_key=reservation_key) + raise + finalize_storage( + context=context, idempotency_key=reservation_key, actual_bytes=len(data) + ) + return article_file + + +def delete_article_file( + *, context: TenantContext, article_file: PortalArticleFile +) -> None: + ensure_portal_editable(article_file.article.portal) + released_bytes = article_file.size + article_file.file.delete(save=False) + article_file.delete() + if released_bytes: + adjust_storage_usage(context=context, delta_bytes=-released_bytes) diff --git a/apps/backend/chatballs/support_portals/content_views.py b/apps/backend/chatballs/support_portals/content_views.py index 2b0bb74..40c95be 100644 --- a/apps/backend/chatballs/support_portals/content_views.py +++ b/apps/backend/chatballs/support_portals/content_views.py @@ -1,32 +1,36 @@ from django.core.exceptions import ValidationError +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.request import Request from rest_framework.response import Response from chatballs.support_portals.api import validation_response from chatballs.support_portals.content_services import ( + add_article_file, add_revision, archive_article, create_article, create_category, + delete_article_file, delete_category, publish_revision, - update_category, update_article, + update_category, ) from chatballs.support_portals.models import PortalArticle from chatballs.support_portals.portal_views import PortalBaseView +from chatballs.support_portals.selectors import category_article_counts from chatballs.support_portals.serializers import ( + article_file_payload, article_payload, category_payload, revision_payload, ) -from chatballs.support_portals.selectors import category_article_counts def _article(portal, article_id: int) -> PortalArticle | None: return ( portal.articles.select_related("portal", "category", "published_revision") - .prefetch_related("revisions") + .prefetch_related("revisions", "revisions__created_by", "files", "feedback") .filter(id=article_id) .first() ) @@ -111,7 +115,7 @@ class ArticleListView(PortalBaseView): return Response({"detail": "Портал не найден"}, status=404) articles = portal.articles.select_related( "category", "published_revision" - ).prefetch_related("revisions").all() + ).prefetch_related("revisions", "files", "feedback").all() return Response({"items": [article_payload(item) for item in articles]}) def post(self, request: Request, portal_id: int) -> Response: @@ -129,7 +133,10 @@ class ArticleListView(PortalBaseView): return validation_response(error) return Response({"detail": "Некорректная статья"}, status=400) article = _article(portal, article.id) - return Response({"article": article_payload(article, revisions=True)}, status=201) + return Response( + {"article": article_payload(article, revisions=True, files=True)}, + status=201, + ) class ArticleDetailView(PortalBaseView): @@ -140,7 +147,7 @@ class ArticleDetailView(PortalBaseView): article = _article(portal, article_id) if article is None: return Response({"detail": "Статья не найдена"}, status=404) - return Response({"article": article_payload(article, revisions=True)}) + return Response({"article": article_payload(article, revisions=True, files=True)}) def patch(self, request: Request, portal_id: int, article_id: int) -> Response: portal = self.portal(request, portal_id) @@ -156,7 +163,7 @@ class ArticleDetailView(PortalBaseView): return validation_response(error) return Response({"detail": "Некорректные данные статьи"}, status=400) article = _article(portal, article.id) - return Response({"article": article_payload(article, revisions=True)}) + return Response({"article": article_payload(article, revisions=True, files=True)}) class ArticleRevisionListView(PortalBaseView): @@ -172,6 +179,7 @@ class ArticleRevisionListView(PortalBaseView): context=request.tenant_context, article=article, data=request.data, + author=request.user, ) except ValidationError as error: return validation_response(error) @@ -196,7 +204,7 @@ class ArticlePublishView(PortalBaseView): return validation_response(error) return Response({"detail": "Некорректная версия"}, status=400) article = _article(portal, article.id) - return Response({"article": article_payload(article, revisions=True)}) + return Response({"article": article_payload(article, revisions=True, files=True)}) class ArticleArchiveView(PortalBaseView): @@ -208,4 +216,66 @@ class ArticleArchiveView(PortalBaseView): if article is None: return Response({"detail": "Статья не найдена"}, status=404) article = archive_article(article) - return Response({"article": article_payload(article, revisions=True)}) + return Response({"article": article_payload(article, revisions=True, files=True)}) + + +class ArticleFileListView(PortalBaseView): + """Файлы статьи портала: список и загрузка (кадры PT7/PT8).""" + + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def get(self, request: Request, portal_id: int, article_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + article = _article(portal, article_id) + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + return Response( + {"items": [article_file_payload(item) for item in article.files.all()]} + ) + + def post(self, request: Request, portal_id: int, article_id: int) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + article = _article(portal, article_id) + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + upload = request.FILES.get("file") + if upload is None: + return Response( + {"detail": "Нужен файл в поле file (multipart/form-data)"}, status=400 + ) + try: + article_file = add_article_file( + context=request.tenant_context, + article=article, + upload=upload, + author=request.user, + ) + except ValidationError as error: + return validation_response(error) + return Response({"file": article_file_payload(article_file)}, status=201) + + +class ArticleFileDetailView(PortalBaseView): + def delete( + self, request: Request, portal_id: int, article_id: int, file_id: int + ) -> Response: + portal = self.portal(request, portal_id) + if portal is None: + return Response({"detail": "Портал не найден"}, status=404) + article = _article(portal, article_id) + if article is None: + return Response({"detail": "Статья не найдена"}, status=404) + article_file = article.files.filter(id=file_id).first() + if article_file is None: + return Response({"detail": "Файл не найден"}, status=404) + try: + delete_article_file( + context=request.tenant_context, article_file=article_file + ) + except ValidationError as error: + return validation_response(error) + return Response(status=204) diff --git a/apps/backend/chatballs/support_portals/domain_services.py b/apps/backend/chatballs/support_portals/domain_services.py index f94910e..ee9a246 100644 --- a/apps/backend/chatballs/support_portals/domain_services.py +++ b/apps/backend/chatballs/support_portals/domain_services.py @@ -1,7 +1,5 @@ from __future__ import annotations -import uuid - import dns.exception import dns.resolver from django.conf import settings @@ -13,14 +11,6 @@ from chatballs.support_portals.models import SupportPortal from chatballs.support_portals.statuses import PortalStatus -def domain_verification_name(portal: SupportPortal) -> str: - return f"_chatballs.{portal.custom_domain}" if portal.custom_domain else "" - - -def domain_verification_value(portal: SupportPortal) -> str: - return f"chatballs-verification={portal.custom_domain_verification_token}" - - def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal: if portal.status == PortalStatus.ARCHIVED: raise ValidationError( @@ -29,15 +19,9 @@ def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal: normalized = normalize_domain(value) portal.custom_domain = validate_domain(normalized) if normalized else "" portal.custom_domain_verified_at = None - portal.custom_domain_verification_token = uuid.uuid4() portal.full_clean() portal.save( - update_fields=[ - "custom_domain", - "custom_domain_verified_at", - "custom_domain_verification_token", - "updated_at", - ] + update_fields=["custom_domain", "custom_domain_verified_at", "updated_at"] ) return portal @@ -49,6 +33,10 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal: ) if not portal.custom_domain: raise ValidationError({"customDomain": "Сначала укажите домен"}) + # Подтверждать владение доменом нечем и незачем: продукт self-hosted, домен + # и установка принадлежат одному владельцу (README дизайн-базлайна, решение + # 6). Остаётся техническая проверка «ведёт ли домен на этот сервер» — она + # нужна, чтобы выписать сертификат. if settings.CHATBALLS_HELP_PUBLIC_IPV4: try: address_answers = dns.resolver.resolve(portal.custom_domain, "A") @@ -70,26 +58,6 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal: {"customDomain": "A-запись домена указывает не на сервер Chatballs"} ) - expected = domain_verification_value(portal) - try: - answers = dns.resolver.resolve(domain_verification_name(portal), "TXT") - values = { - b"".join(answer.strings).decode("utf-8", errors="replace") - for answer in answers - } - except ( - dns.resolver.NoAnswer, - dns.resolver.NXDOMAIN, - dns.resolver.NoNameservers, - dns.exception.Timeout, - ) as error: - raise ValidationError( - {"customDomain": "TXT-запись пока не найдена"} - ) from error - if expected not in values: - raise ValidationError( - {"customDomain": "TXT-запись не содержит код подтверждения"} - ) portal.custom_domain_verified_at = timezone.now() portal.full_clean() portal.save(update_fields=["custom_domain_verified_at", "updated_at"]) diff --git a/apps/backend/chatballs/support_portals/file_views.py b/apps/backend/chatballs/support_portals/file_views.py new file mode 100644 index 0000000..37b71a4 --- /dev/null +++ b/apps/backend/chatballs/support_portals/file_views.py @@ -0,0 +1,50 @@ +from django.http import FileResponse, Http404 +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.views import APIView + +from chatballs.identity.models import Organization +from chatballs.support_portals.models import PortalArticleFile +from chatballs.tenancy.context import TenantContext +from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.ingress import portal_article_file_route + + +class PortalArticleFileView(APIView): + """Файл статьи портала по публичной ссылке. + + Ссылка попадает в Markdown статьи и открывается посетителем портала, у + которого нет сессии хаба: защита — непредсказуемый UUID и резолв + организации через ingress-директорию (как у вложений знаний). + """ + + permission_classes = [AllowAny] + authentication_classes: list = [] + + def get(self, request: Request, public_id) -> FileResponse: + route = portal_article_file_route(str(public_id)) + if route is None: + raise Http404 + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist as error: + raise Http404 from error + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + article_file = PortalArticleFile.objects.filter( + id=route.resource_id, + public_id=public_id, + organization=organization, + article__organization=organization, + ).first() + if article_file is None: + raise Http404 + opened_file = article_file.file.open("rb") + original_name = article_file.original_name + content_type = article_file.content_type + # Картинка встроена в статью тегом , поэтому файл отдаётся inline; + # тип берётся сохранённым, чтобы браузер не угадывал по расширению. + response = FileResponse(opened_file, filename=original_name) + if content_type: + response.headers["Content-Type"] = content_type + return response diff --git a/apps/backend/chatballs/support_portals/host_boundary.py b/apps/backend/chatballs/support_portals/host_boundary.py index 63497cf..ab10c56 100644 --- a/apps/backend/chatballs/support_portals/host_boundary.py +++ b/apps/backend/chatballs/support_portals/host_boundary.py @@ -11,7 +11,15 @@ from chatballs.tenancy.ingress import support_portal_route class SupportPortalHostBoundaryMiddleware: - """Accept app hosts and hosts present in the published portal directory.""" + """Accept app hosts and hosts present in the published portal directory. + + Пока установка не прошла мастер первого запуска, адрес себе она не знает: + человек поднял докер на сервере и открывает её по IP или по своему домену. + Отвечать на это «Invalid host» — значит не дать дойти до мастера, поэтому + до создания первой организации принимается любой хост: данных, порталов и + ссылок, которые можно было бы отравить чужим Host, ещё нет. После + установки список хостов снова закрыт. + """ def __init__(self, get_response): self.get_response = get_response @@ -19,7 +27,7 @@ class SupportPortalHostBoundaryMiddleware: def __call__(self, request: HttpRequest): raw_host = request.META.get("HTTP_HOST") or request.META.get("SERVER_NAME", "") host = normalize_domain(raw_host.partition(":")[0]) - if not self._allowed(host): + if not self._allowed(host) and not _instance_is_unconfigured(): return HttpResponseBadRequest("Invalid host") return self.get_response(request) @@ -31,6 +39,15 @@ class SupportPortalHostBoundaryMiddleware: or str(connections["default"].settings_dict["NAME"]).startswith("test_") ): return True + # Адрес, который человек ввёл в браузере на первом запуске: продукт + # запомнил его в настройках установки и признаёт своим. + from chatballs.identity.instance_settings import public_host + + try: + if host and host == public_host(): + return True + except Exception: + pass for allowed in settings.CHATBALLS_APP_PRIMARY_HOSTS: normalized = normalize_domain(allowed.lstrip(".")) if host == normalized or ( @@ -41,3 +58,24 @@ class SupportPortalHostBoundaryMiddleware: return support_portal_route(host) is not None except Exception: return False + + +# Признак «мастер ещё не пройден» кэшируется: до установки его спрашивает +# каждый запрос, а после установки он больше не меняется. +_configured = False + + +def _instance_is_unconfigured() -> bool: + global _configured + if _configured: + return False + from chatballs.identity.setup import instance_needs_setup + + try: + needs_setup = instance_needs_setup() + except Exception: + # База ещё не поднялась: на этом этапе хост тем более не проверить. + return True + if not needs_setup: + _configured = True + return needs_setup diff --git a/apps/backend/chatballs/support_portals/migrations/0010_article_files.py b/apps/backend/chatballs/support_portals/migrations/0010_article_files.py new file mode 100644 index 0000000..b246460 --- /dev/null +++ b/apps/backend/chatballs/support_portals/migrations/0010_article_files.py @@ -0,0 +1,96 @@ +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import chatballs.support_portals.models + + +class Migration(migrations.Migration): + dependencies = [ + ("support_portals", "0009_portal_theme"), + ("identity", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name="supportportal", + name="custom_domain_verification_token", + ), + migrations.AddField( + model_name="portalarticlerevision", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.CreateModel( + name="PortalArticleFile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "public_id", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "file", + models.FileField( + max_length=512, + upload_to=chatballs.support_portals.models.article_file_upload_path, + ), + ), + ("original_name", models.CharField(max_length=255)), + ("content_type", models.CharField(blank=True, max_length=128)), + ("size", models.PositiveBigIntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "article", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="files", + to="support_portals.portalarticle", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="identity.organization", + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"ordering": ["-created_at", "-id"]}, + ), + migrations.AddConstraint( + model_name="portalarticlefile", + constraint=models.UniqueConstraint( + fields=("article", "original_name"), + name="uniq_portal_article_file_name", + ), + ), + ] diff --git a/apps/backend/chatballs/support_portals/models.py b/apps/backend/chatballs/support_portals/models.py index 5cc9bca..03ae8e9 100644 --- a/apps/backend/chatballs/support_portals/models.py +++ b/apps/backend/chatballs/support_portals/models.py @@ -1,15 +1,17 @@ from __future__ import annotations import uuid + +from django.conf import settings from django.core.exceptions import ValidationError from django.db import models -from chatballs.tenancy.models import TenantRelationModel from chatballs.support_portals.statuses import ArticleStatus, PortalStatus from chatballs.support_portals.themes import ( DEFAULT_PORTAL_THEME, PortalThemeScheme, ) +from chatballs.tenancy.models import TenantRelationModel class SupportPortal(TenantRelationModel): @@ -20,10 +22,10 @@ class SupportPortal(TenantRelationModel): slug = models.SlugField(max_length=64, unique=True) hosted_domain = models.CharField(max_length=253, unique=True) custom_domain = models.CharField(max_length=253, blank=True, default="") + # Отметка технической проверки «домен ведёт на этот сервер». Подтверждения + # владения доменом нет: домен и установка у одного владельца (README + # дизайн-базлайна «Порталы», решение 6). custom_domain_verified_at = models.DateTimeField(null=True, blank=True) - custom_domain_verification_token = models.UUIDField( - default=uuid.uuid4, editable=False - ) name = models.CharField(max_length=255) default_locale = models.CharField(max_length=16, default="ru") status = models.CharField( @@ -271,6 +273,14 @@ class PortalArticleRevision(TenantRelationModel): title = models.CharField(max_length=255) summary = models.CharField(max_length=500, blank=True) content = models.TextField() + # Автор редакции показывается в рейке версий редактора статьи (кадр PT7). + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="+", + null=True, + blank=True, + ) created_at = models.DateTimeField(auto_now_add=True) published_at = models.DateTimeField(null=True, blank=True) @@ -288,6 +298,76 @@ class PortalArticleRevision(TenantRelationModel): ] +def article_file_upload_path(instance: PortalArticleFile, filename: str) -> str: + organization = instance.article.organization + return ( + f"organizations/{organization.public_id}/portal-articles/" + f"{instance.article_id}/{instance.public_id}/{filename}" + ) + + +class PortalArticleFile(TenantRelationModel): + """Файл статьи портала: картинка или документ, вставленный в Markdown. + + Ссылка публичная и защищена непредсказуемым UUID: файл открывается + посетителем портала, у которого нет аутентификации хаба (как у вложений + знаний, ADR-HUB-0023). + """ + + tenant_relation_fields = ("article",) + article = models.ForeignKey( + PortalArticle, + on_delete=models.CASCADE, + related_name="files", + ) + public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + file = models.FileField(upload_to=article_file_upload_path, max_length=512) + # Оригинальное имя уникально в рамках статьи: текст статьи ссылается на файл + # по ссылке, а редактор показывает имя в рейке файлов. + original_name = models.CharField(max_length=255) + content_type = models.CharField(max_length=128, blank=True) + size = models.PositiveBigIntegerField(default=0) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="+", + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["article", "original_name"], + name="uniq_portal_article_file_name", + ) + ] + + def __str__(self) -> str: + return f"portal-file:{self.article_id}/{self.original_name}" + + def public_path(self) -> str: + """Ссылка для Markdown статьи — относительная. + + Портал открывается на своём домене, а страница отдаётся с CSP + ``img-src 'self'``: абсолютная ссылка на домен установки была бы для + неё чужим origin и картинка не отобразилась бы. Относительный путь + работает и на портале, и в предпросмотре редактора. + """ + from django.urls import reverse + + return reverse("portal-article-file", kwargs={"public_id": self.public_id}) + + def public_url(self) -> str: + # Абсолютная ссылка — для мест, где нужен полный адрес (письма, + # сообщения в мессенджер): там относительный путь бесполезен. + from chatballs.identity.instance_settings import public_base_url + + return public_base_url() + self.public_path() + + class PortalArticleFeedback(TenantRelationModel): tenant_relation_fields = ("article",) article = models.ForeignKey( diff --git a/apps/backend/chatballs/support_portals/portal_views.py b/apps/backend/chatballs/support_portals/portal_views.py index a9c0f30..77f5555 100644 --- a/apps/backend/chatballs/support_portals/portal_views.py +++ b/apps/backend/chatballs/support_portals/portal_views.py @@ -8,11 +8,11 @@ from chatballs.api.permissions import HasCapability from chatballs.identity.audit import record_audit_event from chatballs.integrations.models import IntegrationProvider, IntegrationStatus from chatballs.support_portals.api import validation_response -from chatballs.support_portals.models import SupportPortal from chatballs.support_portals.domain_services import ( set_custom_domain, verify_custom_domain, ) +from chatballs.support_portals.models import SupportPortal from chatballs.support_portals.portal_services import ( PortalInput, create_portal, @@ -20,12 +20,16 @@ from chatballs.support_portals.portal_services import ( set_portal_status, update_portal, ) -from chatballs.support_portals.selectors import portal_for_context, portals_for_context +from chatballs.support_portals.selectors import ( + portal_content_counts, + portal_for_context, + portals_for_context, +) +from chatballs.support_portals.serializers import portal_payload from chatballs.support_portals.themes import ( DEFAULT_PORTAL_THEME, PortalThemeScheme, ) -from chatballs.support_portals.serializers import portal_payload from chatballs.webchat.models import ( WebChatWidget, WebChatWidgetMode, @@ -112,11 +116,22 @@ def _input(request: Request, current: SupportPortal | None = None) -> PortalInpu class PortalListView(PortalBaseView): def get(self, request: Request) -> Response: portals = list(portals_for_context(request.tenant_context)) + counts = portal_content_counts(request.tenant_context) # Тарифные лимиты порталов удалены (ADR-HUB-0042 §2): создание доступно всегда. active_count = sum(item.status != "ARCHIVED" for item in portals) return Response( { - "items": [portal_payload(item) for item in portals], + "items": [ + portal_payload( + item, + counts={ + "categories": 0, + "articles": 0, + **counts.get(item.id, {}), + }, + ) + for item in portals + ], "creation": { "available": True, "canCreate": True, @@ -153,7 +168,8 @@ class PortalDetailView(PortalBaseView): portal = self.portal(request, portal_id) if portal is None: return Response({"detail": "Портал не найден"}, status=404) - return Response({"portal": portal_payload(portal)}) + counts = portal_content_counts(request.tenant_context).get(portal.id, {}) + return Response({"portal": portal_payload(portal, counts=counts)}) def patch(self, request: Request, portal_id: int) -> Response: portal = self.portal(request, portal_id) diff --git a/apps/backend/chatballs/support_portals/public_urls.py b/apps/backend/chatballs/support_portals/public_urls.py index 666dacb..0a0f1ee 100644 --- a/apps/backend/chatballs/support_portals/public_urls.py +++ b/apps/backend/chatballs/support_portals/public_urls.py @@ -1,9 +1,10 @@ from django.urls import path -from chatballs.support_portals import public_views +from chatballs.support_portals import file_views, public_views urlpatterns = [ path("", public_views.PublicPortalDetailView.as_view(), name="public-support-portal"), + path("files//", file_views.PortalArticleFileView.as_view(), name="portal-article-file"), path("articles/", public_views.PublicArticleListView.as_view(), name="public-support-articles"), path("articles//", public_views.PublicArticleDetailView.as_view(), name="public-support-article"), path("articles//feedback/", public_views.PublicArticleFeedbackView.as_view(), name="public-support-feedback"), diff --git a/apps/backend/chatballs/support_portals/selectors.py b/apps/backend/chatballs/support_portals/selectors.py index bad24ef..4f997ac 100644 --- a/apps/backend/chatballs/support_portals/selectors.py +++ b/apps/backend/chatballs/support_portals/selectors.py @@ -1,6 +1,10 @@ from django.db.models import Count, Q, QuerySet -from chatballs.support_portals.models import PortalArticle, SupportPortal +from chatballs.support_portals.models import ( + PortalArticle, + PortalCategory, + SupportPortal, +) from chatballs.tenancy.context import TenantContext @@ -44,6 +48,7 @@ def public_articles( published_revision__isnull=False, ) .select_related("category", "published_revision") + .prefetch_related("files") .order_by("category__sort_order", "published_revision__title") ) if category: @@ -106,3 +111,24 @@ def category_article_counts( for category in categories: total(category.id) return totals + + +def portal_content_counts(context: TenantContext) -> dict[int, dict[str, int]]: + """Разделы и статьи по порталам — колонка «Материалы» списка (кадр PT1).""" + + counts: dict[int, dict[str, int]] = {} + categories = ( + PortalCategory.objects.filter(organization=context.organization) + .values("portal_id") + .annotate(total=Count("id")) + ) + for row in categories: + counts.setdefault(row["portal_id"], {})["categories"] = row["total"] + articles = ( + PortalArticle.objects.filter(organization=context.organization) + .values("portal_id") + .annotate(total=Count("id")) + ) + for row in articles: + counts.setdefault(row["portal_id"], {})["articles"] = row["total"] + return counts diff --git a/apps/backend/chatballs/support_portals/serializers.py b/apps/backend/chatballs/support_portals/serializers.py index 4f52d01..b032590 100644 --- a/apps/backend/chatballs/support_portals/serializers.py +++ b/apps/backend/chatballs/support_portals/serializers.py @@ -1,18 +1,16 @@ from django.conf import settings +from chatballs.integrations.models import IntegrationProvider, IntegrationStatus +from chatballs.support_portals.addressing import portal_public_url +from chatballs.support_portals.content_markdown import normalize_file_links from chatballs.support_portals.models import ( PortalArticle, + PortalArticleFile, PortalArticleRevision, PortalCategory, SupportPortal, SupportPortalProduct, ) -from chatballs.integrations.models import IntegrationProvider, IntegrationStatus -from chatballs.support_portals.addressing import portal_public_url -from chatballs.support_portals.domain_services import ( - domain_verification_name, - domain_verification_value, -) def product_link_payload(link: SupportPortalProduct) -> dict: @@ -31,7 +29,7 @@ def product_link_payload(link: SupportPortalProduct) -> dict: } -def portal_payload(portal: SupportPortal) -> dict: +def portal_payload(portal: SupportPortal, *, counts: dict | None = None) -> dict: public_url = portal_public_url( hosted=portal.hosted_domain, custom=portal.custom_domain, @@ -53,15 +51,6 @@ def portal_payload(portal: SupportPortal) -> dict: else None ), "customDomainVerifiedAt": portal.custom_domain_verified_at, - "customDomainVerification": ( - { - "name": domain_verification_name(portal), - "type": "TXT", - "value": domain_verification_value(portal), - } - if portal.custom_domain and portal.custom_domain_verified_at is None - else None - ), "publicUrl": public_url, "name": portal.name, "defaultLocale": portal.default_locale, @@ -77,6 +66,19 @@ def portal_payload(portal: SupportPortal) -> dict: "products": [product_link_payload(link) for link in portal.product_links.all()], "createdAt": portal.created_at, "updatedAt": portal.updated_at, + # Колонка «Материалы» списка порталов (кадр PT1) и подзаголовок карточки. + # В списке счётчики приходят одним запросом, для одиночного ответа + # считаются здесь — иначе после сохранения настроек шапка обнулилась бы. + "categoryCount": ( + counts["categories"] + if counts and "categories" in counts + else portal.categories.count() + ), + "articleCount": ( + counts["articles"] + if counts and "articles" in counts + else portal.articles.count() + ), } @@ -104,13 +106,19 @@ def revision_payload(revision: PortalArticleRevision, *, content: bool = True) - "summary": revision.summary, "createdAt": revision.created_at, "publishedAt": revision.published_at, + "authorName": _author_name(revision.created_by), } if content: - payload["content"] = revision.content + # Ссылки на файлы приводятся к относительным и на отдаче: тексты, + # написанные до этого правила, иначе остались бы с абсолютным хостом + # и картинки резал бы CSP портала. + payload["content"] = normalize_file_links(revision.content) return payload -def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict: +def article_payload( + article: PortalArticle, *, revisions: bool = False, files: bool = False +) -> dict: latest_revision = next(iter(article.revisions.all()), None) payload = { "id": article.id, @@ -131,6 +139,20 @@ def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict: "createdAt": article.created_at, "updatedAt": article.updated_at, } + # files всегда prefetch-нуты в селекторах статей: без этого счётчик + # скрепки в списке дал бы запрос на строку. + # Оценки посетителей: две кнопки под статьёй на портале. Автору важно + # видеть их сумму, иначе кнопки собирают отзывы в никуда. + votes = list(article.feedback.all()) + payload["feedback"] = { + "helpful": sum(1 for vote in votes if vote.helpful), + "unhelpful": sum(1 for vote in votes if not vote.helpful), + } + payload["fileCount"] = len(article.files.all()) + if files: + payload["files"] = [ + article_file_payload(item) for item in article.files.all() + ] if revisions: payload["revisions"] = [ revision_payload(revision) for revision in article.revisions.all() @@ -138,6 +160,29 @@ def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict: return payload +def article_file_payload(article_file: PortalArticleFile) -> dict: + return { + "id": article_file.id, + "name": article_file.original_name, + "contentType": article_file.content_type, + "size": article_file.size, + # path — для вставки в Markdown (тот же origin, что и страница), + # url — полный адрес для мест, где нужен абсолютный. + "path": article_file.public_path(), + "url": article_file.public_url(), + "createdAt": article_file.created_at, + } + + +def _author_name(user) -> str: + if user is None: + return "" + full_name = " ".join( + part for part in (user.first_name, user.last_name) if part + ).strip() + return full_name or user.get_username() + + def public_portal_payload(portal: SupportPortal) -> dict: widget_key = _public_widget_key(portal.widget, "ANONYMOUS") return { @@ -208,10 +253,26 @@ def _public_widget_key(widget, expected_mode: str) -> str | None: def public_article_payload(article: PortalArticle, *, content: bool = True) -> dict: - return { + payload = { "slug": article.slug, "locale": article.locale, "category": category_payload(article.category), "revision": revision_payload(article.published_revision, content=content), "updatedAt": article.updated_at, } + if content: + # Вложения статьи — всё, чего нет в самом тексте. Картинку, вставленную + # в статью, посетитель уже видит; остальное (в том числе картинку, + # просто прикреплённую к статье) он скачивает списком под текстом. + body = payload["revision"]["content"] + payload["attachments"] = [ + { + "name": item.original_name, + "path": item.public_path(), + "size": item.size, + "contentType": item.content_type, + } + for item in article.files.all() + if item.public_path() not in body + ] + return payload diff --git a/apps/backend/chatballs/support_portals/tests/test_management_api.py b/apps/backend/chatballs/support_portals/tests/test_management_api.py index 4f14a5c..49b1f17 100644 --- a/apps/backend/chatballs/support_portals/tests/test_management_api.py +++ b/apps/backend/chatballs/support_portals/tests/test_management_api.py @@ -140,7 +140,9 @@ class SupportPortalManagementTests(SupportPortalTestCase): self.assertEqual(link.support_channel, self.channel) @override_settings(CHATBALLS_HELP_PUBLIC_IPV4="203.0.113.42") - def test_custom_domain_requires_matching_dns_records(self) -> None: + def test_custom_domain_checks_only_the_address_record(self) -> None: + # Подтверждения владения доменом нет — только «ведёт ли домен сюда» + # (README дизайн-базлайна «Порталы», решение 6). portal_id = self.create_portal().json()["portal"]["id"] configured = self.client.put( f"/api/v1/support/portals/{portal_id}/domain/", @@ -148,7 +150,7 @@ class SupportPortalManagementTests(SupportPortalTestCase): format="json", ) self.assertEqual(configured.status_code, 200, configured.content) - verification = configured.json()["portal"]["customDomainVerification"] + self.assertNotIn("customDomainVerification", configured.json()["portal"]) self.assertEqual( configured.json()["portal"]["customDomainAddress"], { @@ -158,13 +160,10 @@ class SupportPortalManagementTests(SupportPortalTestCase): }, ) address_answer = mock.Mock(address="203.0.113.42") - verification_answer = mock.Mock( - strings=[verification["value"].encode("utf-8")] - ) with mock.patch( "dns.resolver.resolve", - side_effect=[[address_answer], [verification_answer]], + return_value=[address_answer], ) as resolve: verified = self.client.post( f"/api/v1/support/portals/{portal_id}/domain/verify/", @@ -174,13 +173,9 @@ class SupportPortalManagementTests(SupportPortalTestCase): self.assertEqual(verified.status_code, 200, verified.content) self.assertIsNotNone(verified.json()["portal"]["customDomainVerifiedAt"]) - self.assertIsNone(verified.json()["portal"]["customDomainVerification"]) self.assertEqual( resolve.call_args_list, - [ - mock.call("help.customer.example", "A"), - mock.call("_chatballs.help.customer.example", "TXT"), - ], + [mock.call("help.customer.example", "A")], ) @override_settings(CHATBALLS_HELP_PUBLIC_IPV4="203.0.113.42") diff --git a/apps/backend/chatballs/support_portals/tests/test_management_content_api.py b/apps/backend/chatballs/support_portals/tests/test_management_content_api.py index 28fea9f..b151d8c 100644 --- a/apps/backend/chatballs/support_portals/tests/test_management_content_api.py +++ b/apps/backend/chatballs/support_portals/tests/test_management_content_api.py @@ -1,3 +1,5 @@ +from django.core.files.uploadedfile import SimpleUploadedFile + from chatballs.support_portals.tests.base import SupportPortalTestCase from chatballs.webchat.testing import create_web_widget @@ -41,6 +43,129 @@ class SupportPortalContentManagementTests(SupportPortalTestCase): self.assertEqual(len(body["revisions"]), 2) self.assertEqual(body["publishedRevision"]["content"], "Версия 2") + def test_article_files_are_uploaded_listed_and_deleted(self) -> None: + # Файлы статьи — рейка редактора и drop в текст (кадры PT7/PT8). + portal_id = self.create_portal().json()["portal"]["id"] + category = self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + {"name": "Мерки"}, + format="json", + ).json()["category"] + article_id = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/", + { + "categoryId": category["id"], + "slug": "kak-snyat-merki", + "title": "Как снять мерки", + "summary": "", + "content": "# Как снять мерки", + }, + format="json", + ).json()["article"]["id"] + + upload = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/{article_id}/files/", + { + "file": SimpleUploadedFile( + "measure-points.png", b"png-bytes", content_type="image/png" + ) + }, + format="multipart", + ) + self.assertEqual(upload.status_code, 201, upload.content) + uploaded = upload.json()["file"] + self.assertEqual(uploaded["name"], "measure-points.png") + self.assertEqual(uploaded["size"], len(b"png-bytes")) + self.assertIn("/api/v1/help/files/", uploaded["url"]) + + detail = self.client.get( + f"/api/v1/support/portals/{portal_id}/articles/{article_id}/" + ) + self.assertEqual(detail.status_code, 200, detail.content) + self.assertEqual( + [item["name"] for item in detail.json()["article"]["files"]], + ["measure-points.png"], + ) + self.assertEqual(detail.json()["article"]["fileCount"], 1) + + removed = self.client.delete( + f"/api/v1/support/portals/{portal_id}/articles/{article_id}" + f"/files/{uploaded['id']}/" + ) + self.assertEqual(removed.status_code, 204, removed.content) + listing = self.client.get( + f"/api/v1/support/portals/{portal_id}/articles/{article_id}/files/" + ) + self.assertEqual(listing.json()["items"], []) + + def test_absolute_file_links_become_relative(self) -> None: + """CSP портала (img-src 'self') режет картинку с чужим хостом. + + Ссылка на файл статьи всегда относительная: и в новой редакции, и в + отдаче старых — иначе изображение просто не появляется на портале. + """ + portal_id = self.create_portal().json()["portal"]["id"] + category = self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + {"name": "Доставка"}, + format="json", + ) + path = "/api/v1/help/files/4ecee829-30d2-4a2a-904e-a09be28d7708/" + article = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/", + { + "categoryId": category.json()["category"]["id"], + "slug": "delivery", + "title": "Доставка", + "summary": "Сроки", + "content": f"![кот](http://localhost{path})", + }, + format="json", + ) + self.assertEqual(article.status_code, 201, article.content) + content = article.json()["article"]["revisions"][0]["content"] + self.assertEqual(content, f"![кот]({path})") + + article_id = article.json()["article"]["id"] + revision = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/{article_id}/revisions/", + { + "title": "Доставка", + "summary": "Сроки", + "content": f"![кот](https://help.example.com:8443{path})", + }, + format="json", + ) + self.assertEqual(revision.status_code, 201, revision.content) + self.assertEqual(revision.json()["revision"]["content"], f"![кот]({path})") + + def test_revision_keeps_its_author(self) -> None: + # Рейка версий показывает автора редакции (кадр PT7). + portal_id = self.create_portal().json()["portal"]["id"] + category = self.client.post( + f"/api/v1/support/portals/{portal_id}/categories/", + {"name": "Аккаунт"}, + format="json", + ).json()["category"] + article_id = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/", + { + "categoryId": category["id"], + "slug": "sign-in", + "title": "Вход", + "summary": "", + "content": "Версия 1", + }, + format="json", + ).json()["article"]["id"] + revision = self.client.post( + f"/api/v1/support/portals/{portal_id}/articles/{article_id}/revisions/", + {"title": "Вход", "summary": "", "content": "Версия 2"}, + format="json", + ) + self.assertEqual(revision.status_code, 201, revision.content) + self.assertTrue(revision.json()["revision"]["authorName"]) + def test_archived_portal_rejects_every_content_mutation(self) -> None: portal_id = self.create_portal().json()["portal"]["id"] category = self.client.post( diff --git a/apps/backend/chatballs/support_portals/tests/test_public_api.py b/apps/backend/chatballs/support_portals/tests/test_public_api.py index 7ee6e32..530b1d8 100644 --- a/apps/backend/chatballs/support_portals/tests/test_public_api.py +++ b/apps/backend/chatballs/support_portals/tests/test_public_api.py @@ -1,3 +1,4 @@ +from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings from django.utils import timezone @@ -56,6 +57,8 @@ class PublicSupportPortalTests(TestCase): }, format="json", ).json()["article"] + self.owner = result.owner + self.article_id = article["id"] revision_id = article["revisions"][0]["id"] self.client.post( f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/", @@ -148,6 +151,62 @@ class PublicSupportPortalTests(TestCase): self.assertEqual(response.status_code, 200, response.content) self.assertEqual(response.json()["portal"]["name"], "Acme Help") + def test_attachments_list_every_file_absent_from_the_text(self) -> None: + """Картинка, вставленная в статью, видна в тексте; всё остальное — + + вложение под статьёй, независимо от типа файла: посетитель должен + как-то добраться до прикреплённого PDF и до прикреплённой картинки. + """ + self.client.force_authenticate(self.owner) + published = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/status/", + {"status": "PUBLISHED"}, + format="json", + ) + self.assertEqual(published.status_code, 200, published.content) + article_id = self.article_id + inserted = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/files/", + {"file": SimpleUploadedFile("scheme.png", b"png-bytes", content_type="image/png")}, + format="multipart", + ).json()["file"] + attached_image = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/files/", + {"file": SimpleUploadedFile("logo.svg", b"", content_type="image/svg+xml")}, + format="multipart", + ).json()["file"] + attached_doc = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/files/", + {"file": SimpleUploadedFile("price.pdf", b"%PDF-", content_type="application/pdf")}, + format="multipart", + ).json()["file"] + revision = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/revisions/", + { + "title": "Первые шаги", + "summary": "Начало работы с продуктом", + "content": "Схема: ![схема](" + inserted["path"] + ")", + }, + format="json", + ).json()["revision"] + self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/publish/", + {"revisionId": revision["id"]}, + format="json", + ) + self.client.logout() + + response = self.client.get( + "/api/v1/help/articles/first-steps/", HTTP_HOST=self.portal_host + ) + self.assertEqual(response.status_code, 200, response.content) + payload = response.json()["article"] + self.assertIn(inserted["path"], payload["revision"]["content"]) + self.assertEqual( + sorted(item["name"] for item in payload["attachments"]), + sorted([attached_doc["name"], attached_image["name"]]), + ) + @override_settings(ROOT_URLCONF="chatballs_backend.urls_platform") def test_gateway_authorizes_only_published_portal_domains(self) -> None: custom_domain = "help.app.example" diff --git a/apps/backend/chatballs/support_portals/urls.py b/apps/backend/chatballs/support_portals/urls.py index e5a707b..0677902 100644 --- a/apps/backend/chatballs/support_portals/urls.py +++ b/apps/backend/chatballs/support_portals/urls.py @@ -15,6 +15,8 @@ urlpatterns = [ path("/articles/", content_views.ArticleListView.as_view(), name="support-portal-articles"), path("/articles/import/", article_import_views.ArticleImportView.as_view(), name="support-portal-article-import"), path("/articles//", content_views.ArticleDetailView.as_view(), name="support-portal-article"), + path("/articles//files/", content_views.ArticleFileListView.as_view(), name="support-portal-article-files"), + path("/articles//files//", content_views.ArticleFileDetailView.as_view(), name="support-portal-article-file-detail"), path("/articles//revisions/", content_views.ArticleRevisionListView.as_view(), name="support-portal-article-revisions"), path("/articles//publish/", content_views.ArticlePublishView.as_view(), name="support-portal-article-publish"), path("/articles//archive/", content_views.ArticleArchiveView.as_view(), name="support-portal-article-archive"), diff --git a/apps/backend/chatballs/tenancy/ingress.py b/apps/backend/chatballs/tenancy/ingress.py index 150fa73..a665c9b 100644 --- a/apps/backend/chatballs/tenancy/ingress.py +++ b/apps/backend/chatballs/tenancy/ingress.py @@ -63,6 +63,10 @@ def attachment_route(public_id: str) -> IngressRoute | None: return _unique_route("attachment_directory", public_id) +def portal_article_file_route(public_id: str) -> IngressRoute | None: + return _unique_route("portal_article_file_directory", public_id) + + def call_invite_route(token_hash: str) -> IngressRoute | None: return _unique_route("call_invite_directory", token_hash) diff --git a/apps/backend/chatballs/tenancy/migrations/0027_portal_article_file_guards.py b/apps/backend/chatballs/tenancy/migrations/0027_portal_article_file_guards.py new file mode 100644 index 0000000..5402205 --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0027_portal_article_file_guards.py @@ -0,0 +1,74 @@ +# Файлы статей портала (support_portals_portalarticlefile): tenant-таблица с +# organization_id, RLS и триггером связи со статьёй по образцу tenancy/0010. +# Скачивание публичное по непредсказуемому UUID, поэтому строка попадает и в +# ingress-директорию — платформенная роль резолвит организацию по public_id. +from django.db import migrations + +TABLE = "support_portals_portalarticlefile" + +FORWARD_RLS = f""" +ALTER TABLE {TABLE} OWNER TO chatballs_schema; +ALTER TABLE {TABLE} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {TABLE} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE {TABLE} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {TABLE} TO chatballs_runtime_app; +GRANT ALL ON TABLE {TABLE} TO chatballs_schema; +GRANT USAGE, SELECT ON SEQUENCE {TABLE}_id_seq + TO chatballs_runtime_app, chatballs_runtime_platform, chatballs_schema; +DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE}; +CREATE POLICY chatballs_tenant_isolation ON {TABLE} + FOR ALL TO chatballs_runtime_app + USING (organization_id = chatballs.current_organization_id()) + WITH CHECK (organization_id = chatballs.current_organization_id()); +DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE}; +CREATE POLICY chatballs_schema_access ON {TABLE} + FOR ALL TO chatballs_schema USING (true) WITH CHECK (true); + +DROP TRIGGER IF EXISTS sp_article_file_article ON {TABLE}; +CREATE CONSTRAINT TRIGGER sp_article_file_article +AFTER INSERT OR UPDATE ON {TABLE} +DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION +chatballs.enforce_tenant_fk('support_portals_portalarticle', 'article_id'); + +DROP TRIGGER IF EXISTS sp_article_file_user ON {TABLE}; +CREATE CONSTRAINT TRIGGER sp_article_file_user +AFTER INSERT OR UPDATE ON {TABLE} +DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION +chatballs.enforce_tenant_user('uploaded_by_id'); + +DROP TRIGGER IF EXISTS sp_revision_user ON support_portals_portalarticlerevision; +CREATE CONSTRAINT TRIGGER sp_revision_user +AFTER INSERT OR UPDATE ON support_portals_portalarticlerevision +DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION +chatballs.enforce_tenant_user('created_by_id'); + +CREATE OR REPLACE VIEW chatballs.portal_article_file_directory +WITH (security_barrier = true) AS + SELECT article_file.id AS resource_id, + article_file.organization_id, + article_file.public_id::text AS lookup_key + FROM {TABLE} article_file; +ALTER VIEW chatballs.portal_article_file_directory OWNER TO chatballs_schema; +REVOKE ALL ON chatballs.portal_article_file_directory FROM PUBLIC; +GRANT SELECT ON chatballs.portal_article_file_directory TO chatballs_runtime_platform; +""" + +REVERSE_RLS = f""" +DROP VIEW IF EXISTS chatballs.portal_article_file_directory; +DROP TRIGGER IF EXISTS sp_revision_user ON support_portals_portalarticlerevision; +DROP TRIGGER IF EXISTS sp_article_file_user ON {TABLE}; +DROP TRIGGER IF EXISTS sp_article_file_article ON {TABLE}; +DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE}; +DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE}; +ALTER TABLE {TABLE} NO FORCE ROW LEVEL SECURITY; +ALTER TABLE {TABLE} DISABLE ROW LEVEL SECURITY; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0026_contact_merge_rls"), + ("support_portals", "0010_article_files"), + ] + + operations = [migrations.RunSQL(FORWARD_RLS, REVERSE_RLS)] diff --git a/apps/backend/chatballs/tenancy/migrations/0028_instance_settings_grants.py b/apps/backend/chatballs/tenancy/migrations/0028_instance_settings_grants.py new file mode 100644 index 0000000..79e4090 --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0028_instance_settings_grants.py @@ -0,0 +1,25 @@ +# Настройки установки (identity_instancesettings): одна строка на инсталляцию, +# не тенантная таблица, без RLS — как tenancy/0025 для настроек хранилища. +# Мастер первого запуска пишет её на платформенном соединении, приложение читает. +from django.db import migrations + +GRANTS = """ +GRANT SELECT, INSERT, UPDATE ON identity_instancesettings + TO chatballs_runtime_app, chatballs_runtime_platform; +GRANT USAGE, SELECT ON SEQUENCE identity_instancesettings_id_seq + TO chatballs_runtime_app, chatballs_runtime_platform; +""" + +REVOKE = """ +REVOKE ALL ON identity_instancesettings + FROM chatballs_runtime_app, chatballs_runtime_platform; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0027_portal_article_file_guards"), + ("identity", "0027_instance_settings"), + ] + + operations = [migrations.RunSQL(GRANTS, REVOKE)] diff --git a/apps/backend/chatballs/tenancy/storage_backends.py b/apps/backend/chatballs/tenancy/storage_backends.py index d1253d4..c306850 100644 --- a/apps/backend/chatballs/tenancy/storage_backends.py +++ b/apps/backend/chatballs/tenancy/storage_backends.py @@ -12,6 +12,8 @@ from __future__ import annotations +import logging + from django.conf import settings from django.core.exceptions import SuspiciousFileOperation from django.core.files.storage import FileSystemStorage, Storage @@ -19,6 +21,8 @@ from django.utils.deconstruct import deconstructible from chatballs.tenancy.database import current_tenant_id +logger = logging.getLogger(__name__) + def _assert_tenant_key(name: str) -> None: organization_id = current_tenant_id() @@ -84,15 +88,31 @@ class _DynamicStorage(Storage): return self._s3(config) return None + def _secondary_has(self, name: str) -> object | None: + """Второе хранилище, если файл лежит там. + + Второе хранилище — необязательный запасной путь для файлов, оставшихся + после переключения бэкенда. Оно может быть недоступно (выключенный + MinIO, устаревшие реквизиты, сеть), и это не должно ронять операцию над + активным хранилищем: недоступное второе считаем пустым и пишем в лог. + """ + secondary = self._secondary() + if secondary is None: + return None + try: + return secondary if secondary.exists(name) else None + except Exception as error: # boto/сетевые ошибки разнообразны + logger.warning( + "secondary storage unavailable, ignoring: %s", error, exc_info=False + ) + return None + def _reader_for(self, name: str): """Хранилище, где файл реально лежит: активное, иначе второе.""" active = self._active() if active.exists(name): return active - secondary = self._secondary() - if secondary is not None and secondary.exists(name): - return secondary - return active + return self._secondary_has(name) or active # --- Storage API --------------------------------------------------------- def _open(self, name, mode="rb"): @@ -111,16 +131,23 @@ class _DynamicStorage(Storage): def delete(self, name): self._guard(name) - for storage in (self._active(), self._secondary()): - if storage is not None and storage.exists(name): - storage.delete(name) + active = self._active() + if active.exists(name): + active.delete(name) + secondary = self._secondary_has(name) + if secondary is not None: + try: + secondary.delete(name) + except Exception as error: + logger.warning( + "secondary storage delete failed, ignoring: %s", error, exc_info=False + ) def exists(self, name): self._guard(name) if self._active().exists(name): return True - secondary = self._secondary() - return secondary is not None and secondary.exists(name) + return self._secondary_has(name) is not None def size(self, name): self._guard(name) diff --git a/apps/backend/chatballs/tenancy/test_storage_settings.py b/apps/backend/chatballs/tenancy/test_storage_settings.py index 1fe56be..7a5fc5e 100644 --- a/apps/backend/chatballs/tenancy/test_storage_settings.py +++ b/apps/backend/chatballs/tenancy/test_storage_settings.py @@ -86,6 +86,44 @@ class StorageSettingsTests(TestCase): storage.delete(self.key) self.assertFalse(storage.exists(self.key)) + def test_unreachable_secondary_storage_does_not_break_local_writes(self) -> None: + """Недоступное второе хранилище не должно ронять работу с активным. + + Реквизиты S3 остаются в строке настроек и после возврата на локальный + диск (чтобы дочитать файлы из бакета). Если бакет недоступен — + выключенный MinIO, устаревшие ключи, — перезапись и удаление файла на + локальном диске обязаны продолжать работать. + """ + row = ss.StorageSettings.load() + row.backend = ss.StorageBackend.LOCAL + row.s3_bucket = "demo" + row.s3_access_key = "AKIA-demo-access" + row.s3_secret_key = "very-secret" + row.save() + DynamicTenantStorage._s3_cache = None + + class _DeadS3: + def exists(self, name): + raise OSError("Could not connect to the endpoint URL") + + def delete(self, name): + raise OSError("Could not connect to the endpoint URL") + + patcher = mock.patch.object(ss, "build_s3_storage", lambda *args, **kwargs: _DeadS3()) + patcher.start() + self.addCleanup(patcher.stop) + + with tenant_atomic(self.organization.id): + storage = storages["default"] + storage.save(self.key, ContentFile(b"local")) + self.assertTrue(storage.exists(self.key)) + # Перезапись одноимённого файла: сначала удаление, потом запись. + storage.delete(self.key) + self.assertFalse(storage.exists(self.key)) + storage.save(self.key, ContentFile(b"replaced")) + with storage.open(self.key) as handle: + self.assertEqual(handle.read(), b"replaced") + def test_secrets_are_encrypted_at_rest_and_masked_in_api(self) -> None: self._enable_s3() from django.db import connection diff --git a/apps/backend/chatballs_backend/settings_app.py b/apps/backend/chatballs_backend/settings_app.py index 676474a..5dd42c3 100644 --- a/apps/backend/chatballs_backend/settings_app.py +++ b/apps/backend/chatballs_backend/settings_app.py @@ -1,8 +1,6 @@ # ruff: noqa: F403,F405 import os -from django.core.exceptions import ImproperlyConfigured - from chatballs_backend.settings_base import * CHATBALLS_RUNTIME_SURFACE = "app" @@ -10,9 +8,9 @@ ROOT_URLCONF = "chatballs_backend.urls_app" ASGI_APPLICATION = "chatballs_backend.asgi_app.application" WSGI_APPLICATION = "chatballs_backend.wsgi_app.application" -_app_hosts = os.environ.get("CHATBALLS_APP_ALLOWED_HOSTS", "") -if not DEBUG and not TESTING and not _app_hosts: - raise ImproperlyConfigured("CHATBALLS_APP_ALLOWED_HOSTS is required for the app surface") +# Домены установки задаются в UI (мастер первого запуска), а не переменной +# окружения: коробка поднимается одной командой и до настройки отвечает на +# локальные адреса. Проверку хоста несёт middleware/список ниже. CHATBALLS_APP_PRIMARY_HOSTS = env_list( "CHATBALLS_APP_ALLOWED_HOSTS", env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]), @@ -42,6 +40,14 @@ CSRF_COOKIE_NAME = os.environ.get( "CHATBALLS_APP_CSRF_COOKIE_NAME", "__Host-chatballs-app-csrf" if CSRF_COOKIE_SECURE else "chatballs_app_csrftoken", ) +# Имя cookie по http и его защищённая пара для запросов по TLS. +# Переключает TlsAwareCookieMiddleware по факту протокола запроса — тем же +# правилом, что и фронтенд (api/client.ts). +CHATBALLS_TLS_COOKIE_NAMES = { + SESSION_COOKIE_NAME: "__Host-chatballs-app-session", + CSRF_COOKIE_NAME: "__Host-chatballs-app-csrf", +} + SESSION_COOKIE_DOMAIN = None CSRF_COOKIE_DOMAIN = None SESSION_COOKIE_PATH = "/" diff --git a/apps/backend/chatballs_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py index 08ada57..9ce2b4e 100644 --- a/apps/backend/chatballs_backend/settings_base.py +++ b/apps/backend/chatballs_backend/settings_base.py @@ -6,7 +6,7 @@ from pathlib import Path from django.core.exceptions import ImproperlyConfigured from chatballs_backend.settings_database import build_databases -from chatballs_backend.settings_env import env_bool, env_list +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 @@ -16,11 +16,16 @@ INSECURE_SECRET_KEY = "local-development-only" # Автоопределение тестового прогона, чтобы manage.py test / pytest работали # без ручного выставления production-окружения. TESTING = "test" in sys.argv or "pytest" in sys.modules -SECRET_KEY = os.environ.get("CHATBALLS_SECRET_KEY", INSECURE_SECRET_KEY) +# Ключ подписи не задаётся человеком: его генерирует первый старт стека в том +# с секретами (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 and (DEBUG or TESTING): - _delivery_mode = "CLOUD" +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" @@ -31,10 +36,12 @@ if TESTING: ALLOWED_HOSTS.extend(["testserver", ".localhost"]) CSRF_TRUSTED_ORIGINS = env_list("CHATBALLS_CSRF_TRUSTED_ORIGINS", []) -# Запрещаем запуск в production с дефолтным/пустым ключом подписи. +# Слабый ключ подписи в production недопустим. При пустом томе секретов это +# означает сломанную установку, а не забытую человеком переменную. if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}: raise ImproperlyConfigured( - "CHATBALLS_SECRET_KEY must be set to a strong value when CHATBALLS_DEBUG is disabled" + "Не удалось прочитать ключ подписи инстанса: том с секретами пуст или " + "недоступен (deploy/secrets/generate-instance-secrets.sh)" ) INSTALLED_APPS = [ @@ -67,6 +74,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "chatballs.http.middleware.TlsAwareCookieMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "chatballs.http.middleware.ContentSecurityPolicyMiddleware", "chatballs.http.middleware.LocalCorsMiddleware", @@ -182,16 +190,25 @@ CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("CHATBALLS_MESSENG # 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. По умолчанию безопасно вне DEBUG; локальная разработка и тесты не ломаются. +# Транспорт и 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", _secure_default) -CSRF_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", _secure_default) -SECURE_SSL_REDIRECT = env_bool("CHATBALLS_SSL_REDIRECT", _secure_default) +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") ) @@ -269,8 +286,11 @@ if ( CHATBALLS_CALL_STUN_URLS = env_list("CHATBALLS_CALL_STUN_URLS", []) # TURN (Coturn, SPEC-HUB-0013 §11): backend выдаёт краткоживущие REST-credentials # по общему static-auth-secret. Пусто локально -> только STUN/direct ICE. +# Адреса TURN владелец задаёт в «Настройках» (там же, где адрес установки); +# переменная остаётся переопределением для установок, ведущих конфигурацию сами. CHATBALLS_CALL_TURN_URLS = env_list("CHATBALLS_CALL_TURN_URLS", []) -CHATBALLS_CALL_TURN_SECRET = os.environ.get("CHATBALLS_CALL_TURN_SECRET", "") +# Общий с 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") diff --git a/apps/backend/chatballs_backend/settings_database.py b/apps/backend/chatballs_backend/settings_database.py index 9bf5bb0..c902b96 100644 --- a/apps/backend/chatballs_backend/settings_database.py +++ b/apps/backend/chatballs_backend/settings_database.py @@ -2,28 +2,30 @@ import os from django.core.exceptions import ImproperlyConfigured +from chatballs_backend.settings_env import env_secret + def _credentials() -> tuple[dict[str, str], dict[str, str]]: + # Имена ролей — константы продукта, а не настройка установки: их создаёт + # deploy/postgres/init-runtime-roles.sh при первом старте. Переменные + # окружения остаются переопределением для нестандартных установок. users = { - "app": os.environ.get( - "POSTGRES_APP_USER", os.environ.get("POSTGRES_USER", "chatballs") - ), - "platform": os.environ.get( - "POSTGRES_PLATFORM_USER", os.environ.get("POSTGRES_USER", "chatballs") - ), + "app": os.environ.get("POSTGRES_APP_USER", "chatballs_app"), + "platform": os.environ.get("POSTGRES_PLATFORM_USER", "chatballs_platform"), "migration": os.environ.get( - "POSTGRES_MIGRATION_USER", os.environ.get("POSTGRES_USER", "chatballs") + "POSTGRES_MIGRATION_USER", "chatballs_migration" ), } + # Пароли ролей генерирует первый старт стека в том с секретами; человек их + # не вводит и не хранит. Переменные окружения остаются переопределением. + fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs") passwords = { - "app": os.environ.get( - "POSTGRES_APP_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs") + "app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback), + "platform": env_secret( + "POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback ), - "platform": os.environ.get( - "POSTGRES_PLATFORM_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs") - ), - "migration": os.environ.get( - "POSTGRES_MIGRATION_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs") + "migration": env_secret( + "POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback ), } return users, passwords @@ -78,9 +80,14 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]: "platform": config("platform"), } if testing: - databases["default"]["USER"] = os.environ.get("POSTGRES_USER", "chatballs") - databases["default"]["PASSWORD"] = os.environ.get( - "POSTGRES_PASSWORD", "chatballs" + # Тесты создают свою БД и подключаются владельцем кластера. Его пароль + # приходит оттуда же, откуда у остальных ролей: файл секрета инстанса, + # переменная окружения — переопределение. + databases["default"]["USER"] = os.environ.get( + "POSTGRES_USER", "chatballs_bootstrap" + ) + databases["default"]["PASSWORD"] = env_secret( + "POSTGRES_PASSWORD", "postgres_password", "chatballs" ) databases["platform"]["TEST"] = {"MIRROR": "default"} return databases diff --git a/apps/backend/chatballs_backend/settings_env.py b/apps/backend/chatballs_backend/settings_env.py index 42c3356..dd566e8 100644 --- a/apps/backend/chatballs_backend/settings_env.py +++ b/apps/backend/chatballs_backend/settings_env.py @@ -1,4 +1,30 @@ import os +from pathlib import Path + +# Секреты инстанса генерируются при первом старте и лежат файлами в томе +# (deploy/secrets/generate-instance-secrets.sh). В .env их нет — установка не +# требует от человека ни одной переменной. +SECRETS_DIR = Path(os.environ.get("CHATBALLS_SECRETS_DIR", "/run/chatballs/secrets")) + + +def env_secret(name: str, secret_file: str, default: str = "") -> str: + """Значение из переменной окружения, иначе из файла секрета, иначе default. + + Переменная окружения имеет приоритет: так существующие установки с .env + продолжают работать без изменений. + """ + value = os.environ.get(name) + if value: + return value + path_override = os.environ.get(f"{name}_FILE") + path = Path(path_override) if path_override else SECRETS_DIR / secret_file + try: + content = path.read_text(encoding="utf-8").strip() + except OSError: + return default + return content or default + + def env_bool(name: str, default: bool = False) -> bool: diff --git a/apps/backend/chatballs_backend/settings_platform.py b/apps/backend/chatballs_backend/settings_platform.py index ca59075..133e316 100644 --- a/apps/backend/chatballs_backend/settings_platform.py +++ b/apps/backend/chatballs_backend/settings_platform.py @@ -1,8 +1,6 @@ # ruff: noqa: F403,F405 import os -from django.core.exceptions import ImproperlyConfigured - from chatballs_backend.settings_base import * CHATBALLS_RUNTIME_SURFACE = "platform" @@ -10,11 +8,9 @@ ROOT_URLCONF = "chatballs_backend.urls_platform" ASGI_APPLICATION = "chatballs_backend.asgi_platform.application" WSGI_APPLICATION = "chatballs_backend.wsgi_platform.application" -_platform_hosts = os.environ.get("CHATBALLS_PLATFORM_ALLOWED_HOSTS", "") -if not DEBUG and not TESTING and not _platform_hosts: - raise ImproperlyConfigured( - "CHATBALLS_PLATFORM_ALLOWED_HOSTS is required for the platform surface" - ) +# Домены установки задаются в UI (мастер первого запуска), а не переменной +# окружения: коробка поднимается одной командой и до настройки отвечает на +# локальные адреса. Проверку хоста несёт middleware/список ниже. ALLOWED_HOSTS = env_list( "CHATBALLS_PLATFORM_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "platform.localhost"], @@ -42,6 +38,14 @@ CSRF_COOKIE_NAME = os.environ.get( if CSRF_COOKIE_SECURE else "chatballs_platform_csrftoken", ) +# Имя cookie по http и его защищённая пара для запросов по TLS. +# Переключает TlsAwareCookieMiddleware по факту протокола запроса — тем же +# правилом, что и фронтенд (api/client.ts). +CHATBALLS_TLS_COOKIE_NAMES = { + SESSION_COOKIE_NAME: "__Host-chatballs-platform-session", + CSRF_COOKIE_NAME: "__Host-chatballs-platform-csrf", +} + SESSION_COOKIE_DOMAIN = None CSRF_COOKIE_DOMAIN = None SESSION_COOKIE_PATH = "/" diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 72b7770..37180f1 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -84,7 +84,7 @@ export function App() { if (activeUser && !initialRoute.organizationPublicId) { const nextPath = pathFromRoute( initialRoute.route, - initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.settingsSection, + initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.portalSettingsSection || initialRoute.settingsSection, initialRoute.productCode, activeUser.organizationPublicId, ); @@ -170,7 +170,7 @@ export function App() { ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> + navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> )} ); diff --git a/apps/internal-ui/src/api/client.ts b/apps/internal-ui/src/api/client.ts index ef00633..27042b9 100644 --- a/apps/internal-ui/src/api/client.ts +++ b/apps/internal-ui/src/api/client.ts @@ -97,13 +97,35 @@ export async function api(path: string, init: RequestInit = {}): Promise { return response.json() as Promise; } -// Multipart-загрузка (вложения знаний): Content-Type выставляет браузер (boundary). -export async function apiUpload(path: string, form: FormData): Promise { - const headers = new Headers({ Accept: "application/json", "X-CSRFToken": getCookie(CSRF_COOKIE_NAME) }); - const response = await fetch(resolveApiUrl(path), { method: "POST", body: form, credentials: "include", headers }); - if (!response.ok) { - const payload = await response.json().catch(() => ({ detail: "Ошибка запроса" })) as ApiErrorPayload; - throw new ApiError(response.status, payload); - } - return response.json() as Promise; +// Multipart-загрузка (вложения знаний, файлы статей портала): Content-Type +// выставляет браузер (boundary). На XHR, а не на fetch, потому что прогресс +// отправки нужен рейке файлов редактора статьи (кадр PT8) — у fetch его нет. +export function apiUpload(path: string, form: FormData, onProgress?: (percent: number) => void): Promise { + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open("POST", resolveApiUrl(path)); + request.withCredentials = true; + request.setRequestHeader("Accept", "application/json"); + request.setRequestHeader("X-CSRFToken", getCookie(CSRF_COOKIE_NAME)); + if (onProgress) { + request.upload.onprogress = (event) => { + if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100)); + }; + } + request.onerror = () => reject(new ApiError(0, { detail: "Ошибка сети" })); + request.onload = () => { + let payload: unknown = null; + try { + payload = request.responseText ? JSON.parse(request.responseText) : null; + } catch { + payload = null; + } + if (request.status >= 200 && request.status < 300) { + resolve(payload as T); + return; + } + reject(new ApiError(request.status, (payload ?? { detail: "Ошибка запроса" }) as ApiErrorPayload)); + }; + request.send(form); + }); } diff --git a/apps/internal-ui/src/features/help-center/HelpArticle.tsx b/apps/internal-ui/src/features/help-center/HelpArticle.tsx index 7bc916f..2f951b1 100644 --- a/apps/internal-ui/src/features/help-center/HelpArticle.tsx +++ b/apps/internal-ui/src/features/help-center/HelpArticle.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { fetchHelpArticle, sendArticleFeedback } from "./api"; import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons"; import { HelpLayout } from "./HelpLayout"; +import { HelpAttachments } from "./HelpAttachments"; import { MarkdownContent, parseMarkdown } from "./MarkdownContent"; import type { HelpArticle as HelpArticleType, HelpManifest } from "./types"; @@ -94,6 +95,7 @@ export function HelpArticle({ +
{feedback === "sent" ? (
diff --git a/apps/internal-ui/src/features/help-center/HelpAttachments.tsx b/apps/internal-ui/src/features/help-center/HelpAttachments.tsx new file mode 100644 index 0000000..a15e238 --- /dev/null +++ b/apps/internal-ui/src/features/help-center/HelpAttachments.tsx @@ -0,0 +1,67 @@ +import type { HelpAttachment } from "./types"; + +// Вложения статьи портала: картинки вставлены в текст самой статьёй, а +// документы посетитель скачивает списком под ней — с иконкой формата и +// размером, чтобы было понятно, что открываешь. + +const EXTENSION_LABEL: Record = { + pdf: "PDF", + doc: "DOC", + docx: "DOC", + xls: "XLS", + xlsx: "XLS", + csv: "CSV", + ppt: "PPT", + pptx: "PPT", + txt: "TXT", + md: "MD", + zip: "ZIP", + rar: "ZIP", + "7z": "ZIP", + png: "PNG", + jpg: "JPG", + jpeg: "JPG", + webp: "WEBP", + gif: "GIF", + svg: "SVG", + avif: "AVIF", + mp4: "MP4", + mp3: "MP3", +}; + +function extensionOf(name: string): string { + const parts = name.split("."); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; +} + +function formatLabel(name: string): string { + const extension = extensionOf(name); + return EXTENSION_LABEL[extension] ?? (extension ? extension.toUpperCase().slice(0, 4) : "ФАЙЛ"); +} + +function readableSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} МБ`; + return `${Math.max(1, Math.round(bytes / 1024))} КБ`; +} + +export function HelpAttachments({ attachments }: { attachments: HelpAttachment[] }) { + if (attachments.length === 0) return null; + return ( +
+

Файлы к статье

+ +
+ ); +} diff --git a/apps/internal-ui/src/features/help-center/HelpCenterApp.tsx b/apps/internal-ui/src/features/help-center/HelpCenterApp.tsx index b2b48af..6373caf 100644 --- a/apps/internal-ui/src/features/help-center/HelpCenterApp.tsx +++ b/apps/internal-ui/src/features/help-center/HelpCenterApp.tsx @@ -1,5 +1,7 @@ import { useEffect, useMemo, useState } from "react"; +import { Loader } from "@chatballs/ui"; + import { fetchHelpManifest } from "./api"; import { HelpArticle } from "./HelpArticle"; import { HelpHome } from "./HelpHome"; @@ -51,7 +53,7 @@ export function HelpCenterApp() { ); } if (!manifest || !themeReady) { - return
; + return
; } return route.articleSlug ? diff --git a/apps/internal-ui/src/features/help-center/styles-article.css b/apps/internal-ui/src/features/help-center/styles-article.css index 79b2842..4e5b9b5 100644 --- a/apps/internal-ui/src/features/help-center/styles-article.css +++ b/apps/internal-ui/src/features/help-center/styles-article.css @@ -310,3 +310,66 @@ .help-article-error a { color: var(--help-text); } + +/* Файлы к статье: картинки живут в тексте, документы — списком под статьёй. + Иконка — метка формата, чтобы посетитель понимал, что скачивает. */ +.help-attachments { + margin-top: var(--help-space-lg, 28px); +} + +.help-attachments h2 { + margin: 0 0 12px; + font-size: 1rem; +} + +.help-attachments ul { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.help-attachments a { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--help-border, rgba(0, 0, 0, 0.12)); + border-radius: var(--help-radius-md, 10px); + color: inherit; + text-decoration: none; +} + +.help-attachments a:hover { + border-color: var(--help-accent, currentcolor); +} + +.help-attachments i { + display: flex; + width: 38px; + height: 38px; + flex: none; + align-items: center; + justify-content: center; + border-radius: var(--help-radius-xs, 6px); + background: var(--help-surface-muted, rgba(0, 0, 0, 0.05)); + font-size: 11px; + font-style: normal; + font-weight: 700; + letter-spacing: 0.02em; +} + +.help-attachments strong { + display: block; + overflow-wrap: anywhere; + font-size: 0.95rem; + font-weight: 600; +} + +.help-attachments small { + display: block; + margin-top: 2px; + font-size: 0.8rem; + opacity: 0.7; +} diff --git a/apps/internal-ui/src/features/help-center/styles-layout.css b/apps/internal-ui/src/features/help-center/styles-layout.css index f3eb885..8d2a4b1 100644 --- a/apps/internal-ui/src/features/help-center/styles-layout.css +++ b/apps/internal-ui/src/features/help-center/styles-layout.css @@ -275,22 +275,3 @@ html.help-center-document body { color: var(--help-text-muted); } -.help-boot-loading { - grid-template-columns: repeat(3, 8px); -} - -.help-boot-loading i { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--help-ink); - animation: helpPulse 900ms ease-in-out infinite alternate; -} - -.help-boot-loading i:nth-child(2) { animation-delay: 150ms; } -.help-boot-loading i:nth-child(3) { animation-delay: 300ms; } - -@keyframes helpPulse { - from { opacity: 0.2; transform: translateY(2px); } - to { opacity: 1; transform: translateY(-2px); } -} diff --git a/apps/internal-ui/src/features/help-center/themes/README.md b/apps/internal-ui/src/features/help-center/themes/README.md index 64108ee..152cd5e 100644 --- a/apps/internal-ui/src/features/help-center/themes/README.md +++ b/apps/internal-ui/src/features/help-center/themes/README.md @@ -14,7 +14,8 @@ **Имя папки обязано совпадать с `id` в манифесте** — это проверяет тест. 2. Заполните `manifest.ts`: `name` и `description` видны владельцу портала в настройках, `schemes` перечисляет поддерживаемые цветовые схемы, `preview` — - три цвета для превью. + три цвета для превью (`bg`/`ink`/`accent`) и необязательный `radius` + скругления плиток на мини-превью. 3. Переопределите нужные токены в `theme.css`. 4. Выберите тему в карточке портала: `Настройки → Оформление`. diff --git a/apps/internal-ui/src/features/help-center/themes/classic/manifest.ts b/apps/internal-ui/src/features/help-center/themes/classic/manifest.ts index 0d953a0..e068fcf 100644 --- a/apps/internal-ui/src/features/help-center/themes/classic/manifest.ts +++ b/apps/internal-ui/src/features/help-center/themes/classic/manifest.ts @@ -5,5 +5,5 @@ export const manifest: PortalThemeManifest = { name: "Классическая", description: "Базовое оформление Help Center: светлая подложка, чернильный акцент.", schemes: ["light", "dark"], - preview: { bg: "#ffffff", ink: "#1f1f1f", accent: "#1f1f1f" }, + preview: { bg: "#ffffff", ink: "#1f1f1f", accent: "#1f1f1f", radius: "5px" }, }; diff --git a/apps/internal-ui/src/features/help-center/themes/ocean/manifest.ts b/apps/internal-ui/src/features/help-center/themes/ocean/manifest.ts index 5a13120..6614230 100644 --- a/apps/internal-ui/src/features/help-center/themes/ocean/manifest.ts +++ b/apps/internal-ui/src/features/help-center/themes/ocean/manifest.ts @@ -5,5 +5,5 @@ export const manifest: PortalThemeManifest = { name: "Ocean", description: "Мягкое океаническое оформление: синий акцент, крупные скругления.", schemes: ["light", "dark"], - preview: { bg: "#ffffff", ink: "#10283d", accent: "#1b5e8f" }, + preview: { bg: "#ffffff", ink: "#10283d", accent: "#1b5e8f", radius: "9px" }, }; diff --git a/apps/internal-ui/src/features/help-center/themes/types.ts b/apps/internal-ui/src/features/help-center/themes/types.ts index 67a6fa8..3da300b 100644 --- a/apps/internal-ui/src/features/help-center/themes/types.ts +++ b/apps/internal-ui/src/features/help-center/themes/types.ts @@ -15,7 +15,8 @@ export type PortalThemeManifest = { // Схемы, которые тема реально поддерживает. Первая — основная: на неё // деградирует портал, если сохранённая схема темой не поддержана. schemes: PortalThemeScheme[]; - // Три цвета для превью в настройках портала: подложка, текст, акцент. - preview: { bg: string; ink: string; accent: string }; + // Превью в настройках портала (кадр PT6): подложка, текст, акцент и + // скругление плиток — тем же радиусом, каким тема скругляет карточки. + preview: { bg: string; ink: string; accent: string; radius?: string }; author?: string; }; diff --git a/apps/internal-ui/src/features/help-center/types.ts b/apps/internal-ui/src/features/help-center/types.ts index 3ff39b2..d26969f 100644 --- a/apps/internal-ui/src/features/help-center/types.ts +++ b/apps/internal-ui/src/features/help-center/types.ts @@ -29,12 +29,21 @@ export type HelpRevision = { publishedAt: string | null; }; +export type HelpAttachment = { + name: string; + path: string; + size: number; + contentType: string; +}; + export type HelpArticle = { slug: string; locale: string; category: HelpCategory; revision: HelpRevision; updatedAt: string; + // Файлы статьи: картинки вставлены в текст, остальное посетитель скачивает. + attachments?: HelpAttachment[]; }; export type HelpPortal = { diff --git a/apps/internal-ui/src/features/settings/DemoInstallBanner.tsx b/apps/internal-ui/src/features/settings/DemoInstallBanner.tsx new file mode 100644 index 0000000..b23fdc5 --- /dev/null +++ b/apps/internal-ui/src/features/settings/DemoInstallBanner.tsx @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { api } from "../../api/client"; +import { Icon, LogoSpinner } from "../../shared/icons"; +import type { DemoState } from "./DemoDataCard"; + +// Полоса поверх рабочей области, пока worker ставит демо-данные. Без неё +// человек, поставивший галочку в мастере, видит пустую систему и не понимает, +// что установка ещё идёт (запись в outbox, разбор — секунды, но не мгновение). + +const POLL_INTERVAL_MS = 2000; + +export function DemoInstallBanner({ reload }: { reload: () => void }) { + const [status, setStatus] = useState(null); + const [dismissed, setDismissed] = useState(false); + const wasInstalling = useRef(false); + + const load = useCallback(async () => { + try { + const state = await api("/api/v1/company/demo/"); + setStatus(state.status); + } catch { + // Нет прав или сеть моргнула — полоса просто не показывается. + setStatus("ABSENT"); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const installing = status === "INSTALLING"; + useEffect(() => { + if (!installing) return undefined; + wasInstalling.current = true; + const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [installing, load]); + + // Установка закончилась — подтянуть сотрудников, группы и агентов сразу. + useEffect(() => { + if (status === "INSTALLED" && wasInstalling.current) reload(); + }, [reload, status]); + + if (dismissed) return null; + if (installing) { + return ( +
+ + Демо-данные устанавливаются — разделы наполнятся через несколько секунд. +
+ ); + } + if (status === "INSTALLED" && wasInstalling.current) { + return ( +
+ + Демо-данные готовы. + + +
+ ); + } + return null; +} diff --git a/apps/internal-ui/src/features/settings/InstanceAddressCard.tsx b/apps/internal-ui/src/features/settings/InstanceAddressCard.tsx new file mode 100644 index 0000000..19c1473 --- /dev/null +++ b/apps/internal-ui/src/features/settings/InstanceAddressCard.tsx @@ -0,0 +1,311 @@ +import { useEffect, useState, type FormEvent } from "react"; + +import { api } from "../../api/client"; +import { FormField, TextAreaField } from "../../shared/form-controls"; +import { Button } from "../../shared/ui-controls"; +import { shortDateTime } from "../../shared/utils"; + +// «Адрес установки и почта» (Настройки). Адрес запомнил мастер первого запуска — +// по нему человек и зашёл, поднимая докер на сервере; здесь его меняют, когда +// завели домен и поставили TLS. Из него строятся внешние ссылки: вложения знаний +// и файлы статей уходят клиенту в мессенджер, и «localhost» там мёртв. +// Почта — тем же местом: без неё нельзя ни пригласить сотрудника, ни сбросить +// пароль, а задавать её в переменных окружения больше негде. + +type EmailPayload = { + host: string; + port: number; + user: string; + hasPassword: boolean; + useTls: boolean; + from: string; + configured: boolean; +}; + +type InstancePayload = { + publicHost: string; + publicScheme: "http" | "https"; + publicUrl: string; + updatedAt: string | null; + email: EmailPayload; + turn: { urls: string[]; ttlSeconds: number; secretReady: boolean }; +}; + +type EmailDraft = { + host: string; + port: string; + user: string; + password: string; + useTls: boolean; + from: string; +}; + +const BASE = "/api/v1/company/administration/instance/"; + +function emailDraftOf(email: EmailPayload): EmailDraft { + return { + host: email.host, + port: String(email.port || 587), + user: email.user, + password: "", + useTls: email.useTls, + from: email.from, + }; +} + +function errorMessage(error: unknown): { detail: string; errors: Record } { + if (error && typeof error === "object" && "payload" in error) { + const payload = (error as { payload?: { detail?: string; errors?: Record } }).payload; + return { detail: payload?.detail ?? "Не удалось сохранить", errors: payload?.errors ?? {} }; + } + return { detail: error instanceof Error ? error.message : "Не удалось сохранить", errors: {} }; +} + +export function InstanceAddressCard({ canManage }: { canManage: boolean }) { + const [current, setCurrent] = useState(null); + const [host, setHost] = useState(""); + const [scheme, setScheme] = useState<"http" | "https">("http"); + const [email, setEmail] = useState(null); + const [turnUrls, setTurnUrls] = useState(""); + const [turnTtl, setTurnTtl] = useState("3600"); + const [busy, setBusy] = useState<"" | "save" | "check">(""); + const [message, setMessage] = useState(""); + const [errorText, setErrorText] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + + function apply(payload: InstancePayload) { + setCurrent(payload); + setHost(payload.publicHost); + setScheme(payload.publicScheme); + setEmail(emailDraftOf(payload.email)); + setTurnUrls(payload.turn.urls.join("\n")); + setTurnTtl(String(payload.turn.ttlSeconds || 3600)); + } + + useEffect(() => { + api<{ instance: InstancePayload }>(BASE) + .then((payload) => apply(payload.instance)) + .catch(() => setErrorText("Не удалось загрузить настройки установки")); + }, []); + + if (!current || !email) return null; + + const touch = () => { setFieldErrors({}); setMessage(""); }; + const setEmailField = (key: keyof EmailDraft) => (value: string) => { + setEmail({ ...email, [key]: value }); + touch(); + }; + + async function save() { + if (!email) return; + setBusy("save"); + setMessage(""); + setErrorText(""); + setFieldErrors({}); + try { + const payload = await api<{ instance: InstancePayload }>(BASE, { + method: "PATCH", + body: JSON.stringify({ + publicHost: host, + publicScheme: scheme, + email: { + host: email.host, + port: Number(email.port) || 587, + user: email.user, + password: email.password, + useTls: email.useTls, + from: email.from, + }, + turn: { + urls: turnUrls.split("\n").map((line) => line.trim()).filter(Boolean), + ttlSeconds: Number(turnTtl) || 3600, + }, + }), + }); + apply(payload.instance); + setMessage("Сохранено. Ссылки и письма пойдут по этим настройкам."); + } catch (error) { + const { detail, errors } = errorMessage(error); + setErrorText(detail); + setFieldErrors(errors); + } finally { + setBusy(""); + } + } + + async function check() { + setBusy("check"); + setMessage(""); + setErrorText(""); + try { + const payload = await api<{ sent: string }>(`${BASE}email-check/`, { method: "POST", body: "{}" }); + setMessage(`Письмо отправлено на ${payload.sent}. Не пришло — проверьте папку «Спам».`); + } catch (error) { + setErrorText(errorMessage(error).detail); + } finally { + setBusy(""); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + void save(); + } + + return ( +
+
+
+ Адрес установки + По нему открывают систему и по нему строятся ссылки на файлы +
+
+ {(["http", "https"] as const).map((value) => ( + + ))} +
+
+

+ Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск. + Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https. +

+
+ { setHost(value); touch(); }} + /> +
+

+ Ссылки будут вида {scheme}://{host || "адрес"}/… +

+ +
+
+ Исходящая почта + Приглашения сотрудникам и сброс пароля +
+
+ {([[true, "TLS"], [false, "без TLS"]] as Array<[boolean, string]>).map(([value, label]) => ( + + ))} +
+
+ {!current.email.configured && ( +

+ Почта не настроена: письма никуда не уходят, пригласить сотрудника не получится. +

+ )} +
+ + + + + +
+ +
+
+ TURN для звонков + Relay на случай, когда прямое соединение не проходит +
+
+

+ Нужен, только если звонки идут через сети со строгим NAT. Общий секрет с сервером + relay {current.turn.secretReady ? "уже создан установкой — вводить его не нужно." : "будет создан при следующем запуске стека."} +

+
+ { setTurnUrls(value); touch(); }} + /> + { setTurnTtl(value); touch(); }} + /> +
+ + {errorText &&
{errorText}
} + {message &&
{message}
} + {canManage && ( +
+ + {current.email.configured && ( + + )} + {current.updatedAt && ( + сохранено {shortDateTime(current.updatedAt)} + )} +
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index 7bc4ade..10687b2 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -14,6 +14,7 @@ import { Icon } from "../../shared/icons"; import { useMediaQuery } from "../../shared/useMediaQuery"; import { DemoDataCard } from "./DemoDataCard"; import { CommunicationSettingsCard } from "./CommunicationSettingsCard"; +import { InstanceAddressCard } from "./InstanceAddressCard"; import { StorageSettingsCard } from "./StorageSettingsCard"; import { GroupsSettingsCard } from "./GroupsSettingsCard"; import { DEFAULT_SETTINGS_SECTION, SETTINGS_SECTIONS, type SettingsSectionKey } from "./sections"; @@ -172,17 +173,22 @@ function OrganizationSection({ user, onUserUpdated }: { user: SessionUser; onUse if (page.loading) return ; if (!page.organization) return ; return ( - void page.save()} - onUploadLogo={(file) => void page.uploadLogo(file)} - onRemoveLogo={() => void page.removeLogo()} - /> + <> + void page.save()} + onUploadLogo={(file) => void page.uploadLogo(file)} + onRemoveLogo={() => void page.removeLogo()} + /> + {/* Адрес установки — свойство инсталляции, а не организации, но живёт + рядом: в облаке он один на всех, в коробке организация одна. */} + + ); } diff --git a/apps/internal-ui/src/features/support-portals/PortalAppearanceSettings.tsx b/apps/internal-ui/src/features/support-portals/PortalAppearanceSettings.tsx index c491cff..89b6c45 100644 --- a/apps/internal-ui/src/features/support-portals/PortalAppearanceSettings.tsx +++ b/apps/internal-ui/src/features/support-portals/PortalAppearanceSettings.tsx @@ -1,15 +1,36 @@ import { useEffect, useMemo, useState } from "react"; -import { SelectField } from "../../shared/form-controls"; +import { Icon } from "../../shared/icons"; +import { Segmented } from "../../shared/ui"; import { Button } from "../../shared/ui-controls"; +import { shortDateTime } from "../../shared/utils"; import { listPortalThemes, resolvePortalTheme, schemeOptions, } from "../help-center/themes/registry"; -import type { PortalThemeSchemeSetting } from "../help-center/themes/types"; +import type { PortalThemeManifest, PortalThemeSchemeSetting } from "../help-center/themes/types"; import { portalErrorMessage, updateSupportPortal, type SupportPortal } from "./model"; +// Кадр PT6: тема выбирается карточкой с мини-превью на цветах манифеста. Ни +// названия, ни описания, ни цвета в UI не придумываются — всё из манифеста +// темы (features/help-center/themes/*/manifest.ts). + +function ThemePreview({ theme }: { theme: PortalThemeManifest }) { + const { bg, ink, accent, radius = "5px" } = theme.preview; + return ( + + + + + + + + + + ); +} + export function PortalAppearanceSettings({ canManage, portal, @@ -23,6 +44,7 @@ export function PortalAppearanceSettings({ const [scheme, setScheme] = useState(portal.themeScheme); const [busy, setBusy] = useState(false); const [feedback, setFeedback] = useState(""); + const [savedAt, setSavedAt] = useState(null); useEffect(() => { setTheme(portal.theme); @@ -35,20 +57,15 @@ export function PortalAppearanceSettings({ const schemes = schemeOptions(selected); // Схема, которую выбранная тема не поддерживает, не должна молча уехать в // сохранение: показываем ближайшую поддерживаемую. - const effectiveScheme = schemes.some(([value]) => value === scheme) - ? scheme - : schemes[0][0]; + const effectiveScheme = schemes.some(([value]) => value === scheme) ? scheme : schemes[0][0]; async function save() { setBusy(true); setFeedback(""); try { - const payload = await updateSupportPortal(portal.id, { - theme, - themeScheme: effectiveScheme, - }); + const payload = await updateSupportPortal(portal.id, { theme, themeScheme: effectiveScheme }); onChanged(payload.portal); - setFeedback("Оформление обновлено"); + setSavedAt(payload.portal.updatedAt); } catch (caught) { setFeedback(portalErrorMessage(caught, "Не удалось сохранить оформление")); } finally { @@ -57,40 +74,56 @@ export function PortalAppearanceSettings({ } return ( -
-
-
-

Оформление

-

{selected.description || "Тема публичных страниц портала и её цветовая схема."}

+
+
+ Тема +
+ {themes.map((item) => ( + + ))} + {!known && ( + // Темы больше нет в сборке: показываем как есть, чтобы сохранение + // не подменило её молча. + + {theme}недоступна + Темы нет в этой сборке установки. Выберите другую, чтобы сохранить оформление. + + )}
-
- [item.id, item.name]), - // Тема, которой больше нет в сборке: показываем как есть, чтобы - // сохранение не подменило её молча. - ...(known ? [] : [[theme, `${theme} — тема недоступна`] as [string, string]]), - ]} - /> - + Цветовая схема + setScheme(value as PortalThemeSchemeSetting)} - options={schemes} + setValue={(value) => setScheme(value)} />
- {canManage && ( - - )} - {feedback &&
{feedback}
} -
+ +
+ {canManage && } + + Предпросмотр портала + + + {savedAt && сохранено {shortDateTime(savedAt)}} +
+ {feedback &&
{feedback}
} +
); } diff --git a/apps/internal-ui/src/features/support-portals/PortalArticleEditor.tsx b/apps/internal-ui/src/features/support-portals/PortalArticleEditor.tsx index 972cb7a..1ceffad 100644 --- a/apps/internal-ui/src/features/support-portals/PortalArticleEditor.tsx +++ b/apps/internal-ui/src/features/support-portals/PortalArticleEditor.tsx @@ -1,47 +1,95 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ContentEditorBreadcrumb } from "../../shared/content-library/ContentEditorBreadcrumb"; -import { ContentEditorCard } from "../../shared/content-library/ContentEditorCard"; -import { FormField, SelectField, TextAreaField } from "../../shared/form-controls"; +import { Icon } from "../../shared/icons"; +import { Segmented } from "../../shared/ui"; import { Button } from "../../shared/ui-controls"; -import { formatDate } from "../../shared/utils"; +import { shortDateTime } from "../../shared/utils"; +import { MarkdownContent } from "../help-center/MarkdownContent"; +import { resolvePortalTheme } from "../help-center/themes/registry"; import { addArticleRevision, createPortalArticle, + deleteArticleFile, loadPortalArticle, portalErrorMessage, updatePortalArticle, + uploadArticleFile, type PortalArticle, + type PortalArticleFile, type PortalCategory, + type SupportPortal, } from "./model"; +import { + applyMarkdownTool, + cursorPosition, + editorStats, + fileMarkdown, + fileMeta, + isImageFile, + MARKDOWN_TOOLS, +} from "./markdownTools"; +import { LOCALE_OPTIONS } from "./portalText"; + +// Редактор статьи (дизайн-базлайн v2, кадры PT7/PT8): полноэкранный split — +// слева текст с панелью Markdown, в центре живой предпросмотр в теме портала, +// справа рейка с разделом, файлами и версиями. Модалок нет. + +type Mode = "edit" | "split" | "view"; + +type UploadingFile = { name: string; percent: number }; + +const MODES: Array<[Mode, string, "edit" | "split" | "eye", string]> = [ + ["edit", "Текст", "edit", "Только редактор"], + ["split", "Вместе", "split", "Текст и предпросмотр"], + ["view", "Просмотр", "eye", "Только предпросмотр"], +]; + +function draftKey(portalId: number, articleId: number | null): string { + return `chatballs:portal-article-draft:${portalId}:${articleId ?? "new"}`; +} export function PortalArticleEditor({ article, + canManage, categories, - defaultLocale, + portal, onClose, onPublish, onSaved, - portalId, }: { article: PortalArticle | null; + canManage: boolean; categories: PortalCategory[]; - defaultLocale: string; + portal: SupportPortal; onClose: () => void; onPublish: (article: PortalArticle, revisionId: number) => void; onSaved: () => Promise; - portalId: number; }) { + const portalId = portal.id; const [loaded, setLoaded] = useState(article); + const [mode, setMode] = useState("split"); const [categoryId, setCategoryId] = useState(String(article?.category.id ?? categories[0]?.id ?? "")); - const [locale, setLocale] = useState(article?.locale ?? defaultLocale); + const [locale, setLocale] = useState(article?.locale ?? portal.defaultLocale); const [slug, setSlug] = useState(article?.slug ?? ""); + const [editingSlug, setEditingSlug] = useState(!article); const [selectedRevisionId, setSelectedRevisionId] = useState(null); const [title, setTitle] = useState(""); const [summary, setSummary] = useState(""); const [content, setContent] = useState(""); + const [caret, setCaret] = useState(0); + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(null); + const [dropping, setDropping] = useState(false); + const [railDropping, setRailDropping] = useState(false); + const [autoSavedAt, setAutoSavedAt] = useState(null); + // Снимок последней сохранённой редакции: по нему видно, есть ли правки, + // которых ещё нет ни в одной редакции — и, значит, нет на портале. + const [baseline, setBaseline] = useState<{ title: string; summary: string; content: string } | null>(null); const [busy, setBusy] = useState(Boolean(article)); const [error, setError] = useState(""); + const textRef = useRef(null); + const fileInputRef = useRef(null); + const imageInputRef = useRef(null); useEffect(() => { if (!article) return; @@ -49,43 +97,141 @@ export function PortalArticleEditor({ loadPortalArticle(portalId, article.id) .then(({ article: detail }) => { setLoaded(detail); + setFiles(detail.files ?? []); const revision = detail.revisions?.[0] ?? detail.publishedRevision; setSelectedRevisionId(revision?.id ?? null); setTitle(revision?.title ?? ""); setSummary(revision?.summary ?? ""); - setContent(revision?.content ?? ""); + // Локальный автосейв поднимается поверх последней редакции: он новее. + const draft = window.localStorage.getItem(draftKey(portalId, detail.id)); + setContent(draft ?? revision?.content ?? ""); + setBaseline({ + title: revision?.title ?? "", + summary: revision?.summary ?? "", + content: revision?.content ?? "", + }); }) .catch((caught) => setError(portalErrorMessage(caught, "Не удалось загрузить статью"))) .finally(() => setBusy(false)); }, [article, portalId]); - const revisions = useMemo(() => loaded?.revisions ?? [], [loaded]); + // Автосохранение черновика в браузере: статусная строка показывает время. + useEffect(() => { + if (!content) return undefined; + const timer = window.setTimeout(() => { + try { + window.localStorage.setItem(draftKey(portalId, loaded?.id ?? null), content); + setAutoSavedAt(new Date()); + } catch { + // Приватный режим браузера: автосейв просто недоступен. + } + }, 1200); + return () => window.clearTimeout(timer); + }, [content, loaded?.id, portalId]); - function selectRevision(value: string) { - const revision = revisions.find((item) => item.id === Number(value)); + const revisions = useMemo(() => loaded?.revisions ?? [], [loaded]); + const latestRevision = revisions[0] ?? null; + const publishedRevisionId = loaded?.publishedRevision?.id ?? null; + const category = categories.find((item) => item.id === Number(categoryId)); + const theme = resolvePortalTheme(portal.theme); + + function selectRevision(revisionId: number) { + const revision = revisions.find((item) => item.id === revisionId); if (!revision) return; setSelectedRevisionId(revision.id); setTitle(revision.title); setSummary(revision.summary); setContent(revision.content); + setBaseline({ title: revision.title, summary: revision.summary, content: revision.content }); } - async function save() { + function applyEdit(next: { value: string; selectionStart: number; selectionEnd: number }) { + setContent(next.value); + window.requestAnimationFrame(() => { + const field = textRef.current; + if (!field) return; + field.focus(); + field.setSelectionRange(next.selectionStart, next.selectionEnd); + setCaret(next.selectionStart); + }); + } + + function insertText(snippet: string) { + const field = textRef.current; + const start = field?.selectionStart ?? content.length; + const end = field?.selectionEnd ?? content.length; + applyEdit({ + value: `${content.slice(0, start)}${snippet}${content.slice(end)}`, + selectionStart: start + snippet.length, + selectionEnd: start + snippet.length, + }); + } + + const upload = useCallback(async (list: FileList | File[], insert: boolean) => { + if (!loaded) { + setError("Сначала создайте статью — файлы прикрепляются к существующей статье"); + return; + } + setError(""); + for (const file of Array.from(list)) { + setUploading({ name: file.name, percent: 0 }); + try { + const payload = await uploadArticleFile(portalId, loaded.id, file, (percent) => { + setUploading({ name: file.name, percent }); + }); + setFiles((current) => [payload.file, ...current.filter((item) => item.id !== payload.file.id)]); + // Вставляем в текст только по явной команде «изображение»: файл, + // прикреплённый к статье, в текст не лезет — он вложение. + if (insert && isImageFile(payload.file.contentType, payload.file.name)) { + insertText(`\n${fileMarkdown(payload.file.name, payload.file.path, true)}\n`); + } + } catch (caught) { + setError(portalErrorMessage(caught, `Не удалось загрузить ${file.name}`)); + } finally { + setUploading(null); + } + } + // content нужен в зависимостях: вставка файла дописывает текущий текст. + }, [loaded, portalId, content]); + + async function removeFile(file: PortalArticleFile) { + try { + if (loaded) await deleteArticleFile(portalId, loaded.id, file.id); + setFiles((current) => current.filter((item) => item.id !== file.id)); + } catch (caught) { + setError(portalErrorMessage(caught, "Не удалось удалить файл")); + } + } + + async function save(): Promise<{ article: PortalArticle; revisionId: number } | null> { setBusy(true); setError(""); try { - if (article) { - await updatePortalArticle(portalId, article.id, { + if (loaded) { + await updatePortalArticle(portalId, loaded.id, { categoryId: Number(categoryId), locale, slug, }); - const payload = await addArticleRevision(portalId, article.id, { title, summary, content }); - const detail = await loadPortalArticle(portalId, article.id); + const payload = await addArticleRevision(portalId, loaded.id, { title, summary, content }); + const detail = await loadPortalArticle(portalId, loaded.id); setLoaded(detail.article); + setFiles(detail.article.files ?? []); setSelectedRevisionId(payload.revision.id); + window.localStorage.removeItem(draftKey(portalId, loaded.id)); + // Бэкенд приводит ссылки на файлы к относительным — снимок и текст + // берутся из сохранённой редакции, иначе экран сразу считался бы + // изменённым. + setContent(payload.revision.content); + setBaseline({ + title: payload.revision.title, + summary: payload.revision.summary, + content: payload.revision.content, + }); + await onSaved(); + return { article: detail.article, revisionId: payload.revision.id }; } else { - await createPortalArticle(portalId, { + const created = await createPortalArticle(portalId, { categoryId: Number(categoryId), slug, locale, @@ -93,73 +239,384 @@ export function PortalArticleEditor({ summary, content, }); + window.localStorage.removeItem(draftKey(portalId, null)); + setLoaded(created.article); + const createdRevisionId = created.article.revisions?.[0]?.id ?? null; + setSelectedRevisionId(createdRevisionId); + setEditingSlug(false); + const createdRevision = created.article.revisions?.[0]; + if (createdRevision) { + setContent(createdRevision.content); + setBaseline({ + title: createdRevision.title, + summary: createdRevision.summary, + content: createdRevision.content, + }); + } + await onSaved(); + return createdRevisionId === null ? null : { article: created.article, revisionId: createdRevisionId }; } - await onSaved(); - if (!article) onClose(); } catch (caught) { setError(portalErrorMessage(caught, "Не удалось сохранить статью")); + return null; } finally { setBusy(false); } } - const ready = Boolean(title.trim() && content.trim() && (article || (categoryId && slug.trim()))); - const categoryName = categories.find((item) => item.id === Number(categoryId))?.name; - const active = loaded?.status === "PUBLISHED"; + // «Опубликовать» публикует то, что человек видит на экране: несохранённый + // текст сначала становится новой редакцией, иначе публиковалась бы старая + // и правка молча пропадала бы. + async function publish() { + if (!loaded) { + const created = await save(); + if (created) onPublish(created.article, created.revisionId); + return; + } + // Сверяемся с сервером, а не с состоянием экрана: публикуется ровно то, + // что человек видит. Совпало с последней редакцией — публикуем её, + // иначе сначала сохраняем правки новой редакцией. + const latest = await loadPortalArticle(portalId, loaded.id) + .then(({ article: detail }) => detail.revisions?.[0] ?? null) + .catch(() => null); + if (latest && latest.title === title && latest.summary === summary && latest.content === content) { + onPublish(loaded, latest.id); + return; + } + const saved = await save(); + if (saved) onPublish(saved.article, saved.revisionId); + } + + const ready = Boolean(title.trim() && content.trim() && categoryId && slug.trim()); + const draftPending = latestRevision !== null && latestRevision.id !== publishedRevisionId; + // Правки живут в браузере, пока не создана редакция: на портале их нет. + const dirty = baseline !== null + && (baseline.title !== title || baseline.summary !== summary || baseline.content !== content); + const badge = loaded === null + ? { tone: "draft", text: "Новая статья · не сохранена" } + : dirty + ? { tone: "draft", text: "Есть несохранённые правки — их ещё нет на портале" } + : draftPending + ? { tone: "draft", text: `Редакция ${latestRevision!.revision} · черновик` } + : { tone: "live", text: `Редакция ${loaded.publishedRevision?.revision ?? "—"} · опубликована` }; return ( -
- -
-
- {article ? "Статья" : "Новая статья"}} - meta={<>{active ? "Опубликована" : "Черновик"}{loaded && обновлено {formatDate(loaded.updatedAt)}}} - error={error} - actions={<> - - {article && selectedRevisionId && ( - - )} - } +
+
+ + {category?.name ?? ""} + + {badge.text} + + {canManage && ( + + )} + {canManage && ( + + )} +
+ + {error &&
{error}
} + +
+ {mode !== "view" && ( +
+
+ setTitle(event.target.value)} + /> +
+ Адрес: + {editingSlug + ? loaded && setEditingSlug(false)} + onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())} + /> + : /articles/{slug}} + {!editingSlug && }
-
- - {article && revisions.length > 0 && ( - [ - String(revision.id), - `Версия ${revision.revision}${revision.id === article.publishedRevision?.id ? " · опубликована" : ""}`, - ])} - /> - )} -
- - -