Compare commits

...
3 Commits
Author SHA1 Message Date
AndreyandClaude Fable 5.1 29164db094 🔖 release: 1.4.0
SVG-логотип организации; воркер больше не засоряет журнал сбоями опроса
подключений и не опрашивает демо-подключения.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:55 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
22 changed files with 381 additions and 26 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.3.1
1.4.0
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
channel__is_active=True,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
and not integration.config.get("demoSeed")
]
total = 0
for integration in integrations:
@@ -15,6 +15,7 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
from chatballs.conversations.selectors import conversation_messages
from chatballs.conversations.serializers import conversation_payload, message_payload
from chatballs.conversations.transports import email as email_transport
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
@@ -359,11 +360,11 @@ class EmailPollTests(TestCase):
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
messages, marker = email_transport.poll_updates(integration)
with self.assertRaises(PollFailed):
email_transport.poll_updates(integration)
self.assertEqual(messages, [])
self.assertEqual(marker, "7:99")
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
self.assertEqual(integration.poll_marker, "7:99")
@@ -0,0 +1,83 @@
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
первый сбой, выход на максимальную паузу и восстановление, а курсор при
сбое не двигается. Демо-подключения не опрашиваются вовсе.
"""
from __future__ import annotations
from unittest import mock
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations import poller
from chatballs.conversations.transports import backoff, poll
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
class PollBackoffTests(TestCase):
def setUp(self) -> None:
backoff.reset()
self.addCleanup(backoff.reset)
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
with tenant_atomic(self.organization.id):
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
self.integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Bot",
secret="0000:not-a-token",
channel=self.channel,
poll_marker="41",
)
def _fail(self, *_args, **_kwargs):
raise PollFailed("HTTP Error 401: Unauthorized")
def test_failure_is_logged_once_and_then_skipped(self) -> None:
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
self.assertEqual(poll(self.integration), ([], "41"))
self.assertEqual(len(logs.output), 1)
self.assertIn("401", logs.output[0])
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
self.assertEqual(poll(self.integration), ([], "41"))
self.assertTrue(backoff.should_skip(self.integration.id))
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
clock = [1000.0]
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
delays = []
for _ in range(10):
poll(self.integration)
state = backoff._failures[self.integration.id]
delays.append(state.delay)
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
self.assertEqual(poll(self.integration), ([], "42"))
self.assertIn("recovered", logs.output[0])
self.assertNotIn(self.integration.id, backoff._failures)
def test_demo_seed_connections_are_not_polled(self) -> None:
with tenant_atomic(self.organization.id):
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
context = TenantContext.for_resource(self.organization)
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
poller.poll_all_messengers(context)
polled.assert_not_called()
@@ -1,6 +1,8 @@
from chatballs.conversations.transports import backoff
from chatballs.conversations.transports import email as _email
from chatballs.conversations.transports import max as _max
from chatballs.conversations.transports import telegram as _telegram
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import t
from chatballs.integrations.models import IntegrationProvider
@@ -51,7 +53,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
def poll(integration):
return _POLL[integration.provider](integration)
"""Опрос подключения с паузой после сбоя (transports.backoff).
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
подключение пропускается с растущей паузой, а журнал видит только смену
состояния. Курсор при сбое не двигается.
"""
if backoff.should_skip(integration.id):
return [], integration.poll_marker
try:
result = _POLL[integration.provider](integration)
except PollFailed as error:
backoff.record_failure(integration, error)
return [], integration.poll_marker
backoff.record_success(integration)
return result
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
@@ -0,0 +1,76 @@
"""Пауза между попытками опроса подключения после сбоя.
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
подключение пропускается с растущей паузой, а в журнал попадают только
изменения состояния: первый сбой, выход на максимальную паузу и
восстановление.
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
попытка всё равно нужна.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
FIRST_DELAY_SECONDS = 6.0
MAX_DELAY_SECONDS = 900.0
@dataclass
class _Failure:
failures: int
next_attempt_at: float
delay: float
_failures: dict[int, _Failure] = {}
def _now() -> float:
return time.monotonic()
def should_skip(integration_id: int) -> bool:
state = _failures.get(integration_id)
return state is not None and _now() < state.next_attempt_at
def record_failure(integration, error: object) -> None:
previous = _failures.get(integration.id)
failures = (previous.failures if previous else 0) + 1
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
if failures == 1:
logger.warning(
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
integration.provider, integration.id, error, delay,
)
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
logger.warning(
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
)
def record_success(integration) -> None:
state = _failures.pop(integration.id, None)
if state is not None:
logger.info(
"%s poll recovered for integration %s after %s failure(s)",
integration.provider, integration.id, state.failures,
)
def reset() -> None:
"""Для тестов: забыть все сбои."""
_failures.clear()
@@ -26,6 +26,7 @@ from chatballs.conversations.transports.base import (
guess_content_type,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
logger = logging.getLogger(__name__)
@@ -135,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
client = _imap_connect(integration)
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
try:
client.select("INBOX", readonly=True)
validity = _status_value(client, "UIDVALIDITY")
@@ -162,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
return messages, new_marker
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
finally:
try:
client.logout()
@@ -0,0 +1,6 @@
class PollFailed(Exception):
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
записать, принимает слой пауз между попытками (``transports.backoff``).
"""
@@ -27,6 +27,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -242,8 +243,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
updates = data.get("updates") or []
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
new_marker = data.get("marker")
@@ -23,6 +23,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -124,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
if not data.get("ok"):
return [], integration.poll_marker
updates = data.get("result") or []
+2 -1
View File
@@ -355,7 +355,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: a record identifier is expected",
"settings.storage_unavailable": "The storage is unavailable: {error}",
"admin.actor_system": "System",
"admin.image_formats": "PNG, JPEG and WebP are supported",
"admin.image_formats": "PNG, JPEG, WebP and SVG are supported",
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
"admin.logo_too_large": "The logo must not exceed 2 MB",
"calls.calls_off_entry_point": "Calls are switched off for this entry point",
"calls.failure_code_required": "FAILED needs a normalized failure_code",
+2 -1
View File
@@ -359,7 +359,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: ожидается идентификатор записи",
"settings.storage_unavailable": "Хранилище недоступно: {error}",
"admin.actor_system": "Система",
"admin.image_formats": "Поддерживаются PNG, JPEG и WebP",
"admin.image_formats": "Поддерживаются PNG, JPEG, WebP и SVG",
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
"admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ",
"calls.calls_off_entry_point": "Звонки отключены для этой точки входа",
"calls.failure_code_required": "Для FAILED требуется нормализованный failure_code",
@@ -11,6 +11,7 @@ from django.db import transaction
from chatballs.i18n import t
from chatballs.i18n.languages import normalize_language
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
@@ -85,6 +86,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
return "image/jpeg", ".jpg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp", ".webp"
if looks_like_svg(data):
return SVG_CONTENT_TYPE, ".svg"
return None
@@ -102,6 +105,10 @@ def replace_organization_logo(
detected = _image_type(data)
if detected is None:
raise ValidationError({"file": t("admin.image_formats")})
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
# файл отклоняется целиком, а не переписывается молча.
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
content_type, suffix = detected
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
@@ -107,11 +107,18 @@ class OrganizationLogoView(APIView):
organization = request.tenant_context.organization
if not organization.logo:
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
return FileResponse(
response = FileResponse(
organization.logo.open("rb"),
content_type=organization.logo_content_type or "application/octet-stream",
filename="organization-logo",
)
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
# браузеру нельзя угадывать тип.
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
response["X-Content-Type-Options"] = "nosniff"
return response
def post(self, request: Request) -> Response:
upload = request.FILES.get("file")
@@ -115,7 +115,8 @@ def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, curre
provider=item["provider"],
name=item["name"],
secret=item.get("secret", ""),
config=item.get("config", {}),
# Токены демо-подключений ненастоящие: воркер их не опрашивает.
config={**item.get("config", {}), "demoSeed": True},
),
)
Integration.objects.filter(pk=integration.pk).update(
@@ -0,0 +1,72 @@
"""Проверка SVG-логотипа организации.
SVG — это XML с возможностью исполнять скрипты и тянуть внешние ресурсы.
Логотип отдаётся с адреса самого приложения, поэтому опасный файл принимать
нельзя даже с защитными заголовками при отдаче: файл проверяется при загрузке
и отклоняется целиком, а не «чистится» — переписывать чужую графику молча
хуже, чем попросить другой файл.
Отклоняется: DOCTYPE и сущности, элементы script/foreignObject/iframe/
embed/object/audio/video, атрибуты-обработчики on*, ссылки javascript: и
data:text, внешние адреса в href/xlink:href и в url() внутри стилей.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
SVG_CONTENT_TYPE = "image/svg+xml"
_FORBIDDEN_TAGS = frozenset({"script", "foreignobject", "iframe", "embed", "object", "audio", "video"})
_HREF_ATTRIBUTES = frozenset({"href", "{http://www.w3.org/1999/xlink}href"})
_DECLARATION = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#|data:image/)", re.IGNORECASE)
def looks_like_svg(data: bytes) -> bool:
head = data.lstrip(b"\xef\xbb\xbf \t\r\n")[:4096].lower()
if head.startswith(b"<svg"):
return True
return head.startswith((b"<?xml", b"<!--")) and b"<svg" in head
def _local(name: str) -> str:
return name.rsplit("}", 1)[-1].lower()
def _dangerous_value(value: str) -> bool:
compact = re.sub(r"\s+", "", value).lower()
return compact.startswith("javascript:") or compact.startswith("data:text") or compact.startswith("vbscript:")
def svg_is_safe(data: bytes) -> bool:
if _DECLARATION.search(data):
return False
try:
root = ET.fromstring(data)
except ET.ParseError:
return False
if _local(root.tag) != "svg":
return False
for element in root.iter():
tag = _local(element.tag) if isinstance(element.tag, str) else ""
if tag in _FORBIDDEN_TAGS:
return False
for name, value in element.attrib.items():
local = _local(name)
if local.startswith("on"):
return False
if _dangerous_value(value):
return False
if name in _HREF_ATTRIBUTES or local == "href":
stripped = value.strip()
if stripped and not (stripped.startswith("#") or stripped.lower().startswith("data:image/")):
return False
if local == "style" and _EXTERNAL_URL.search(value):
return False
if tag == "style" and element.text and (
"@import" in element.text.lower() or _EXTERNAL_URL.search(element.text)
):
return False
return True
@@ -172,16 +172,16 @@ class AdministrationApiTests(TestCase):
"/api/v1/company/administration/logo/",
{
"file": SimpleUploadedFile(
"logo.svg",
b"<svg></svg>",
content_type="image/svg+xml",
"logo.txt",
b"not an image at all",
content_type="text/plain",
)
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("PNG, JPEG и WebP", response.json()["detail"])
self.assertIn("PNG, JPEG, WebP и SVG", response.json()["detail"])
def test_audit_returns_readable_label_next_to_the_action_code(self) -> None:
record_audit_event(
@@ -0,0 +1,77 @@
"""SVG-логотип организации: принимается чистый, отклоняется опасный.
Логотип отдаётся с адреса приложения, поэтому SVG проверяется при загрузке
(скрипты, обработчики, внешние ссылки), а при отдаче получает защитные
заголовки как второй рубеж.
"""
from __future__ import annotations
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.logo_svg import svg_is_safe
from chatballs.testing import TenantAPIClient
CLEAN_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">
<defs><linearGradient id="g"><stop offset="0" stop-color="#1677ff"/><stop offset="1" stop-color="#003eb3"/></linearGradient></defs>
<style>.mark { fill: url(#g); }</style>
<circle class="mark" cx="32" cy="32" r="30"/>
<use xlink:href="#mark"/>
</svg>
"""
class SvgSafetyTests(TestCase):
def test_clean_svg_is_accepted(self) -> None:
self.assertTrue(svg_is_safe(CLEAN_SVG))
def test_dangerous_svg_is_rejected(self) -> None:
samples = {
"script": b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
"handler": b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>',
"javascript href": b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="javascript:alert(1)"><rect/></a></svg>',
"external image": b'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/t.png"/></svg>',
"foreignObject": b'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>x</div></foreignObject></svg>',
"external css": b'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/a.css);</style></svg>',
"style url": b'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(http://evil.example/x)"/></svg>',
"doctype entity": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
"not svg": b'<html><body>hi</body></html>',
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
}
for name, sample in samples.items():
with self.subTest(name):
self.assertFalse(svg_is_safe(sample))
class SvgLogoApiTests(TestCase):
def setUp(self) -> None:
result = bootstrap_owner(email="svg-owner@example.com", password="temporary-password")
self.client = TenantAPIClient()
self.client.force_authenticate(result.owner)
def _upload(self, data: bytes):
return self.client.post(
"/api/v1/company/administration/logo/",
{"file": SimpleUploadedFile("logo.svg", data, content_type="image/svg+xml")},
format="multipart",
)
def test_svg_logo_round_trip_with_protective_headers(self) -> None:
uploaded = self._upload(CLEAN_SVG)
self.assertEqual(uploaded.status_code, 200, uploaded.content)
downloaded = self.client.get("/api/v1/company/administration/logo/")
self.assertEqual(downloaded.status_code, 200)
self.assertEqual(downloaded["Content-Type"], "image/svg+xml")
self.assertIn("sandbox", downloaded["Content-Security-Policy"])
self.assertEqual(downloaded["X-Content-Type-Options"], "nosniff")
self.assertEqual(b"".join(downloaded.streaming_content), CLEAN_SVG)
def test_svg_with_script_is_refused_with_a_field_error(self) -> None:
response = self._upload(b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
self.assertEqual(response.status_code, 400)
self.assertIn("file", response.json()["errors"])
@@ -12,6 +12,7 @@ import secrets
from datetime import timedelta
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from chatballs.conversations import transports
@@ -39,7 +40,11 @@ def notifier_integrations(context):
provider__in=(IntegrationProvider.TELEGRAM, IntegrationProvider.MAX),
config__purpose=NOTIFIER_PURPOSE,
).exclude(secret="")
return qs.filter(organization=context.organization)
# Демо-подключения из демо-набора не опрашиваются: токены ненастоящие.
# Отсутствие ключа в JSON — тоже «не демо», поэтому isnull, а не exclude.
return qs.filter(organization=context.organization).filter(
Q(config__demoSeed__isnull=True) | Q(config__demoSeed=False)
)
def deep_link(integration: Integration, code: str) -> str:
@@ -38,7 +38,7 @@ export function OrganizationLogoField({
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
+1 -1
View File
@@ -157,7 +157,7 @@ export const en: Record<MessageKey, Message> = {
"admin.password_was_generated_shown_once": "The password was generated and is shown once — copy it now.",
"admin.people_count": "{count} people",
"admin.period": "Period",
"admin.png_jpeg_or_webp_up": "PNG, JPEG or WebP · up to 2 MB. Shown in the sidebar, the widget and emails.",
"admin.png_jpeg_or_webp_up": "PNG, JPEG, WebP or SVG · up to 2 MB. Shown in the sidebar, the widget and emails.",
"admin.position_groups": "Position and groups",
"admin.qr_code_setting_up_totp": "QR code for setting up TOTP",
"admin.recover_access": "Recover access",
+1 -1
View File
@@ -158,7 +158,7 @@ export const ru = {
"admin.password_was_generated_shown_once": "Пароль сгенерирован и показан один раз — скопируйте его сейчас.",
"admin.people_count": "{count} чел.",
"admin.period": "Период",
"admin.png_jpeg_or_webp_up": "PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах.",
"admin.png_jpeg_or_webp_up": "PNG, JPEG, WebP или SVG · до 2 МБ. Виден в сайдбаре, виджете и письмах.",
"admin.position_groups": "Должность и группы",
"admin.qr_code_setting_up_totp": "QR-код для подключения TOTP",
"admin.recover_access": "Восстановить доступ",