mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132c684630 | ||
|
|
bfe90e9abd | ||
|
|
5b49904d6a | ||
|
|
cd50cce5a8 |
No files matched your search
@@ -23,9 +23,9 @@ jobs:
|
||||
name: ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -46,9 +46,9 @@ jobs:
|
||||
name: deployment CLI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Версия релиза
|
||||
id: version
|
||||
@@ -57,9 +57,9 @@ jobs:
|
||||
echo "value=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
|
||||
- name: Backend
|
||||
id: backend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
- name: Frontend
|
||||
id: frontend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
|
||||
- name: Gateway
|
||||
id: gateway
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
- name: Postgres
|
||||
id: postgres
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
|
||||
cat dist/compose.yaml
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-compose
|
||||
path: |
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
|
||||
- name: Приложить к релизу
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
dist/compose.yaml
|
||||
|
||||
@@ -72,8 +72,12 @@ class InstanceSettings(models.Model):
|
||||
|
||||
|
||||
_CACHE_TTL_SECONDS = 10.0
|
||||
# Перечитать кэш при промахе по хосту можно не чаще раза в секунду на процесс:
|
||||
# иначе поток запросов с чужим Host превращался бы в поток запросов к базе.
|
||||
_MISS_REFRESH_SECONDS = 1.0
|
||||
_lock = threading.Lock()
|
||||
_cached: tuple[float, tuple[str, str]] | None = None
|
||||
_last_miss_refresh = 0.0
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
@@ -112,6 +116,31 @@ def accepted_hosts() -> tuple[str, ...]:
|
||||
return tuple(host for host in _hosts() if host)
|
||||
|
||||
|
||||
def host_is_accepted(host: str) -> bool:
|
||||
"""Свой ли это адрес — с перечитыванием кэша при промахе.
|
||||
|
||||
Кэш живёт в каждом процессе gunicorn отдельно. Мастер первого запуска или
|
||||
смена адреса в «Настройках» сбрасывают его только там, где выполнялись;
|
||||
соседний процесс до 10 секунд отвечал бы «Invalid host» на адрес, который
|
||||
установка уже считает своим. Поэтому промах — повод перечитать строку, но
|
||||
не чаще раза в секунду.
|
||||
"""
|
||||
|
||||
global _last_miss_refresh
|
||||
if not host:
|
||||
return False
|
||||
known = {normalize_domain(item) for item in accepted_hosts()}
|
||||
if host in known:
|
||||
return True
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
if now - _last_miss_refresh < _MISS_REFRESH_SECONDS:
|
||||
return False
|
||||
_last_miss_refresh = now
|
||||
invalidate_cache()
|
||||
return host in {normalize_domain(item) for item in accepted_hosts()}
|
||||
|
||||
|
||||
def default_language() -> str:
|
||||
"""Язык установки: экраны до входа и умолчание для организаций.
|
||||
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.identity import instance_settings
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.instance_settings import (
|
||||
InstanceSettings,
|
||||
@@ -201,3 +204,51 @@ class InstanceSettingsAccessTests(TestCase):
|
||||
f"/api/v1/organizations/{self.result.organization.public_id}/company/administration/instance/"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class StaleHostCacheTests(TestCase):
|
||||
"""Адрес, записанный мастером в одном процессе, принимает и соседний.
|
||||
|
||||
Кэш адреса живёт в каждом процессе gunicorn по 10 секунд. Соседний процесс
|
||||
с устаревшим кэшем отвечал «Invalid host» на первый же запрос после
|
||||
мастера — в интерфейсе это «Ошибка загрузки», исчезавшая после обновления
|
||||
страницы. Промах по хосту теперь перечитывает кэш.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.result = bootstrap_owner(email="cache-owner@example.com", password=PASSWORD)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.result.owner)
|
||||
# Строка настроек должна существовать: update() ниже её не создаёт.
|
||||
InstanceSettings.load()
|
||||
self.addCleanup(invalidate_cache)
|
||||
|
||||
def _stale_cache_with_no_host(self) -> None:
|
||||
# Соседний процесс: только что прочитал пустой адрес, TTL ещё не вышел.
|
||||
instance_settings._cached = (time.monotonic(), ("", ""))
|
||||
instance_settings._last_miss_refresh = 0.0
|
||||
|
||||
def test_host_written_by_another_process_is_accepted_at_once(self) -> None:
|
||||
self._stale_cache_with_no_host()
|
||||
# Запись мимо save(): invalidate_cache() в этом процессе не вызывается,
|
||||
# как и в реальности, где мастер отработал в другом воркере.
|
||||
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
|
||||
public_host="crm.example.test"
|
||||
)
|
||||
|
||||
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="crm.example.test")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
|
||||
def test_unknown_host_does_not_reread_more_than_once_a_second(self) -> None:
|
||||
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
|
||||
public_host="crm.example.test"
|
||||
)
|
||||
self._stale_cache_with_no_host()
|
||||
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
|
||||
# Первый промах перечитал кэш и уже знает настоящий адрес.
|
||||
self.assertEqual(set(accepted_hosts()), {"crm.example.test"})
|
||||
# Второй промах в ту же секунду базу не трогает: кэш подменён, но не перечитан.
|
||||
instance_settings._cached = (time.monotonic(), ("", ""))
|
||||
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
|
||||
self.assertEqual(accepted_hosts(), ())
|
||||
@@ -41,11 +41,13 @@ class SupportPortalHostBoundaryMiddleware:
|
||||
return True
|
||||
# Адреса, которые человек задал сам: тот, на котором прошли мастер, и
|
||||
# предыдущий — чтобы смена адреса в «Настройках» не выбрасывала того,
|
||||
# кто её делает, до того как новый домен вообще заработал.
|
||||
from chatballs.identity.instance_settings import accepted_hosts
|
||||
# кто её делает, до того как новый домен вообще заработал. Промах
|
||||
# перечитывает кэш: соседний процесс gunicorn мог ещё не увидеть адрес,
|
||||
# который мастер записал секунду назад.
|
||||
from chatballs.identity.instance_settings import host_is_accepted
|
||||
|
||||
try:
|
||||
if host and host in {normalize_domain(item) for item in accepted_hosts()}:
|
||||
if host_is_accepted(host):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -505,7 +505,15 @@
|
||||
|
||||
.knowledge-table .row-actions { text-align: right; }
|
||||
|
||||
/* --- Подвал таблицы --- */
|
||||
/* --- Пустое состояние (кадр KB1): блок по центру рабочей области --- */
|
||||
|
||||
.knowledge-empty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.knowledge-empty > div {
|
||||
width: 480px;
|
||||
|
||||
Reference in new issue
Block a user