mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(webchat): настройки оформления виджета на сервере
config.appearance подключения: цвет #RRGGBB, положение, размер 48/56/64, форма и иконки кнопки, свой CSS до 10 КБ. Из CSS вырезаются @import, внешние url() и expression. Публичная конфигурация отдаёт appearance, config.accent продолжает работать; конфигурация не кэшируется, лоадер — не дольше 5 минут.
This commit is contained in:
1 parent
0f01a8ce47
commit
21a7626f95
11 files changed
+458
-15
No files matched your search
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: T-020
|
||||
title: Настройки оформления на сервере и очистка своего CSS
|
||||
milestone: M04
|
||||
status: done
|
||||
depends_on: []
|
||||
order: 2
|
||||
spec: "0021"
|
||||
created: 2026-09-29
|
||||
branch: skaro/T-020-nastroyki-oformleniya-na-serve
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Сервер хранит и валидирует config.appearance и отдаёт его виджету (R-1, R-6, R-7 серверная часть, R-11).
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [x] PATCH подключения валидирует appearance: accent #RRGGBB, позиция, размер 48/56/64, форма, URL иконок, customCss до 10 КБ
|
||||
- [x] Из customCss вырезаются @import, url() с внешними адресами и expression (тест)
|
||||
- [x] Публичная конфигурация отдаёт appearance; существующий config.accent продолжает работать
|
||||
- [x] Кэш конфигурации и лоадера не задерживает изменения дольше 5 минут
|
||||
|
||||
## Итог
|
||||
|
||||
Сервер хранит и проверяет config.appearance веб-подключения: цвет, положение, размер, форму, иконки и свой CSS до 10 КБ. Из CSS вырезаются @import, внешние url() и expression. Публичная конфигурация отдаёт appearance целиком, config.accent продолжает работать, кэш не задерживает изменения дольше 5 минут. Ветка перенесена на свежую main, конфликты со схемой своих полей разрешены, обе части сохранены.
|
||||
@@ -496,6 +496,13 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.widget_needs_channel": "Portal widget must be bound to a channel",
|
||||
"settings.allowed_origins_list": "allowedOrigins must be a list of strings",
|
||||
"settings.api_key_required": "Custom API key is required",
|
||||
"settings.appearance": "Appearance settings are not recognised",
|
||||
"settings.appearance_accent": "The colour is a HEX value like #1677ff",
|
||||
"settings.appearance_css_too_large": "Custom CSS is limited to 10 KB",
|
||||
"settings.appearance_icon": "The icon must be a link to an uploaded file",
|
||||
"settings.appearance_position": "The button goes on the left or the right",
|
||||
"settings.appearance_shape": "The button shape is a circle, rounded or square",
|
||||
"settings.appearance_size": "The button size is 48, 56 or 64",
|
||||
"settings.custom_base_url_required": "Custom Base URL is required",
|
||||
"settings.custom_model_required": "Custom model is required",
|
||||
"settings.email_hosts_required": "Email address, IMAP host and SMTP host are required",
|
||||
|
||||
@@ -500,6 +500,13 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.widget_needs_channel": "Виджет портала должен быть привязан к каналу",
|
||||
"settings.allowed_origins_list": "allowedOrigins — список строк",
|
||||
"settings.api_key_required": "Укажите API-ключ",
|
||||
"settings.appearance": "Настройки оформления не распознаны",
|
||||
"settings.appearance_accent": "Цвет — HEX вида #1677ff",
|
||||
"settings.appearance_css_too_large": "Свой CSS — не больше 10 КБ",
|
||||
"settings.appearance_icon": "Иконка — ссылка на загруженный файл",
|
||||
"settings.appearance_position": "Кнопка — слева или справа",
|
||||
"settings.appearance_shape": "Форма кнопки — круг, скруглённая или квадрат",
|
||||
"settings.appearance_size": "Размер кнопки — 48, 56 или 64",
|
||||
"settings.custom_base_url_required": "Укажите Base URL",
|
||||
"settings.custom_model_required": "Укажите модель",
|
||||
"settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from chatballs.integrations.models import Integration
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
from chatballs.webchat.appearance import stored_appearance
|
||||
from chatballs.webchat.field_schema import fields_payload
|
||||
|
||||
# Пароль прокси наружу не отдаётся: в списке подключений его видел бы каждый,
|
||||
@@ -86,6 +87,11 @@ def integration_payload(integration: Integration) -> dict[str, object]:
|
||||
"consentVersion": integration.config.get("consent_version", ""),
|
||||
# Свои поля веб-подключения (SPEC-0019).
|
||||
"fields": fields_payload(integration.config.get("fields", [])),
|
||||
"appearance": (
|
||||
stored_appearance(integration.config)
|
||||
if integration.provider == IntegrationProvider.WEB
|
||||
else None
|
||||
),
|
||||
# Email-подключение (ADR-CHATBALLS-0035).
|
||||
"email": integration.config.get("email", ""),
|
||||
"imapHost": integration.config.get("imap_host", ""),
|
||||
|
||||
@@ -22,6 +22,7 @@ from chatballs.integrations.runtime import (
|
||||
advance_revision_after_configuration_change,
|
||||
)
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.webchat.appearance import normalize_appearance
|
||||
from chatballs.webchat.field_schema import normalize_fields
|
||||
|
||||
|
||||
@@ -95,6 +96,7 @@ def _normalized_config(provider: str, config: dict, previous_config: dict | None
|
||||
if provider == IntegrationProvider.EMAIL:
|
||||
return _email_config(config)
|
||||
if provider == IntegrationProvider.WEB:
|
||||
appearance = normalize_appearance(config, previous_config)
|
||||
allowed = config.get("allowedOrigins", config.get("allowed_domains", []))
|
||||
if not isinstance(allowed, list) or not all(isinstance(item, str) for item in allowed):
|
||||
raise ValidationError({"config": t("settings.allowed_origins_list")})
|
||||
@@ -106,7 +108,9 @@ def _normalized_config(provider: str, config: dict, previous_config: dict | None
|
||||
return {
|
||||
"allowed_domains": [item.strip() for item in allowed if item.strip()],
|
||||
"title": str(config.get("title", "")).strip(),
|
||||
"accent": str(config.get("accent", "")).strip(),
|
||||
# Прежнее место цвета: его читают виджеты, сохранённые до appearance.
|
||||
"accent": appearance["accent"],
|
||||
"appearance": appearance,
|
||||
"greeting": str(config.get("greeting", "")).strip(),
|
||||
"quick_replies": quick_replies,
|
||||
"consent_text": str(config.get("consentText", config.get("consent_text", ""))).strip(),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Оформление веб-виджета: ``integration.config.appearance`` (SPEC-0021 R-1).
|
||||
|
||||
Иконки: пустая строка — стандартный знак агента; у шапки пустая строка —
|
||||
«как у кнопки», ``None`` — без иконки.
|
||||
"""
|
||||
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.webchat.custom_css import sanitize_custom_css
|
||||
|
||||
DEFAULT_ACCENT = "#1677ff"
|
||||
POSITIONS = ("left", "right")
|
||||
SIZES = (48, 56, 64)
|
||||
SHAPES = ("circle", "rounded", "square")
|
||||
MAX_CUSTOM_CSS_BYTES = 10 * 1024
|
||||
MAX_ICON_URL_LENGTH = 2048
|
||||
|
||||
DEFAULTS = {
|
||||
"accent": "",
|
||||
"launcherIcon": "",
|
||||
"headerIcon": "",
|
||||
"launcherPosition": "right",
|
||||
"launcherSize": 56,
|
||||
"launcherShape": "circle",
|
||||
"customCss": "",
|
||||
}
|
||||
|
||||
_HEX = re.compile(r"#[0-9a-f]{6}")
|
||||
_URL_NOISE = re.compile(r"[\x00-\x20\x7f\\]")
|
||||
|
||||
|
||||
def _invalid(key: str) -> ValidationError:
|
||||
return ValidationError({"config": t(key)})
|
||||
|
||||
|
||||
def _accent(value: object) -> str:
|
||||
accent = str(value or "").strip().lower()
|
||||
if accent and not _HEX.fullmatch(accent):
|
||||
raise _invalid("settings.appearance_accent")
|
||||
return accent
|
||||
|
||||
|
||||
def _choice(value: object, allowed: tuple, error: str) -> object:
|
||||
if isinstance(value, bool) or value not in allowed:
|
||||
raise _invalid(error)
|
||||
return allowed[allowed.index(value)]
|
||||
|
||||
|
||||
def _icon(value: object, *, nullable: bool) -> str | None:
|
||||
if value is None:
|
||||
return None if nullable else ""
|
||||
if not isinstance(value, str):
|
||||
raise _invalid("settings.appearance_icon")
|
||||
url = value.strip()
|
||||
if not url:
|
||||
return ""
|
||||
parsed = urlsplit(url)
|
||||
public = parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
# Путь от корня — файл в хранилище на этом же сервере; «//host» — уже чужой адрес.
|
||||
local = not parsed.scheme and url.startswith("/") and not url.startswith("//")
|
||||
if len(url) > MAX_ICON_URL_LENGTH or _URL_NOISE.search(url) or not (public or local):
|
||||
raise _invalid("settings.appearance_icon")
|
||||
return url
|
||||
|
||||
|
||||
def _custom_css(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
raise _invalid("settings.appearance")
|
||||
if len(value.encode("utf-8")) > MAX_CUSTOM_CSS_BYTES:
|
||||
raise _invalid("settings.appearance_css_too_large")
|
||||
return sanitize_custom_css(value).strip()
|
||||
|
||||
|
||||
def stored_appearance(config: dict | None) -> dict:
|
||||
"""Сохранённое оформление с умолчаниями; цвет — и из прежнего ``config.accent``."""
|
||||
config = config if isinstance(config, dict) else {}
|
||||
stored = config.get("appearance")
|
||||
appearance = {**DEFAULTS, **(stored if isinstance(stored, dict) else {})}
|
||||
appearance["accent"] = appearance["accent"] or str(config.get("accent") or "")
|
||||
return appearance
|
||||
|
||||
|
||||
def normalize_appearance(config: dict, previous: dict | None = None) -> dict:
|
||||
"""Оформление из ``config`` запроса.
|
||||
|
||||
Форма подключения отправляет config целиком, но без ``appearance`` —
|
||||
тогда оформление остаётся прежним, меняется только цвет из ``config.accent``.
|
||||
"""
|
||||
raw = config.get("appearance")
|
||||
if raw is None:
|
||||
appearance = stored_appearance(previous)
|
||||
if "accent" in config:
|
||||
appearance["accent"] = _accent(config["accent"])
|
||||
return appearance
|
||||
if not isinstance(raw, dict):
|
||||
raise _invalid("settings.appearance")
|
||||
return {
|
||||
"accent": _accent(raw["accent"] if "accent" in raw else config.get("accent")),
|
||||
"launcherIcon": _icon(raw.get("launcherIcon"), nullable=False),
|
||||
"headerIcon": _icon(raw.get("headerIcon", ""), nullable=True),
|
||||
"launcherPosition": _choice(
|
||||
raw.get("launcherPosition", DEFAULTS["launcherPosition"]),
|
||||
POSITIONS,
|
||||
"settings.appearance_position",
|
||||
),
|
||||
"launcherSize": _choice(
|
||||
raw.get("launcherSize", DEFAULTS["launcherSize"]),
|
||||
SIZES,
|
||||
"settings.appearance_size",
|
||||
),
|
||||
"launcherShape": _choice(
|
||||
raw.get("launcherShape", DEFAULTS["launcherShape"]),
|
||||
SHAPES,
|
||||
"settings.appearance_shape",
|
||||
),
|
||||
"customCss": _custom_css(raw.get("customCss")),
|
||||
}
|
||||
|
||||
|
||||
def public_appearance(presentation: dict) -> dict:
|
||||
"""Оформление для виджета: умолчания подставлены, иконка шапки разрешена."""
|
||||
appearance = stored_appearance(presentation)
|
||||
appearance["accent"] = appearance["accent"] or DEFAULT_ACCENT
|
||||
if appearance["headerIcon"] == "":
|
||||
appearance["headerIcon"] = appearance["launcherIcon"]
|
||||
return appearance
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Очистка своего CSS виджета (SPEC-0021 R-7).
|
||||
|
||||
CSS вставляется ``<style>`` внутри iframe чата. Сервер вырезает то, чем из
|
||||
стилей можно подгрузить чужое или выполнить код: ``@import``, ``url(...)`` с
|
||||
внешним адресом и ``expression(...)``. CSP страницы чата — второй рубеж, а не
|
||||
единственный.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Экранирование буквы (``\\69`` → «i», ``\\m`` → «m») браузер раскрывает до
|
||||
# разбора, и ``@\\69mport`` работает как ``@import``. Раскрываем только буквы:
|
||||
# экраны цифр и знаков (``.\\31 23``, ``.hover\\:x``) значимы для селекторов.
|
||||
_HEX_ESCAPE = re.compile(r"\\([0-9a-fA-F]{1,6})[ \t\r\n\f]?")
|
||||
_LETTER_ESCAPE = re.compile(r"\\([g-zG-Z])")
|
||||
|
||||
_IMPORT = re.compile(r"""@import(?:"[^"\n]*"?|'[^'\n]*'?|[^;{}\n"'])*;?""", re.IGNORECASE)
|
||||
_EXPRESSION = re.compile(r"expression\s*\([^)]*\)?", re.IGNORECASE)
|
||||
_URL = re.compile(
|
||||
r"""url\(\s*("(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?|[^)]*?)\s*(?:\)|$)""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Пробелы и управляющие символы парсер адресов выкидывает: «/\t/evil» — это «//evil».
|
||||
_URL_NOISE = re.compile(r"[\x00-\x20\x7f]")
|
||||
_SCHEME = re.compile(r"^[a-z][a-z0-9+.\-]*:", re.IGNORECASE)
|
||||
|
||||
|
||||
def _unescape_letters(css: str) -> str:
|
||||
def hex_letter(match: re.Match) -> str:
|
||||
code = int(match.group(1), 16)
|
||||
char = chr(code) if code < 0x80 else ""
|
||||
return char if char.isalpha() else match.group(0)
|
||||
|
||||
return _LETTER_ESCAPE.sub(r"\1", _HEX_ESCAPE.sub(hex_letter, css))
|
||||
|
||||
|
||||
def _is_local_url(raw: str) -> bool:
|
||||
"""Адрес не уводит за пределы страницы чата: якорь, путь или data:image."""
|
||||
target = _URL_NOISE.sub("", raw.strip("\"'"))
|
||||
if "\\" in target:
|
||||
return False
|
||||
if target.startswith("//"):
|
||||
return False
|
||||
if _SCHEME.match(target):
|
||||
return target.lower().startswith("data:image/")
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_custom_css(css: str) -> str:
|
||||
css = _unescape_letters(css)
|
||||
css = _IMPORT.sub("", css)
|
||||
css = _EXPRESSION.sub("", css)
|
||||
css = _URL.sub(lambda match: match.group(0) if _is_local_url(match.group(1)) else "", css)
|
||||
# Закрывающий </style> не должен выйти из тега, как бы виджет ни вставлял CSS.
|
||||
return css.replace("<", "\\3c ")
|
||||
@@ -26,10 +26,9 @@ from chatballs.identity.instance_settings import default_language
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.webchat.appearance import public_appearance
|
||||
from chatballs.webchat.models import WebChatWidget, WebSession
|
||||
|
||||
DEFAULT_ACCENT = "#1677ff"
|
||||
|
||||
_STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSED: "waiting"}
|
||||
_ROLE = {"CONTACT": "client", "AI": "ai", "OPERATOR": "operator", "SYSTEM": "system"}
|
||||
|
||||
@@ -105,6 +104,7 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
|
||||
fallback.append({"label": t("webchat.write_in_telegram"), "url": f"https://t.me/{username}"})
|
||||
elif sib.provider == IntegrationProvider.MAX and username:
|
||||
fallback.append({"label": t("webchat.write_in_max"), "url": ""})
|
||||
appearance = public_appearance(cfg)
|
||||
return {
|
||||
"available": True,
|
||||
"widgetKey": widget.public_key,
|
||||
@@ -115,7 +115,8 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
|
||||
# Что разрешено в этой точке входа: виджет прячет микрофон при запрете.
|
||||
"features": features_payload(integration),
|
||||
"title": cfg.get("title") or channel.name,
|
||||
"accent": cfg.get("accent") or DEFAULT_ACCENT,
|
||||
"accent": appearance["accent"],
|
||||
"appearance": appearance,
|
||||
"greeting": cfg.get("greeting") or t("webchat.default_greeting", language=language),
|
||||
"consent": {
|
||||
"text": consent.get("consent_text") or t("webchat.default_consent", language=language),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration
|
||||
from chatballs.testing import system_tenant_context
|
||||
from chatballs.webchat.custom_css import sanitize_custom_css
|
||||
|
||||
|
||||
class CustomCssSanitizingTests(SimpleTestCase):
|
||||
def test_import_is_cut(self) -> None:
|
||||
for css in (
|
||||
'@import "https://evil.example/x.css";',
|
||||
"@import url(https://evil.example/x.css) screen;",
|
||||
"@IMPORT 'x.css';",
|
||||
"@\\69mport 'x.css';",
|
||||
):
|
||||
with self.subTest(css=css):
|
||||
self.assertNotIn("import", sanitize_custom_css(css + "\n.cb-header{color:red}").lower())
|
||||
self.assertIn(".cb-header{color:red}", sanitize_custom_css("@import 'x.css';\n.cb-header{color:red}"))
|
||||
|
||||
def test_external_url_is_cut(self) -> None:
|
||||
for target in (
|
||||
"https://evil.example/a.png",
|
||||
"'http://evil.example/a.png'",
|
||||
'"//evil.example/a.png"',
|
||||
"javascript:alert(1)",
|
||||
'"/\t/evil.example/a.png"',
|
||||
"/\\evil.example/a.png",
|
||||
"https://evil.example/a.png",
|
||||
):
|
||||
with self.subTest(target=target):
|
||||
css = f".cb-body{{background:url({target})}}"
|
||||
self.assertNotIn("evil", sanitize_custom_css(css))
|
||||
self.assertNotIn("javascript", sanitize_custom_css(css))
|
||||
escaped = sanitize_custom_css(".cb-body{background:\\75 rl(https://evil.example/a.png)}")
|
||||
self.assertNotIn("evil", escaped)
|
||||
unterminated = sanitize_custom_css(".cb-body{background:url(https://evil.example/a.png")
|
||||
self.assertNotIn("evil", unterminated)
|
||||
|
||||
def test_local_url_is_kept(self) -> None:
|
||||
css = (
|
||||
".a{background:url(#grad)}"
|
||||
".b{background:url('/media/organizations/x/bg.png')}"
|
||||
".c{background:url(data:image/png;base64,AAAA)}"
|
||||
".d{background:url(img/bg.png)}"
|
||||
)
|
||||
self.assertEqual(sanitize_custom_css(css), css)
|
||||
|
||||
def test_expression_is_cut(self) -> None:
|
||||
cleaned = sanitize_custom_css(".cb-header{width:expression(alert(1));color:red}")
|
||||
self.assertNotIn("expression", cleaned)
|
||||
self.assertIn("color:red", cleaned)
|
||||
self.assertNotIn("expression", sanitize_custom_css(".x{width:e\\78pression(alert(1))}"))
|
||||
|
||||
def test_style_tag_cannot_be_closed(self) -> None:
|
||||
cleaned = sanitize_custom_css('.x{content:"</style><script>alert(1)</script>"}')
|
||||
self.assertNotIn("<", cleaned)
|
||||
|
||||
def test_regular_css_is_untouched_and_sanitizing_is_stable(self) -> None:
|
||||
css = '.cb-header{background:#0d8a7e!important}\n.hover\\:x > .\\31 23{content:"\\2014"}'
|
||||
self.assertEqual(sanitize_custom_css(css), css)
|
||||
once = sanitize_custom_css('.x{content:"<"}')
|
||||
self.assertEqual(sanitize_custom_css(once), once)
|
||||
|
||||
|
||||
class WebAppearanceApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(self.organization)
|
||||
self.channel = Channel.objects.create(organization=self.organization, code="site", name="Сайт")
|
||||
self.integration = create_integration(
|
||||
context=self.context,
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.WEB,
|
||||
name="Виджет",
|
||||
channel_id=self.channel.id,
|
||||
config={"allowedOrigins": ["example.com"], "accent": "#0d8a7e"},
|
||||
),
|
||||
)
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _url(self) -> str:
|
||||
return f"/api/v1/organizations/{self.organization.public_id}/integrations/{self.integration.id}/"
|
||||
|
||||
def _patch(self, config: dict):
|
||||
return self.client.patch(
|
||||
self._url(),
|
||||
data={"config": {"allowedOrigins": ["example.com"], **config}},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def _public_config(self) -> dict:
|
||||
response = self.client.get(
|
||||
"/api/v1/webchat/config/",
|
||||
{"widgetKey": self.integration.web_chat_widget.public_key},
|
||||
HTTP_ORIGIN="https://example.com",
|
||||
)
|
||||
self.assertEqual(response["Cache-Control"], "no-cache")
|
||||
return response.json()
|
||||
|
||||
def test_valid_appearance_is_saved_and_published(self) -> None:
|
||||
appearance = {
|
||||
"accent": "#FFD400",
|
||||
"launcherIcon": "/media/organizations/x/webchat/icon.svg",
|
||||
"headerIcon": None,
|
||||
"launcherPosition": "left",
|
||||
"launcherSize": 64,
|
||||
"launcherShape": "rounded",
|
||||
"customCss": "@import 'x.css';\n.cb-header{background:url(https://evil.example/a.png);color:red}",
|
||||
}
|
||||
response = self._patch({"appearance": appearance})
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
saved = response.json()["integration"]["config"]
|
||||
self.assertEqual(saved["accent"], "#ffd400")
|
||||
self.assertEqual(saved["appearance"]["launcherPosition"], "left")
|
||||
self.assertEqual(saved["appearance"]["launcherSize"], 64)
|
||||
self.assertIsNone(saved["appearance"]["headerIcon"])
|
||||
self.assertEqual(saved["appearance"]["customCss"], ".cb-header{background:;color:red}")
|
||||
|
||||
public = self._public_config()
|
||||
self.assertEqual(public["accent"], "#ffd400")
|
||||
self.assertEqual(
|
||||
public["appearance"],
|
||||
{
|
||||
"accent": "#ffd400",
|
||||
"launcherIcon": "/media/organizations/x/webchat/icon.svg",
|
||||
"headerIcon": None,
|
||||
"launcherPosition": "left",
|
||||
"launcherSize": 64,
|
||||
"launcherShape": "rounded",
|
||||
"customCss": ".cb-header{background:;color:red}",
|
||||
},
|
||||
)
|
||||
|
||||
def test_invalid_appearance_is_rejected(self) -> None:
|
||||
for appearance in (
|
||||
{"accent": "blue"},
|
||||
{"accent": "#12345"},
|
||||
{"launcherPosition": "top"},
|
||||
{"launcherSize": 50},
|
||||
{"launcherSize": True},
|
||||
{"launcherShape": "oval"},
|
||||
{"launcherIcon": "javascript:alert(1)"},
|
||||
{"launcherIcon": "//evil.example/icon.svg"},
|
||||
{"headerIcon": "data:image/svg+xml,<svg/>"},
|
||||
{"customCss": "a{}" * 4000},
|
||||
):
|
||||
with self.subTest(appearance=appearance):
|
||||
response = self._patch({"appearance": appearance})
|
||||
self.assertEqual(response.status_code, 400, response.content)
|
||||
self.assertEqual(self._patch({"accent": "not-a-colour"}).status_code, 400)
|
||||
self.integration.refresh_from_db()
|
||||
self.assertEqual(self.integration.config["accent"], "#0d8a7e")
|
||||
|
||||
def test_custom_css_limit_is_10_kb(self) -> None:
|
||||
exactly = "a" * (10 * 1024)
|
||||
self.assertEqual(self._patch({"appearance": {"customCss": exactly}}).status_code, 200)
|
||||
self.assertEqual(self._patch({"appearance": {"customCss": exactly + "a"}}).status_code, 400)
|
||||
|
||||
def test_legacy_accent_keeps_working(self) -> None:
|
||||
# Виджет, сохранённый до appearance: цвет только в config.accent.
|
||||
Integration.objects.filter(id=self.integration.id).update(
|
||||
config={"allowed_domains": ["example.com"], "accent": "#be123c"}
|
||||
)
|
||||
widget = self.integration.web_chat_widget
|
||||
widget.presentation_config = {"accent": "#be123c"}
|
||||
widget.save(update_fields=["presentation_config"])
|
||||
|
||||
public = self._public_config()
|
||||
self.assertEqual(public["accent"], "#be123c")
|
||||
self.assertEqual(public["appearance"]["accent"], "#be123c")
|
||||
self.assertEqual(public["appearance"]["launcherPosition"], "right")
|
||||
self.assertEqual(public["appearance"]["launcherSize"], 56)
|
||||
self.assertEqual(public["appearance"]["launcherShape"], "circle")
|
||||
|
||||
# Прежняя форма шлёт config без appearance: оформление не сбрасывается.
|
||||
self._patch({"appearance": {"launcherPosition": "left", "headerIcon": "https://cdn.example/h.png"}})
|
||||
response = self._patch({"accent": "#4f46e5", "title": "Поддержка"})
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
public = self._public_config()
|
||||
self.assertEqual(public["accent"], "#4f46e5")
|
||||
self.assertEqual(public["appearance"]["launcherPosition"], "left")
|
||||
self.assertEqual(public["appearance"]["headerIcon"], "https://cdn.example/h.png")
|
||||
|
||||
def test_header_icon_defaults_to_launcher_icon(self) -> None:
|
||||
self._patch({"appearance": {"launcherIcon": "https://cdn.example/l.svg"}})
|
||||
public = self._public_config()
|
||||
self.assertEqual(public["appearance"]["headerIcon"], "https://cdn.example/l.svg")
|
||||
|
||||
def test_loader_is_cached_no_longer_than_five_minutes(self) -> None:
|
||||
response = self.client.get("/chat-widget.js")
|
||||
self.assertEqual(response["Cache-Control"], "public, max-age=300")
|
||||
@@ -36,6 +36,8 @@ from chatballs.webchat.throttling import (
|
||||
WebchatTrafficThrottle,
|
||||
)
|
||||
|
||||
LOADER_MAX_AGE_SECONDS = 300
|
||||
|
||||
|
||||
class _Public(APIView):
|
||||
authentication_classes: list = [] # публичные endpoint'ы: токен сессии, без CSRF/сессии Django
|
||||
@@ -117,14 +119,19 @@ class WebchatConfigView(_Public):
|
||||
channel_code = request.GET.get("channel", "")
|
||||
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
|
||||
if context is None or widget is None:
|
||||
return Response({"available": False})
|
||||
return Response(
|
||||
services.public_config(
|
||||
context=context,
|
||||
widget=widget,
|
||||
origin=host_origin(request),
|
||||
response = Response({"available": False})
|
||||
else:
|
||||
response = Response(
|
||||
services.public_config(
|
||||
context=context,
|
||||
widget=widget,
|
||||
origin=host_origin(request),
|
||||
)
|
||||
)
|
||||
)
|
||||
# Оформление меняют в админке, и сайт должен увидеть его на следующей
|
||||
# загрузке страницы (SPEC-0021 R-11). Ответ к тому же зависит от Origin.
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
class WebchatSessionView(_Public):
|
||||
@@ -309,5 +316,6 @@ class WebchatCallDeclineView(_PublicSession):
|
||||
class WidgetLoaderView(View):
|
||||
def get(self, request) -> HttpResponse:
|
||||
response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
|
||||
response["Cache-Control"] = "public, max-age=300"
|
||||
# Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
|
||||
response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
|
||||
return response
|
||||
@@ -40,8 +40,8 @@ def ensure_widget(integration: Integration) -> WebChatWidget | None:
|
||||
config = integration.config if isinstance(integration.config, dict) else {}
|
||||
presentation = {
|
||||
key: config[key]
|
||||
for key in ("title", "accent", "greeting", "quick_replies")
|
||||
if config.get(key) not in (None, "", [])
|
||||
for key in ("title", "accent", "greeting", "quick_replies", "appearance")
|
||||
if config.get(key) not in (None, "", [], {})
|
||||
}
|
||||
fields = public_fields(config.get("fields", []))
|
||||
if fields:
|
||||
|
||||
Reference in new issue
Block a user