From 4c2984a81e2ab28cdfe69d5e54446fb38c8c35f7 Mon Sep 17 00:00:00 2001 From: lokses Date: Tue, 22 Sep 2026 12:53:04 +0300 Subject: [PATCH] =?UTF-8?q?chat:=20same-origin=20/chat/api=20proxy=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20CORS=20=D0=BA=20Mattermost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Браузер больше не ходит на chat.* с portal.* — REST идёт через Django с PAT; WS backoff без бесконечных 403; utilities.js ждёт DOMContentLoaded. Co-authored-by: Cursor --- acore/urls.py | 2 + apps/mattermost_connector/chat_proxy.py | 111 ++++++++++++++++++++++++ apps/mattermost_connector/tests.py | 50 +++++++++++ core/tests.py | 1 + core/views.py | 3 +- static/assets/js/chat.js | 65 ++++++++++++-- static/assets/js/utilities.js | 11 +-- 7 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 apps/mattermost_connector/chat_proxy.py diff --git a/acore/urls.py b/acore/urls.py index cdaaf4c..a421475 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -28,6 +28,7 @@ from apps.hiring import recruiting from core.accounting_portal import accounting_portal_view from apps.it_manager.billing import internet_payment_update_view from apps.employees.views_password import password_setup_view +from apps.mattermost_connector.chat_proxy import chat_api_proxy from apps.locations.views import pharmacy_update_view from apps.visit_manager.views_calendar import ( calendar_event_create_view, @@ -99,6 +100,7 @@ urlpatterns = [ path('profile/', views.profile_view, name='profile'), path('chat/', views.chat_view, name='chat'), path('chat/sync/', views.chat_sync_now_view, name='chat_sync_now'), + path('chat/api/', chat_api_proxy, name='chat_api_proxy'), path('databases/', views.databases_view, name='databases'), path('databases/rebuild/', directory_portal.directory_rebuild_view, name='directory_rebuild'), path('databases//', directory_portal.directory_type_detail_view, name='directory_type_detail'), diff --git a/apps/mattermost_connector/chat_proxy.py b/apps/mattermost_connector/chat_proxy.py new file mode 100644 index 0000000..6aa83d5 --- /dev/null +++ b/apps/mattermost_connector/chat_proxy.py @@ -0,0 +1,111 @@ +"""Same-origin proxy Mattermost API для встроенного чата портала. + +Браузер ходит на /chat/api/..., Django проксирует на Mattermost с PAT +пользователя — без CORS между portal и chat-хостом. +""" +from __future__ import annotations + +import logging +from urllib.parse import urljoin + +import httpx +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_http_methods + +from apps.mattermost_connector.services.token_service import ensure_personal_access_token +from core.models import ConnectorConfig + +logger = logging.getLogger(__name__) + +_HOP_BY_HOP = { + 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailers', 'transfer-encoding', 'upgrade', 'host', 'content-length', +} +_MAX_BODY = 50 * 1024 * 1024 # 50 MB + + +def mattermost_server_api_base(cfg: ConnectorConfig) -> str: + """Базовый URL Mattermost для сервер→сервер (host/port из коннектора).""" + scheme = 'https' if cfg.use_ssl else 'http' + port = cfg.port or (443 if cfg.use_ssl else 80) + if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80): + return f'{scheme}://{cfg.host}'.rstrip('/') + return f'{scheme}://{cfg.host}:{port}'.rstrip('/') + + +def _verify_ssl(cfg: ConnectorConfig) -> bool: + extra = cfg.extra_config or {} + if 'verify_ssl' in extra: + return bool(extra['verify_ssl']) + return True + + +@login_required +@ensure_csrf_cookie +@require_http_methods(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) +def chat_api_proxy(request, api_path: str = ''): + """Прокси /chat/api/ → {mattermost}/api/v4/.""" + api_path = (api_path or '').lstrip('/') + if not api_path or '..' in api_path or api_path.startswith(('http://', 'https://')): + return HttpResponseBadRequest('bad path') + + cfg = ConnectorConfig.get_for('mattermost') + if not cfg or not cfg.is_enabled or not cfg.host: + return HttpResponseForbidden('mattermost not configured') + + if not request.user.mattermost_user_id: + return HttpResponseForbidden('not synced') + + token = ensure_personal_access_token(request.user) + if not token: + return HttpResponseForbidden('no token') + + base = mattermost_server_api_base(cfg) + url = urljoin(base + '/', f'api/v4/{api_path}') + qs = request.META.get('QUERY_STRING') or '' + if qs: + url = f'{url}?{qs}' + + headers = { + 'Authorization': f'Bearer {token}', + 'Accept': request.headers.get('Accept', 'application/json'), + } + content_type = request.headers.get('Content-Type') + if content_type: + headers['Content-Type'] = content_type + + body = request.body if request.method in ('POST', 'PUT', 'PATCH', 'DELETE') else None + if body and len(body) > _MAX_BODY: + return HttpResponse(status=413) + + try: + with httpx.Client(timeout=60.0, verify=_verify_ssl(cfg), follow_redirects=False) as client: + upstream = client.request(request.method, url, content=body, headers=headers) + except httpx.HTTPError as e: + logger.warning('chat proxy upstream error %s %s: %s', request.method, api_path, e) + return HttpResponse( + f'upstream error: {e.__class__.__name__}', + status=502, + content_type='text/plain; charset=utf-8', + ) + + if len(upstream.content) > _MAX_BODY: + return HttpResponse(status=413) + + resp = HttpResponse(upstream.content, status=upstream.status_code) + ct = upstream.headers.get('content-type') + if ct: + resp['Content-Type'] = ct + # Не прокидываем hop-by-hop и set-cookie от MM + for key, value in upstream.headers.items(): + lk = key.lower() + if lk in _HOP_BY_HOP or lk == 'set-cookie': + continue + if lk in ('content-type', 'content-encoding', 'content-length'): + continue + # полезные для скачивания + if lk in ('content-disposition', 'cache-control', 'etag'): + resp[key] = value + return resp diff --git a/apps/mattermost_connector/tests.py b/apps/mattermost_connector/tests.py index 522a60d..7661388 100755 --- a/apps/mattermost_connector/tests.py +++ b/apps/mattermost_connector/tests.py @@ -341,3 +341,53 @@ class TaskDoneAttachmentTokenTests(TestCase): pk = 42 integ = _task_done_attachment(_T())['attachments'][0]['actions'][0]['integration'] self.assertNotIn('token', integ['context']) + + +class ChatApiProxyTests(TestCase): + def setUp(self): + from django.contrib.auth.models import Group + from core.models import ConnectorConfig + from core.test_utils import login_2fa + + self.user = make_employee(mattermost_user_id='mm-proxy') + self.user.groups.add(Group.objects.create(name='chat-proxy-testers')) + MattermostUser.objects.create( + django_user=self.user, mattermost_id='mm-proxy', username='proxy', + email=self.user.email, is_active=True, + ) + mm = MattermostUser.objects.get(django_user=self.user) + mm.set_access_token('user-pat-token') + mm.save(update_fields=['access_token_encrypted']) + ConnectorConfig.objects.create( + connector_type='mattermost', + is_enabled=True, + host='mm.test.local', + port=443, + use_ssl=True, + extra_config={'public_url': 'https://chat.example.com'}, + ) + login_2fa(self.client, self.user) + + @mock.patch('apps.mattermost_connector.chat_proxy.httpx.Client') + def test_proxies_get_channels(self, client_cls): + upstream = mock.Mock() + upstream.status_code = 200 + upstream.content = b'[{"id":"ch1","type":"O"}]' + upstream.headers = {'content-type': 'application/json'} + client = mock.MagicMock() + client.__enter__.return_value = client + client.__exit__.return_value = False + client.request.return_value = upstream + client_cls.return_value = client + + resp = self.client.get('/chat/api/users/me/channels?include_deleted=true') + self.assertEqual(resp.status_code, 200) + self.assertIn(b'ch1', resp.content) + args, kwargs = client.request.call_args + self.assertEqual(args[0], 'GET') + self.assertTrue(args[1].startswith('https://mm.test.local/api/v4/users/me/channels')) + self.assertEqual(kwargs['headers']['Authorization'], 'Bearer user-pat-token') + + def test_rejects_path_traversal(self): + resp = self.client.get('/chat/api/users/../admin') + self.assertEqual(resp.status_code, 400) diff --git a/core/tests.py b/core/tests.py index 86ea13a..36897cc 100644 --- a/core/tests.py +++ b/core/tests.py @@ -384,6 +384,7 @@ class ChatColleaguesTests(TestCase): def test_colleagues_excludes_self_and_inactive(self): context = self._build() self.assertEqual(context['chat_state'], 'ready') + self.assertEqual(context['chat_config']['apiBase'], '/chat/api') ids = [c['mattermostId'] for c in context['chat_colleagues']] self.assertIn('mm-colleague', ids) self.assertNotIn('mm-me', ids) diff --git a/core/views.py b/core/views.py index 9ece93e..9e984f0 100644 --- a/core/views.py +++ b/core/views.py @@ -818,7 +818,8 @@ def _build_chat_context(request): return { 'chat_state': 'ready', 'chat_config': { - 'apiBase': f'{public_url}/api/v4', + # Same-origin proxy — иначе браузер упирается в CORS Mattermost. + 'apiBase': '/chat/api', 'wsUrl': ws_url, 'token': token, 'currentUserId': request.user.mattermost_user_id, diff --git a/static/assets/js/chat.js b/static/assets/js/chat.js index 3c66dbf..bfbbd9f 100644 --- a/static/assets/js/chat.js +++ b/static/assets/js/chat.js @@ -131,6 +131,7 @@ function chatInit() { chatShowArchived = false; chatPrefDirectShow = {}; chatPrefGroupShow = {}; + chatWsFailures = 0; chatLoadTeams().then(chatLoadPreferences).then(chatLoadChannels).then(chatOpenPendingDm); chatConnectSocket(); @@ -172,17 +173,45 @@ function chatOpenDmWithUser(userId) { }).catch(function (e) { console.error(e); }); } +function chatCsrfToken() { + var m = document.cookie.match(/(?:^|; )csrftoken=([^;]*)/); + return m ? decodeURIComponent(m[1]) : ''; +} + function chatApiFetch(path, options) { options = options || {}; - options.headers = Object.assign({'Authorization': 'Bearer ' + chatConfig.token}, options.headers || {}); + var headers = Object.assign({ + 'Authorization': 'Bearer ' + chatConfig.token, + 'X-CSRFToken': chatCsrfToken(), + }, options.headers || {}); + // FormData: не фиксируем Content-Type — браузер сам поставит boundary + if (typeof FormData !== 'undefined' && options.body instanceof FormData) { + delete headers['Content-Type']; + } + options.headers = headers; return fetch(chatConfig.apiBase + path, options).then(function (res) { - if (!res.ok) throw new Error('Mattermost API ' + path + ' -> ' + res.status); - return res.json(); + if (!res.ok) { + var err = new Error('Mattermost API ' + path + ' -> ' + res.status); + err.status = res.status; + throw err; + } + var ct = res.headers.get('content-type') || ''; + if (ct.indexOf('application/json') >= 0 || ct.indexOf('+json') >= 0) { + return res.json(); + } + return res.text().then(function (t) { + try { return JSON.parse(t); } catch (e) { return t; } + }); }); } function chatApiFetchBlob(path) { - return fetch(chatConfig.apiBase + path, {headers: {'Authorization': 'Bearer ' + chatConfig.token}}).then(function (res) { + return fetch(chatConfig.apiBase + path, { + headers: { + 'Authorization': 'Bearer ' + chatConfig.token, + 'X-CSRFToken': chatCsrfToken(), + }, + }).then(function (res) { if (!res.ok) throw new Error('Mattermost API ' + path + ' -> ' + res.status); return res.blob(); }); @@ -306,8 +335,9 @@ function chatLoadChannels() { chatLoadLastActivity(); // уточнюємо порядок за реальним часом останніх постів }).catch(function (e) { console.error(e); + var detail = (e && e.status) ? (' (' + e.status + ')') : ''; var msg = '
' - + gettext('Не удалось загрузить каналы') + '
'; + + gettext('Не удалось загрузить каналы') + detail + ''; var el = document.getElementById('chat-channel-list'); if (el) el.innerHTML = msg; var dm = document.getElementById('chat-dm-list'); @@ -1169,12 +1199,27 @@ function chatStartNewDm() { }).catch(function (e) { console.error(e); }); } +var chatWsFailures = 0; +var chatWsMaxBackoff = 60000; + function chatConnectSocket() { - if (!chatConfig) return; - var socket = new WebSocket(chatConfig.wsUrl); + if (!chatConfig || !chatConfig.wsUrl) return; + // Live WS идёт на public_url Mattermost (не через Django). Без AllowCorsFrom / + // разрешения Origin на chat.* будет 403 — не долбим реконнект бесконечно. + if (chatWsFailures >= 8) return; + + var socket; + try { + socket = new WebSocket(chatConfig.wsUrl); + } catch (e) { + console.warn('chat websocket create failed', e); + chatWsFailures += 1; + return; + } chatSocket = socket; socket.onopen = function () { + chatWsFailures = 0; socket.send(JSON.stringify({seq: 1, action: 'authentication_challenge', data: {token: chatConfig.token}})); }; @@ -1187,11 +1232,13 @@ function chatConnectSocket() { socket.onclose = function () { if (!document.getElementById('chat-app')) return; - chatReconnectTimer = setTimeout(chatConnectSocket, 3000); + chatWsFailures += 1; + var delay = Math.min(chatWsMaxBackoff, 3000 * Math.pow(2, Math.min(chatWsFailures - 1, 4))); + chatReconnectTimer = setTimeout(chatConnectSocket, delay); }; socket.onerror = function () { - socket.close(); + try { socket.close(); } catch (e) { /* ignore */ } }; } diff --git a/static/assets/js/utilities.js b/static/assets/js/utilities.js index ae61429..586af93 100644 --- a/static/assets/js/utilities.js +++ b/static/assets/js/utilities.js @@ -57,12 +57,13 @@ function utilitiesFormModeUpdate() { } } -document.body.addEventListener('htmx:afterSwap', function (evt) { +document.addEventListener('DOMContentLoaded', function () { + document.body.addEventListener('htmx:afterSwap', function (evt) { + if (document.getElementById('utilities-page')) { + utilitiesFormModeUpdate(); + } + }); if (document.getElementById('utilities-page')) { utilitiesFormModeUpdate(); } }); - -if (document.getElementById('utilities-page')) { - utilitiesFormModeUpdate(); -}