From 811a164c4402240ec34236377229af8010df3800 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 29 Sep 2026 12:32:53 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(web-chat):=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=B8=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=BE=D0=B9=20SVG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/organizations/{id}/integrations/{id}/assets/ принимает SVG или PNG до 256 КБ (PNG не меньше 96×96) и возвращает { url }. SVG очищается до сохранения; файл лежит в хранилище организации на диске или в S3 и отдаётся виджету без сессии по непредсказуемому UUID. --- ...agruzka-ikonok-vidzheta-s-ochistkoy-svg.md | 26 +++ apps/backend/chatballs/i18n/messages/en.py | 6 + apps/backend/chatballs/i18n/messages/ru.py | 6 + .../chatballs/identity/audit_catalog.py | 1 + .../chatballs/integrations/deletion.py | 3 + apps/backend/chatballs/integrations/urls.py | 2 + apps/backend/chatballs/tenancy/ingress.py | 4 + .../migrations/0038_widget_asset_guards.py | 68 ++++++ apps/backend/chatballs/webchat/asset_views.py | 97 ++++++++ apps/backend/chatballs/webchat/assets.py | 125 ++++++++++ .../webchat/migrations/0006_widget_asset.py | 38 +++ apps/backend/chatballs/webchat/models.py | 45 ++++ .../chatballs/webchat/svg_sanitizer.py | 119 ++++++++++ .../chatballs/webchat/test_svg_sanitizer.py | 78 +++++++ .../chatballs/webchat/test_widget_assets.py | 217 ++++++++++++++++++ .../webchat/test_widget_assets_rls.py | 78 +++++++ apps/backend/chatballs/webchat/urls.py | 2 + 17 files changed, 915 insertions(+) create mode 100644 .skaro/tasks/T-019-zagruzka-ikonok-vidzheta-s-ochistkoy-svg.md create mode 100644 apps/backend/chatballs/tenancy/migrations/0038_widget_asset_guards.py create mode 100644 apps/backend/chatballs/webchat/asset_views.py create mode 100644 apps/backend/chatballs/webchat/assets.py create mode 100644 apps/backend/chatballs/webchat/migrations/0006_widget_asset.py create mode 100644 apps/backend/chatballs/webchat/svg_sanitizer.py create mode 100644 apps/backend/chatballs/webchat/test_svg_sanitizer.py create mode 100644 apps/backend/chatballs/webchat/test_widget_assets.py create mode 100644 apps/backend/chatballs/webchat/test_widget_assets_rls.py diff --git a/.skaro/tasks/T-019-zagruzka-ikonok-vidzheta-s-ochistkoy-svg.md b/.skaro/tasks/T-019-zagruzka-ikonok-vidzheta-s-ochistkoy-svg.md new file mode 100644 index 0000000..f2e9731 --- /dev/null +++ b/.skaro/tasks/T-019-zagruzka-ikonok-vidzheta-s-ochistkoy-svg.md @@ -0,0 +1,26 @@ +--- +id: T-019 +title: Загрузка иконок виджета с очисткой SVG +milestone: M04 +status: done +depends_on: [] +order: 1 +spec: "0021" +created: 2026-09-29 +branch: skaro/T-019-zagruzka-ikonok-vidzheta-s-och +--- + +## Цель + +Администратор загружает SVG или PNG для кнопки и шапки; файл безопасен и отдаётся публично (R-3, R-4). + +## Критерии приёмки + +- [x] POST /api/v1/integrations/{id}/assets/ принимает SVG/PNG до 256 КБ, PNG от 96×96, возвращает {url}; остальное отклоняется понятной ошибкой +- [x] SVG очищается на сервере: script, on*, foreignObject, внешние href/xlink:href, javascript: удалены (тест с вредным SVG) +- [x] Файл лежит в хранилище организации organizations/{public_id}/…, работает и на диске, и на S3 +- [x] Файл отдаётся виджету на чужом сайте без авторизации, чужая организация не может перезаписать + +## Итог + +Добавлен POST /api/v1/organizations/{org}/integrations/{id}/assets/: он принимает SVG или PNG до 256 КБ (PNG от 96×96) и возвращает { url }. SVG очищается на сервере до сохранения. Файл хранится в папке организации на диске или в S3 и отдаётся без авторизации по непредсказуемому UUID (GET /api/v1/webchat/assets//). Модель WidgetAsset защищена RLS, триггерами связей и каталогом входа. При удалении интеграции её иконки стираются. diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 736d208..d57628a 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -159,6 +159,11 @@ MESSAGES: dict[str, object] = { "settings.storages_missing": "django-storages[s3] is not installed", "settings.ttl_is_seconds": "The lifetime is a number of seconds", "settings.unknown_storage_kind": "Unknown storage type", + "webchat.asset_choose_file": "Choose an icon file", + "webchat.asset_formats": "SVG and PNG are supported", + "webchat.asset_png_too_small": "PNG must be at least 96×96 pixels", + "webchat.asset_svg_unreadable": "Could not read the SVG. Export the file again from your graphics editor", + "webchat.asset_too_large": "The icon must not exceed 256 KB", "webchat.invalid_phone": "Invalid phone number", "webchat.no_active_invite": "No active invitation found", "webchat.session_not_found": "Session not found", @@ -265,6 +270,7 @@ MESSAGES: dict[str, object] = { "audit.action_integrations_integration_created": "Integration added", "audit.action_integrations_integration_deleted": "Integration deleted", "audit.action_integrations_integration_updated": "Integration changed", + "audit.action_integrations_widget_asset_uploaded": "Widget icon uploaded", "audit.action_organization_created": "Organization created from the interface", "audit.action_organization_owner_activated": "Organization owner activated", "audit.action_organization_owner_invitation_requested": "Owner invitation sent", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 8584d11..6c3cddf 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -163,6 +163,11 @@ MESSAGES: dict[str, object] = { "settings.storages_missing": "django-storages[s3] не установлен", "settings.ttl_is_seconds": "Время жизни — число секунд", "settings.unknown_storage_kind": "Неизвестный тип хранилища", + "webchat.asset_choose_file": "Выберите файл иконки", + "webchat.asset_formats": "Поддерживаются SVG и PNG", + "webchat.asset_png_too_small": "PNG должен быть не меньше 96×96 пикселей", + "webchat.asset_svg_unreadable": "Не удалось прочитать SVG. Сохраните файл заново в графическом редакторе", + "webchat.asset_too_large": "Размер иконки не должен превышать 256 КБ", "webchat.invalid_phone": "Некорректный номер телефона", "webchat.no_active_invite": "Активное приглашение не найдено", "webchat.session_not_found": "Сессия не найдена", @@ -269,6 +274,7 @@ MESSAGES: dict[str, object] = { "audit.action_integrations_integration_created": "Добавлена интеграция", "audit.action_integrations_integration_deleted": "Удалена интеграция", "audit.action_integrations_integration_updated": "Изменена интеграция", + "audit.action_integrations_widget_asset_uploaded": "Загружена иконка виджета", "audit.action_organization_created": "Создана организация из интерфейса", "audit.action_organization_owner_activated": "Активирован владелец организации", "audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу", diff --git a/apps/backend/chatballs/identity/audit_catalog.py b/apps/backend/chatballs/identity/audit_catalog.py index 7d2e332..f2c1f91 100644 --- a/apps/backend/chatballs/identity/audit_catalog.py +++ b/apps/backend/chatballs/identity/audit_catalog.py @@ -118,6 +118,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = { "integrations.integration_created": "audit.action_integrations_integration_created", "integrations.integration_updated": "audit.action_integrations_integration_updated", "integrations.integration_deleted": "audit.action_integrations_integration_deleted", + "integrations.widget_asset_uploaded": "audit.action_integrations_widget_asset_uploaded", "channels.channel_created": "audit.action_channels_channel_created", "channels.channel_updated": "audit.action_channels_channel_updated", "channels.channel_deleted": "audit.action_channels_channel_deleted", diff --git a/apps/backend/chatballs/integrations/deletion.py b/apps/backend/chatballs/integrations/deletion.py index 3a638e7..7ac562e 100644 --- a/apps/backend/chatballs/integrations/deletion.py +++ b/apps/backend/chatballs/integrations/deletion.py @@ -4,6 +4,7 @@ from django.db.models.deletion import ProtectedError from chatballs.i18n import t from chatballs.integrations.models import Integration from chatballs.tenancy.context import TenantContext +from chatballs.webchat.assets import discard_widget_asset_files, widget_asset_files class IntegrationInUse(Exception): @@ -13,7 +14,9 @@ class IntegrationInUse(Exception): def delete_integration(*, context: TenantContext, integration: Integration) -> None: if integration.organization_id != context.organization_id: raise ValidationError({"integration": t("settings.integration_other_organization")}) + asset_files = widget_asset_files(integration) try: integration.delete() except ProtectedError as error: raise IntegrationInUse(t("settings.integration_in_use")) from error + discard_widget_asset_files(context=context, files=asset_files) diff --git a/apps/backend/chatballs/integrations/urls.py b/apps/backend/chatballs/integrations/urls.py index e88653e..77f32c7 100644 --- a/apps/backend/chatballs/integrations/urls.py +++ b/apps/backend/chatballs/integrations/urls.py @@ -1,9 +1,11 @@ from django.urls import path from chatballs.integrations import views +from chatballs.webchat.asset_views import WidgetAssetUploadView urlpatterns = [ path("", views.IntegrationListView.as_view(), name="integration-list"), path("/", views.IntegrationDetailView.as_view(), name="integration-detail"), path("/test/", views.IntegrationTestView.as_view(), name="integration-test"), + path("/assets/", WidgetAssetUploadView.as_view(), name="integration-widget-assets"), ] diff --git a/apps/backend/chatballs/tenancy/ingress.py b/apps/backend/chatballs/tenancy/ingress.py index 872b227..b8ffe8c 100644 --- a/apps/backend/chatballs/tenancy/ingress.py +++ b/apps/backend/chatballs/tenancy/ingress.py @@ -90,6 +90,10 @@ def web_widget_route(public_key: str) -> IngressRoute | None: return _unique_route("web_widget_directory", public_key) +def widget_asset_route(public_id: str) -> IngressRoute | None: + return _unique_route("widget_asset_directory", public_id) + + def support_portal_route(hostname: str) -> IngressRoute | None: return _unique_route("support_portal_directory", hostname.strip().lower().rstrip(".")) diff --git a/apps/backend/chatballs/tenancy/migrations/0038_widget_asset_guards.py b/apps/backend/chatballs/tenancy/migrations/0038_widget_asset_guards.py new file mode 100644 index 0000000..6957989 --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0038_widget_asset_guards.py @@ -0,0 +1,68 @@ +# Иконки виджета (webchat_widgetasset): tenant-таблица с organization_id, RLS и +# триггерами связей по образцу tenancy/0027. Иконка показывается на чужом сайте +# без сессии, поэтому строка попадает в ingress-каталог: публичная отдача +# находит организацию по непредсказуемому public_id. +from django.db import migrations + +TABLE = "webchat_widgetasset" + +FORWARD_SQL = 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 webchat_asset_integration ON {TABLE}; +CREATE CONSTRAINT TRIGGER webchat_asset_integration +AFTER INSERT OR UPDATE ON {TABLE} +DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION +chatballs.enforce_tenant_fk('integrations_integration', 'integration_id'); + +DROP TRIGGER IF EXISTS webchat_asset_user ON {TABLE}; +CREATE CONSTRAINT TRIGGER webchat_asset_user +AFTER INSERT OR UPDATE ON {TABLE} +DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION +chatballs.enforce_tenant_user('uploaded_by_id'); + +CREATE OR REPLACE VIEW chatballs.widget_asset_directory +WITH (security_barrier = true) AS + SELECT asset.id AS resource_id, + asset.organization_id, + asset.public_id::text AS lookup_key + FROM {TABLE} asset; +ALTER VIEW chatballs.widget_asset_directory OWNER TO chatballs_schema; +REVOKE ALL ON chatballs.widget_asset_directory FROM PUBLIC; +GRANT SELECT ON chatballs.widget_asset_directory + TO chatballs_runtime_app, chatballs_runtime_platform; +""" + +REVERSE_SQL = f""" +DROP VIEW IF EXISTS chatballs.widget_asset_directory; +DROP TRIGGER IF EXISTS webchat_asset_user ON {TABLE}; +DROP TRIGGER IF EXISTS webchat_asset_integration 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", "0037_queue_policy_guards"), + ("webchat", "0006_widget_asset"), + ] + + operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)] diff --git a/apps/backend/chatballs/webchat/asset_views.py b/apps/backend/chatballs/webchat/asset_views.py new file mode 100644 index 0000000..8d0d1e6 --- /dev/null +++ b/apps/backend/chatballs/webchat/asset_views.py @@ -0,0 +1,97 @@ +from django.core.exceptions import ValidationError +from django.http import FileResponse, Http404 +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.permissions import AllowAny +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.i18n import t +from chatballs.identity.audit import record_audit_event +from chatballs.integrations.models import Integration, IntegrationProvider +from chatballs.integrations.selectors import integration_for_context +from chatballs.tenancy.context import TenantContext +from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.ingress import widget_asset_route +from chatballs.tenancy.lookup import load_organization +from chatballs.webchat.assets import upload_widget_asset, widget_asset_url +from chatballs.webchat.models import WidgetAsset + +# Адрес иконки неизменен: каждая загрузка получает новый UUID. +_IMMUTABLE_CACHE = "public, max-age=31536000, immutable" + + +class WidgetAssetUploadView(APIView): + """Загрузка иконки кнопки или шапки веб-виджета → ``{ url }``.""" + + parser_classes = [MultiPartParser, FormParser] + permission_classes = [HasCapability] + required_capability = "integrations.manage" + + def post(self, request: Request, integration_id: int) -> Response: + try: + integration = integration_for_context( + context=request.tenant_context, integration_id=integration_id + ) + except Integration.DoesNotExist: + integration = None + if integration is None or integration.provider != IntegrationProvider.WEB: + return Response({"detail": t("settings.integration_not_found")}, status=404) + upload = request.FILES.get("file") + if upload is None: + detail = t("webchat.asset_choose_file") + return Response({"detail": detail, "errors": {"file": detail}}, status=400) + try: + asset = upload_widget_asset( + context=request.tenant_context, + integration=integration, + upload=upload, + uploaded_by=request.user, + ) + except ValidationError as error: + detail = error.message_dict["file"][0] + return Response({"detail": detail, "errors": {"file": detail}}, status=400) + record_audit_event( + action="integrations.widget_asset_uploaded", + actor=request.user, + organization=request.tenant_context.organization, + object_type="Integration", + object_id=str(integration.id), + request=request, + ) + return Response({"url": widget_asset_url(asset)}, status=201) + + +class WidgetAssetView(APIView): + """Иконка виджета по публичной ссылке. + + Её показывает лоадер на чужом сайте, где нет сессии: защита — + непредсказуемый UUID и резолв организации через ingress-каталог. + """ + + permission_classes = [AllowAny] + authentication_classes: list = [] + + def get(self, request: Request, public_id) -> FileResponse: + route = widget_asset_route(str(public_id)) + organization = load_organization(route.organization_id) if route else None + if organization is None: + raise Http404 + with tenant_atomic(TenantContext.for_resource(organization)): + asset = WidgetAsset.objects.filter( + id=route.resource_id, public_id=public_id, organization=organization + ).first() + if asset is None: + raise Http404 + opened_file = asset.file.open("rb") + content_type = asset.content_type + response = FileResponse(opened_file, content_type=content_type) + # SVG очищен при загрузке; заголовки — второй рубеж на случай, если + # адрес откроют как документ: ничего не исполнять, никуда не ходить, + # тип не угадывать. Картинку можно встраивать на любой сайт. + response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox" + response["X-Content-Type-Options"] = "nosniff" + response["Cross-Origin-Resource-Policy"] = "cross-origin" + response["Cache-Control"] = _IMMUTABLE_CACHE + return response diff --git a/apps/backend/chatballs/webchat/assets.py b/apps/backend/chatballs/webchat/assets.py new file mode 100644 index 0000000..b02e940 --- /dev/null +++ b/apps/backend/chatballs/webchat/assets.py @@ -0,0 +1,125 @@ +"""Иконки кнопки и шапки виджета (SPEC-0021 R-3, R-4). + +Принимаются SVG и PNG до 256 КБ; PNG — не меньше 96×96, чтобы кнопка не +расплывалась на экранах с высокой плотностью. SVG очищается до сохранения. +Тип определяется по содержимому, а не по имени файла и заявленному типу. +""" + +from __future__ import annotations + +import uuid + +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.urls import reverse + +from chatballs.i18n import t +from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg +from chatballs.integrations.models import Integration +from chatballs.tenancy.context import TenantContext +from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.storage import adjust_storage_usage +from chatballs.tenancy.storage_quota import finalize_storage, release_storage, reserve_storage +from chatballs.webchat.models import WidgetAsset +from chatballs.webchat.svg_sanitizer import UnsafeSvg, sanitize_svg + +MAX_ASSET_BYTES = 256 * 1024 +MIN_PNG_SIDE = 96 +PNG_CONTENT_TYPE = "image/png" +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def _png_size(data: bytes) -> tuple[int, int] | None: + """Ширина и высота из заголовка IHDR — первого блока любого PNG.""" + + if len(data) < 24 or data[12:16] != b"IHDR": + return None + return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big") + + +def _asset_content(data: bytes) -> tuple[bytes, str, str]: + if data.startswith(_PNG_SIGNATURE): + size = _png_size(data) + if size is None: + raise ValidationError({"file": t("webchat.asset_formats")}) + if min(size) < MIN_PNG_SIDE: + raise ValidationError({"file": t("webchat.asset_png_too_small")}) + return data, PNG_CONTENT_TYPE, ".png" + if looks_like_svg(data): + try: + return sanitize_svg(data), SVG_CONTENT_TYPE, ".svg" + except UnsafeSvg as error: + raise ValidationError({"file": t("webchat.asset_svg_unreadable")}) from error + raise ValidationError({"file": t("webchat.asset_formats")}) + + +def _read(upload: UploadedFile) -> bytes: + if upload.size is not None and upload.size > MAX_ASSET_BYTES: + raise ValidationError({"file": t("webchat.asset_too_large")}) + data = upload.read(MAX_ASSET_BYTES + 1) + if not data: + raise ValidationError({"file": t("webchat.asset_choose_file")}) + if len(data) > MAX_ASSET_BYTES: + raise ValidationError({"file": t("webchat.asset_too_large")}) + return data + + +@transaction.atomic +def upload_widget_asset( + *, + context: TenantContext, + integration: Integration, + upload: UploadedFile, + uploaded_by=None, +) -> WidgetAsset: + content, content_type, suffix = _asset_content(_read(upload)) + asset = WidgetAsset( + organization_id=context.organization_id, + integration=integration, + content_type=content_type, + size=len(content), + uploaded_by=uploaded_by, + ) + reservation_key = f"widget-asset:{uuid.uuid4()}" + reserve_storage(context=context, expected_bytes=len(content), idempotency_key=reservation_key) + try: + asset.file.save(suffix, ContentFile(content), save=False) + asset.save() + except Exception: + release_storage(context=context, idempotency_key=reservation_key) + raise + finalize_storage(context=context, idempotency_key=reservation_key, actual_bytes=len(content)) + return asset + + +def widget_asset_url(asset: WidgetAsset) -> str: + """Адрес от корня установки: лоадер дописывает к нему свой origin.""" + + return reverse("webchat-asset", kwargs={"public_id": asset.public_id}) + + +def widget_asset_files(integration: Integration) -> list[tuple[str, int]]: + """Файлы иконок интеграции: строки уходят с ней каскадом, файлы — нет.""" + + return list(integration.widget_assets.values_list("file", "size")) + + +def discard_widget_asset_files(*, context: TenantContext, files: list[tuple[str, int]]) -> None: + """Освобождает место сразу, а файлы стирает после коммита удаления.""" + + if not files: + return + adjust_storage_usage(context=context, delta_bytes=-sum(size for _name, size in files)) + storage = WidgetAsset._meta.get_field("file").storage + organization_id = context.organization_id + + def delete_files() -> None: + # Ограждение хранилища требует контекст организации, а после коммита + # транзакции с SET LOCAL его уже нет. + with tenant_atomic(organization_id): + for name, _size in files: + storage.delete(name) + + transaction.on_commit(delete_files) diff --git a/apps/backend/chatballs/webchat/migrations/0006_widget_asset.py b/apps/backend/chatballs/webchat/migrations/0006_widget_asset.py new file mode 100644 index 0000000..50cedd9 --- /dev/null +++ b/apps/backend/chatballs/webchat/migrations/0006_widget_asset.py @@ -0,0 +1,38 @@ +# Иконки кнопки и шапки виджета (SPEC-0021 R-3). RLS, триггеры связей и +# каталог входа для публичной отдачи — в tenancy/0038. + +import chatballs.webchat.models +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0040_instance_public_port'), + ('integrations', '0010_integration_runtime_revision'), + ('webchat', '0005_remove_widget_mode'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WidgetAsset', + 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.webchat.models.widget_asset_upload_path)), + ('content_type', models.CharField(max_length=64)), + ('size', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='widget_assets', to='integrations.integration')), + ('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'], + }, + ), + ] diff --git a/apps/backend/chatballs/webchat/models.py b/apps/backend/chatballs/webchat/models.py index a0da8ac..7b36f46 100644 --- a/apps/backend/chatballs/webchat/models.py +++ b/apps/backend/chatballs/webchat/models.py @@ -1,5 +1,8 @@ import secrets +import uuid +from pathlib import PurePath +from django.conf import settings from django.core.exceptions import ValidationError from django.db import models @@ -83,3 +86,45 @@ class WebSession(TenantRelationModel): def __str__(self) -> str: return f"websession:{self.identity_id}" + + +def widget_asset_upload_path(instance: "WidgetAsset", filename: str) -> str: + organization = instance.integration.organization + return ( + f"organizations/{organization.public_id}/webchat/" + f"{instance.integration_id}/{instance.public_id}{PurePath(filename).suffix}" + ) + + +class WidgetAsset(TenantRelationModel): + """Иконка кнопки или шапки виджета (SPEC-0021 R-3). + + Файл показывается на чужом сайте без сессии, поэтому ссылка публичная и + защищена непредсказуемым UUID, как у файлов статей портала. Каждая + загрузка — новый ключ: прежний адрес не перезаписывается. + """ + + tenant_relation_fields = ("integration",) + integration = models.ForeignKey( + "integrations.Integration", + on_delete=models.CASCADE, + related_name="widget_assets", + ) + public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + file = models.FileField(upload_to=widget_asset_upload_path, max_length=512) + content_type = models.CharField(max_length=64) + size = models.PositiveIntegerField(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"] + + def __str__(self) -> str: + return f"widget-asset:{self.integration_id}/{self.public_id}" diff --git a/apps/backend/chatballs/webchat/svg_sanitizer.py b/apps/backend/chatballs/webchat/svg_sanitizer.py new file mode 100644 index 0000000..2830836 --- /dev/null +++ b/apps/backend/chatballs/webchat/svg_sanitizer.py @@ -0,0 +1,119 @@ +"""Очистка SVG-иконки виджета. + +Иконка кнопки и шапки показывается на чужих сайтах и отдаётся с адреса +приложения, поэтому SVG чистится до сохранения. В отличие от логотипа +организации (``identity.logo_svg``) файл не отклоняется целиком: опасное +вырезается, картинка остаётся. + +Удаляется: элементы вне пространства имён SVG, script, foreignObject и +встраиваемые iframe/embed/object/audio/video; атрибуты-обработчики on*; +href и xlink:href, которые ведут не на якорь ``#``; любые значения с +javascript:/vbscript:/data:text; анимации, подменяющие ссылки и обработчики; +стили с @import, expression и внешними url(). Отклоняется то, что очистить +нельзя: не XML, не SVG, DOCTYPE и сущности. +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET + +SVG_NAMESPACE = "http://www.w3.org/2000/svg" +XLINK_NAMESPACE = "http://www.w3.org/1999/xlink" + +# Без регистрации ElementTree пишет ns0:svg. Параметр default_namespace не +# подходит: он требует пространство имён и у атрибутов. +ET.register_namespace("", SVG_NAMESPACE) +ET.register_namespace("xlink", XLINK_NAMESPACE) + +_REMOVED_TAGS = frozenset( + {"script", "foreignobject", "iframe", "embed", "object", "audio", "video"} +) +_ANIMATION_TAGS = frozenset({"animate", "set", "animatemotion", "animatetransform"}) +_DECLARATION = re.compile(rb" str: + return name.rsplit("}", 1)[-1].lower() + + +def _namespace(name: str) -> str: + return name[1:].split("}", 1)[0] if name.startswith("{") else "" + + +def _compact(value: str) -> str: + return re.sub(r"[\s\x00-\x1f]+", "", value).lower() + + +def _unsafe_value(value: str) -> bool: + compact = _compact(value) + return any(scheme in compact for scheme in _UNSAFE_SCHEMES) + + +def _unsafe_css(text: str) -> bool: + compact = _compact(text) + return "@import" in compact or "expression(" in compact or bool(_EXTERNAL_URL.search(text)) + + +def _unsafe_attribute(name: str, value: str) -> bool: + local = _local(name) + if local.startswith("on") or _unsafe_value(value): + return True + if local == "href": + return not value.strip().startswith("#") + return local == "style" and _unsafe_css(value) + + +def _unsafe_animation(element: ET.Element) -> bool: + target = _local(element.get("attributeName", "")) + return target == "href" or target.startswith("on") + + +def _removed(element: ET.Element) -> bool: + tag = element.tag if isinstance(element.tag, str) else "" + if _namespace(tag) != SVG_NAMESPACE: + return True + local = _local(tag) + if local in _REMOVED_TAGS: + return True + if local in _ANIMATION_TAGS and _unsafe_animation(element): + return True + return local == "style" and _unsafe_css(element.text or "") + + +def _qualify(root: ET.Element) -> None: + """SVG без xmlns браузер в не рисует: переводим его в пространство SVG.""" + + for element in root.iter(): + if isinstance(element.tag, str) and not element.tag.startswith("{"): + element.tag = f"{{{SVG_NAMESPACE}}}{element.tag}" + + +def _clean(element: ET.Element) -> None: + for child in list(element): + if _removed(child): + element.remove(child) + else: + _clean(child) + for name in [name for name, value in element.attrib.items() if _unsafe_attribute(name, value)]: + del element.attrib[name] + + +def sanitize_svg(data: bytes) -> bytes: + if _DECLARATION.search(data): + raise UnsafeSvg("DOCTYPE and entities are not allowed") + try: + root = ET.fromstring(data) + except ET.ParseError as error: + raise UnsafeSvg("SVG is not well-formed XML") from error + _qualify(root) + if not isinstance(root.tag, str) or root.tag != f"{{{SVG_NAMESPACE}}}svg": + raise UnsafeSvg("Root element is not svg") + _clean(root) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) diff --git a/apps/backend/chatballs/webchat/test_svg_sanitizer.py b/apps/backend/chatballs/webchat/test_svg_sanitizer.py new file mode 100644 index 0000000..eedcc89 --- /dev/null +++ b/apps/backend/chatballs/webchat/test_svg_sanitizer.py @@ -0,0 +1,78 @@ +"""Очистка SVG-иконки виджета: опасное вырезается, картинка остаётся (SPEC-0021 R-4).""" + +from __future__ import annotations + +from django.test import SimpleTestCase + +from chatballs.webchat.svg_sanitizer import UnsafeSvg, sanitize_svg + +HARMFUL_SVG = b""" + + + +
x
+
+ + + + + + + + + + + + + +
+""" + + +class SvgSanitizerTests(SimpleTestCase): + def test_harmful_parts_are_removed(self) -> None: + cleaned = sanitize_svg(HARMFUL_SVG).decode("utf-8") + lowered = cleaned.lower() + + for forbidden in ( + " None: + cleaned = sanitize_svg(HARMFUL_SVG).decode("utf-8") + + self.assertIn('viewBox="0 0 64 64"', cleaned) + self.assertIn('', cleaned) + self.assertIn('xlink:href="#dot"', cleaned) + self.assertIn('fill="url(#dot)"', cleaned) + self.assertIn('attributeName="opacity"', cleaned) + self.assertTrue(cleaned.startswith(" None: + cleaned = sanitize_svg(b'') + + self.assertIn(b'', cleaned) + self.assertIn(b'', cleaned) + + def test_unreadable_svg_is_refused(self) -> None: + samples = { + "doctype": b']>', + "broken xml": b'', + "not svg": b"hi", + "foreign root": b'', + } + for name, sample in samples.items(): + with self.subTest(name), self.assertRaises(UnsafeSvg): + sanitize_svg(sample) diff --git a/apps/backend/chatballs/webchat/test_widget_assets.py b/apps/backend/chatballs/webchat/test_widget_assets.py new file mode 100644 index 0000000..51ed23f --- /dev/null +++ b/apps/backend/chatballs/webchat/test_widget_assets.py @@ -0,0 +1,217 @@ +"""Иконки виджета: загрузка, очистка, хранилище организации и публичная отдача. + +SPEC-0021 R-3, R-4: SVG или PNG до 256 КБ (PNG не меньше 96×96) → +``{ url }``; файл лежит под ``organizations/{public_id}/`` на диске или в S3 и +открывается на чужом сайте без сессии. +""" + +from __future__ import annotations + +import struct +import zlib +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from django.core.files.storage import FileSystemStorage +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from chatballs.channels.models import Channel +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership +from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider +from chatballs.tenancy import storage_settings as ss +from chatballs.tenancy.storage_backends import DynamicTenantStorage +from chatballs.testing import TenantAPIClient +from chatballs.webchat.models import WidgetAsset +from chatballs.webchat.test_svg_sanitizer import HARMFUL_SVG +from chatballs.webchat.testing import create_web_widget + + +def png(width: int, height: int) -> bytes: + def chunk(kind: bytes, body: bytes) -> bytes: + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body)) + + header = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0) + pixels = zlib.compress(b"".join(b"\x00" + b"\x80" * width for _ in range(height))) + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", pixels) + chunk(b"IEND", b"") + + +class WidgetAssetApiTests(TestCase): + def setUp(self) -> None: + self.media = TemporaryDirectory() + self.bucket = TemporaryDirectory() + self.addCleanup(self.media.cleanup) + self.addCleanup(self.bucket.cleanup) + override = override_settings(MEDIA_ROOT=self.media.name) + override.enable() + self.addCleanup(override.disable) + ss.invalidate_cache() + self.addCleanup(ss.invalidate_cache) + DynamicTenantStorage._s3_cache = None + + result = bootstrap_owner(email="assets-owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + channel = Channel.objects.create(organization=self.organization, code="site", name="Сайт") + self.integration = create_web_widget(channel).integration + self.client = TenantAPIClient() + self.client.force_authenticate(result.owner) + + def _url(self, integration_id: int, organization: Organization | None = None) -> str: + public_id = (organization or self.organization).public_id + return f"/api/v1/organizations/{public_id}/integrations/{integration_id}/assets/" + + def _upload(self, name: str, data: bytes, *, client=None, url: str | None = None): + return (client or self.client).post( + url or self._url(self.integration.id), + {"file": SimpleUploadedFile(name, data, content_type="application/octet-stream")}, + format="multipart", + ) + + def _anonymous_get(self, url: str): + return APIClient().get(url, HTTP_ORIGIN="https://customer-site.example") + + def _enable_s3(self) -> None: + patcher = mock.patch.object( + ss, "build_s3_storage", lambda config, **kwargs: FileSystemStorage(location=self.bucket.name) + ) + patcher.start() + self.addCleanup(patcher.stop) + row = ss.StorageSettings.load() + row.backend = ss.StorageBackend.S3 + row.s3_bucket = "demo" + row.s3_access_key = "AKIA-demo-access" + row.s3_secret_key = "very-secret" + row.save() + DynamicTenantStorage._s3_cache = None + + def _second_owner_client(self) -> tuple[Organization, TenantAPIClient]: + other = Organization.objects.create(name="Other", slug="other") + owner = HumanUser.objects.create_user(email="other-owner@example.com", password="Password-123", full_name="Other") + OrganizationMembership.objects.create(organization=other, user=owner, role=EmployeeRole.OWNER, position_title="Owner") + client = TenantAPIClient() + client.force_authenticate(owner) + return other, client + + # --- приём и отказ ------------------------------------------------------- + + def test_harmful_svg_is_cleaned_and_served_without_session(self) -> None: + response = self._upload("logo.svg", HARMFUL_SVG) + + self.assertEqual(response.status_code, 201, response.content) + url = response.json()["url"] + self.assertRegex(url, r"^/api/v1/webchat/assets/[0-9a-f-]{36}/$") + served = self._anonymous_get(url) + self.assertEqual(served.status_code, 200) + self.assertEqual(served["Content-Type"], "image/svg+xml") + self.assertEqual(served["Cross-Origin-Resource-Policy"], "cross-origin") + self.assertEqual(served["X-Content-Type-Options"], "nosniff") + self.assertIn("sandbox", served["Content-Security-Policy"]) + body = b"".join(served.streaming_content).lower() + for forbidden in (b" None: + data = png(96, 128) + + response = self._upload("icon.png", data) + + self.assertEqual(response.status_code, 201, response.content) + served = self._anonymous_get(response.json()["url"]) + self.assertEqual(served["Content-Type"], "image/png") + self.assertEqual(b"".join(served.streaming_content), data) + + def test_unsuitable_files_are_refused_with_a_clear_message(self) -> None: + cases = { + "small png": ("icon.png", png(95, 200), "PNG должен быть не меньше 96×96 пикселей"), + "too large": ("icon.png", png(96, 96) + b"\x00" * (256 * 1024), "Размер иконки не должен превышать 256 КБ"), + "gif": ("icon.gif", b"GIF89a\x01\x00\x01\x00", "Поддерживаются SVG и PNG"), + "svg named png": ("icon.png", b"", "Поддерживаются SVG и PNG"), + "broken svg": ("icon.svg", b'', "Не удалось прочитать SVG. Сохраните файл заново в графическом редакторе"), + "empty": ("icon.svg", b"", "Выберите файл иконки"), + } + for name, (filename, data, message) in cases.items(): + with self.subTest(name): + response = self._upload(filename, data) + self.assertEqual(response.status_code, 400, response.content) + self.assertEqual(response.json()["detail"], message) + self.assertEqual(response.json()["errors"], {"file": message}) + self.assertFalse(WidgetAsset.objects.exists()) + + def test_only_web_widget_accepts_icons(self) -> None: + provider = Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.LLM_PROVIDER, + provider=IntegrationProvider.DEMO, + name="Demo", + ) + + response = self._upload("icon.png", png(96, 96), url=self._url(provider.id)) + + self.assertEqual(response.status_code, 404) + + # --- хранилище ---------------------------------------------------------- + + def test_file_lives_in_organization_folder_on_disk(self) -> None: + response = self._upload("icon.png", png(96, 96)) + + self.assertEqual(response.status_code, 201, response.content) + asset = WidgetAsset.objects.get() + prefix = f"organizations/{self.organization.public_id}/webchat/{self.integration.id}/" + self.assertTrue(asset.file.name.startswith(prefix), asset.file.name) + self.assertTrue((Path(self.media.name) / asset.file.name).is_file()) + + def test_file_lives_in_organization_folder_on_s3(self) -> None: + self._enable_s3() + + response = self._upload("logo.svg", HARMFUL_SVG) + + self.assertEqual(response.status_code, 201, response.content) + asset = WidgetAsset.objects.get() + self.assertTrue(asset.file.name.startswith(f"organizations/{self.organization.public_id}/webchat/")) + self.assertTrue((Path(self.bucket.name) / asset.file.name).is_file()) + self.assertFalse((Path(self.media.name) / asset.file.name).exists()) + served = self._anonymous_get(response.json()["url"]) + self.assertEqual(served.status_code, 200) + self.assertNotIn(b" None: + self._upload("icon.png", png(96, 96)) + path = Path(self.media.name) / WidgetAsset.objects.get().file.name + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.delete( + f"/api/v1/organizations/{self.organization.public_id}/integrations/{self.integration.id}/" + ) + + self.assertEqual(response.status_code, 204, response.content) + self.assertFalse(WidgetAsset.objects.exists()) + self.assertFalse(path.exists()) + + # --- изоляция ----------------------------------------------------------- + + def test_other_organization_cannot_upload_to_foreign_widget(self) -> None: + other, client = self._second_owner_client() + + through_own = self._upload("icon.png", png(96, 96), client=client, url=self._url(self.integration.id, other)) + through_foreign = self._upload("icon.png", png(96, 96), client=client) + + self.assertEqual(through_own.status_code, 404) + self.assertIn(through_foreign.status_code, (403, 404)) + self.assertFalse(WidgetAsset.objects.exists()) + + def test_every_upload_gets_its_own_address(self) -> None: + first = self._upload("icon.png", png(96, 96)).json()["url"] + second = self._upload("icon.png", png(128, 128)).json()["url"] + + self.assertNotEqual(first, second) + served = self._anonymous_get(first) + self.assertEqual(b"".join(served.streaming_content), png(96, 96)) + + def test_unknown_address_is_not_found(self) -> None: + response = self._anonymous_get("/api/v1/webchat/assets/00000000-0000-4000-8000-000000000000/") + + self.assertEqual(response.status_code, 404) diff --git a/apps/backend/chatballs/webchat/test_widget_assets_rls.py b/apps/backend/chatballs/webchat/test_widget_assets_rls.py new file mode 100644 index 0000000..0710a95 --- /dev/null +++ b/apps/backend/chatballs/webchat/test_widget_assets_rls.py @@ -0,0 +1,78 @@ +"""Иконки виджета под реальной ролью backend-app: RLS, триггеры и каталог входа. + +Обычные тесты ходят в базу ролью-владельцем схемы, для которой RLS открыта. +Здесь запросы идут ролью ``chatballs_runtime_app``, как в production. +""" + +from __future__ import annotations + +from tempfile import TemporaryDirectory + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import connection +from django.test import TransactionTestCase, override_settings +from rest_framework.test import APIClient + +from chatballs.channels.models import Channel +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership +from chatballs.tenancy import storage_settings as ss +from chatballs.testing import TenantAPIClient +from chatballs.webchat.test_widget_assets import png +from chatballs.webchat.testing import create_web_widget + + +class WidgetAssetRuntimeRoleTests(TransactionTestCase): + def setUp(self) -> None: + media = TemporaryDirectory() + self.addCleanup(media.cleanup) + override = override_settings(MEDIA_ROOT=media.name) + override.enable() + self.addCleanup(override.disable) + ss.invalidate_cache() + self.addCleanup(ss.invalidate_cache) + + result = bootstrap_owner(email="rls-assets@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + channel = Channel.objects.create(organization=self.organization, code="site", name="Сайт") + self.integration = create_web_widget(channel).integration + self.owner = result.owner + + other = Organization.objects.create(name="Other", slug="rls-assets-other") + self.other_owner = HumanUser.objects.create_user(email="rls-assets-other@example.com") + OrganizationMembership.objects.create( + organization=other, user=self.other_owner, role=EmployeeRole.OWNER, position_title="Owner" + ) + self.other = other + + def _as_app_role(self, request): + with connection.cursor() as cursor: + cursor.execute("SET ROLE chatballs_runtime_app") + try: + return request() + finally: + with connection.cursor() as cursor: + cursor.execute("RESET ROLE") + + def _upload(self, user, organization: Organization): + client = TenantAPIClient() + client.force_authenticate(user) + return client.post( + f"/api/v1/organizations/{organization.public_id}/integrations/{self.integration.id}/assets/", + {"file": SimpleUploadedFile("icon.png", png(96, 96))}, + format="multipart", + ) + + def test_upload_and_public_download_work_under_app_role(self) -> None: + uploaded = self._as_app_role(lambda: self._upload(self.owner, self.organization)) + self.assertEqual(uploaded.status_code, 201, uploaded.content) + + served = self._as_app_role(lambda: APIClient().get(uploaded.json()["url"])) + + self.assertEqual(served.status_code, 200) + self.assertEqual(b"".join(served.streaming_content), png(96, 96)) + + def test_foreign_organization_cannot_reach_the_widget_under_app_role(self) -> None: + response = self._as_app_role(lambda: self._upload(self.other_owner, self.other)) + + self.assertEqual(response.status_code, 404) diff --git a/apps/backend/chatballs/webchat/urls.py b/apps/backend/chatballs/webchat/urls.py index 8fa5986..9a1a503 100644 --- a/apps/backend/chatballs/webchat/urls.py +++ b/apps/backend/chatballs/webchat/urls.py @@ -1,8 +1,10 @@ from django.urls import path from chatballs.webchat import views +from chatballs.webchat.asset_views import WidgetAssetView urlpatterns = [ + path("assets//", WidgetAssetView.as_view(), name="webchat-asset"), path("config/", views.WebchatConfigView.as_view(), name="webchat-config"), path("session/", views.WebchatSessionView.as_view(), name="webchat-session"), path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"),