mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(web-chat): загрузка иконок виджета с очисткой SVG
POST /api/v1/organizations/{id}/integrations/{id}/assets/ принимает SVG или
PNG до 256 КБ (PNG не меньше 96×96) и возвращает { url }. SVG очищается до
сохранения; файл лежит в хранилище организации на диске или в S3 и
отдаётся виджету без сессии по непредсказуемому UUID.
This commit is contained in:
1 parent
cc091dbd03
commit
811a164c44
17 files changed
+915
No files matched your search
@@ -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/<uuid>/). Модель WidgetAsset защищена RLS, триггерами связей и каталогом входа. При удалении интеграции её иконки стираются.
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Отправлено приглашение владельцу",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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("<int:integration_id>/", views.IntegrationDetailView.as_view(), name="integration-detail"),
|
||||
path("<int:integration_id>/test/", views.IntegrationTestView.as_view(), name="integration-test"),
|
||||
path("<int:integration_id>/assets/", WidgetAssetUploadView.as_view(), name="integration-widget-assets"),
|
||||
]
|
||||
@@ -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("."))
|
||||
|
||||
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
|
||||
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#)", re.IGNORECASE)
|
||||
_UNSAFE_SCHEMES = ("javascript:", "vbscript:", "data:text")
|
||||
|
||||
|
||||
class UnsafeSvg(ValueError):
|
||||
"""SVG не разбирается или не может быть очищен."""
|
||||
|
||||
|
||||
def _local(name: str) -> 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 браузер в <img> не рисует: переводим его в пространство 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)
|
||||
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 64 64" onload="alert('load')">
|
||||
<script type="text/javascript">alert('script')</script>
|
||||
<foreignObject width="10" height="10">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" onclick="alert('html')">x</div>
|
||||
</foreignObject>
|
||||
<defs><circle id="dot" r="4"/></defs>
|
||||
<a xlink:href="javascript:alert('link')"><rect width="8" height="8" onclick="alert('click')"/></a>
|
||||
<a href="java	script:alert('tab')"><rect width="4" height="4"/></a>
|
||||
<image href="https://evil.example/pixel.png" width="1" height="1"/>
|
||||
<image xlink:href="data:image/svg+xml;base64,PHN2Zy8+" width="1" height="1"/>
|
||||
<use xlink:href="#dot" x="32" y="32"/>
|
||||
<use href="https://evil.example/sprite.svg#icon"/>
|
||||
<set attributeName="href" to="javascript:alert('smil')"/>
|
||||
<animate attributeName="onclick" values="alert('smil')"/>
|
||||
<animate attributeName="opacity" values="0;1" dur="1s"/>
|
||||
<rect width="16" height="16" fill="url(#dot)" style="fill:url(https://evil.example/x)"/>
|
||||
<style>@import url(https://evil.example/a.css); .mark { fill: #1677ff; }</style>
|
||||
<circle class="mark" cx="32" cy="32" r="30"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
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 (
|
||||
"<script",
|
||||
"foreignobject",
|
||||
"onload",
|
||||
"onclick",
|
||||
"javascript:",
|
||||
"evil.example",
|
||||
"data:",
|
||||
"@import",
|
||||
"alert(",
|
||||
):
|
||||
with self.subTest(forbidden):
|
||||
self.assertNotIn(forbidden, lowered)
|
||||
|
||||
def test_picture_and_local_references_stay(self) -> None:
|
||||
cleaned = sanitize_svg(HARMFUL_SVG).decode("utf-8")
|
||||
|
||||
self.assertIn('viewBox="0 0 64 64"', cleaned)
|
||||
self.assertIn('<circle class="mark" cx="32" cy="32" r="30" />', cleaned)
|
||||
self.assertIn('xlink:href="#dot"', cleaned)
|
||||
self.assertIn('fill="url(#dot)"', cleaned)
|
||||
self.assertIn('attributeName="opacity"', cleaned)
|
||||
self.assertTrue(cleaned.startswith("<?xml"))
|
||||
self.assertIn('<svg xmlns="http://www.w3.org/2000/svg"', cleaned)
|
||||
|
||||
def test_svg_without_namespace_gets_it(self) -> None:
|
||||
cleaned = sanitize_svg(b'<svg viewBox="0 0 4 4"><path d="M0 0h4v4z"/></svg>')
|
||||
|
||||
self.assertIn(b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4">', cleaned)
|
||||
self.assertIn(b'<path d="M0 0h4v4z" />', cleaned)
|
||||
|
||||
def test_unreadable_svg_is_refused(self) -> None:
|
||||
samples = {
|
||||
"doctype": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
|
||||
"not svg": b"<html><body>hi</body></html>",
|
||||
"foreign root": b'<svg xmlns="http://evil.example/ns"/>',
|
||||
}
|
||||
for name, sample in samples.items():
|
||||
with self.subTest(name), self.assertRaises(UnsafeSvg):
|
||||
sanitize_svg(sample)
|
||||
@@ -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"<script", b"foreignobject", b"onload", b"onclick", b"javascript:", b"evil.example"):
|
||||
self.assertNotIn(forbidden, body)
|
||||
self.assertIn(b'<circle class="mark"', body)
|
||||
|
||||
def test_png_from_96_pixels_is_stored_as_is(self) -> 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"<html><script>alert(1)</script></html>", "Поддерживаются SVG и PNG"),
|
||||
"broken svg": ("icon.svg", b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>', "Не удалось прочитать 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"<script", b"".join(served.streaming_content))
|
||||
|
||||
def test_deleting_widget_integration_removes_icon_files(self) -> 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)
|
||||
@@ -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)
|
||||
@@ -1,8 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.webchat import views
|
||||
from chatballs.webchat.asset_views import WidgetAssetView
|
||||
|
||||
urlpatterns = [
|
||||
path("assets/<uuid:public_id>/", 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"),
|
||||
|
||||
Reference in new issue
Block a user