Разработана первая версия приложений
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Local deployment configuration and secrets
|
||||
backend/.env
|
||||
backend/secrets/
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
# JavaScript / Expo / Playwright
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Java
|
||||
target/
|
||||
|
||||
# IDE and OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,7 @@
|
||||
# HAN Chat MVP
|
||||
|
||||
Production-like MVP implementation described by `../architectory` and `../modules`.
|
||||
|
||||
The deployment entry point is `backend/docker-compose.yml`. Copy
|
||||
`backend/.env.example` to `backend/.env`, provide external managed PostgreSQL,
|
||||
Selectel S3 and Bitrix24 credentials, then follow `backend/deployment/RUNBOOK.md`.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Copy to .env, replace every placeholder, then run: ./scripts/validate-env .env
|
||||
APP_ENV=production-like
|
||||
RELEASE_VERSION=change-me-release
|
||||
LOG_LEVEL=INFO
|
||||
COMPOSE_PROJECT_NAME=han-chat
|
||||
|
||||
# Immutable service images built by their owning modules.
|
||||
API_BACKEND_IMAGE=han-chat-api-backend:local
|
||||
MESSAGE_SAFETY_IMAGE=han-chat-message-safety:local
|
||||
BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local
|
||||
BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local
|
||||
KEYCLOAK_IMAGE=han-chat-keycloak:local
|
||||
|
||||
# Managed PostgreSQL is external to Compose. All production DSNs must verify TLS.
|
||||
HAN_PG_HOST=managed-pg.private.example
|
||||
HAN_PG_PORT=6432
|
||||
HAN_PG_DATABASE=han_chat
|
||||
PG_CA_HOST_PATH=/opt/han-chat/secrets/pg/ca.pem
|
||||
DATABASE_URL=postgresql+asyncpg://han_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dhan_app
|
||||
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_local
|
||||
BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync%2Chan_app
|
||||
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync
|
||||
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dmessage_safety
|
||||
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:6432/han_chat?currentSchema=keycloak&sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||
KEYCLOAK_DB_USERNAME=keycloak_user
|
||||
KEYCLOAK_DB_PASSWORD=change-me
|
||||
|
||||
PUBLIC_HOST=chat.example.ru
|
||||
PUBLIC_WEB_URL=https://chat.example.ru
|
||||
PUBLIC_API_URL=https://chat.example.ru/api
|
||||
PUBLIC_AUTH_URL=https://chat.example.ru/auth
|
||||
|
||||
NGINX_HTTP_PORT=80
|
||||
NGINX_HTTPS_PORT=443
|
||||
NGINX_TLS_ENABLED=true
|
||||
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
|
||||
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
|
||||
NGINX_HSTS_MAX_AGE=0
|
||||
NGINX_CLIENT_MAX_BODY_SIZE=8m
|
||||
NGINX_RATE_LIMIT_API=60r/m
|
||||
NGINX_RATE_LIMIT_AUTH=10r/m
|
||||
NGINX_RATE_LIMIT_PUBLIC=60r/m
|
||||
NGINX_RATE_LIMIT_POLLING=60r/m
|
||||
NGINX_RATE_LIMIT_DOWNLOADS=30r/m
|
||||
NGINX_RATE_LIMIT_BITRIX=120r/m
|
||||
NGINX_RATE_LIMIT_WS=30r/m
|
||||
NGINX_MESSAGE_READ_TIMEOUT_SEC=330
|
||||
NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32
|
||||
TRUSTED_PROXY_CIDRS=172.16.0.0/12
|
||||
BITRIX_FRAME_ANCESTORS=https://*.bitrix24.ru
|
||||
S3_CONNECT_SRC=https://s3.storage.selcloud.ru
|
||||
|
||||
FRONTEND_DEV_PROXY_ENABLED=false
|
||||
EXPO_DEV_SERVER_URL=http://host.docker.internal:8081
|
||||
ACME_EMAIL=ops@example.ru
|
||||
|
||||
KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth
|
||||
KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth
|
||||
KEYCLOAK_REALM=han-chat
|
||||
KEYCLOAK_AUDIENCE=han-chat-api
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_CODE=change-me
|
||||
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
|
||||
KEYCLOAK_OTP_HMAC_KEY=change-me
|
||||
KEYCLOAK_OTP_TTL_SEC=300
|
||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||
KEYCLOAK_ADMIN=bootstrap-admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||
CURSOR_HMAC_SECRET=change-me
|
||||
|
||||
REDIS_API_PASSWORD=change-me
|
||||
REDIS_SAFETY_PASSWORD=change-me
|
||||
REDIS_HEALTH_PASSWORD=change-me
|
||||
REDIS_URL=redis://api_backend:change-me@redis:6379/0
|
||||
REDIS_REALTIME_URL=redis://api_backend:change-me@redis:6379/1
|
||||
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:change-me@redis:6379/2
|
||||
REDIS_MAXMEMORY=384mb
|
||||
REDIS_EVICTION_POLICY=volatile-lru
|
||||
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN=change-me
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN=change-me
|
||||
BITRIX_INTERNAL_API_TOKEN=change-me
|
||||
BITRIX_API_FORWARD_TOKEN=change-me
|
||||
BITRIX_API_INBOX_TOKEN=change-me
|
||||
BITRIX_SYNC_SERVICE_TOKEN=change-me
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
|
||||
|
||||
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
|
||||
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
|
||||
BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox
|
||||
MESSAGE_SAFETY_URL=http://message-safety:8080
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC=5
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2
|
||||
MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300
|
||||
MESSAGE_SAFETY_TASK_TTL_SEC=900
|
||||
MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60
|
||||
MESSAGE_SAFETY_RULES_VERSION=2026-01-01
|
||||
BITRIX_SYNC_ENABLED=false
|
||||
BITRIX_SYNC_CRM_BASE_URL=https://example.bitrix24.ru
|
||||
BITRIX_SYNC_CRM_WEBHOOK_URL=change-me
|
||||
BITRIX_SYNC_CONTACT_MAP_INTERVAL_SEC=60
|
||||
BITRIX_SYNC_CONTACT_UPDATE_INTERVAL_SEC=30
|
||||
BITRIX_SYNC_CRM_MAX_CONCURRENCY=2
|
||||
BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50
|
||||
BITRIX_SYNC_WEBHOOK_TOKEN=change-me
|
||||
BITRIX_CLIENT_ID=change-me
|
||||
BITRIX_CLIENT_SECRET=change-me
|
||||
BITRIX_CONNECTOR_ID=han_mobile_app
|
||||
BITRIX_CONNECTOR_NAME=HAN Mobile App
|
||||
BITRIX_OPEN_LINE_ID=8
|
||||
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
|
||||
BITRIX_APPLICATION_TOKEN=change-me
|
||||
# Generate with: python -c "import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
|
||||
BITRIX_TOKEN_ENCRYPTION_KEY=change-me
|
||||
BITRIX_HTTP_TIMEOUT_SEC=10
|
||||
|
||||
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
|
||||
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
|
||||
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
|
||||
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
||||
SELECTEL_S3_ACCESS_KEY=change-me
|
||||
SELECTEL_S3_SECRET_KEY=change-me
|
||||
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=change-me
|
||||
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
|
||||
OTEL_REMOTE_AUTH_HEADER=change-me
|
||||
OTEL_TRACES_SAMPLER_ARG=0.10
|
||||
OTEL_QUEUE_SIZE=10000
|
||||
@@ -0,0 +1,7 @@
|
||||
*.sh text eol=lf
|
||||
scripts/validate-env text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.conf text eol=lf
|
||||
*.template text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
backups/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.git
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
tests
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /build
|
||||
RUN pip install --no-cache-dir --upgrade pip build
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
RUN python -m build --wheel
|
||||
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system --gid 10001 han && adduser --system --uid 10001 --ingroup han han
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/dist/*.whl /tmp/
|
||||
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
|
||||
COPY alembic.ini ./
|
||||
COPY alembic ./alembic
|
||||
USER 10001:10001
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["uvicorn"]
|
||||
CMD ["app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-proxy-headers"]
|
||||
@@ -0,0 +1,68 @@
|
||||
# HAN Chat API backend
|
||||
|
||||
FastAPI-сервис публичного API, internal Open Lines/settings API, WebSocket realtime,
|
||||
Message Safety orchestration, S3 lifecycle и PostgreSQL workers.
|
||||
|
||||
## Запуск
|
||||
|
||||
Python 3.12+:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/Scripts/pip install -e ".[dev]"
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Миграции выполняются отдельным deployment step. Приложение не применяет DDL при старте.
|
||||
|
||||
Workers запускаются независимо:
|
||||
|
||||
```bash
|
||||
han-delivery-worker
|
||||
han-safety-worker
|
||||
han-cleanup-worker
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Сервис читает только инфраструктурные параметры и секреты из корневого `backend/.env`.
|
||||
Локальный `.env` не коммитится. Business settings создаются миграцией в
|
||||
`han_app.app_settings`.
|
||||
|
||||
Обязательны:
|
||||
|
||||
- `APP_ENV`, `API_PORT`, `LOG_LEVEL`, `DATABASE_URL`;
|
||||
- `REDIS_URL`, `REDIS_REALTIME_URL`;
|
||||
- `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_INTERNAL_URL`, `KEYCLOAK_REALM`,
|
||||
`KEYCLOAK_AUDIENCE`;
|
||||
- `MESSAGE_SAFETY_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`,
|
||||
`MESSAGE_SAFETY_POST_TIMEOUT_SEC`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`,
|
||||
`MESSAGE_SAFETY_TASK_POLL_MAX_SEC`,
|
||||
`MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD`, `MESSAGE_SAFETY_CIRCUIT_OPEN_SEC`;
|
||||
- `BITRIX_LOCAL_APP_BASE_URL`, `BITRIX_LOCAL_APP_INTERNAL_TOKEN`,
|
||||
`BITRIX_API_INBOX_TOKEN`, `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`,
|
||||
`BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD`,
|
||||
`BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC`;
|
||||
- `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`;
|
||||
- `SELECTEL_S3_ENDPOINT_URL`, `SELECTEL_S3_BUCKET_DOCUMENTS`,
|
||||
`SELECTEL_S3_BUCKET_ATTACHMENTS`, `SELECTEL_S3_BUCKET_QUARANTINE`,
|
||||
`SELECTEL_S3_ACCESS_KEY`, `SELECTEL_S3_SECRET_KEY`;
|
||||
- `CURSOR_HMAC_SECRET` — случайный секрет не короче 32 байт;
|
||||
- `OTEL_EXPORTER_OTLP_ENDPOINT` — опциональный endpoint collector.
|
||||
|
||||
Токены генерируются `openssl rand -hex 32`. S3 read-only credentials Message Safety
|
||||
не передаются этому контейнеру. В production подключение PostgreSQL должно использовать
|
||||
TLS, а internal endpoints — быть доступны только из backend-сети.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
mypy app
|
||||
pytest
|
||||
```
|
||||
|
||||
`/health/live` проверяет процесс. `/health/ready` проверяет критические зависимости и
|
||||
возвращает `503`, если сервис не может безопасно обслуживать protected API.
|
||||
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
from app.db import Base
|
||||
from app.settings import get_settings
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_schemas=True,
|
||||
version_table_schema="han_app",
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
include_schemas=True,
|
||||
version_table_schema="han_app",
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
else:
|
||||
asyncio.run(run_async_migrations())
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Initial han_app schema, seed and CRM sync triggers.
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
from app.db import Base
|
||||
|
||||
revision: str = "0001_initial"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
SEED = {
|
||||
"auth.phone.enabled": ("true", "boolean", True),
|
||||
"auth.password.enabled": ("false", "boolean", True),
|
||||
"otp.phone.max_send_attempts_per_24h": ("3", "integer", False),
|
||||
"otp.phone.min_seconds_between_attempts": ("30", "integer", False),
|
||||
"operator.call.phone": ("+74999591007", "string", True),
|
||||
"consent.personal_data.required": ("true", "boolean", True),
|
||||
"consent.personal_data.document_url": (
|
||||
"https://www.han0107.ru/privacy/persdata-agree-mobile",
|
||||
"string",
|
||||
True,
|
||||
),
|
||||
"consent.personal_data.version": ("2026-06-10", "string", True),
|
||||
"consent.user_agreement.required": ("true", "boolean", True),
|
||||
"consent.user_agreement.document_url": (
|
||||
"https://www.han0107.ru/user-agreement",
|
||||
"string",
|
||||
True,
|
||||
),
|
||||
"consent.user_agreement.version": ("2026-06-10", "string", True),
|
||||
"consent.marketing.required": ("false", "boolean", True),
|
||||
"consent.marketing.version": ("2026-06-10", "string", True),
|
||||
"chat.attachments.allowed_extensions": (
|
||||
"jpg,jpeg,png,webp,heic,heif,pdf",
|
||||
"string_list",
|
||||
True,
|
||||
),
|
||||
"chat.attachments.allowed_mime_types": (
|
||||
"image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf",
|
||||
"string_list",
|
||||
True,
|
||||
),
|
||||
"chat.attachments.disallowed_extensions": ("svg,doc,docx,xls,xlsx,csv", "string_list", False),
|
||||
"chat.attachments.max_size_mb": ("5", "integer", True),
|
||||
"chat.attachments.storage": ("selectel_s3", "string", False),
|
||||
"chat.attachments.upload_mode": ("presigned_put", "string", False),
|
||||
"chat.attachments.safety_scan_required": ("true", "boolean", False),
|
||||
"chat.attachments.presigned_upload_ttl_seconds": ("600", "integer", False),
|
||||
"rate_limit.message_send.per_user": ("30/minute", "string", False),
|
||||
"rate_limit.message_send.per_dialog": ("20/minute", "string", False),
|
||||
"rate_limit.download_url.per_user": ("60/hour", "string", False),
|
||||
"rate_limit.public_endpoints.per_ip": ("60/minute", "string", False),
|
||||
"rate_limit.login.per_ip": ("10/minute", "string", False),
|
||||
"ux.session.idle_timeout_minutes": ("30", "integer", True),
|
||||
"security.cors.allowed_origins": ("https://tohin.ru", "string_list", False),
|
||||
"security.public_cache.max_age_seconds": ("3600", "integer", False),
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS han_app")
|
||||
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user
|
||||
ON han_app.dialogs(user_id)
|
||||
WHERE record_status='A'
|
||||
AND status IN ('open','waiting_for_company','waiting_for_client');
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_profiles_active_bitrix_contact
|
||||
ON han_app.client_profiles(bitrix_contact_id)
|
||||
WHERE bitrix_contact_id IS NOT NULL AND record_status='A';
|
||||
|
||||
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = han_app, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entity_id uuid;
|
||||
v_task_type text;
|
||||
v_dedup text;
|
||||
BEGIN
|
||||
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||
v_entity_id := NEW.id;
|
||||
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||
ELSE
|
||||
v_entity_id := NEW.user_id;
|
||||
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||
END IF;
|
||||
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
||||
encode(digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
||||
INSERT INTO han_app.sync_queue
|
||||
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup,
|
||||
jsonb_build_object('entity_id', v_entity_id), 'pending', 0,
|
||||
now(), now(), now())
|
||||
ON CONFLICT (dedup_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities;
|
||||
CREATE TRIGGER trg_identity_contact_sync
|
||||
AFTER INSERT OR UPDATE OF phone_number, record_status
|
||||
ON han_app.user_identities
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
|
||||
CREATE TRIGGER trg_profile_contact_sync
|
||||
AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone,
|
||||
foreign_phone, email, record_status
|
||||
ON han_app.client_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||
"""
|
||||
)
|
||||
for key, (value, value_type, public) in SEED.items():
|
||||
bind.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, record_status, updated_at)
|
||||
VALUES (%s, %s, %s, %s, 'A', now())
|
||||
ON CONFLICT (setting_key) DO UPDATE SET
|
||||
setting_value = EXCLUDED.setting_value,
|
||||
value_type = EXCLUDED.value_type,
|
||||
is_public = EXCLUDED.is_public,
|
||||
record_status = 'A',
|
||||
updated_at = now()
|
||||
""",
|
||||
(key, value, value_type, public),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Initial data migration is forward-only")
|
||||
@@ -0,0 +1 @@
|
||||
"""HAN Chat API backend."""
|
||||
@@ -0,0 +1,106 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import phonenumbers
|
||||
from jwt import ExpiredSignatureError, InvalidTokenError, PyJWK
|
||||
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, code: str = "unauthorized") -> None:
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
subject: str
|
||||
phone_number: str | None
|
||||
claims: dict[str, Any]
|
||||
|
||||
|
||||
class JWKSValidator:
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
self.settings = settings
|
||||
self.http = http
|
||||
self._keys: dict[str, PyJWK] = {}
|
||||
self._loaded_at = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def refresh(self) -> None:
|
||||
async with self._lock:
|
||||
discovery_url = (
|
||||
f"{str(self.settings.keycloak_internal_url).rstrip('/')}"
|
||||
f"/realms/{self.settings.keycloak_realm}/.well-known/openid-configuration"
|
||||
)
|
||||
discovery = (await self.http.get(discovery_url, timeout=3)).raise_for_status().json()
|
||||
jwks_uri = discovery["jwks_uri"]
|
||||
public_prefix = str(self.settings.keycloak_public_url).rstrip("/")
|
||||
internal_prefix = str(self.settings.keycloak_internal_url).rstrip("/")
|
||||
if jwks_uri.startswith(public_prefix):
|
||||
jwks_uri = internal_prefix + jwks_uri[len(public_prefix) :]
|
||||
payload = (await self.http.get(jwks_uri, timeout=3)).raise_for_status().json()
|
||||
self._keys = {
|
||||
key["kid"]: PyJWK.from_dict(key)
|
||||
for key in payload.get("keys", [])
|
||||
if key.get("kid") and key.get("kty") == "RSA"
|
||||
}
|
||||
self._loaded_at = time.monotonic()
|
||||
|
||||
async def validate(self, token: str) -> Principal:
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except InvalidTokenError as exc:
|
||||
raise AuthError() from exc
|
||||
if header.get("alg") != "RS256" or not header.get("kid"):
|
||||
raise AuthError()
|
||||
kid = str(header["kid"])
|
||||
stale = time.monotonic() - self._loaded_at > self.settings.jwks_cache_ttl_seconds
|
||||
if stale or kid not in self._keys:
|
||||
try:
|
||||
await self.refresh()
|
||||
except (httpx.HTTPError, KeyError, ValueError):
|
||||
if (
|
||||
kid not in self._keys
|
||||
or time.monotonic() - self._loaded_at > self.settings.jwks_stale_grace_seconds
|
||||
):
|
||||
raise AuthError() from None
|
||||
key = self._keys.get(kid)
|
||||
if key is None:
|
||||
raise AuthError()
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key.key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.settings.keycloak_audience,
|
||||
issuer=self.settings.issuer,
|
||||
options={"require": ["exp", "sub"]},
|
||||
leeway=30,
|
||||
)
|
||||
except ExpiredSignatureError as exc:
|
||||
raise AuthError("token_expired") from exc
|
||||
except InvalidTokenError as exc:
|
||||
raise AuthError() from exc
|
||||
subject = claims.get("sub")
|
||||
if not isinstance(subject, str) or not subject:
|
||||
raise AuthError()
|
||||
return Principal(subject, canonical_phone(claims), claims)
|
||||
|
||||
|
||||
def canonical_phone(claims: dict[str, Any]) -> str | None:
|
||||
for name in ("phone_number", "preferred_username"):
|
||||
value = claims.get(name)
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
try:
|
||||
number = phonenumbers.parse(value, None)
|
||||
except phonenumbers.NumberParseException:
|
||||
continue
|
||||
if phonenumbers.is_valid_number(number) and value.startswith("+"):
|
||||
return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
"""Operational command-line entry points."""
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from app.db import AppSetting, Database
|
||||
from app.settings import get_settings
|
||||
|
||||
VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
|
||||
|
||||
|
||||
def load_seed(path: Path) -> list[dict[str, Any]]:
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(document, dict) or document.get("schema_version") != 1:
|
||||
raise ValueError("settings file must have schema_version: 1")
|
||||
settings = document.get("settings")
|
||||
if not isinstance(settings, dict) or not settings:
|
||||
raise ValueError("settings file must contain a non-empty settings mapping")
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key, raw in settings.items():
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
raise ValueError("setting keys must be non-empty strings")
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{key}: setting must be a mapping")
|
||||
value_type = raw.get("type")
|
||||
if value_type not in VALUE_TYPES:
|
||||
raise ValueError(f"{key}: unsupported type {value_type!r}")
|
||||
if not isinstance(raw.get("public"), bool):
|
||||
raise ValueError(f"{key}: public must be a boolean")
|
||||
description = raw.get("description")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise ValueError(f"{key}: description must be a string")
|
||||
rows.append(
|
||||
{
|
||||
"setting_key": key,
|
||||
"setting_value": serialize_value(key, value_type, raw.get("value")),
|
||||
"value_type": value_type,
|
||||
"is_public": raw["public"],
|
||||
"description": description,
|
||||
"record_status": "A",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def serialize_value(key: str, value_type: str, value: Any) -> str:
|
||||
if value_type == "boolean":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{key}: boolean value expected")
|
||||
return str(value).lower()
|
||||
if value_type == "integer":
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"{key}: integer value expected")
|
||||
return str(value)
|
||||
if value_type == "string_list":
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return ",".join(value)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
raise ValueError(f"{key}: string or list of strings expected")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{key}: string value expected")
|
||||
return value
|
||||
|
||||
|
||||
async def seed(path: Path) -> int:
|
||||
rows = load_seed(path)
|
||||
database = Database(get_settings().database_url)
|
||||
try:
|
||||
async with database.sessions() as session:
|
||||
for row in rows:
|
||||
statement = insert(AppSetting).values(**row)
|
||||
excluded = statement.excluded
|
||||
statement = statement.on_conflict_do_update(
|
||||
index_elements=[AppSetting.setting_key],
|
||||
set_={
|
||||
"setting_value": excluded.setting_value,
|
||||
"value_type": excluded.value_type,
|
||||
"is_public": excluded.is_public,
|
||||
"description": excluded.description,
|
||||
"record_status": "A",
|
||||
"updated_at": func.now(),
|
||||
},
|
||||
where=or_(
|
||||
AppSetting.setting_value.is_distinct_from(excluded.setting_value),
|
||||
AppSetting.value_type.is_distinct_from(excluded.value_type),
|
||||
AppSetting.is_public.is_distinct_from(excluded.is_public),
|
||||
AppSetting.description.is_distinct_from(excluded.description),
|
||||
AppSetting.record_status != "A",
|
||||
),
|
||||
)
|
||||
await session.execute(statement)
|
||||
await session.commit()
|
||||
finally:
|
||||
await database.close()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Idempotently seed application settings")
|
||||
parser.add_argument("--file", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
count = asyncio.run(seed(args.file))
|
||||
print(f"Application settings seeded: {count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AppSetting, Database
|
||||
from app.services import REQUIRED_SETTINGS
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
async def validate() -> int:
|
||||
database = Database(get_settings().database_url)
|
||||
try:
|
||||
async with database.sessions() as session:
|
||||
active_keys = set(
|
||||
(
|
||||
await session.execute(
|
||||
select(AppSetting.setting_key).where(AppSetting.record_status == "A")
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
missing = sorted(REQUIRED_SETTINGS - active_keys)
|
||||
if missing:
|
||||
raise RuntimeError(f"Mandatory application settings are missing: {', '.join(missing)}")
|
||||
return len(REQUIRED_SETTINGS)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
count = asyncio.run(validate())
|
||||
print(f"Mandatory application settings validated: {count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,358 @@
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import INET, UUID
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
SCHEMA = "han_app"
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
type_annotation_map = {dict[str, Any]: JSON}
|
||||
|
||||
|
||||
class Common:
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
record_status: Mapped[str] = mapped_column(String(1), default="A", server_default="A")
|
||||
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status_change_reason: Mapped[str | None] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
updater_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
|
||||
|
||||
class UserIdentity(Common, Base):
|
||||
__tablename__ = "user_identities"
|
||||
__table_args__ = (Index("ix_user_identities_phone", "phone_number"), {"schema": SCHEMA})
|
||||
keycloak_sub: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
phone_number: Mapped[str] = mapped_column(String(32))
|
||||
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class ClientProfile(Common, Base):
|
||||
__tablename__ = "client_profiles"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT"), unique=True
|
||||
)
|
||||
bitrix_contact_id: Mapped[str | None] = mapped_column(String(64))
|
||||
full_name: Mapped[str | None] = mapped_column(String(255))
|
||||
citizenship: Mapped[str | None] = mapped_column(String(128))
|
||||
russian_phone: Mapped[str | None] = mapped_column(String(32))
|
||||
foreign_phone: Mapped[str | None] = mapped_column(String(32))
|
||||
email: Mapped[str | None] = mapped_column(String(320))
|
||||
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class UserConsent(Common, Base):
|
||||
__tablename__ = "user_consents"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "consent_type", "document_version"),
|
||||
CheckConstraint("consent_type IN ('personal_data','user_agreement','marketing')"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
consent_type: Mapped[str] = mapped_column(String(32))
|
||||
document_version: Mapped[str] = mapped_column(String(64))
|
||||
accepted: Mapped[bool] = mapped_column(Boolean)
|
||||
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
client_ip: Mapped[str | None] = mapped_column(INET)
|
||||
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class UxSession(Common, Base):
|
||||
__tablename__ = "ux_sessions"
|
||||
__table_args__ = (
|
||||
CheckConstraint("start_reason IN ('first_launch','cold_start','idle_timeout')"),
|
||||
Index("ix_ux_sessions_user_started", "user_id", "started_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
start_reason: Mapped[str] = mapped_column(String(32))
|
||||
platform: Mapped[str] = mapped_column(String(32))
|
||||
app_version: Mapped[str] = mapped_column(String(64))
|
||||
device_id_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Dialog(Common, Base):
|
||||
__tablename__ = "dialogs"
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('open','waiting_for_company','waiting_for_client','closed')"),
|
||||
Index("ix_dialogs_user_updated", "user_id", "updated_at", "id"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Message(Common, Base):
|
||||
__tablename__ = "messages"
|
||||
__table_args__ = (
|
||||
CheckConstraint("sender_type IN ('client','company')"),
|
||||
CheckConstraint("content_kind IN ('text','file')"),
|
||||
CheckConstraint("safety_status IN ('pending','allowed','blocked','needs_review')"),
|
||||
CheckConstraint(
|
||||
"delivery_status IN ('accepted','processing','delivered','rejected','failed')"
|
||||
),
|
||||
Index("ix_messages_dialog_created", "dialog_id", "created_at", "id"),
|
||||
UniqueConstraint("dialog_id", "client_idempotency_key"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
dialog_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.dialogs.id", ondelete="RESTRICT")
|
||||
)
|
||||
sender_type: Mapped[str] = mapped_column(String(16))
|
||||
content_kind: Mapped[str] = mapped_column(String(16))
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
safety_status: Mapped[str] = mapped_column(String(16))
|
||||
delivery_status: Mapped[str] = mapped_column(String(16))
|
||||
external_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
client_idempotency_key: Mapped[str | None] = mapped_column(String(128))
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class MessageAttachment(Common, Base):
|
||||
__tablename__ = "message_attachments"
|
||||
__table_args__ = (
|
||||
CheckConstraint("direction IN ('client_upload','company_inbound')"),
|
||||
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
|
||||
CheckConstraint("size_bytes > 0"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
dialog_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.dialogs.id"))
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.messages.id"), unique=True
|
||||
)
|
||||
owner_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
|
||||
direction: Mapped[str] = mapped_column(String(32))
|
||||
original_file_name: Mapped[str] = mapped_column(String(255))
|
||||
safe_file_name: Mapped[str] = mapped_column(String(255))
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Document(Common, Base):
|
||||
__tablename__ = "documents"
|
||||
__table_args__ = (UniqueConstraint("storage_bucket", "object_key"), {"schema": SCHEMA})
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum_sha256: Mapped[str] = mapped_column(String(64))
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class SafetyTask(Base):
|
||||
__tablename__ = "safety_tasks"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
task_id: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
deadline_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
next_poll_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class DeliveryOutbox(Base):
|
||||
__tablename__ = "delivery_outbox"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class IdempotencyRecord(Base):
|
||||
__tablename__ = "idempotency_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("scope", "user_id", "idempotency_key"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
scope: Mapped[str] = mapped_column(String(128))
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
idempotency_key: Mapped[str] = mapped_column(String(128))
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
response_status: Mapped[int | None] = mapped_column(Integer)
|
||||
response_body_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
resource_type: Mapped[str | None] = mapped_column(String(64))
|
||||
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class OpenLinesInboxReceipt(Base):
|
||||
__tablename__ = "openlines_inbox_receipts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("event_id"),
|
||||
UniqueConstraint("external_chat_id", "bitrix_message_id"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
event_id: Mapped[str] = mapped_column(String(255))
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
event_type: Mapped[str] = mapped_column(String(32))
|
||||
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
event_type: Mapped[str] = mapped_column(String(128))
|
||||
actor_type: Mapped[str] = mapped_column(String(32))
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
request_id: Mapped[str] = mapped_column(String(64))
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64))
|
||||
resource_type: Mapped[str | None] = mapped_column(String(64))
|
||||
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
ip: Mapped[str | None] = mapped_column(INET)
|
||||
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
outcome: Mapped[str] = mapped_column(String(32))
|
||||
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
setting_key: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
setting_value: Mapped[str] = mapped_column(Text)
|
||||
value_type: Mapped[str] = mapped_column(String(32))
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
record_status: Mapped[str] = mapped_column(String(1), default="A")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class TextResource(Common, Base):
|
||||
__tablename__ = "text_resources"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("mnemonic", "locale"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
mnemonic: Mapped[str] = mapped_column(String(255))
|
||||
locale: Mapped[str] = mapped_column(String(16), default="ru")
|
||||
text_value: Mapped[str] = mapped_column(Text)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class PopularQuestion(Common, Base):
|
||||
__tablename__ = "popular_questions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("mnemonic", "locale"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
mnemonic: Mapped[str] = mapped_column(String(255))
|
||||
locale: Mapped[str] = mapped_column(String(16), default="ru")
|
||||
question_text: Mapped[str] = mapped_column(Text)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class SyncQueue(Base):
|
||||
__tablename__ = "sync_queue"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
task_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
dedup_key: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class EntityExternalMapping(Base):
|
||||
__tablename__ = "entity_external_mapping"
|
||||
__table_args__ = (UniqueConstraint("entity_type", "entity_id"), {"schema": SCHEMA})
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entity_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
external_id: Mapped[str] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, url: str) -> None:
|
||||
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
|
||||
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
||||
|
||||
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||
async with self.sessions() as session:
|
||||
yield session
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.engine.dispose()
|
||||
@@ -0,0 +1,338 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
from botocore.config import Config
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.settings import Settings
|
||||
|
||||
RATE_LIMIT_LUA = """
|
||||
local current = redis.call('INCR', KEYS[1])
|
||||
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
return {current, ttl}
|
||||
"""
|
||||
|
||||
|
||||
class DependencyFailure(Exception):
|
||||
def __init__(self, code: str = "dependency_unavailable", timeout: bool = False) -> None:
|
||||
self.code = code
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CircuitBreaker:
|
||||
threshold: int
|
||||
open_seconds: float
|
||||
failures: int = 0
|
||||
opened_at: float | None = None
|
||||
|
||||
def allow(self) -> bool:
|
||||
if self.opened_at is None:
|
||||
return True
|
||||
if time.monotonic() - self.opened_at >= self.open_seconds:
|
||||
self.opened_at = None
|
||||
self.failures = max(0, self.threshold - 1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def success(self) -> None:
|
||||
self.failures = 0
|
||||
self.opened_at = None
|
||||
|
||||
def failure(self) -> None:
|
||||
self.failures += 1
|
||||
if self.failures >= self.threshold:
|
||||
self.opened_at = time.monotonic()
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self.redis = redis
|
||||
|
||||
async def consume(self, key: str, limit: int, window: int) -> int:
|
||||
try:
|
||||
count, ttl = await self.redis.eval(RATE_LIMIT_LUA, 1, key, window)
|
||||
except Exception as exc:
|
||||
raise DependencyFailure() from exc
|
||||
if int(count) > limit:
|
||||
return max(1, int(ttl))
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def key(identity_type: str, identity: str, route: str, window: int) -> str:
|
||||
safe_identity = hashlib.sha256(identity.encode()).hexdigest()[:32]
|
||||
bucket = int(time.time()) // window
|
||||
return f"han:api:rl:{identity_type}:{safe_identity}:{route}:{bucket}"
|
||||
|
||||
|
||||
class RedisIdempotency:
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self.redis = redis
|
||||
|
||||
async def get(self, scope: str, user_id: uuid.UUID, key: str) -> dict[str, Any] | None:
|
||||
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
||||
raw = await self.redis.get(f"han:api:idem:{scope}:{user_id}:{key_hash}")
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
async def put(self, scope: str, user_id: uuid.UUID, key: str, value: dict[str, Any]) -> None:
|
||||
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
||||
await self.redis.set(
|
||||
f"han:api:idem:{scope}:{user_id}:{key_hash}",
|
||||
json.dumps(value, separators=(",", ":"), default=str),
|
||||
ex=86400,
|
||||
)
|
||||
|
||||
|
||||
class SafetyClient:
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
self.settings = settings
|
||||
self.http = http
|
||||
self.breaker = CircuitBreaker(
|
||||
settings.message_safety_circuit_failure_threshold,
|
||||
settings.message_safety_circuit_open_sec,
|
||||
)
|
||||
|
||||
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"POST",
|
||||
"/internal/safety/v1/messages/check",
|
||||
request_id,
|
||||
json=payload,
|
||||
timeout=self.settings.message_safety_post_timeout_sec,
|
||||
)
|
||||
|
||||
async def poll(self, task_id: str, request_id: str) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"GET",
|
||||
f"/internal/safety/v1/messages/tasks/{task_id}",
|
||||
request_id,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
async def _call(self, method: str, path: str, request_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
if not self.breaker.allow():
|
||||
raise DependencyFailure()
|
||||
headers = {
|
||||
"X-Service-Token": self.settings.message_safety_service_token.get_secret_value(),
|
||||
"X-Request-ID": request_id,
|
||||
}
|
||||
try:
|
||||
response = await self.http.request(
|
||||
method,
|
||||
f"{str(self.settings.message_safety_url).rstrip('/')}{path}",
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure(timeout=True) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure() from exc
|
||||
if response.status_code == 401 or response.status_code >= 500:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure()
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure() from exc
|
||||
if not isinstance(body, dict):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure()
|
||||
self.breaker.success()
|
||||
body["_status"] = response.status_code
|
||||
return body
|
||||
|
||||
|
||||
class OpenLinesClient:
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
self.settings = settings
|
||||
self.http = http
|
||||
self.breaker = CircuitBreaker(
|
||||
settings.bitrix_local_app_circuit_failure_threshold,
|
||||
settings.bitrix_local_app_circuit_open_sec,
|
||||
)
|
||||
|
||||
async def send(
|
||||
self, message_id: uuid.UUID, payload: dict[str, Any], request_id: str
|
||||
) -> dict[str, Any]:
|
||||
if not self.breaker.allow():
|
||||
raise DependencyFailure()
|
||||
try:
|
||||
response = await self.http.post(
|
||||
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
|
||||
"/internal/openlines/v1/messages",
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": "Bearer "
|
||||
+ self.settings.bitrix_local_app_internal_token.get_secret_value(),
|
||||
"Idempotency-Key": str(message_id),
|
||||
"X-Request-ID": request_id,
|
||||
},
|
||||
timeout=self.settings.bitrix_local_app_http_timeout_sec,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.TimeoutException as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure(timeout=True) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure() from exc
|
||||
self.breaker.success()
|
||||
return response.json()
|
||||
|
||||
async def ready(self) -> bool:
|
||||
try:
|
||||
response = await self.http.get(
|
||||
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
|
||||
"/internal/openlines/v1/status",
|
||||
headers={
|
||||
"Authorization": "Bearer "
|
||||
+ self.settings.bitrix_local_app_internal_token.get_secret_value()
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
return response.is_success
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
async def fresh_openlines_payload(
|
||||
payload: dict[str, Any], s3: "S3Client"
|
||||
) -> dict[str, Any]:
|
||||
result = json.loads(json.dumps(payload, default=str))
|
||||
for file in result.get("message", {}).get("files", []):
|
||||
bucket = file.pop("_storage_bucket")
|
||||
key = file.pop("_object_key")
|
||||
file["download_url"] = await s3.presign_get(bucket, key)
|
||||
return result
|
||||
|
||||
|
||||
class S3Client:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=str(settings.selectel_s3_endpoint_url),
|
||||
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
|
||||
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
|
||||
config=Config(connect_timeout=3, read_timeout=10, retries={"max_attempts": 2}),
|
||||
)
|
||||
|
||||
async def ready(self) -> bool:
|
||||
try:
|
||||
for bucket in (
|
||||
self.settings.selectel_s3_bucket_quarantine,
|
||||
self.settings.selectel_s3_bucket_attachments,
|
||||
self.settings.selectel_s3_bucket_documents,
|
||||
):
|
||||
await asyncio.to_thread(self.client.head_bucket, Bucket=bucket)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def presign_put(self, key: str, mime: str, ttl: int) -> str:
|
||||
return await asyncio.to_thread(
|
||||
self.client.generate_presigned_url,
|
||||
"put_object",
|
||||
Params={
|
||||
"Bucket": self.settings.selectel_s3_bucket_quarantine,
|
||||
"Key": key,
|
||||
"ContentType": mime,
|
||||
},
|
||||
ExpiresIn=ttl,
|
||||
)
|
||||
|
||||
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
|
||||
return await asyncio.to_thread(
|
||||
self.client.generate_presigned_url,
|
||||
"get_object",
|
||||
Params={"Bucket": bucket, "Key": key},
|
||||
ExpiresIn=ttl,
|
||||
)
|
||||
|
||||
async def head(self, bucket: str, key: str) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(self.client.head_object, Bucket=bucket, Key=key)
|
||||
|
||||
async def promote(self, source_key: str, destination_key: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
self.client.copy_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_attachments,
|
||||
Key=destination_key,
|
||||
CopySource={
|
||||
"Bucket": self.settings.selectel_s3_bucket_quarantine,
|
||||
"Key": source_key,
|
||||
},
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.client.delete_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_quarantine,
|
||||
Key=source_key,
|
||||
)
|
||||
|
||||
async def delete_quarantine(self, key: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
self.client.delete_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_quarantine,
|
||||
Key=key,
|
||||
)
|
||||
|
||||
async def upload_inbound(
|
||||
self,
|
||||
http: httpx.AsyncClient,
|
||||
download_url: str,
|
||||
destination_key: str,
|
||||
mime_type: str,
|
||||
max_bytes: int,
|
||||
) -> tuple[int, str]:
|
||||
parsed = urlparse(download_url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise DependencyFailure("unsafe_inbound_url")
|
||||
try:
|
||||
addresses = await asyncio.to_thread(
|
||||
socket.getaddrinfo, parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM
|
||||
)
|
||||
except OSError as exc:
|
||||
raise DependencyFailure("unsafe_inbound_url") from exc
|
||||
if any(
|
||||
ipaddress.ip_address(address[4][0]).is_private
|
||||
or ipaddress.ip_address(address[4][0]).is_loopback
|
||||
or ipaddress.ip_address(address[4][0]).is_link_local
|
||||
or ipaddress.ip_address(address[4][0]).is_reserved
|
||||
for address in addresses
|
||||
):
|
||||
raise DependencyFailure("unsafe_inbound_url")
|
||||
data = bytearray()
|
||||
try:
|
||||
async with http.stream(
|
||||
"GET", download_url, timeout=10, follow_redirects=False
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
if response.headers.get("content-type", "").split(";")[0] != mime_type:
|
||||
raise DependencyFailure("inbound_mime_mismatch")
|
||||
async for chunk in response.aiter_bytes():
|
||||
data.extend(chunk)
|
||||
if len(data) > max_bytes:
|
||||
raise DependencyFailure("inbound_file_too_large")
|
||||
except httpx.HTTPError as exc:
|
||||
raise DependencyFailure() from exc
|
||||
await asyncio.to_thread(
|
||||
self.client.put_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_attachments,
|
||||
Key=destination_key,
|
||||
Body=bytes(data),
|
||||
ContentType=mime_type,
|
||||
)
|
||||
return len(data), hashlib.sha256(data).hexdigest()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
CHANNEL_PREFIX = "han:realtime:dialog:"
|
||||
|
||||
|
||||
class LocalFanout:
|
||||
def __init__(self) -> None:
|
||||
self._queues: set[asyncio.Queue[dict[str, Any]]] = set()
|
||||
|
||||
async def publish(self, event: dict[str, Any]) -> None:
|
||||
for queue in tuple(self._queues):
|
||||
with suppress(asyncio.QueueFull):
|
||||
queue.put_nowait(event)
|
||||
|
||||
async def subscribe(self) -> AsyncIterator[dict[str, Any]]:
|
||||
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
|
||||
self._queues.add(queue)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
self._queues.discard(queue)
|
||||
|
||||
|
||||
class RealtimeFanout:
|
||||
def __init__(self, redis: Redis, local: LocalFanout | None = None) -> None:
|
||||
self.redis = redis
|
||||
self.local = local or LocalFanout()
|
||||
|
||||
async def publish(self, event: dict[str, Any]) -> None:
|
||||
event = {"event_id": str(uuid.uuid4()), **event}
|
||||
channel = CHANNEL_PREFIX + str(event["dialog_id"])
|
||||
try:
|
||||
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
|
||||
except Exception:
|
||||
await self.local.publish(event)
|
||||
|
||||
async def events(self, dialog_ids: set[uuid.UUID]) -> AsyncIterator[dict[str, Any]]:
|
||||
channels = [CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
|
||||
pubsub = self.redis.pubsub()
|
||||
try:
|
||||
await pubsub.subscribe(*channels)
|
||||
except Exception:
|
||||
await pubsub.aclose()
|
||||
async for event in self.local.subscribe():
|
||||
if uuid.UUID(str(event["dialog_id"])) in dialog_ids:
|
||||
yield event
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1)
|
||||
if message:
|
||||
yield json.loads(message["data"])
|
||||
else:
|
||||
await asyncio.sleep(0)
|
||||
finally:
|
||||
await pubsub.unsubscribe(*channels)
|
||||
await pubsub.aclose()
|
||||
@@ -0,0 +1,148 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class Device(StrictModel):
|
||||
platform: Literal["ios", "android", "web"]
|
||||
app_version: str = Field(min_length=1, max_length=64)
|
||||
device_id: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class ConsentChoice(StrictModel):
|
||||
accepted: bool
|
||||
version: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class ConsentSet(StrictModel):
|
||||
personal_data: ConsentChoice
|
||||
user_agreement: ConsentChoice
|
||||
marketing: ConsentChoice
|
||||
|
||||
|
||||
class BootstrapRequest(StrictModel):
|
||||
consents: ConsentSet
|
||||
device: Device
|
||||
|
||||
|
||||
class ConsentsRequest(StrictModel):
|
||||
consents: ConsentSet
|
||||
|
||||
|
||||
class SessionStartRequest(StrictModel):
|
||||
start_reason: Literal["first_launch", "cold_start", "idle_timeout"]
|
||||
device: Device
|
||||
|
||||
|
||||
class TextMessageRequest(StrictModel):
|
||||
content_kind: Literal["text"]
|
||||
text: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class FileMessageRequest(StrictModel):
|
||||
content_kind: Literal["file"]
|
||||
attachment_id: uuid.UUID
|
||||
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
MessageRequest = Annotated[
|
||||
TextMessageRequest | FileMessageRequest, Field(discriminator="content_kind")
|
||||
]
|
||||
|
||||
|
||||
class AttachmentInitRequest(StrictModel):
|
||||
file_name: str = Field(min_length=1, max_length=255)
|
||||
mime_type: str = Field(min_length=1, max_length=128)
|
||||
size_bytes: int = Field(gt=0)
|
||||
|
||||
|
||||
class AttachmentCompleteRequest(StrictModel):
|
||||
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class OpenLinesFile(StrictModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
mime_type: str = Field(min_length=1, max_length=128)
|
||||
size_bytes: int = Field(gt=0)
|
||||
download_url: HttpUrl
|
||||
|
||||
|
||||
class OpenLinesMessage(StrictModel):
|
||||
text: str = Field(default="", max_length=4000)
|
||||
files: list[OpenLinesFile] = Field(default_factory=list, max_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def non_empty(self) -> "OpenLinesMessage":
|
||||
if not self.text.strip() and not self.files:
|
||||
raise ValueError("message must contain text or file")
|
||||
return self
|
||||
|
||||
|
||||
class OpenLinesInbox(StrictModel):
|
||||
event_id: str = Field(min_length=1, max_length=255)
|
||||
event_type: Literal["message.new", "dialog.closed"]
|
||||
external_chat_id: uuid.UUID
|
||||
bitrix_message_id: str | None = Field(default=None, max_length=255)
|
||||
occurred_at: datetime
|
||||
message: OpenLinesMessage | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def event_shape(self) -> "OpenLinesInbox":
|
||||
if self.event_type == "message.new" and (
|
||||
not self.bitrix_message_id or self.message is None
|
||||
):
|
||||
raise ValueError("message.new requires bitrix_message_id and message")
|
||||
return self
|
||||
|
||||
|
||||
class DialogStatus(StrEnum):
|
||||
OPEN = "open"
|
||||
WAITING_COMPANY = "waiting_for_company"
|
||||
WAITING_CLIENT = "waiting_for_client"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
def canonical_fingerprint(
|
||||
method: str, route: str, path_params: dict[str, str], body: object, user_id: uuid.UUID
|
||||
) -> str:
|
||||
value = {
|
||||
"method": method.upper(),
|
||||
"route": route,
|
||||
"path": dict(sorted(path_params.items())),
|
||||
"body": body,
|
||||
"user_id": str(user_id),
|
||||
}
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def encode_cursor(data: dict[str, str], secret: bytes) -> str:
|
||||
payload = json.dumps({"v": 1, **data}, sort_keys=True, separators=(",", ":")).encode()
|
||||
signature = hmac.digest(secret, payload, "sha256")
|
||||
return urlsafe_b64encode(payload + signature).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_cursor(value: str, secret: bytes) -> dict[str, str]:
|
||||
try:
|
||||
raw = urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
payload, signature = raw[:-32], raw[-32:]
|
||||
if not hmac.compare_digest(signature, hmac.digest(secret, payload, "sha256")):
|
||||
raise ValueError("invalid cursor")
|
||||
decoded = json.loads(payload)
|
||||
if decoded.pop("v") != 1:
|
||||
raise ValueError("unsupported cursor")
|
||||
return decoded
|
||||
except (ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("invalid cursor") from exc
|
||||
@@ -0,0 +1,930 @@
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import PurePath
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import Principal
|
||||
from app.db import (
|
||||
AppSetting,
|
||||
AuditEvent,
|
||||
ClientProfile,
|
||||
DeliveryOutbox,
|
||||
Dialog,
|
||||
Document,
|
||||
IdempotencyRecord,
|
||||
Message,
|
||||
MessageAttachment,
|
||||
OpenLinesInboxReceipt,
|
||||
SafetyTask,
|
||||
UserConsent,
|
||||
UserIdentity,
|
||||
UxSession,
|
||||
)
|
||||
from app.integrations import (
|
||||
DependencyFailure,
|
||||
OpenLinesClient,
|
||||
S3Client,
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.schemas import (
|
||||
AttachmentCompleteRequest,
|
||||
AttachmentInitRequest,
|
||||
BootstrapRequest,
|
||||
ConsentsRequest,
|
||||
FileMessageRequest,
|
||||
MessageRequest,
|
||||
OpenLinesInbox,
|
||||
SessionStartRequest,
|
||||
encode_cursor,
|
||||
)
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
class DomainError(Exception):
|
||||
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
|
||||
self.code, self.status, self.message = code, status, message
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
REQUIRED_SETTINGS = {
|
||||
"auth.phone.enabled",
|
||||
"auth.password.enabled",
|
||||
"otp.phone.max_send_attempts_per_24h",
|
||||
"otp.phone.min_seconds_between_attempts",
|
||||
"operator.call.phone",
|
||||
"consent.personal_data.required",
|
||||
"consent.personal_data.document_url",
|
||||
"consent.personal_data.version",
|
||||
"consent.user_agreement.required",
|
||||
"consent.user_agreement.document_url",
|
||||
"consent.user_agreement.version",
|
||||
"consent.marketing.required",
|
||||
"consent.marketing.version",
|
||||
"chat.attachments.allowed_extensions",
|
||||
"chat.attachments.allowed_mime_types",
|
||||
"chat.attachments.max_size_mb",
|
||||
"chat.attachments.presigned_upload_ttl_seconds",
|
||||
"rate_limit.message_send.per_user",
|
||||
"rate_limit.message_send.per_dialog",
|
||||
"rate_limit.download_url.per_user",
|
||||
"rate_limit.public_endpoints.per_ip",
|
||||
"ux.session.idle_timeout_minutes",
|
||||
"security.cors.allowed_origins",
|
||||
"security.public_cache.max_age_seconds",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SettingsSnapshot:
|
||||
values: dict[str, str]
|
||||
version: str
|
||||
|
||||
def boolean(self, key: str) -> bool:
|
||||
return self.values[key].lower() == "true"
|
||||
|
||||
def integer(self, key: str) -> int:
|
||||
return int(self.values[key])
|
||||
|
||||
def strings(self, key: str) -> list[str]:
|
||||
return [item.strip() for item in self.values[key].split(",") if item.strip()]
|
||||
|
||||
def limit(self, key: str) -> tuple[int, int]:
|
||||
amount, period = self.values[key].split("/", 1)
|
||||
windows = {"second": 1, "minute": 60, "hour": 3600, "day": 86400}
|
||||
return int(amount), windows[period]
|
||||
|
||||
|
||||
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
||||
rows = (
|
||||
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
|
||||
).scalars()
|
||||
values = {row.setting_key: row.setting_value for row in rows}
|
||||
missing = REQUIRED_SETTINGS - values.keys()
|
||||
if missing:
|
||||
raise DomainError(
|
||||
"dependency_unavailable",
|
||||
503,
|
||||
"Required settings are unavailable",
|
||||
{"missing": sorted(missing)},
|
||||
)
|
||||
version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24]
|
||||
return SettingsSnapshot(values, version)
|
||||
|
||||
|
||||
async def resolve_user(session: AsyncSession, principal: Principal) -> UserIdentity:
|
||||
user = (
|
||||
await session.execute(
|
||||
select(UserIdentity).where(
|
||||
UserIdentity.keycloak_sub == principal.subject, UserIdentity.record_status == "A"
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise DomainError("resource_state_conflict", 409, "Bootstrap required")
|
||||
return user
|
||||
|
||||
|
||||
def audit(
|
||||
event_type: str,
|
||||
request_id: str,
|
||||
user_id: uuid.UUID | None,
|
||||
resource_type: str | None = None,
|
||||
resource_id: uuid.UUID | None = None,
|
||||
ux_session_id: uuid.UUID | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> AuditEvent:
|
||||
return AuditEvent(
|
||||
event_type=event_type,
|
||||
actor_type="user" if user_id else "service",
|
||||
user_id=user_id,
|
||||
ux_session_id=ux_session_id,
|
||||
request_id=request_id,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
outcome="success",
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
def validate_consents(consents: Any, snapshot: SettingsSnapshot) -> None:
|
||||
for name in ("personal_data", "user_agreement", "marketing"):
|
||||
choice = getattr(consents, name)
|
||||
if choice.version != snapshot.values[f"consent.{name}.version"]:
|
||||
raise DomainError(
|
||||
"validation_error", 400, "Consent version is not current", {"field": name}
|
||||
)
|
||||
if snapshot.boolean(f"consent.{name}.required") and not choice.accepted:
|
||||
raise DomainError("consents_required", 403, "Required consents must be accepted")
|
||||
|
||||
|
||||
async def bootstrap(
|
||||
session: AsyncSession,
|
||||
principal: Principal,
|
||||
body: BootstrapRequest,
|
||||
snapshot: SettingsSnapshot,
|
||||
request_id: str,
|
||||
) -> dict[str, Any]:
|
||||
if not principal.phone_number:
|
||||
raise DomainError("phone_claim_missing", 400, "Verified phone claim is missing")
|
||||
validate_consents(body.consents, snapshot)
|
||||
now = datetime.now(UTC)
|
||||
statement = (
|
||||
insert(UserIdentity)
|
||||
.values(
|
||||
id=uuid.uuid4(),
|
||||
keycloak_sub=principal.subject,
|
||||
phone_number=principal.phone_number,
|
||||
last_login_at=now,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[UserIdentity.keycloak_sub],
|
||||
set_={"phone_number": principal.phone_number, "last_login_at": now, "updated_at": now},
|
||||
)
|
||||
.returning(UserIdentity.id)
|
||||
)
|
||||
user_id = (await session.execute(statement)).scalar_one()
|
||||
await session.execute(
|
||||
insert(ClientProfile)
|
||||
.values(id=uuid.uuid4(), user_id=user_id)
|
||||
.on_conflict_do_nothing(index_elements=[ClientProfile.user_id])
|
||||
)
|
||||
for consent_type in ("personal_data", "user_agreement", "marketing"):
|
||||
choice = getattr(body.consents, consent_type)
|
||||
await session.execute(
|
||||
insert(UserConsent)
|
||||
.values(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
consent_type=consent_type,
|
||||
document_version=choice.version,
|
||||
accepted=choice.accepted,
|
||||
accepted_at=now,
|
||||
)
|
||||
.on_conflict_do_nothing(
|
||||
index_elements=[
|
||||
UserConsent.user_id,
|
||||
UserConsent.consent_type,
|
||||
UserConsent.document_version,
|
||||
]
|
||||
)
|
||||
)
|
||||
session.add(audit("auth.bootstrap", request_id, user_id))
|
||||
await session.commit()
|
||||
return {"user_id": user_id, "profile_ready": True}
|
||||
|
||||
|
||||
async def record_consents(
|
||||
session: AsyncSession,
|
||||
user: UserIdentity,
|
||||
body: ConsentsRequest,
|
||||
snapshot: SettingsSnapshot,
|
||||
request_id: str,
|
||||
ux_session_id: uuid.UUID | None,
|
||||
) -> dict[str, Any]:
|
||||
validate_consents(body.consents, snapshot)
|
||||
now = datetime.now(UTC)
|
||||
versions: dict[str, str] = {}
|
||||
for consent_type in ("personal_data", "user_agreement", "marketing"):
|
||||
choice = getattr(body.consents, consent_type)
|
||||
versions[consent_type] = choice.version
|
||||
await session.execute(
|
||||
insert(UserConsent)
|
||||
.values(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
ux_session_id=ux_session_id,
|
||||
consent_type=consent_type,
|
||||
document_version=choice.version,
|
||||
accepted=choice.accepted,
|
||||
accepted_at=now,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
session.add(audit("consent.recorded", request_id, user.id, ux_session_id=ux_session_id))
|
||||
await session.commit()
|
||||
return {"recorded_at": now, "versions": versions}
|
||||
|
||||
|
||||
async def start_session(
|
||||
session: AsyncSession, user: UserIdentity, body: SessionStartRequest, request_id: str
|
||||
) -> dict[str, Any]:
|
||||
now, session_id = datetime.now(UTC), uuid.uuid4()
|
||||
device_hash = (
|
||||
hashlib.sha256(body.device.device_id.encode()).hexdigest()
|
||||
if body.device.device_id
|
||||
else None
|
||||
)
|
||||
session.add(
|
||||
UxSession(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
start_reason=body.start_reason,
|
||||
platform=body.device.platform,
|
||||
app_version=body.device.app_version,
|
||||
device_id_hash=device_hash,
|
||||
started_at=now,
|
||||
)
|
||||
)
|
||||
session.add(audit("session_start", request_id, user.id, ux_session_id=session_id))
|
||||
await session.commit()
|
||||
return {"ux_session_id": session_id, "started_at": now}
|
||||
|
||||
|
||||
async def get_profile(session: AsyncSession, user: UserIdentity) -> dict[str, Any]:
|
||||
profile = (
|
||||
await session.execute(
|
||||
select(ClientProfile).where(
|
||||
ClientProfile.user_id == user.id, ClientProfile.record_status == "A"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
document_count = (
|
||||
await session.scalar(
|
||||
select(func.count(Document.id)).where(
|
||||
Document.user_id == user.id, Document.record_status == "A"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"profile": {
|
||||
"personal_data": {
|
||||
"full_name": profile.full_name,
|
||||
"citizenship": profile.citizenship,
|
||||
"russian_phone": profile.russian_phone,
|
||||
"foreign_phone": profile.foreign_phone,
|
||||
"email": profile.email,
|
||||
},
|
||||
"documents": {"count": document_count},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def dialog_dto(dialog: Dialog) -> dict[str, Any]:
|
||||
return {
|
||||
"dialog_id": dialog.id,
|
||||
"status": dialog.status,
|
||||
"last_message_preview": None,
|
||||
"unread_count": 0,
|
||||
"created_at": dialog.created_at,
|
||||
"updated_at": dialog.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def message_dto(
|
||||
message: Message, attachments: list[MessageAttachment] | None = None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"message_id": message.id,
|
||||
"dialog_id": message.dialog_id,
|
||||
"sender_type": message.sender_type,
|
||||
"content_kind": message.content_kind,
|
||||
"text": message.text,
|
||||
"attachments": [
|
||||
{
|
||||
"attachment_id": item.id,
|
||||
"file_name": item.safe_file_name,
|
||||
"mime_type": item.mime_type,
|
||||
"size_bytes": item.size_bytes,
|
||||
"scan_status": item.scan_status,
|
||||
}
|
||||
for item in (attachments or [])
|
||||
],
|
||||
"safety_status": message.safety_status,
|
||||
"delivery_status": message.delivery_status,
|
||||
"created_at": message.created_at,
|
||||
}
|
||||
|
||||
|
||||
def message_cursor(message: Message, settings: Settings) -> str:
|
||||
return encode_cursor(
|
||||
{"created_at": message.created_at.isoformat(), "id": str(message.id)},
|
||||
settings.cursor_hmac_secret.get_secret_value().encode(),
|
||||
)
|
||||
|
||||
|
||||
async def publish_message(
|
||||
fanout: RealtimeFanout,
|
||||
message: Message,
|
||||
settings: Settings,
|
||||
attachments: list[MessageAttachment] | None = None,
|
||||
) -> None:
|
||||
await fanout.publish(
|
||||
{
|
||||
"type": "message.new",
|
||||
"dialog_id": str(message.dialog_id),
|
||||
"message": message_dto(message, attachments),
|
||||
"cursor": message_cursor(message, settings),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def publish_message_status(
|
||||
fanout: RealtimeFanout, message: Message, settings: Settings
|
||||
) -> None:
|
||||
await fanout.publish(
|
||||
{
|
||||
"type": "message.status",
|
||||
"dialog_id": str(message.dialog_id),
|
||||
"message_id": str(message.id),
|
||||
"safety_status": message.safety_status,
|
||||
"delivery_status": message.delivery_status,
|
||||
"cursor": message_cursor(message, settings),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def publish_dialog_status(fanout: RealtimeFanout, dialog: Dialog) -> None:
|
||||
await fanout.publish(
|
||||
{"type": "dialog.status", "dialog_id": str(dialog.id), "status": dialog.status}
|
||||
)
|
||||
|
||||
|
||||
async def owned_dialog(session: AsyncSession, user_id: uuid.UUID, dialog_id: uuid.UUID) -> Dialog:
|
||||
dialog = (
|
||||
await session.execute(
|
||||
select(Dialog).where(
|
||||
Dialog.id == dialog_id, Dialog.user_id == user_id, Dialog.record_status == "A"
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if dialog is None:
|
||||
raise DomainError("not_found", 404, "Resource was not found")
|
||||
return dialog
|
||||
|
||||
|
||||
async def create_dialog(
|
||||
session: AsyncSession, user: UserIdentity, request_id: str, idempotency_key: str
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
scope = "dialogs.create"
|
||||
fingerprint = hashlib.sha256(f"POST:/api/v1/dialogs:{user.id}".encode()).hexdigest()
|
||||
record = (
|
||||
await session.execute(
|
||||
select(IdempotencyRecord).where(
|
||||
IdempotencyRecord.scope == scope,
|
||||
IdempotencyRecord.user_id == user.id,
|
||||
IdempotencyRecord.idempotency_key == idempotency_key,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record:
|
||||
if record.request_fingerprint != fingerprint:
|
||||
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
||||
if record.status == "completed" and record.response_body_json:
|
||||
return record.response_body_json, record.response_status or 200
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Dialog).where(
|
||||
Dialog.user_id == user.id,
|
||||
Dialog.record_status == "A",
|
||||
Dialog.status.in_(["open", "waiting_for_company", "waiting_for_client"]),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
result = dialog_dto(existing)
|
||||
session.add(
|
||||
IdempotencyRecord(
|
||||
scope=scope,
|
||||
user_id=user.id,
|
||||
idempotency_key=idempotency_key,
|
||||
request_fingerprint=fingerprint,
|
||||
status="completed",
|
||||
response_status=200,
|
||||
response_body_json=json.loads(json.dumps(result, default=str)),
|
||||
resource_type="dialog",
|
||||
resource_id=existing.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=24),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return result, 200
|
||||
dialog = Dialog(id=uuid.uuid4(), user_id=user.id, status="open")
|
||||
session.add(dialog)
|
||||
session.add(audit("dialog.created", request_id, user.id, "dialog", dialog.id))
|
||||
result = dialog_dto(dialog)
|
||||
session.add(
|
||||
IdempotencyRecord(
|
||||
scope=scope,
|
||||
user_id=user.id,
|
||||
idempotency_key=idempotency_key,
|
||||
request_fingerprint=fingerprint,
|
||||
status="completed",
|
||||
response_status=201,
|
||||
response_body_json=json.loads(json.dumps(result, default=str)),
|
||||
resource_type="dialog",
|
||||
resource_id=dialog.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=24),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(dialog)
|
||||
return dialog_dto(dialog), 201
|
||||
|
||||
|
||||
async def init_attachment(
|
||||
session: AsyncSession,
|
||||
user: UserIdentity,
|
||||
dialog_id: uuid.UUID,
|
||||
body: AttachmentInitRequest,
|
||||
snapshot: SettingsSnapshot,
|
||||
s3: S3Client,
|
||||
request_id: str,
|
||||
) -> dict[str, Any]:
|
||||
await owned_dialog(session, user.id, dialog_id)
|
||||
extension = PurePath(body.file_name).suffix.lower().lstrip(".")
|
||||
if (
|
||||
extension not in snapshot.strings("chat.attachments.allowed_extensions")
|
||||
or body.mime_type not in snapshot.strings("chat.attachments.allowed_mime_types")
|
||||
or body.size_bytes > snapshot.integer("chat.attachments.max_size_mb") * 1024 * 1024
|
||||
):
|
||||
raise DomainError("validation_error", 400, "File type or size is not allowed")
|
||||
attachment_id = uuid.uuid4()
|
||||
key = f"quarantine/users/{user.id}/dialogs/{dialog_id}/{attachment_id}"
|
||||
ttl = snapshot.integer("chat.attachments.presigned_upload_ttl_seconds")
|
||||
expires = datetime.now(UTC) + timedelta(seconds=ttl)
|
||||
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", unicodedata.normalize("NFKC", body.file_name))
|
||||
item = MessageAttachment(
|
||||
id=attachment_id,
|
||||
dialog_id=dialog_id,
|
||||
owner_user_id=user.id,
|
||||
direction="client_upload",
|
||||
original_file_name=body.file_name,
|
||||
safe_file_name=safe_name,
|
||||
mime_type=body.mime_type,
|
||||
size_bytes=body.size_bytes,
|
||||
scan_status="pending",
|
||||
storage_bucket=s3.settings.selectel_s3_bucket_quarantine,
|
||||
object_key=key,
|
||||
quarantine_object_key=key,
|
||||
upload_expires_at=expires,
|
||||
)
|
||||
session.add(item)
|
||||
session.add(
|
||||
audit("attachment.upload_initialized", request_id, user.id, "attachment", attachment_id)
|
||||
)
|
||||
await session.commit()
|
||||
url = await s3.presign_put(key, body.mime_type, ttl)
|
||||
return {
|
||||
"attachment_id": attachment_id,
|
||||
"upload_url": url,
|
||||
"upload_headers": {"Content-Type": body.mime_type},
|
||||
"expires_at": expires,
|
||||
}
|
||||
|
||||
|
||||
async def complete_attachment(
|
||||
session: AsyncSession,
|
||||
user: UserIdentity,
|
||||
dialog_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
body: AttachmentCompleteRequest,
|
||||
s3: S3Client,
|
||||
request_id: str,
|
||||
) -> dict[str, Any]:
|
||||
item = (
|
||||
await session.execute(
|
||||
select(MessageAttachment).where(
|
||||
MessageAttachment.id == attachment_id,
|
||||
MessageAttachment.dialog_id == dialog_id,
|
||||
MessageAttachment.owner_user_id == user.id,
|
||||
MessageAttachment.record_status == "A",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if item is None:
|
||||
raise DomainError("not_found", 404, "Resource was not found")
|
||||
checksum = body.checksum.removeprefix("sha256:")
|
||||
if item.completed_at:
|
||||
if item.checksum_sha256 != checksum:
|
||||
raise DomainError("resource_state_conflict", 409, "Attachment checksum changed")
|
||||
return attachment_dto(item)
|
||||
try:
|
||||
head = await s3.head(item.storage_bucket, item.object_key)
|
||||
except Exception as exc:
|
||||
raise DomainError("dependency_unavailable", 503, "Object storage is unavailable") from exc
|
||||
if int(head["ContentLength"]) != item.size_bytes or head.get("ContentType") != item.mime_type:
|
||||
raise DomainError("attachment_checksum_mismatch", 400, "Uploaded metadata does not match")
|
||||
item.checksum_sha256 = checksum
|
||||
item.completed_at = datetime.now(UTC)
|
||||
session.add(audit("attachment.upload_completed", request_id, user.id, "attachment", item.id))
|
||||
await session.commit()
|
||||
return attachment_dto(item)
|
||||
|
||||
|
||||
def attachment_dto(item: MessageAttachment) -> dict[str, Any]:
|
||||
return {
|
||||
"attachment_id": item.id,
|
||||
"file_name": item.safe_file_name,
|
||||
"mime_type": item.mime_type,
|
||||
"size_bytes": item.size_bytes,
|
||||
"checksum": f"sha256:{item.checksum_sha256}" if item.checksum_sha256 else None,
|
||||
"scan_status": item.scan_status,
|
||||
"completed_at": item.completed_at,
|
||||
}
|
||||
|
||||
|
||||
async def send_message(
|
||||
session: AsyncSession,
|
||||
user: UserIdentity,
|
||||
dialog_id: uuid.UUID,
|
||||
body: MessageRequest,
|
||||
idem_key: str,
|
||||
request_id: str,
|
||||
settings: Settings,
|
||||
safety: SafetyClient,
|
||||
openlines: OpenLinesClient,
|
||||
s3: S3Client,
|
||||
fanout: RealtimeFanout,
|
||||
) -> dict[str, Any]:
|
||||
scope = f"dialogs.{dialog_id}.messages.create"
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
idem = (
|
||||
await session.execute(
|
||||
select(IdempotencyRecord).where(
|
||||
IdempotencyRecord.scope == scope,
|
||||
IdempotencyRecord.user_id == user.id,
|
||||
IdempotencyRecord.idempotency_key == idem_key,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if idem:
|
||||
if idem.request_fingerprint != fingerprint:
|
||||
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
||||
if idem.status == "completed" and idem.response_body_json:
|
||||
if idem.response_status == 422:
|
||||
raise DomainError("message_blocked", 422, "Message was blocked by safety policy")
|
||||
return idem.response_body_json
|
||||
prior = (
|
||||
await session.execute(
|
||||
select(Message).where(
|
||||
Message.dialog_id == dialog_id,
|
||||
Message.client_idempotency_key == idem_key,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if prior and prior.delivery_status == "delivered":
|
||||
return message_dto(prior)
|
||||
raise DomainError(
|
||||
"dependency_unavailable",
|
||||
503,
|
||||
"Previous request is still being recovered",
|
||||
{"retry_after": 2},
|
||||
)
|
||||
dialog = await owned_dialog(session, user.id, dialog_id)
|
||||
now, message_id = datetime.now(UTC), uuid.uuid4()
|
||||
idem = IdempotencyRecord(
|
||||
scope=scope,
|
||||
user_id=user.id,
|
||||
idempotency_key=idem_key,
|
||||
request_fingerprint=fingerprint,
|
||||
status="in_progress",
|
||||
resource_type="message",
|
||||
resource_id=message_id,
|
||||
expires_at=now + timedelta(hours=24),
|
||||
)
|
||||
session.add(idem)
|
||||
attachment: MessageAttachment | None = None
|
||||
if isinstance(body, FileMessageRequest):
|
||||
attachment = (
|
||||
await session.execute(
|
||||
select(MessageAttachment).where(
|
||||
MessageAttachment.id == body.attachment_id,
|
||||
MessageAttachment.owner_user_id == user.id,
|
||||
MessageAttachment.dialog_id == dialog_id,
|
||||
MessageAttachment.record_status == "A",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not attachment or not attachment.completed_at:
|
||||
raise DomainError("attachment_not_completed", 400, "Attachment upload is incomplete")
|
||||
if attachment.checksum_sha256 != body.checksum.removeprefix("sha256:"):
|
||||
raise DomainError("attachment_checksum_mismatch", 400, "Attachment checksum differs")
|
||||
text, kind = "", "file"
|
||||
else:
|
||||
text, kind = unicodedata.normalize("NFKC", body.text).strip(), "text"
|
||||
message = Message(
|
||||
id=message_id,
|
||||
dialog_id=dialog_id,
|
||||
sender_type="client",
|
||||
content_kind=kind,
|
||||
text=text,
|
||||
safety_status="pending",
|
||||
delivery_status="accepted",
|
||||
client_idempotency_key=idem_key,
|
||||
occurred_at=now,
|
||||
)
|
||||
session.add(message)
|
||||
if attachment:
|
||||
attachment.message_id = message_id
|
||||
session.add(audit("message.submitted", request_id, user.id, "message", message_id))
|
||||
await session.commit()
|
||||
await publish_message(fanout, message, settings, [attachment] if attachment else [])
|
||||
payload: dict[str, Any] = {"message_id": str(message_id), "content_kind": kind, "text": text}
|
||||
if attachment:
|
||||
payload["attachment"] = {
|
||||
"attachment_id": str(attachment.id),
|
||||
"quarantine_object_key": attachment.quarantine_object_key,
|
||||
"checksum": f"sha256:{attachment.checksum_sha256}",
|
||||
"mime_type": attachment.mime_type,
|
||||
"size_bytes": attachment.size_bytes,
|
||||
}
|
||||
try:
|
||||
verdict = await safety.check(payload, request_id)
|
||||
if verdict["_status"] == 203:
|
||||
task_id = verdict["task_id"]
|
||||
task = SafetyTask(
|
||||
task_id=task_id,
|
||||
message_id=message.id,
|
||||
attachment_id=attachment.id if attachment else None,
|
||||
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
|
||||
status="polling",
|
||||
deadline_at=now
|
||||
+ timedelta(seconds=settings.message_safety_task_poll_max_sec + 900),
|
||||
next_poll_at=now,
|
||||
)
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
deadline = time_monotonic() + settings.message_safety_task_poll_max_sec
|
||||
while time_monotonic() < deadline:
|
||||
await sleep(settings.message_safety_task_poll_interval_sec)
|
||||
verdict = await safety.poll(task_id, request_id)
|
||||
if verdict["_status"] != 203:
|
||||
break
|
||||
else:
|
||||
raise DependencyFailure(timeout=True)
|
||||
if verdict["_status"] == 403 or (
|
||||
verdict["_status"] == 400
|
||||
and verdict.get("error", {}).get("code") == "stub_final_error"
|
||||
and verdict.get("verdict") == "deny"
|
||||
):
|
||||
message.text = ""
|
||||
message.safety_status = "blocked"
|
||||
message.delivery_status = "rejected"
|
||||
if attachment and attachment.quarantine_object_key:
|
||||
attachment.scan_status = "infected"
|
||||
await s3.delete_quarantine(attachment.quarantine_object_key)
|
||||
session.add(audit("message.blocked", request_id, user.id, "message", message.id))
|
||||
idem.status = "completed"
|
||||
idem.response_status = 422
|
||||
idem.response_body_json = {
|
||||
"error": {"code": "message_blocked", "message_id": str(message.id)}
|
||||
}
|
||||
await session.commit()
|
||||
await publish_message_status(fanout, message, settings)
|
||||
raise DomainError("message_blocked", 422, "Message was blocked by safety policy")
|
||||
if verdict["_status"] != 200:
|
||||
raise DependencyFailure()
|
||||
if attachment and attachment.quarantine_object_key:
|
||||
destination = f"attachments/dialogs/{dialog_id}/{attachment.id}"
|
||||
await s3.promote(attachment.quarantine_object_key, destination)
|
||||
attachment.storage_bucket = settings.selectel_s3_bucket_attachments
|
||||
attachment.object_key = destination
|
||||
attachment.quarantine_object_key = None
|
||||
attachment.scan_status = "clean"
|
||||
message.safety_status = "allowed"
|
||||
outbox = DeliveryOutbox(
|
||||
message_id=message.id,
|
||||
external_chat_id=dialog_id,
|
||||
payload_json={
|
||||
"message_id": str(message.id),
|
||||
"external_chat_id": str(dialog_id),
|
||||
"occurred_at": message.occurred_at.isoformat(),
|
||||
"user": {"id": str(user.id), "display_name": user.phone_number},
|
||||
"message": {
|
||||
"content_kind": message.content_kind,
|
||||
"text": message.text,
|
||||
"files": (
|
||||
[
|
||||
{
|
||||
"attachment_id": str(attachment.id),
|
||||
"name": attachment.safe_file_name,
|
||||
"mime_type": attachment.mime_type,
|
||||
"size_bytes": attachment.size_bytes,
|
||||
"_storage_bucket": attachment.storage_bucket,
|
||||
"_object_key": attachment.object_key,
|
||||
}
|
||||
]
|
||||
if attachment
|
||||
else []
|
||||
),
|
||||
},
|
||||
},
|
||||
next_attempt_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(outbox)
|
||||
await session.commit()
|
||||
await publish_message_status(fanout, message, settings)
|
||||
delivery_payload = await fresh_openlines_payload(outbox.payload_json, s3)
|
||||
await openlines.send(message.id, delivery_payload, request_id)
|
||||
message.delivery_status = "delivered"
|
||||
dialog.status = "waiting_for_company"
|
||||
dialog.last_message_at = datetime.now(UTC)
|
||||
outbox.status = "delivered"
|
||||
session.add(audit("message.delivered", request_id, user.id, "message", message.id))
|
||||
result = message_dto(message, [attachment] if attachment else [])
|
||||
idem.status = "completed"
|
||||
idem.response_status = 201
|
||||
idem.response_body_json = json.loads(json.dumps(result, default=str))
|
||||
await session.commit()
|
||||
await publish_message_status(fanout, message, settings)
|
||||
await publish_dialog_status(fanout, dialog)
|
||||
return result
|
||||
except DomainError:
|
||||
raise
|
||||
except DependencyFailure as exc:
|
||||
message.delivery_status = "failed"
|
||||
session.add(audit("message.failed", request_id, user.id, "message", message.id))
|
||||
await session.commit()
|
||||
await publish_message_status(fanout, message, settings)
|
||||
raise DomainError(
|
||||
"dependency_timeout" if exc.timeout else "dependency_unavailable",
|
||||
504 if exc.timeout else 503,
|
||||
"A required dependency did not complete the request",
|
||||
) from exc
|
||||
|
||||
|
||||
async def apply_inbox(
|
||||
session: AsyncSession,
|
||||
event: OpenLinesInbox,
|
||||
request_id: str,
|
||||
snapshot: SettingsSnapshot,
|
||||
s3: S3Client,
|
||||
http: httpx.AsyncClient,
|
||||
settings: Settings,
|
||||
fanout: RealtimeFanout,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
fingerprint = hashlib.sha256(event.model_dump_json().encode()).hexdigest()
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(OpenLinesInboxReceipt).where(OpenLinesInboxReceipt.event_id == event.event_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
if existing.payload_fingerprint != fingerprint:
|
||||
raise DomainError("idempotency_key_reused", 409, "Event id was reused")
|
||||
return {"status": "duplicate"}, 200
|
||||
dialog = (
|
||||
await session.execute(
|
||||
select(Dialog).where(Dialog.id == event.external_chat_id, Dialog.record_status == "A")
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dialog:
|
||||
raise DomainError("not_found", 404, "Resource was not found")
|
||||
receipt = OpenLinesInboxReceipt(
|
||||
event_id=event.event_id,
|
||||
external_chat_id=event.external_chat_id,
|
||||
bitrix_message_id=event.bitrix_message_id,
|
||||
event_type=event.event_type,
|
||||
payload_fingerprint=fingerprint,
|
||||
status="processing",
|
||||
)
|
||||
session.add(receipt)
|
||||
message: Message | None = None
|
||||
attachment: MessageAttachment | None = None
|
||||
if event.event_type == "dialog.closed":
|
||||
dialog.status = "closed"
|
||||
dialog.closed_at = event.occurred_at
|
||||
else:
|
||||
assert event.message is not None
|
||||
inbound_file = event.message.files[0] if event.message.files else None
|
||||
attachment_data: tuple[str, int, str] | None = None
|
||||
if inbound_file:
|
||||
extension = PurePath(inbound_file.name).suffix.lower().lstrip(".")
|
||||
max_bytes = snapshot.integer("chat.attachments.max_size_mb") * 1024 * 1024
|
||||
if (
|
||||
extension not in snapshot.strings("chat.attachments.allowed_extensions")
|
||||
or inbound_file.mime_type
|
||||
not in snapshot.strings("chat.attachments.allowed_mime_types")
|
||||
or inbound_file.size_bytes > max_bytes
|
||||
):
|
||||
raise DomainError("validation_error", 400, "Inbound file is not allowed")
|
||||
attachment_id = uuid.uuid4()
|
||||
object_key = f"attachments/dialogs/{dialog.id}/{attachment_id}"
|
||||
try:
|
||||
actual_size, checksum = await s3.upload_inbound(
|
||||
http,
|
||||
str(inbound_file.download_url),
|
||||
object_key,
|
||||
inbound_file.mime_type,
|
||||
max_bytes,
|
||||
)
|
||||
except DependencyFailure as exc:
|
||||
raise DomainError(
|
||||
"dependency_unavailable", 503, "Inbound file transfer failed"
|
||||
) from exc
|
||||
if actual_size != inbound_file.size_bytes:
|
||||
raise DomainError("validation_error", 400, "Inbound file size differs")
|
||||
attachment_data = object_key, actual_size, checksum
|
||||
message = Message(
|
||||
dialog_id=dialog.id,
|
||||
sender_type="company",
|
||||
content_kind="file" if inbound_file else "text",
|
||||
text=event.message.text,
|
||||
safety_status="allowed",
|
||||
delivery_status="delivered",
|
||||
external_message_id=event.bitrix_message_id,
|
||||
occurred_at=event.occurred_at,
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
if inbound_file and attachment_data:
|
||||
object_key, actual_size, checksum = attachment_data
|
||||
safe_name = re.sub(
|
||||
r"[^A-Za-z0-9._-]",
|
||||
"_",
|
||||
unicodedata.normalize("NFKC", inbound_file.name),
|
||||
)
|
||||
attachment = MessageAttachment(
|
||||
dialog_id=dialog.id,
|
||||
message_id=message.id,
|
||||
owner_user_id=dialog.user_id,
|
||||
direction="company_inbound",
|
||||
original_file_name=inbound_file.name,
|
||||
safe_file_name=safe_name,
|
||||
mime_type=inbound_file.mime_type,
|
||||
size_bytes=actual_size,
|
||||
checksum_sha256=checksum,
|
||||
scan_status="clean",
|
||||
storage_bucket=s3.settings.selectel_s3_bucket_attachments,
|
||||
object_key=object_key,
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(attachment)
|
||||
receipt.message_id = message.id
|
||||
dialog.status = "waiting_for_client"
|
||||
dialog.last_message_at = event.occurred_at
|
||||
receipt.status = "applied"
|
||||
session.add(audit("openlines.inbox_applied", request_id, dialog.user_id, "dialog", dialog.id))
|
||||
await session.commit()
|
||||
if message is not None:
|
||||
await publish_message(fanout, message, settings, [attachment] if attachment else [])
|
||||
await publish_dialog_status(fanout, dialog)
|
||||
return {"status": "applied"}, 201
|
||||
|
||||
|
||||
async def sleep(seconds: float) -> None:
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
def time_monotonic() -> float:
|
||||
import time
|
||||
|
||||
return time.monotonic()
|
||||
@@ -0,0 +1,78 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import AnyHttpUrl, Field, SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore"
|
||||
)
|
||||
|
||||
app_env: str = Field(alias="APP_ENV")
|
||||
api_port: int = Field(default=8000, alias="API_PORT")
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
database_url: str = Field(alias="DATABASE_URL")
|
||||
redis_url: str = Field(alias="REDIS_URL")
|
||||
redis_realtime_url: str = Field(alias="REDIS_REALTIME_URL")
|
||||
|
||||
keycloak_public_url: AnyHttpUrl = Field(alias="KEYCLOAK_PUBLIC_URL")
|
||||
keycloak_internal_url: AnyHttpUrl = Field(alias="KEYCLOAK_INTERNAL_URL")
|
||||
keycloak_realm: str = Field(alias="KEYCLOAK_REALM")
|
||||
keycloak_audience: str = Field(alias="KEYCLOAK_AUDIENCE")
|
||||
jwks_cache_ttl_seconds: int = 300
|
||||
jwks_stale_grace_seconds: int = 900
|
||||
|
||||
message_safety_url: AnyHttpUrl = Field(alias="MESSAGE_SAFETY_URL")
|
||||
message_safety_service_token: SecretStr = Field(alias="MESSAGE_SAFETY_SERVICE_TOKEN")
|
||||
message_safety_post_timeout_sec: float = Field(
|
||||
default=5, alias="MESSAGE_SAFETY_POST_TIMEOUT_SEC"
|
||||
)
|
||||
message_safety_task_poll_interval_sec: float = Field(
|
||||
default=2, alias="MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC"
|
||||
)
|
||||
message_safety_task_poll_max_sec: float = Field(
|
||||
default=300, alias="MESSAGE_SAFETY_TASK_POLL_MAX_SEC"
|
||||
)
|
||||
message_safety_circuit_failure_threshold: int = Field(
|
||||
default=5, alias="MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD"
|
||||
)
|
||||
message_safety_circuit_open_sec: int = Field(
|
||||
default=30, alias="MESSAGE_SAFETY_CIRCUIT_OPEN_SEC"
|
||||
)
|
||||
|
||||
bitrix_local_app_base_url: AnyHttpUrl = Field(alias="BITRIX_LOCAL_APP_BASE_URL")
|
||||
bitrix_local_app_internal_token: SecretStr = Field(alias="BITRIX_LOCAL_APP_INTERNAL_TOKEN")
|
||||
bitrix_api_inbox_token: SecretStr = Field(alias="BITRIX_API_INBOX_TOKEN")
|
||||
bitrix_local_app_http_timeout_sec: float = Field(
|
||||
default=10, alias="BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC"
|
||||
)
|
||||
bitrix_local_app_circuit_failure_threshold: int = Field(
|
||||
default=5, alias="BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD"
|
||||
)
|
||||
bitrix_local_app_circuit_open_sec: int = Field(
|
||||
default=30, alias="BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC"
|
||||
)
|
||||
keycloak_settings_bridge_token: SecretStr = Field(alias="KEYCLOAK_SETTINGS_BRIDGE_TOKEN")
|
||||
|
||||
selectel_s3_endpoint_url: AnyHttpUrl = Field(alias="SELECTEL_S3_ENDPOINT_URL")
|
||||
selectel_s3_bucket_documents: str = Field(alias="SELECTEL_S3_BUCKET_DOCUMENTS")
|
||||
selectel_s3_bucket_attachments: str = Field(alias="SELECTEL_S3_BUCKET_ATTACHMENTS")
|
||||
selectel_s3_bucket_quarantine: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
|
||||
selectel_s3_access_key: SecretStr = Field(alias="SELECTEL_S3_ACCESS_KEY")
|
||||
selectel_s3_secret_key: SecretStr = Field(alias="SELECTEL_S3_SECRET_KEY")
|
||||
otel_exporter_otlp_endpoint: str | None = Field(
|
||||
default=None, alias="OTEL_EXPORTER_OTLP_ENDPOINT"
|
||||
)
|
||||
cursor_hmac_secret: SecretStr = Field(alias="CURSOR_HMAC_SECRET")
|
||||
trusted_proxy_cidrs: str = Field(default="127.0.0.1/32", alias="TRUSTED_PROXY_CIDRS")
|
||||
worker_poll_interval_sec: float = Field(default=2, alias="WORKER_POLL_INTERVAL_SEC")
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
return f"{str(self.keycloak_public_url).rstrip('/')}/realms/{self.keycloak_realm}"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,234 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import Database, DeliveryOutbox, Dialog, Message, MessageAttachment, SafetyTask
|
||||
from app.integrations import (
|
||||
DependencyFailure,
|
||||
OpenLinesClient,
|
||||
S3Client,
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.services import publish_dialog_status, publish_message_status
|
||||
from app.settings import Settings, get_settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
async def delivery_once(
|
||||
db: Database,
|
||||
client: OpenLinesClient,
|
||||
s3: S3Client,
|
||||
fanout: RealtimeFanout,
|
||||
settings: Settings,
|
||||
worker_id: str,
|
||||
batch_size: int = 20,
|
||||
) -> int:
|
||||
async with db.sessions() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(DeliveryOutbox)
|
||||
.where(
|
||||
DeliveryOutbox.status.in_(["pending", "retry"]),
|
||||
DeliveryOutbox.next_attempt_at <= datetime.now(UTC),
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
ids = [row.id for row in rows]
|
||||
for row in rows:
|
||||
row.status = "processing"
|
||||
row.locked_at = datetime.now(UTC)
|
||||
row.locked_by = worker_id
|
||||
await session.commit()
|
||||
for row_id in ids:
|
||||
async with db.sessions() as session:
|
||||
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
|
||||
if row is None:
|
||||
continue
|
||||
message = None
|
||||
dialog = None
|
||||
try:
|
||||
payload = await fresh_openlines_payload(row.payload_json, s3)
|
||||
await client.send(row.message_id, payload, f"worker-{worker_id}")
|
||||
row.status = "delivered"
|
||||
message = await session.get(Message, row.message_id)
|
||||
if message:
|
||||
message.delivery_status = "delivered"
|
||||
dialog = await session.get(Dialog, message.dialog_id)
|
||||
if dialog:
|
||||
dialog.status = "waiting_for_company"
|
||||
dialog.last_message_at = datetime.now(UTC)
|
||||
except DependencyFailure:
|
||||
row.attempt_count += 1
|
||||
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
|
||||
row.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||
seconds=min(3600, 2**row.attempt_count)
|
||||
)
|
||||
row.last_error_code = "dependency_unavailable"
|
||||
row.locked_at = None
|
||||
row.locked_by = None
|
||||
await session.commit()
|
||||
if message:
|
||||
await publish_message_status(fanout, message, settings)
|
||||
if dialog:
|
||||
await publish_dialog_status(fanout, dialog)
|
||||
return len(ids)
|
||||
|
||||
|
||||
async def safety_once(
|
||||
db: Database,
|
||||
safety: SafetyClient,
|
||||
s3: S3Client,
|
||||
fanout: RealtimeFanout,
|
||||
settings: Settings,
|
||||
worker_id: str,
|
||||
batch_size: int = 20,
|
||||
) -> int:
|
||||
async with db.sessions() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(SafetyTask)
|
||||
.where(
|
||||
SafetyTask.status.in_(["polling", "failed"]),
|
||||
SafetyTask.next_poll_at <= datetime.now(UTC),
|
||||
SafetyTask.deadline_at > datetime.now(UTC),
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
ids = [row.id for row in rows]
|
||||
for row in rows:
|
||||
row.locked_at, row.locked_by = datetime.now(UTC), worker_id
|
||||
await session.commit()
|
||||
for task_id in ids:
|
||||
async with db.sessions() as session:
|
||||
task = await session.get(SafetyTask, task_id, with_for_update=True)
|
||||
if task is None:
|
||||
continue
|
||||
message = None
|
||||
try:
|
||||
verdict = await safety.poll(task.task_id, f"worker-{worker_id}")
|
||||
message = await session.get(Message, task.message_id)
|
||||
attachment = (
|
||||
await session.get(MessageAttachment, task.attachment_id)
|
||||
if task.attachment_id
|
||||
else None
|
||||
)
|
||||
if verdict["_status"] == 200 and message:
|
||||
if attachment and attachment.quarantine_object_key:
|
||||
destination = f"attachments/dialogs/{message.dialog_id}/{attachment.id}"
|
||||
await s3.promote(attachment.quarantine_object_key, destination)
|
||||
attachment.storage_bucket = s3.settings.selectel_s3_bucket_attachments
|
||||
attachment.object_key = destination
|
||||
attachment.quarantine_object_key = None
|
||||
attachment.scan_status = "clean"
|
||||
message.safety_status = "allowed"
|
||||
task.status = "completed"
|
||||
elif verdict["_status"] == 403 and message:
|
||||
message.text = ""
|
||||
message.safety_status = "blocked"
|
||||
message.delivery_status = "rejected"
|
||||
if attachment and attachment.quarantine_object_key:
|
||||
await s3.delete_quarantine(attachment.quarantine_object_key)
|
||||
attachment.scan_status = "infected"
|
||||
task.status = "completed"
|
||||
else:
|
||||
task.next_poll_at = datetime.now(UTC) + timedelta(seconds=2)
|
||||
except DependencyFailure:
|
||||
task.attempt_count += 1
|
||||
task.status = "failed"
|
||||
task.next_poll_at = datetime.now(UTC) + timedelta(
|
||||
seconds=min(300, 2**task.attempt_count)
|
||||
)
|
||||
task.locked_at = None
|
||||
task.locked_by = None
|
||||
await session.commit()
|
||||
if message:
|
||||
await publish_message_status(fanout, message, settings)
|
||||
return len(ids)
|
||||
|
||||
|
||||
async def cleanup_once(db: Database, s3: S3Client, batch_size: int = 100) -> int:
|
||||
async with db.sessions() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MessageAttachment)
|
||||
.where(
|
||||
MessageAttachment.record_status == "A",
|
||||
MessageAttachment.quarantine_object_key.is_not(None),
|
||||
MessageAttachment.upload_expires_at < datetime.now(UTC),
|
||||
MessageAttachment.message_id.is_(None),
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
if row.quarantine_object_key:
|
||||
await s3.delete_quarantine(row.quarantine_object_key)
|
||||
row.record_status = "D"
|
||||
row.status_changed_at = datetime.now(UTC)
|
||||
row.status_change_reason = "expired_quarantine_cleanup"
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def loop(kind: str) -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings.database_url)
|
||||
http = httpx.AsyncClient()
|
||||
safety = SafetyClient(settings, http)
|
||||
openlines = OpenLinesClient(settings, http)
|
||||
s3 = S3Client(settings)
|
||||
redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
|
||||
fanout = RealtimeFanout(redis_rt)
|
||||
worker_id = f"{kind}-{uuid.uuid4()}"
|
||||
try:
|
||||
while True:
|
||||
count = 0
|
||||
if kind == "delivery":
|
||||
count = await delivery_once(db, openlines, s3, fanout, settings, worker_id)
|
||||
elif kind == "safety":
|
||||
count = await safety_once(db, safety, s3, fanout, settings, worker_id)
|
||||
else:
|
||||
count = await cleanup_once(db, s3)
|
||||
if not count:
|
||||
await asyncio.sleep(settings.worker_poll_interval_sec)
|
||||
finally:
|
||||
await http.aclose()
|
||||
await redis_rt.aclose()
|
||||
await db.close()
|
||||
|
||||
|
||||
def delivery_main() -> None:
|
||||
asyncio.run(loop("delivery"))
|
||||
|
||||
|
||||
def safety_main() -> None:
|
||||
asyncio.run(loop("safety"))
|
||||
|
||||
|
||||
def cleanup_main() -> None:
|
||||
asyncio.run(loop("cleanup"))
|
||||
@@ -0,0 +1,56 @@
|
||||
services:
|
||||
api-backend:
|
||||
build: .
|
||||
image: han-chat/api-backend:local
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV}
|
||||
API_PORT: ${API_PORT:-8000}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
REDIS_REALTIME_URL: ${REDIS_REALTIME_URL}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL}
|
||||
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE}
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN: ${MESSAGE_SAFETY_SERVICE_TOKEN}
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
|
||||
MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD: ${MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||
MESSAGE_SAFETY_CIRCUIT_OPEN_SEC: ${MESSAGE_SAFETY_CIRCUIT_OPEN_SEC:-30}
|
||||
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL}
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN: ${BITRIX_LOCAL_APP_INTERNAL_TOKEN}
|
||||
BITRIX_API_INBOX_TOKEN: ${BITRIX_API_INBOX_TOKEN}
|
||||
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-10}
|
||||
BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD: ${BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||
BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC: ${BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC:-30}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
|
||||
SELECTEL_S3_ENDPOINT_URL: ${SELECTEL_S3_ENDPOINT_URL}
|
||||
SELECTEL_S3_BUCKET_DOCUMENTS: ${SELECTEL_S3_BUCKET_DOCUMENTS}
|
||||
SELECTEL_S3_BUCKET_ATTACHMENTS: ${SELECTEL_S3_BUCKET_ATTACHMENTS}
|
||||
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
|
||||
SELECTEL_S3_ACCESS_KEY: ${SELECTEL_S3_ACCESS_KEY}
|
||||
SELECTEL_S3_SECRET_KEY: ${SELECTEL_S3_SECRET_KEY}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
|
||||
CURSOR_HMAC_SECRET: ${CURSOR_HMAC_SECRET}
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/live', timeout=2)"
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- backend
|
||||
- observability
|
||||
|
||||
networks:
|
||||
backend:
|
||||
observability:
|
||||
@@ -0,0 +1,314 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HAN Chat API
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/health/live:
|
||||
get:
|
||||
operationId: healthLive
|
||||
responses:
|
||||
"200": {description: Process is live}
|
||||
/health/ready:
|
||||
get:
|
||||
operationId: healthReady
|
||||
responses:
|
||||
"200": {description: Ready or partially degraded}
|
||||
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||
/api/v1/public/app-config:
|
||||
get:
|
||||
operationId: getPublicAppConfig
|
||||
responses:
|
||||
"200": {description: Public application configuration}
|
||||
/api/v1/public/content:
|
||||
get:
|
||||
operationId: getPublicContent
|
||||
parameters:
|
||||
- {name: locale, in: query, schema: {type: string, default: ru}}
|
||||
responses:
|
||||
"200": {description: Active UI content}
|
||||
/api/v1/auth/bootstrap:
|
||||
post:
|
||||
operationId: bootstrap
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/BootstrapRequest"}
|
||||
responses:
|
||||
"200": {description: Local user resolved}
|
||||
"401": {$ref: "#/components/responses/Unauthorized"}
|
||||
/api/v1/consents:
|
||||
post:
|
||||
operationId: recordConsents
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/ConsentsRequest"}
|
||||
responses:
|
||||
"201": {description: Immutable consent records saved}
|
||||
/api/v1/analytics/session-start:
|
||||
post:
|
||||
operationId: startUxSession
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/SessionStartRequest"}
|
||||
responses:
|
||||
"201": {description: UX session created}
|
||||
/api/v1/me:
|
||||
get:
|
||||
operationId: getCurrentProfile
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Block profile}
|
||||
/api/v1/me/documents:
|
||||
get:
|
||||
operationId: listDocuments
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Cursor-paginated documents}
|
||||
/api/v1/documents/{document_id}:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DocumentId"}
|
||||
get:
|
||||
operationId: getDocument
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Document metadata}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
/api/v1/documents/{document_id}/download-url:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DocumentId"}
|
||||
get:
|
||||
operationId: getDocumentDownloadUrl
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Short-lived presigned GET}
|
||||
/api/v1/dialogs:
|
||||
post:
|
||||
operationId: createDialog
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/IdempotencyKey"}
|
||||
responses:
|
||||
"200": {description: Existing active dialog}
|
||||
"201": {description: New active dialog}
|
||||
get:
|
||||
operationId: listDialogs
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Cursor-paginated dialogs}
|
||||
/api/v1/dialogs/{dialog_id}:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
get:
|
||||
operationId: getDialog
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Dialog summary}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
/api/v1/dialogs/{dialog_id}/messages:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
get:
|
||||
operationId: listMessages
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {name: after, in: query, schema: {type: string}}
|
||||
- {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 50}}
|
||||
responses:
|
||||
"200": {description: Message history or polling delta}
|
||||
post:
|
||||
operationId: sendMessage
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/IdempotencyKey"}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- {$ref: "#/components/schemas/TextMessageRequest"}
|
||||
- {$ref: "#/components/schemas/FileMessageRequest"}
|
||||
discriminator: {propertyName: content_kind}
|
||||
responses:
|
||||
"201": {description: Safety-allowed and delivered message}
|
||||
"422": {description: Message blocked, content redacted}
|
||||
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||
/api/v1/dialogs/{dialog_id}/attachments/init:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
post:
|
||||
operationId: initAttachment
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/AttachmentInitRequest"}
|
||||
responses:
|
||||
"201": {description: Upload metadata and presigned PUT}
|
||||
/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
- {$ref: "#/components/parameters/AttachmentId"}
|
||||
post:
|
||||
operationId: completeAttachment
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/ChecksumRequest"}
|
||||
responses:
|
||||
"200": {description: Upload metadata verified}
|
||||
/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
- {$ref: "#/components/parameters/AttachmentId"}
|
||||
get:
|
||||
operationId: getAttachmentDownloadUrl
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Audited short-lived presigned GET}
|
||||
/internal/openlines/v1/inbox:
|
||||
post:
|
||||
operationId: applyOpenLinesInbox
|
||||
security: [{serviceBearer: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/OpenLinesInbox"}
|
||||
responses:
|
||||
"200": {description: Duplicate acknowledged}
|
||||
"201": {description: Event applied}
|
||||
/internal/settings/v1/otp:
|
||||
get:
|
||||
operationId: getOtpSettings
|
||||
security: [{serviceBearer: []}]
|
||||
responses:
|
||||
"200": {description: Product OTP limits and cache metadata}
|
||||
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
serviceBearer: {type: http, scheme: bearer}
|
||||
parameters:
|
||||
DialogId: {name: dialog_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
AttachmentId: {name: attachment_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
DocumentId: {name: document_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: JWT is absent, invalid or expired
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
NotFound:
|
||||
description: Resource absent or owned by another user
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
DependencyUnavailable:
|
||||
description: Required dependency is unavailable
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
schemas:
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
required: [code, message, request_id, details]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
request_id: {type: string}
|
||||
details: {type: object}
|
||||
ConsentChoice:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [accepted, version]
|
||||
properties:
|
||||
accepted: {type: boolean}
|
||||
version: {type: string, maxLength: 64}
|
||||
ConsentSet:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [personal_data, user_agreement, marketing]
|
||||
properties:
|
||||
personal_data: {$ref: "#/components/schemas/ConsentChoice"}
|
||||
user_agreement: {$ref: "#/components/schemas/ConsentChoice"}
|
||||
marketing: {$ref: "#/components/schemas/ConsentChoice"}
|
||||
Device:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [platform, app_version]
|
||||
properties:
|
||||
platform: {type: string, enum: [ios, android, web]}
|
||||
app_version: {type: string, maxLength: 64}
|
||||
device_id: {type: [string, "null"], maxLength: 255}
|
||||
BootstrapRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [consents, device]
|
||||
properties:
|
||||
consents: {$ref: "#/components/schemas/ConsentSet"}
|
||||
device: {$ref: "#/components/schemas/Device"}
|
||||
ConsentsRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [consents]
|
||||
properties: {consents: {$ref: "#/components/schemas/ConsentSet"}}
|
||||
SessionStartRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [start_reason, device]
|
||||
properties:
|
||||
start_reason: {type: string, enum: [first_launch, cold_start, idle_timeout]}
|
||||
device: {$ref: "#/components/schemas/Device"}
|
||||
TextMessageRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [content_kind, text]
|
||||
properties:
|
||||
content_kind: {const: text}
|
||||
text: {type: string, minLength: 1, maxLength: 4000}
|
||||
FileMessageRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [content_kind, attachment_id, checksum]
|
||||
properties:
|
||||
content_kind: {const: file}
|
||||
attachment_id: {type: string, format: uuid}
|
||||
checksum: {type: string, pattern: "^sha256:[0-9a-f]{64}$"}
|
||||
AttachmentInitRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [file_name, mime_type, size_bytes]
|
||||
properties:
|
||||
file_name: {type: string, minLength: 1, maxLength: 255}
|
||||
mime_type: {type: string, minLength: 1, maxLength: 128}
|
||||
size_bytes: {type: integer, minimum: 1}
|
||||
ChecksumRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [checksum]
|
||||
properties:
|
||||
checksum: {type: string, pattern: "^sha256:[0-9a-f]{64}$"}
|
||||
OpenLinesInbox:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [event_id, event_type, external_chat_id, occurred_at]
|
||||
properties:
|
||||
event_id: {type: string, minLength: 1, maxLength: 255}
|
||||
event_type: {type: string, enum: [message.new, dialog.closed]}
|
||||
external_chat_id: {type: string, format: uuid}
|
||||
bitrix_message_id: {type: [string, "null"], maxLength: 255}
|
||||
occurred_at: {type: string, format: date-time}
|
||||
message: {type: [object, "null"]}
|
||||
@@ -0,0 +1,67 @@
|
||||
[project]
|
||||
name = "han-api-backend"
|
||||
version = "0.1.0"
|
||||
description = "HAN Chat FastAPI backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"boto3>=1.39,<2",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"phonenumbers>=9,<10",
|
||||
"prometheus-client>=0.22,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"pyyaml>=6,<7",
|
||||
"pyjwt[crypto]>=2.10,<3",
|
||||
"redis>=6,<7",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"structlog>=25,<26",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"aiosqlite>=0.21,<1",
|
||||
"mypy>=1.16,<2",
|
||||
"pytest>=8.4,<9",
|
||||
"pytest-asyncio>=1.0,<2",
|
||||
"ruff>=0.12,<1",
|
||||
"types-boto3-s3>=1.39,<2",
|
||||
"types-PyYAML>=6,<7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
han-api = "app.main:run"
|
||||
han-delivery-worker = "app.workers:delivery_main"
|
||||
han-safety-worker = "app.workers:safety_main"
|
||||
han-cleanup-worker = "app.workers:cleanup_main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
|
||||
ignore = ["S101"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
check_untyped_defs = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
ignore_missing_imports = true
|
||||
disable_error_code = ["no-untyped-def", "misc", "arg-type", "assignment"]
|
||||
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
|
||||
exclude = ["alembic/"]
|
||||
@@ -0,0 +1,155 @@
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.integrations import (
|
||||
DependencyFailure,
|
||||
OpenLinesClient,
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
def settings() -> Settings:
|
||||
common = {
|
||||
"APP_ENV": "test",
|
||||
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||
"REDIS_URL": "redis://localhost/0",
|
||||
"REDIS_REALTIME_URL": "redis://localhost/1",
|
||||
"KEYCLOAK_PUBLIC_URL": "https://auth.example",
|
||||
"KEYCLOAK_INTERNAL_URL": "http://keycloak:8080",
|
||||
"KEYCLOAK_REALM": "han",
|
||||
"KEYCLOAK_AUDIENCE": "api",
|
||||
"MESSAGE_SAFETY_URL": "http://safety:8080",
|
||||
"MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token",
|
||||
"BITRIX_LOCAL_APP_BASE_URL": "http://bitrix:8080",
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "bitrix-token",
|
||||
"BITRIX_API_INBOX_TOKEN": "inbox-token",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN": "settings-token",
|
||||
"SELECTEL_S3_ENDPOINT_URL": "https://s3.example",
|
||||
"SELECTEL_S3_BUCKET_DOCUMENTS": "documents",
|
||||
"SELECTEL_S3_BUCKET_ATTACHMENTS": "attachments",
|
||||
"SELECTEL_S3_BUCKET_QUARANTINE": "quarantine",
|
||||
"SELECTEL_S3_ACCESS_KEY": "access",
|
||||
"SELECTEL_S3_SECRET_KEY": "secret",
|
||||
"CURSOR_HMAC_SECRET": "x" * 32,
|
||||
}
|
||||
return Settings.model_validate(common)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_contract_status_and_service_token() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["X-Service-Token"] == "safety-token"
|
||||
assert request.url.path == "/internal/safety/v1/messages/check"
|
||||
return httpx.Response(203, json={"verdict": "pending", "task_id": "task-1"})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
result = await SafetyClient(settings(), http).check(
|
||||
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||
"request-1",
|
||||
)
|
||||
assert result == {"verdict": "pending", "task_id": "task-1", "_status": 203}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_file_body_uses_exact_attachment_schema() -> None:
|
||||
attachment_id = uuid.uuid4()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = __import__("json").loads(request.content)
|
||||
assert body["attachment"] == {
|
||||
"attachment_id": str(attachment_id),
|
||||
"quarantine_object_key": "quarantine/users/u/file",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 42,
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
}
|
||||
assert "file" not in body
|
||||
return httpx.Response(200, json={"verdict": "allow"})
|
||||
|
||||
payload = {
|
||||
"message_id": str(uuid.uuid4()),
|
||||
"content_kind": "file",
|
||||
"text": "",
|
||||
"attachment": {
|
||||
"attachment_id": str(attachment_id),
|
||||
"quarantine_object_key": "quarantine/users/u/file",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 42,
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
},
|
||||
}
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
await SafetyClient(settings(), http).check(payload, "request-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_auth_failure_is_dependency_failure() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"error": {"code": "service_unauthorized"}})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
with pytest.raises(DependencyFailure):
|
||||
await SafetyClient(settings(), http).check(
|
||||
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||
"request-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openlines_contract_uses_bearer_and_idempotency() -> None:
|
||||
message_id = uuid.uuid4()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["Authorization"] == "Bearer bitrix-token"
|
||||
assert request.headers["Idempotency-Key"] == str(message_id)
|
||||
assert request.url.path == "/internal/openlines/v1/messages"
|
||||
return httpx.Response(201, json={"status": "accepted"})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
result = await OpenLinesClient(settings(), http).send(
|
||||
message_id, {"message_id": str(message_id)}, "request-1"
|
||||
)
|
||||
assert result["status"] == "accepted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openlines_payload_gets_fresh_download_url_without_storage_fields() -> None:
|
||||
class FakeS3:
|
||||
calls = 0
|
||||
|
||||
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
|
||||
self.calls += 1
|
||||
return f"https://download.example/{bucket}/{key}?generation={self.calls}"
|
||||
|
||||
payload = {
|
||||
"message_id": str(uuid.uuid4()),
|
||||
"external_chat_id": str(uuid.uuid4()),
|
||||
"occurred_at": "2026-07-10T12:00:00+00:00",
|
||||
"user": {"id": str(uuid.uuid4()), "display_name": "+79990000000"},
|
||||
"message": {
|
||||
"content_kind": "file",
|
||||
"text": "",
|
||||
"files": [{
|
||||
"attachment_id": str(uuid.uuid4()),
|
||||
"name": "file.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 42,
|
||||
"_storage_bucket": "attachments",
|
||||
"_object_key": "dialogs/d/file",
|
||||
}],
|
||||
},
|
||||
}
|
||||
s3 = FakeS3()
|
||||
first = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type]
|
||||
second = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type]
|
||||
assert first["occurred_at"] and first["user"]["id"]
|
||||
assert first["message"]["content_kind"] == "file"
|
||||
assert (
|
||||
first["message"]["files"][0]["download_url"]
|
||||
!= second["message"]["files"][0]["download_url"]
|
||||
)
|
||||
assert all(not key.startswith("_") for key in first["message"]["files"][0])
|
||||
@@ -0,0 +1,58 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
|
||||
from app.main import app, websocket_token
|
||||
|
||||
EXPECTED_PATHS = {
|
||||
"/health/live",
|
||||
"/health/ready",
|
||||
"/api/v1/public/app-config",
|
||||
"/api/v1/public/content",
|
||||
"/api/v1/auth/bootstrap",
|
||||
"/api/v1/consents",
|
||||
"/api/v1/analytics/session-start",
|
||||
"/api/v1/me",
|
||||
"/api/v1/me/documents",
|
||||
"/api/v1/documents/{document_id}",
|
||||
"/api/v1/documents/{document_id}/download-url",
|
||||
"/api/v1/dialogs",
|
||||
"/api/v1/dialogs/{dialog_id}",
|
||||
"/api/v1/dialogs/{dialog_id}/messages",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/init",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url",
|
||||
"/internal/openlines/v1/inbox",
|
||||
"/internal/settings/v1/otp",
|
||||
}
|
||||
|
||||
|
||||
def test_openapi_31_contains_all_http_contracts() -> None:
|
||||
schema = app.openapi()
|
||||
assert schema["openapi"].startswith("3.1.")
|
||||
assert EXPECTED_PATHS <= schema["paths"].keys()
|
||||
assert all(not path.startswith("/internal/safety") for path in schema["paths"])
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
assert committed["openapi"] == "3.1.0"
|
||||
assert committed["paths"].keys() == schema["paths"].keys()
|
||||
|
||||
|
||||
def test_websocket_route_is_registered() -> None:
|
||||
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
|
||||
|
||||
|
||||
def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
|
||||
jwt = "header.payload.signature"
|
||||
encoded = base64.urlsafe_b64encode(jwt.encode()).decode().rstrip("=")
|
||||
websocket = SimpleNamespace(
|
||||
headers={"sec-websocket-protocol": f"han-chat-v1, han.jwt.{encoded}"},
|
||||
query_params={},
|
||||
)
|
||||
assert websocket_token(websocket) == (jwt, f"han.jwt.{encoded}")
|
||||
|
||||
|
||||
def test_committed_openapi_server_does_not_double_api_prefix() -> None:
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
assert committed["servers"] == [{"url": "/"}]
|
||||
@@ -0,0 +1,26 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.cli.seed_settings import load_seed
|
||||
from app.services import REQUIRED_SETTINGS
|
||||
|
||||
|
||||
def test_production_like_seed_contains_all_mandatory_settings() -> None:
|
||||
path = Path(__file__).resolve().parents[3] / "deployment/app-settings.production-like.yaml"
|
||||
rows = load_seed(path)
|
||||
|
||||
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
|
||||
assert all(row["record_status"] == "A" for row in rows)
|
||||
|
||||
|
||||
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
" bad.integer: {type: integer, value: nope, public: false}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="integer value expected"):
|
||||
load_seed(path)
|
||||
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.auth import canonical_phone
|
||||
from app.integrations import CircuitBreaker, RateLimiter
|
||||
from app.schemas import (
|
||||
FileMessageRequest,
|
||||
MessageRequest,
|
||||
TextMessageRequest,
|
||||
canonical_fingerprint,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
)
|
||||
|
||||
|
||||
def test_phone_claim_priority_and_e164_validation() -> None:
|
||||
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
|
||||
assert canonical_phone(claims) == "+74999591007"
|
||||
assert canonical_phone({"phone_number": "89999999999"}) is None
|
||||
|
||||
|
||||
def test_message_discriminated_union() -> None:
|
||||
adapter = TypeAdapter(MessageRequest)
|
||||
assert isinstance(
|
||||
adapter.validate_python({"content_kind": "text", "text": "Здравствуйте"}),
|
||||
TextMessageRequest,
|
||||
)
|
||||
assert isinstance(
|
||||
adapter.validate_python(
|
||||
{
|
||||
"content_kind": "file",
|
||||
"attachment_id": str(uuid.uuid4()),
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
}
|
||||
),
|
||||
FileMessageRequest,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
adapter.validate_python(
|
||||
{"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())}
|
||||
)
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical_and_user_scoped() -> None:
|
||||
user = uuid.uuid4()
|
||||
first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user)
|
||||
second = canonical_fingerprint("POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, user)
|
||||
assert first == second
|
||||
assert first != canonical_fingerprint(
|
||||
"POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, uuid.uuid4()
|
||||
)
|
||||
|
||||
|
||||
def test_cursor_roundtrip_and_tamper_rejection() -> None:
|
||||
secret = b"test-secret" * 4
|
||||
cursor = encode_cursor({"id": str(uuid.uuid4()), "created_at": "2026-01-01"}, secret)
|
||||
assert decode_cursor(cursor, secret)["created_at"] == "2026-01-01"
|
||||
with pytest.raises(ValueError, match="invalid cursor"):
|
||||
decode_cursor(cursor[:-2] + "aa", secret)
|
||||
|
||||
|
||||
def test_rate_limit_keys_do_not_expose_identity() -> None:
|
||||
key = RateLimiter.key("ip", "203.0.113.7", "public", 60)
|
||||
assert "203.0.113.7" not in key
|
||||
assert key.startswith("han:api:rl:ip:")
|
||||
|
||||
|
||||
def test_circuit_breaker_opens_and_half_opens(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clock = [10.0]
|
||||
monkeypatch.setattr("app.integrations.time.monotonic", lambda: clock[0])
|
||||
breaker = CircuitBreaker(2, 30)
|
||||
breaker.failure()
|
||||
breaker.failure()
|
||||
assert not breaker.allow()
|
||||
clock[0] = 41.0
|
||||
assert breaker.allow()
|
||||
breaker.success()
|
||||
assert breaker.allow()
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
WORKDIR /service
|
||||
COPY app ./app
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,30 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://unused
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
[handlers]
|
||||
keys = console
|
||||
[formatters]
|
||||
keys = generic
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,42 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
url = os.environ["BITRIX_DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
include_schemas=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run(connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata, include_schemas=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_online() -> None:
|
||||
engine = async_engine_from_config(config.get_section(config.config_ini_section) or {})
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_offline()
|
||||
else:
|
||||
asyncio.run(run_online())
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Create durable OAuth, mapping, inbox, outbox, setup and audit storage."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.models import Base
|
||||
|
||||
revision = "0001_bitrix_local"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS bitrix_local")
|
||||
Base.metadata.create_all(bind=bind, checkfirst=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
Base.metadata.drop_all(bind=op.get_bind(), checkfirst=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""HAN Bitrix24 Open Lines adapter."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
SCHEMA = "bitrix_local"
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Common:
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
record_status: Mapped[str] = mapped_column(String(1), default="A")
|
||||
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status_change_reason: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
|
||||
class PortalInstallation(Common, Base):
|
||||
__tablename__ = "portal_installations"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_portal_active_domain",
|
||||
"domain",
|
||||
unique=True,
|
||||
postgresql_where=text("record_status = 'A'"),
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
member_id: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
domain: Mapped[str] = mapped_column(String(255))
|
||||
client_endpoint: Mapped[str] = mapped_column(String(1024))
|
||||
access_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
access_nonce: Mapped[str] = mapped_column(String(64))
|
||||
refresh_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
refresh_nonce: Mapped[str] = mapped_column(String(64))
|
||||
application_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
application_nonce: Mapped[str] = mapped_column(String(64))
|
||||
key_version: Mapped[str] = mapped_column(String(32))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
scope: Mapped[str | None] = mapped_column(Text)
|
||||
install_status: Mapped[str] = mapped_column(String(32), default="installed")
|
||||
setup_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class ConnectorSetup(Common, Base):
|
||||
__tablename__ = "connector_setup"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("portal_id", "connector_id", "line_id"),
|
||||
Index("ix_setup_retry", "next_retry_at", "attempt_count"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
|
||||
)
|
||||
connector_id: Mapped[str] = mapped_column(String(64))
|
||||
line_id: Mapped[str] = mapped_column(String(32))
|
||||
registered: Mapped[bool] = mapped_column(default=False)
|
||||
activated: Mapped[bool] = mapped_column(default=False)
|
||||
bindings_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
desired_version: Mapped[str] = mapped_column(String(32), default="1")
|
||||
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class DialogSession(Common, Base):
|
||||
__tablename__ = "dialog_sessions"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_dialog_active_external_chat",
|
||||
"external_chat_id",
|
||||
unique=True,
|
||||
postgresql_where=text("record_status = 'A'"),
|
||||
),
|
||||
Index("ix_dialog_bitrix_chat", "bitrix_chat_id"),
|
||||
Index("ix_dialog_session", "session_id"),
|
||||
Index("ix_dialog_status_updated", "status", "updated_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
bitrix_chat_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
session_id: Mapped[str | None] = mapped_column(String(255))
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
|
||||
|
||||
class InboxEvent(Common, Base):
|
||||
__tablename__ = "inbox_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("event_id"),
|
||||
Index(
|
||||
"uq_inbox_chat_message",
|
||||
"external_chat_id",
|
||||
"bitrix_message_id",
|
||||
unique=True,
|
||||
postgresql_where=text("bitrix_message_id IS NOT NULL"),
|
||||
),
|
||||
Index("ix_inbox_worker", "status", "next_attempt_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
event_id: Mapped[str] = mapped_column(String(255))
|
||||
event_type: Mapped[str] = mapped_column(String(64))
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
normalized_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
api_ack_status: Mapped[str | None] = mapped_column(String(32))
|
||||
delivery_ack_status: Mapped[str | None] = mapped_column(String(32))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class OutboundMessage(Common, Base):
|
||||
__tablename__ = "outbound_messages"
|
||||
__table_args__ = (Index("ix_outbound_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), unique=True)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
response_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class DeliveryAckOutbox(Common, Base):
|
||||
__tablename__ = "delivery_ack_outbox"
|
||||
__table_args__ = (Index("ix_ack_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
|
||||
inbox_event_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.inbox_events.id"), unique=True
|
||||
)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class InstallRun(Common, Base):
|
||||
__tablename__ = "install_runs"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
result_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class AuditEvent(Common, Base):
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = (Index("ix_audit_created", "created_at"), {"schema": SCHEMA})
|
||||
event_type: Mapped[str] = mapped_column(String(128))
|
||||
actor_type: Mapped[str] = mapped_column(String(32), default="system")
|
||||
safe_details: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
@@ -0,0 +1,104 @@
|
||||
openapi: 3.1.0
|
||||
info: {title: HAN Bitrix24 Local App, version: 1.0.0}
|
||||
paths:
|
||||
/bitrix/handler:
|
||||
get: {responses: {"200": {description: Callback probe}}}
|
||||
post:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {type: object}}
|
||||
application/x-www-form-urlencoded: {schema: {type: object}}
|
||||
multipart/form-data: {schema: {type: object}}
|
||||
responses:
|
||||
"200": {description: Duplicate or ignored callback}
|
||||
"202": {description: Durably accepted callback}
|
||||
"400": {description: Invalid callback}
|
||||
"403": {description: Invalid application token}
|
||||
/bitrix/install:
|
||||
get: {responses: {"200": {description: Install probe}}}
|
||||
post:
|
||||
responses:
|
||||
"200": {description: OAuth saved and setup attempted}
|
||||
"400": {description: Invalid install callback}
|
||||
/bitrix/placement:
|
||||
get: {responses: {"200": {description: Connector placement HTML}}}
|
||||
/health/live:
|
||||
get: {responses: {"200": {description: Live}}}
|
||||
/health/ready:
|
||||
get: {responses: {"200": {description: Ready}, "503": {description: Not ready}}}
|
||||
/internal/openlines/v1/messages:
|
||||
post:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
|
||||
- {$ref: "#/components/parameters/RequestId"}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {$ref: "#/components/schemas/OutboundMessage"}}
|
||||
responses:
|
||||
"201": {description: Delivered}
|
||||
"200": {description: Idempotent duplicate}
|
||||
"400": {description: Invalid request}
|
||||
"401": {description: Unauthorized}
|
||||
"409": {description: Idempotency key reused}
|
||||
"503": {description: Dependency unavailable or ambiguous delivery}
|
||||
/internal/openlines/v1/dialogs/{external_chat_id}:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: external_chat_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
responses:
|
||||
"200": {description: Active dialog mapping}
|
||||
"404": {description: Mapping not found}
|
||||
/internal/openlines/v1/status:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
responses: {"200": {description: Safe adapter status}}
|
||||
/internal/openlines/v1/setup/retry:
|
||||
post:
|
||||
security: [{BearerAuth: []}]
|
||||
responses: {"200": {description: Idempotent setup reconcile result}}
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth: {type: http, scheme: bearer}
|
||||
parameters:
|
||||
RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||
schemas:
|
||||
OutboundFile:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [attachment_id, name, mime_type, size_bytes, download_url]
|
||||
properties:
|
||||
attachment_id: {type: string, format: uuid}
|
||||
name: {type: string, maxLength: 255}
|
||||
mime_type: {type: string, maxLength: 255}
|
||||
size_bytes: {type: integer, minimum: 1, maximum: 5242880}
|
||||
download_url: {type: string, maxLength: 4096}
|
||||
OutboundMessage:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id, external_chat_id, occurred_at, user, message]
|
||||
properties:
|
||||
message_id: {type: string, format: uuid}
|
||||
external_chat_id: {type: string, format: uuid}
|
||||
occurred_at: {type: string, format: date-time}
|
||||
user:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, display_name]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
display_name: {type: string, maxLength: 255}
|
||||
message:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [content_kind, text, files]
|
||||
properties:
|
||||
content_kind: {type: string, enum: [text, file]}
|
||||
text: {type: string, maxLength: 10000}
|
||||
files:
|
||||
type: array
|
||||
maxItems: 1
|
||||
items: {$ref: "#/components/schemas/OutboundFile"}
|
||||
@@ -0,0 +1,33 @@
|
||||
[project]
|
||||
name = "han-bitrix-local-app"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"cryptography>=45,<46",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
@@ -0,0 +1,70 @@
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
|
||||
os.environ.setdefault("BITRIX_DATABASE_URL", "postgresql://unused/unused")
|
||||
os.environ.setdefault("BITRIX_CLIENT_ID", "client")
|
||||
os.environ.setdefault("BITRIX_CLIENT_SECRET", "secret")
|
||||
os.environ.setdefault("BITRIX_APPLICATION_TOKEN", "application-token")
|
||||
os.environ.setdefault("BITRIX_INTERNAL_API_TOKEN", "internal-token-32-characters-long")
|
||||
os.environ.setdefault("BITRIX_API_FORWARD_URL", "http://api/internal/openlines/v1/inbox")
|
||||
os.environ.setdefault("BITRIX_API_FORWARD_TOKEN", "forward-token-32-characters-long")
|
||||
os.environ.setdefault(
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY",
|
||||
base64.urlsafe_b64encode(b"k" * 32).decode().rstrip("="),
|
||||
)
|
||||
|
||||
import pytest
|
||||
|
||||
from app.main import (
|
||||
TokenCipher,
|
||||
canonical_fingerprint,
|
||||
normalize_event,
|
||||
retry_delay,
|
||||
validate_portal,
|
||||
)
|
||||
|
||||
|
||||
def test_token_cipher_binds_aad():
|
||||
cipher = TokenCipher(os.environ["BITRIX_TOKEN_ENCRYPTION_KEY"], "v1")
|
||||
ciphertext, nonce = cipher.encrypt("secret", "member", "han0107.bitrix24.ru", "access")
|
||||
assert cipher.decrypt(ciphertext, nonce, "member", "han0107.bitrix24.ru", "access") == "secret"
|
||||
with pytest.raises(Exception):
|
||||
cipher.decrypt(ciphertext, nonce, "other", "han0107.bitrix24.ru", "access")
|
||||
|
||||
|
||||
def test_normalize_message_and_finish():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"chat": {"id": external},
|
||||
"message": {"id": "b-1", "text": "Ответ", "files": []},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
assert message["event_type"] == "message.new"
|
||||
assert message["external_chat_id"] == external
|
||||
closed = normalize_event(
|
||||
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
|
||||
)
|
||||
assert closed["event_type"] == "dialog.closed"
|
||||
|
||||
|
||||
def test_fingerprint_ignores_signed_query_and_portal_validation():
|
||||
payload = {"message": {"files": [{"download_url": "https://s3/object?sig=one"}]}}
|
||||
other = {"message": {"files": [{"download_url": "https://s3/object?sig=two"}]}}
|
||||
assert canonical_fingerprint(payload) == canonical_fingerprint(other)
|
||||
validate_portal(
|
||||
"han0107.bitrix24.ru",
|
||||
"https://han0107.bitrix24.ru/rest/",
|
||||
"han0107.bitrix24.ru",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
validate_portal("evil.example", "https://evil.example/rest/", "han0107.bitrix24.ru")
|
||||
assert 0 <= retry_delay(4, 300) <= 8
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
WORKDIR /service
|
||||
COPY app ./app
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,30 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://unused
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
[handlers]
|
||||
keys = console
|
||||
[formatters]
|
||||
keys = generic
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,34 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", os.environ["BITRIX_SYNC_DATABASE_URL"])
|
||||
target_metadata = None
|
||||
|
||||
|
||||
def run_offline() -> None:
|
||||
context.configure(url=config.get_main_option("sqlalchemy.url"), literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_online() -> None:
|
||||
engine = async_engine_from_config(config.get_section(config.config_ini_section) or {})
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def do_run(connection) -> None:
|
||||
context.configure(connection=connection)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_offline()
|
||||
else:
|
||||
asyncio.run(run_online())
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Establish the bitrix-sync connectivity-stub migration baseline."""
|
||||
|
||||
revision = "0001_sync_baseline"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The connectivity stub deliberately owns no runtime tables.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
"""HAN bitrix-sync DB connectivity stub."""
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated, Protocol
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
app_env: str = "production-like"
|
||||
bitrix_sync_enabled: bool = True
|
||||
bitrix_sync_database_url: str | None = None
|
||||
bitrix_sync_service_token: str = Field(min_length=16)
|
||||
bitrix_sync_db_check_interval_sec: float = Field(default=60, ge=0.05)
|
||||
bitrix_sync_db_check_timeout_sec: float = Field(default=5, ge=0.05)
|
||||
bitrix_sync_db_check_jitter_ratio: float = Field(default=0.1, ge=0, le=0.5)
|
||||
bitrix_sync_db_retry_base_sec: float = Field(default=5, ge=0.05)
|
||||
bitrix_sync_db_retry_max_sec: float = Field(default=60, ge=0.05)
|
||||
bitrix_sync_ready_max_staleness_sec: float = Field(default=150, ge=1)
|
||||
bitrix_sync_db_pool_size: int = Field(default=2, ge=1, le=5)
|
||||
bitrix_sync_db_pool_recycle_sec: int = Field(default=300, ge=30)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled(self) -> Settings:
|
||||
if self.bitrix_sync_enabled and not self.bitrix_sync_database_url:
|
||||
raise ValueError("BITRIX_SYNC_DATABASE_URL is required when sync is enabled")
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class Snapshot:
|
||||
state: str
|
||||
started_at: datetime
|
||||
last_started_at: datetime | None = None
|
||||
last_finished_at: datetime | None = None
|
||||
last_success_at: datetime | None = None
|
||||
success: bool | None = None
|
||||
duration_ms: int | None = None
|
||||
error_code: str | None = None
|
||||
consecutive_failures: int = 0
|
||||
next_check_at: datetime | None = None
|
||||
worker_running: bool = False
|
||||
|
||||
|
||||
class Probe(Protocol):
|
||||
async def check(self) -> None: ...
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
class DatabaseProbe:
|
||||
def __init__(self, engine: AsyncEngine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
async def check(self) -> None:
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.scalar(text("SELECT 1"))
|
||||
if result != 1:
|
||||
raise RuntimeError("unexpected_result")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.engine.dispose()
|
||||
|
||||
|
||||
def database_url(value: str) -> str:
|
||||
if value.startswith("postgresql://"):
|
||||
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return value
|
||||
|
||||
|
||||
def classify_error(exc: Exception) -> str:
|
||||
name = type(exc).__name__.lower()
|
||||
text_value = str(exc).lower()
|
||||
if isinstance(exc, TimeoutError):
|
||||
return "db_query_timeout"
|
||||
if "auth" in name or "password" in text_value:
|
||||
return "db_auth_failed"
|
||||
if "ssl" in name or "tls" in text_value or "certificate" in text_value:
|
||||
return "db_tls_failed"
|
||||
if str(exc) == "unexpected_result":
|
||||
return "unexpected_result"
|
||||
return "db_unavailable"
|
||||
|
||||
|
||||
def jsonable(value):
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
return value
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None, probe: Probe | None = None) -> FastAPI:
|
||||
cfg = settings or Settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.snapshot = Snapshot(
|
||||
state="disabled" if not cfg.bitrix_sync_enabled else "starting",
|
||||
started_at=utcnow(),
|
||||
)
|
||||
app.state.stop = asyncio.Event()
|
||||
app.state.probe = probe
|
||||
app.state.loop_task = None
|
||||
if cfg.bitrix_sync_enabled:
|
||||
if app.state.probe is None:
|
||||
engine = create_async_engine(
|
||||
database_url(cfg.bitrix_sync_database_url or ""),
|
||||
pool_size=cfg.bitrix_sync_db_pool_size,
|
||||
max_overflow=0,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec,
|
||||
connect_args={"server_settings": {"application_name": "han-bitrix-sync"}},
|
||||
)
|
||||
app.state.probe = DatabaseProbe(engine)
|
||||
await run_probe(app)
|
||||
app.state.snapshot.worker_running = True
|
||||
app.state.loop_task = asyncio.create_task(
|
||||
periodic_loop(app), name="db-connectivity-probe"
|
||||
)
|
||||
yield
|
||||
app.state.snapshot.state = "stopping"
|
||||
app.state.stop.set()
|
||||
if app.state.loop_task:
|
||||
app.state.loop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await app.state.loop_task
|
||||
if app.state.probe:
|
||||
await app.state.probe.close()
|
||||
|
||||
app = FastAPI(
|
||||
title="HAN Bitrix Sync Connectivity Stub",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url=None if cfg.app_env != "test" else "/docs",
|
||||
)
|
||||
app.state.settings = cfg
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_id(request: Request, call_next):
|
||||
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
return response
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(_: Request, exc: HTTPException):
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||
|
||||
@app.get("/health/live")
|
||||
async def live():
|
||||
return {"status": "live"}
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def ready(request: Request):
|
||||
snapshot: Snapshot = request.app.state.snapshot
|
||||
if not cfg.bitrix_sync_enabled:
|
||||
return JSONResponse({"status": "not_ready", "reason": "sync_disabled"}, status_code=503)
|
||||
age = (
|
||||
(utcnow() - snapshot.last_success_at).total_seconds()
|
||||
if snapshot.last_success_at
|
||||
else None
|
||||
)
|
||||
max_age = max(
|
||||
cfg.bitrix_sync_ready_max_staleness_sec,
|
||||
2 * cfg.bitrix_sync_db_check_interval_sec * (1 + cfg.bitrix_sync_db_check_jitter_ratio),
|
||||
)
|
||||
is_ready = (
|
||||
snapshot.state == "healthy"
|
||||
and snapshot.worker_running
|
||||
and age is not None
|
||||
and age <= max_age
|
||||
)
|
||||
if is_ready:
|
||||
return {
|
||||
"status": "ready",
|
||||
"mode": "db_connectivity_stub",
|
||||
"database": {
|
||||
"status": "ok",
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"age_seconds": round(age or 0, 3),
|
||||
},
|
||||
}
|
||||
reason = "worker_not_running" if not snapshot.worker_running else "database_unavailable"
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "not_ready",
|
||||
"reason": reason,
|
||||
"database": {
|
||||
"status": "down",
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"consecutive_failures": snapshot.consecutive_failures,
|
||||
},
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
@app.get("/internal/sync/v1/status")
|
||||
async def status(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
candidate = (
|
||||
authorization[7:] if authorization and authorization.startswith("Bearer ") else ""
|
||||
)
|
||||
if not hmac.compare_digest(candidate, cfg.bitrix_sync_service_token):
|
||||
raise HTTPException(
|
||||
401,
|
||||
{
|
||||
"error": {
|
||||
"code": "service_unauthorized",
|
||||
"message": "Service authentication failed",
|
||||
"request_id": request.state.request_id,
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
snapshot: Snapshot = request.app.state.snapshot
|
||||
last_check = None
|
||||
if snapshot.last_started_at:
|
||||
last_check = {
|
||||
"started_at": jsonable(snapshot.last_started_at),
|
||||
"finished_at": jsonable(snapshot.last_finished_at),
|
||||
"success": snapshot.success,
|
||||
"duration_ms": snapshot.duration_ms,
|
||||
"error_code": snapshot.error_code,
|
||||
}
|
||||
next_in = (
|
||||
max(0, (snapshot.next_check_at - utcnow()).total_seconds())
|
||||
if snapshot.next_check_at
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"service": "bitrix-sync",
|
||||
"enabled": cfg.bitrix_sync_enabled,
|
||||
"mode": "db_connectivity_stub",
|
||||
"crm_sync_implemented": False,
|
||||
"state": snapshot.state,
|
||||
"started_at": jsonable(snapshot.started_at),
|
||||
"last_check": last_check,
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"consecutive_failures": snapshot.consecutive_failures,
|
||||
"next_check_in_seconds": round(next_in, 3) if next_in is not None else None,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def run_probe(app: FastAPI) -> None:
|
||||
cfg: Settings = app.state.settings
|
||||
snapshot: Snapshot = app.state.snapshot
|
||||
snapshot.last_started_at = utcnow()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with asyncio.timeout(cfg.bitrix_sync_db_check_timeout_sec):
|
||||
await app.state.probe.check()
|
||||
except Exception as exc:
|
||||
snapshot.success = False
|
||||
snapshot.error_code = classify_error(exc)
|
||||
snapshot.consecutive_failures += 1
|
||||
snapshot.state = "degraded"
|
||||
else:
|
||||
snapshot.success = True
|
||||
snapshot.error_code = None
|
||||
snapshot.consecutive_failures = 0
|
||||
snapshot.last_success_at = utcnow()
|
||||
snapshot.state = "healthy"
|
||||
finally:
|
||||
snapshot.last_finished_at = utcnow()
|
||||
snapshot.duration_ms = round((time.monotonic() - started) * 1000)
|
||||
|
||||
|
||||
async def periodic_loop(app: FastAPI) -> None:
|
||||
cfg: Settings = app.state.settings
|
||||
snapshot: Snapshot = app.state.snapshot
|
||||
try:
|
||||
while not app.state.stop.is_set():
|
||||
if snapshot.consecutive_failures:
|
||||
cap = min(
|
||||
cfg.bitrix_sync_db_retry_base_sec * 2 ** (snapshot.consecutive_failures - 1),
|
||||
cfg.bitrix_sync_db_retry_max_sec,
|
||||
)
|
||||
delay = random.uniform(0, cap)
|
||||
else:
|
||||
jitter = (
|
||||
cfg.bitrix_sync_db_check_interval_sec * cfg.bitrix_sync_db_check_jitter_ratio
|
||||
)
|
||||
delay = cfg.bitrix_sync_db_check_interval_sec + random.uniform(-jitter, jitter)
|
||||
snapshot.next_check_at = utcnow() + timedelta(seconds=delay)
|
||||
try:
|
||||
await asyncio.wait_for(app.state.stop.wait(), timeout=delay)
|
||||
break
|
||||
except TimeoutError:
|
||||
await run_probe(app)
|
||||
finally:
|
||||
snapshot.worker_running = False
|
||||
|
||||
|
||||
_settings = Settings()
|
||||
app = create_app(_settings)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8080)
|
||||
@@ -0,0 +1,46 @@
|
||||
openapi: 3.1.0
|
||||
info: {title: HAN Bitrix Sync Connectivity Stub, version: 1.0.0}
|
||||
paths:
|
||||
/health/live:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: Process is live
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/Live"}}}
|
||||
/health/ready:
|
||||
get:
|
||||
responses:
|
||||
"200": {description: Latest PostgreSQL probe is fresh and successful}
|
||||
"503": {description: Disabled, stale, or database unavailable}
|
||||
/internal/sync/v1/status:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||
responses:
|
||||
"200":
|
||||
description: Connectivity-loop status
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/Status"}}}
|
||||
"401": {description: Service authentication failed}
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth: {type: http, scheme: bearer}
|
||||
schemas:
|
||||
Live:
|
||||
type: object
|
||||
required: [status]
|
||||
properties: {status: {const: live}}
|
||||
Status:
|
||||
type: object
|
||||
required: [service, enabled, mode, crm_sync_implemented, state, started_at]
|
||||
properties:
|
||||
service: {const: bitrix-sync}
|
||||
enabled: {type: boolean}
|
||||
mode: {const: db_connectivity_stub}
|
||||
crm_sync_implemented: {const: false}
|
||||
state: {type: string, enum: [starting, disabled, healthy, degraded, stopping]}
|
||||
started_at: {type: string, format: date-time}
|
||||
last_check: {type: [object, "null"]}
|
||||
last_success_at: {type: [string, "null"], format: date-time}
|
||||
consecutive_failures: {type: integer, minimum: 0}
|
||||
next_check_in_seconds: {type: [number, "null"]}
|
||||
@@ -0,0 +1,30 @@
|
||||
[project]
|
||||
name = "han-bitrix-sync"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"fastapi>=0.116,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
@@ -0,0 +1,65 @@
|
||||
import os
|
||||
|
||||
os.environ.setdefault("BITRIX_SYNC_ENABLED", "false")
|
||||
os.environ.setdefault("BITRIX_SYNC_SERVICE_TOKEN", "test-sync-token-32-characters")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import Settings, create_app
|
||||
|
||||
|
||||
class Probe:
|
||||
def __init__(self, fail=False):
|
||||
self.fail = fail
|
||||
self.calls = 0
|
||||
|
||||
async def check(self):
|
||||
self.calls += 1
|
||||
if self.fail:
|
||||
raise OSError("down")
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_semantics():
|
||||
settings = Settings(
|
||||
bitrix_sync_enabled=False,
|
||||
bitrix_sync_service_token="test-sync-token-32-characters",
|
||||
)
|
||||
app = create_app(settings)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
assert (await client.get("/health/live")).status_code == 200
|
||||
ready = await client.get("/health/ready")
|
||||
assert ready.status_code == 503
|
||||
assert ready.json()["reason"] == "sync_disabled"
|
||||
status = await client.get(
|
||||
"/internal/sync/v1/status",
|
||||
headers={"Authorization": "Bearer test-sync-token-32-characters"},
|
||||
)
|
||||
assert status.json()["state"] == "disabled"
|
||||
assert status.json()["crm_sync_implemented"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_probe_and_auth():
|
||||
probe = Probe()
|
||||
settings = Settings(
|
||||
bitrix_sync_enabled=True,
|
||||
bitrix_sync_database_url="postgresql://unused/unused",
|
||||
bitrix_sync_service_token="test-sync-token-32-characters",
|
||||
bitrix_sync_db_check_interval_sec=60,
|
||||
)
|
||||
app = create_app(settings, probe)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
assert probe.calls == 1
|
||||
assert (await client.get("/health/ready")).status_code == 200
|
||||
assert (await client.get("/internal/sync/v1/status")).status_code == 401
|
||||
@@ -0,0 +1,207 @@
|
||||
# HAN Chat production-like deployment runbook
|
||||
|
||||
This is the executable checklist for the single-VM contour. PostgreSQL and S3
|
||||
are managed external services. Never use `docker compose down -v`, an Alembic
|
||||
downgrade, or a mutable image tag during deployment.
|
||||
|
||||
## Gate 0 — decisions and ownership
|
||||
|
||||
- [ ] Release SHA/digests, maintenance window, on-call and rollback owner recorded.
|
||||
- [ ] RPO/RTO accepted; initial targets are PG RPO <=15 minutes and RTO <=4 hours.
|
||||
- [ ] Remote OTLP backend selected, or debug-only acceptance limitation accepted.
|
||||
- [ ] Mock OTP, Safety stub and bitrix-sync stub risks explicitly accepted.
|
||||
|
||||
## Gate 1 — VPC, DNS and security groups
|
||||
|
||||
- [ ] Managed PostgreSQL has only a private endpoint and accepts traffic from VM SG.
|
||||
- [ ] Internet can reach only VM TCP 80/443; SSH is restricted to VPN/ops CIDR.
|
||||
- [ ] Ports 6379, 4317/4318, 8000, 8080 and 9000 are denied externally.
|
||||
- [ ] DNS `A` for `PUBLIC_HOST` points at the VM and outbound HTTPS is available.
|
||||
|
||||
## Gate 2 — VM hardening
|
||||
|
||||
- [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active.
|
||||
- [ ] Key-only deploy account works in a second session; root/password SSH is off.
|
||||
- [ ] UFW/cloud SG and `DOCKER-USER` policy survive reboot.
|
||||
- [ ] Docker Engine and Compose support `include` and long-form `env_file`.
|
||||
|
||||
## Gate 3 — managed PostgreSQL
|
||||
|
||||
- [ ] Daily backup, PITR, deletion protection, encryption and alerts are enabled.
|
||||
- [ ] Provider CA is installed at `PG_CA_HOST_PATH`; all DSNs use `verify-full`.
|
||||
- [ ] Schemas `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
|
||||
have separate migration/runtime roles with tested negative grants.
|
||||
- [ ] Migration tested against an empty DB and a clone of the previous release.
|
||||
|
||||
## Gate 4 — Selectel S3
|
||||
|
||||
- [ ] Quarantine, attachments and documents buckets are private and encrypted.
|
||||
- [ ] API credentials are prefix-scoped; Safety credentials are quarantine read-only.
|
||||
- [ ] Browser CORS permits exact HTTPS origin and PUT headers only.
|
||||
- [ ] Quarantine lifecycle exceeds Safety poll/recovery; data retention is approved.
|
||||
|
||||
## Gate 5 — immutable release
|
||||
|
||||
- [ ] Checkout is detached at the approved SHA and working tree is clean.
|
||||
- [ ] Service images are immutable and scanned; no unresolved critical/high issue.
|
||||
- [ ] Root `docker-compose.yml` is the only deployment entry point.
|
||||
|
||||
## Gate 6 — environment and secrets
|
||||
|
||||
```sh
|
||||
umask 077
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
# Replace placeholders using a protected editor/secret manager.
|
||||
./scripts/validate-env .env
|
||||
docker compose --env-file .env config --quiet
|
||||
```
|
||||
|
||||
- [ ] Token pairs match, PG verifies TLS, public URLs are HTTPS.
|
||||
- [ ] Mock OTP risk is accepted and all secrets are unique >=128-bit values.
|
||||
- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match.
|
||||
|
||||
## Gate 7 — images and static frontend
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env pull
|
||||
docker compose --env-file .env build --pull frontend-static nginx redis
|
||||
docker compose --env-file .env run --rm frontend-static
|
||||
```
|
||||
|
||||
- [ ] Frontend export was tested/scanned and copied by `frontend-static` into its named volume.
|
||||
- [ ] Build artifacts contain no secrets or unintended source maps.
|
||||
- [ ] At least 30% VM disk remains free.
|
||||
|
||||
## Gate 8 — topology
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env config --services
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
- [ ] Exactly nginx publishes `80:80` and `443:443`; no PostgreSQL service exists.
|
||||
- [ ] Redis AOF/RDB/ACL and OTEL persistent queue volumes are present.
|
||||
- [ ] `backend` and `observability` are internal networks.
|
||||
|
||||
## Gate 9 — ACME/TLS bootstrap
|
||||
|
||||
Set `NGINX_TLS_ENABLED=false` only for this bootstrap command:
|
||||
|
||||
```sh
|
||||
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
|
||||
docker compose --profile certbot run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
|
||||
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
docker compose --env-file .env up -d --force-recreate nginx
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
```
|
||||
|
||||
First rehearse with Certbot `--staging`. Install a twice-daily systemd timer for
|
||||
`deployment/scripts/ssl-renew.sh`; test `certbot renew --dry-run`. Enable HSTS
|
||||
only after chain, hostname, redirect and TLS 1.2/1.3 checks pass.
|
||||
|
||||
## Gate 10 — migrations and seed
|
||||
|
||||
Create a provider PITR marker, then:
|
||||
|
||||
```sh
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
- [ ] Expected Alembic revisions are active and runtime users did not perform DDL.
|
||||
- [ ] Seed succeeds twice and mandatory settings contain no secret.
|
||||
- [ ] Schema remains backward-compatible with the previous images.
|
||||
|
||||
## Gate 11 — Keycloak
|
||||
|
||||
```sh
|
||||
docker compose up -d keycloak
|
||||
docker compose ps keycloak
|
||||
```
|
||||
|
||||
- [ ] Discovery/JWKS issuer is the exact public `/auth` HTTPS URL.
|
||||
- [ ] Frontend client is public PKCE S256; implicit/password/social flows are off.
|
||||
- [ ] Wrong/replayed OTP and limits fail safely; settings bridge is fail-closed.
|
||||
- [ ] Bootstrap admin was removed/rotated and named admin MFA is enabled.
|
||||
|
||||
## Gate 12 — ordered startup and readiness
|
||||
|
||||
```sh
|
||||
docker compose up -d redis
|
||||
docker compose up -d keycloak otel-collector
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d api-backend
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
- [ ] No restart loop/OOM; critical readiness is green.
|
||||
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
|
||||
- [ ] External `/internal/*` is 404 and OTEL accepts telemetry.
|
||||
|
||||
## Gate 13 — Bitrix24
|
||||
|
||||
- [ ] Install, handler and placement URLs use the exact public HTTPS paths.
|
||||
- [ ] Connector `han_mobile_app` is active on Open Line 8; events are bound once.
|
||||
- [ ] OAuth is encrypted; callback/application/service tokens never enter logs.
|
||||
- [ ] Outbound and operator reply paths are idempotent; internal status is private.
|
||||
|
||||
## Gate 14 — smoke and E2E
|
||||
|
||||
```sh
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
- [ ] Guest, OTP/PKCE/bootstrap/session, refresh and logout paths pass.
|
||||
- [ ] Safety allow/deny/pending/timeout and one concurrent slow poll pass.
|
||||
- [ ] File quarantine/promote/delete, owner-only download and audit pass.
|
||||
- [ ] WS reconnect plus REST reconciliation, ownership 404, idempotency and 429 pass.
|
||||
- [ ] Logs contain no PII, message body, token or presigned query.
|
||||
|
||||
## Gate 15 — observability
|
||||
|
||||
- [ ] Known request ID links nginx, API and downstream trace; UX ID is not a label.
|
||||
- [ ] Three signals reach the selected backend; SLO queries and alerts are tested.
|
||||
- [ ] Remote outage fills/drains the bounded persistent queue without business outage.
|
||||
- [ ] Secret/PII canary is absent. Collector restart/drop/refused metrics are checked.
|
||||
|
||||
For local acceptance only, start the redacted debug collector with:
|
||||
`docker compose --profile observability-local up -d otel-collector-local`.
|
||||
|
||||
## Gate 16 — open traffic
|
||||
|
||||
- [ ] Gates 0–15 are signed; fresh backup/PITR evidence and previous images exist.
|
||||
- [ ] HSTS is enabled, release digests/schema/realm versions are recorded.
|
||||
- [ ] No active page; on-call and product owner accept stub limitations.
|
||||
- [ ] Observe 5xx/auth/delivery/DB/Redis/OOM/OTEL queue/Bitrix for 60 minutes.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Provider backup/PITR is authoritative. A supplemental verified logical dump:
|
||||
|
||||
```sh
|
||||
PG_BACKUP_DSN='postgresql://...?...sslmode=verify-full&sslrootcert=...' \
|
||||
deployment/scripts/backup.sh /opt/han-chat/backups
|
||||
```
|
||||
|
||||
Quarterly, restore PG and S3 into an isolated VPC, deploy the same image digests,
|
||||
do not route production DNS/Bitrix callbacks, run smoke, and record measured RPO/RTO.
|
||||
Redis may be restored empty; its AOF/RDB is not a business backup.
|
||||
|
||||
## Rollback
|
||||
|
||||
Only roll back to images compatible with the current schema:
|
||||
|
||||
```sh
|
||||
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
|
||||
deployment/scripts/rollback.sh /secure/path/previous-release.env
|
||||
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
Never run Alembic downgrade. After a backward-incompatible migration choose a
|
||||
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
|
||||
Always verify outbox/inbox/recovery so an ambiguous message is not sent twice.
|
||||
@@ -0,0 +1,31 @@
|
||||
schema_version: 1
|
||||
settings:
|
||||
auth.phone.enabled: {type: boolean, value: true, public: true}
|
||||
auth.password.enabled: {type: boolean, value: false, public: true}
|
||||
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
|
||||
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
|
||||
operator.call.phone: {type: string, value: "+74999591007", public: true}
|
||||
consent.personal_data.required: {type: boolean, value: true, public: true}
|
||||
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
|
||||
consent.personal_data.version: {type: string, value: "2026-06-10", public: true}
|
||||
consent.user_agreement.required: {type: boolean, value: true, public: true}
|
||||
consent.user_agreement.document_url: {type: string, value: "https://www.han0107.ru/user-agreement", public: true}
|
||||
consent.user_agreement.version: {type: string, value: "2026-06-10", public: true}
|
||||
consent.marketing.required: {type: boolean, value: false, public: true}
|
||||
consent.marketing.version: {type: string, value: "2026-06-10", public: true}
|
||||
chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true}
|
||||
chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true}
|
||||
chat.attachments.disallowed_extensions: {type: string_list, value: "svg,doc,docx,xls,xlsx,csv", public: false}
|
||||
chat.attachments.max_size_mb: {type: integer, value: 5, public: true}
|
||||
chat.attachments.storage: {type: string, value: selectel_s3, public: false}
|
||||
chat.attachments.upload_mode: {type: string, value: presigned_put, public: false}
|
||||
chat.attachments.safety_scan_required: {type: boolean, value: true, public: false}
|
||||
chat.attachments.presigned_upload_ttl_seconds: {type: integer, value: 600, public: true}
|
||||
rate_limit.message_send.per_user: {type: string, value: "30/minute", public: true}
|
||||
rate_limit.message_send.per_dialog: {type: string, value: "20/minute", public: false}
|
||||
rate_limit.download_url.per_user: {type: string, value: "60/hour", public: false}
|
||||
rate_limit.public_endpoints.per_ip: {type: string, value: "60/minute", public: true}
|
||||
rate_limit.login.per_ip: {type: string, value: "10/minute", public: true}
|
||||
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
|
||||
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
|
||||
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
|
||||
@@ -0,0 +1,72 @@
|
||||
services:
|
||||
migrate-api:
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
profiles: ["ops"]
|
||||
env_file:
|
||||
- path: ../.env
|
||||
required: false
|
||||
entrypoint: []
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
migrate-bitrix-local:
|
||||
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
|
||||
profiles: ["ops"]
|
||||
env_file:
|
||||
- path: ../.env
|
||||
required: false
|
||||
entrypoint: []
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
migrate-bitrix-sync:
|
||||
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
|
||||
profiles: ["ops"]
|
||||
env_file:
|
||||
- path: ../.env
|
||||
required: false
|
||||
entrypoint: []
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
seed-settings:
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
profiles: ["ops"]
|
||||
env_file:
|
||||
- path: ../.env
|
||||
required: false
|
||||
entrypoint: []
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- >-
|
||||
python -m app.cli.seed_settings
|
||||
--file /deployment/app-settings.production-like.yaml
|
||||
&& python -m app.cli.validate_settings
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
- ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro
|
||||
networks: [backend]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
toolbox:
|
||||
image: curlimages/curl:8.11.1
|
||||
profiles: ["ops"]
|
||||
entrypoint: ["sleep", "infinity"]
|
||||
networks: [backend, observability]
|
||||
restart: "no"
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ -z "${PG_BACKUP_DSN:-}" ]; then
|
||||
echo "PG_BACKUP_DSN is required (managed PostgreSQL TLS DSN, supplied via secure environment)." >&2
|
||||
exit 64
|
||||
fi
|
||||
case "$PG_BACKUP_DSN" in
|
||||
*sslmode=verify-full*sslrootcert=*) ;;
|
||||
*) echo "PG_BACKUP_DSN must enforce sslmode=verify-full and sslrootcert." >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
output_dir=${1:-/opt/han-chat/backups}
|
||||
umask 077
|
||||
mkdir -p "$output_dir"
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
archive="$output_dir/han-chat-$stamp.dump"
|
||||
|
||||
pg_dump --dbname="$PG_BACKUP_DSN" --format=custom --no-owner --no-privileges --file="$archive"
|
||||
pg_restore --list "$archive" >/dev/null
|
||||
sha256sum "$archive" > "$archive.sha256"
|
||||
chmod 600 "$archive" "$archive.sha256"
|
||||
echo "Logical backup verified: $archive"
|
||||
echo "This supplements, but does not replace, provider backup/PITR and restore rehearsal."
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
if [ "${PITR_MARKER_CONFIRMED:-false}" != "true" ]; then
|
||||
echo "Refusing migration: create provider PITR marker, then set PITR_MARKER_CONFIRMED=true" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
./scripts/validate-env "${ENV_FILE:-.env}"
|
||||
docker compose --env-file "${ENV_FILE:-.env}" config --quiet
|
||||
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api alembic current
|
||||
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local alembic current
|
||||
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync alembic current
|
||||
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api
|
||||
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local
|
||||
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync
|
||||
echo "Migrations completed; record revisions in release evidence."
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
previous_env=${1:-}
|
||||
if [ -z "$previous_env" ] || [ ! -r "$previous_env" ]; then
|
||||
echo "Usage: $0 /secure/path/previous-release.env" >&2
|
||||
exit 64
|
||||
fi
|
||||
if [ "${SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED:-false}" != "true" ]; then
|
||||
echo "Refusing rollback: set SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true after migration review." >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
./scripts/validate-env "$previous_env"
|
||||
docker compose --env-file "$previous_env" config --quiet
|
||||
docker compose --env-file "$previous_env" up -d --remove-orphans
|
||||
docker compose --env-file "$previous_env" ps
|
||||
|
||||
echo "Application images rolled back without Alembic downgrade."
|
||||
echo "Run deployment/scripts/smoke.sh with ENV_FILE=$previous_env and verify outbox/inbox idempotency."
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
./scripts/validate-env "${ENV_FILE:-.env}"
|
||||
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm seed-settings
|
||||
echo "Seed and mandatory-settings validation completed."
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
ENV_FILE=${ENV_FILE:-.env}
|
||||
./scripts/validate-env "$ENV_FILE"
|
||||
set -a
|
||||
. "./$ENV_FILE"
|
||||
set +a
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
http_code=$(curl -sS -o /dev/null -w '%{http_code}' "http://${PUBLIC_HOST}/")
|
||||
[ "$http_code" = "308" ] || { echo "Expected HTTP 308, got $http_code" >&2; exit 1; }
|
||||
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/app-config" -o "$tmp/app-config.json"
|
||||
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/content" -o "$tmp/content.json"
|
||||
curl -fsS "${PUBLIC_WEB_URL}/auth/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration" -o "$tmp/oidc.json"
|
||||
|
||||
internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/safety/v1/messages/check")
|
||||
[ "$internal_code" = "404" ] || { echo "Public /internal returned $internal_code, expected 404" >&2; exit 1; }
|
||||
|
||||
headers=$(curl -fsSI "${PUBLIC_WEB_URL}/")
|
||||
printf '%s' "$headers" | grep -qi '^x-content-type-options: nosniff'
|
||||
printf '%s' "$headers" | grep -qi '^x-request-id:'
|
||||
printf '%s' "$headers" | grep -qi '^content-security-policy:'
|
||||
|
||||
openssl s_client -connect "${PUBLIC_HOST}:443" -servername "$PUBLIC_HOST" </dev/null 2>/dev/null \
|
||||
| openssl x509 -noout -checkend 604800
|
||||
echo "Public edge smoke passed."
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
lock=/tmp/han-chat-cert-renew.lock
|
||||
exec 9>"$lock"
|
||||
flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; }
|
||||
|
||||
docker compose --profile certbot run --rm certbot renew \
|
||||
--webroot -w /var/www/certbot --quiet
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose exec -T nginx nginx -s reload
|
||||
echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}"
|
||||
@@ -0,0 +1,26 @@
|
||||
name: han-chat
|
||||
|
||||
include:
|
||||
- path: ./infra/compose/application.yml
|
||||
- path: ./nginx/docker-compose.yml
|
||||
- path: ./redis/docker-compose.yml
|
||||
- path: ./observability/docker-compose.yml
|
||||
- path: ./deployment/docker-compose.jobs.yml
|
||||
|
||||
networks:
|
||||
public:
|
||||
name: han-chat-public
|
||||
backend:
|
||||
name: han-chat-backend
|
||||
internal: true
|
||||
observability:
|
||||
name: han-chat-observability
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
nginx-certs:
|
||||
nginx-acme-webroot:
|
||||
nginx-cache:
|
||||
frontend-static:
|
||||
otel-queue:
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.expo
|
||||
.git
|
||||
.env*
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,7 @@
|
||||
EXPO_PUBLIC_API_BASE_URL=https://tohin.ru
|
||||
EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
|
||||
EXPO_PUBLIC_APP_ENV=production-like
|
||||
|
||||
# Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены.
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.expo/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG EXPO_PUBLIC_API_BASE_URL
|
||||
ARG EXPO_PUBLIC_AUTH_BASE_URL
|
||||
ARG EXPO_PUBLIC_KEYCLOAK_REALM
|
||||
ARG EXPO_PUBLIC_KEYCLOAK_CLIENT_ID
|
||||
ARG EXPO_PUBLIC_APP_ENV=production
|
||||
ENV EXPO_PUBLIC_API_BASE_URL=$EXPO_PUBLIC_API_BASE_URL \
|
||||
EXPO_PUBLIC_AUTH_BASE_URL=$EXPO_PUBLIC_AUTH_BASE_URL \
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM=$EXPO_PUBLIC_KEYCLOAK_REALM \
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \
|
||||
EXPO_PUBLIC_APP_ENV=$EXPO_PUBLIC_APP_ENV
|
||||
RUN npm run build
|
||||
|
||||
# One-shot Compose init container copies the immutable export to nginx's volume.
|
||||
FROM alpine:3.22 AS static
|
||||
COPY --from=build /app/dist /dist
|
||||
RUN mkdir /output
|
||||
ENTRYPOINT ["/bin/sh", "-ec"]
|
||||
CMD ["rm -rf /output/* /output/.[!.]* /output/..?* 2>/dev/null || true; cp -a /dist/. /output/"]
|
||||
@@ -0,0 +1,31 @@
|
||||
# HAN Chat frontend test site
|
||||
|
||||
Expo / React Native Web SPA для проверки публичных пользовательских потоков HAN Chat.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
npm install
|
||||
npm run web
|
||||
```
|
||||
|
||||
Проверки: `npm test`, `npm run typecheck`, `npm run build`, `npm run test:e2e`.
|
||||
|
||||
## Production
|
||||
|
||||
`npm run build` создаёт `dist/`. Каталог монтируется в корневой nginx системы; отдельный frontend nginx не используется. Для SPA nginx должен применять `try_files $uri /index.html`, не кэшировать `index.html` и бессрочно кэшировать hashed assets.
|
||||
|
||||
Dockerfile собирает статический OCI-артефакт `/dist` без runtime-сервера:
|
||||
|
||||
```bash
|
||||
docker build --target static \
|
||||
--build-arg EXPO_PUBLIC_API_BASE_URL=https://tohin.ru \
|
||||
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth \
|
||||
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=han-chat \
|
||||
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend .
|
||||
```
|
||||
|
||||
Во frontend разрешены только публичные URL, realm, client id и имя среды. Service tokens, S3 credentials и mock OTP code добавлять запрещено. На web refresh token хранится в browser storage с известным XSS-риском; production требует строгой CSP и отсутствия сторонних scripts.
|
||||
|
||||
WebSocket использует subprotocols `han-chat-v1` и `bearer.<JWT>`. Они должны совпадать с реализацией api-backend. Query-token намеренно не используется.
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
|
||||
const config: ExpoConfig = {
|
||||
name: "HAN Chat Test",
|
||||
slug: "han-chat-test",
|
||||
version: "1.0.0",
|
||||
scheme: "han-chat",
|
||||
orientation: "portrait",
|
||||
userInterfaceStyle: "light",
|
||||
experiments: { typedRoutes: true },
|
||||
plugins: ["expo-router", "expo-secure-store"],
|
||||
web: { bundler: "metro", output: "static" },
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import React from "react";
|
||||
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||
import { AppProvider } from "../src/app-context";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <SafeAreaProvider>
|
||||
<AppProvider>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: "#f6f8fb" }}>
|
||||
<StatusBar style="dark" />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</SafeAreaView>
|
||||
</AppProvider>
|
||||
</SafeAreaProvider>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import type { Consents } from "../../src/types";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
|
||||
export default function AuthCallbackScreen() {
|
||||
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
|
||||
const app = useApp();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const retry = () => {
|
||||
if (!params.code || !params.state || typeof window === "undefined") return;
|
||||
const raw = window.sessionStorage.getItem("han.pending-consents");
|
||||
if (!raw) return;
|
||||
setError(undefined);
|
||||
void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (params.error) {
|
||||
setError(new Error("Авторизация отменена или отклонена."));
|
||||
return;
|
||||
}
|
||||
if (!params.code || !params.state) return;
|
||||
const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null;
|
||||
if (!raw) {
|
||||
setError(new Error("Не найдены локально принятые согласия. Начните вход заново."));
|
||||
return;
|
||||
}
|
||||
const consents = JSON.parse(raw) as Consents;
|
||||
void app.finishCallback(params.code, params.state, consents)
|
||||
.then(() => window.sessionStorage.removeItem("han.pending-consents"))
|
||||
.catch(setError);
|
||||
}, [params.code, params.state, params.error]);
|
||||
return <View style={styles.page}>
|
||||
<Text accessibilityRole="header" style={styles.title}>Завершение входа</Text>
|
||||
{!error && <Loading />}
|
||||
{error && <><ErrorNotice error={error} /><Button title="Повторить bootstrap" onPress={retry} /></>}
|
||||
</View>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { getDiagnostics, sessionMemory } from "../src/api";
|
||||
import { getTokenInfo } from "../src/auth";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { isProduction } from "../src/config";
|
||||
import { getRealtimeDiagnostics } from "../src/realtime";
|
||||
import { Header, styles } from "../src/ui";
|
||||
|
||||
export default function DiagnosticsScreen() {
|
||||
const app = useApp();
|
||||
const [, render] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => render((value) => value + 1), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
if (isProduction) return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Диагностика</Text>
|
||||
<Text style={styles.error}>Экран отключён в production.</Text>
|
||||
</ScrollView>;
|
||||
const token = getTokenInfo();
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.text}>Режим: {app.authStatus}</Text>
|
||||
<Text style={styles.text}>Realtime: {app.realtimeState}</Text>
|
||||
<Text style={styles.text}>UX-сессия: {sessionMemory.id ?? "нет"}</Text>
|
||||
<Text style={styles.text}>Access token истекает: {token ? new Date(token.expiresAt).toLocaleString("ru-RU") : "нет"}</Text>
|
||||
{getRealtimeDiagnostics().map((item) =>
|
||||
<Text key={item.dialog} style={styles.text}>Cursor {item.dialog}: {item.cursor}</Text>,
|
||||
)}
|
||||
<Text style={styles.muted}>Токены, OTP, персональные данные, сообщения и presigned URL здесь никогда не отображаются.</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Последние запросы</Text>
|
||||
{!getDiagnostics().length && <Text style={styles.muted}>Запросов ещё нет.</Text>}
|
||||
{getDiagnostics().map((item) => <View key={`${item.at}-${item.requestId}`} style={styles.row}>
|
||||
<Text style={styles.badge}>{item.status}</Text>
|
||||
<Text style={styles.text}>{item.method} {item.path}</Text>
|
||||
<Text style={styles.muted}>request_id: {item.requestId}</Text>
|
||||
</View>)}
|
||||
</View>
|
||||
</ScrollView>;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
|
||||
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
||||
import type { Message } from "../../src/types";
|
||||
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../../src/ui";
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: "Открыт",
|
||||
waiting_for_company: "Ожидает ответа компании",
|
||||
waiting_for_client: "Ожидает вашего ответа",
|
||||
closed: "Закрыт",
|
||||
accepted: "Принято",
|
||||
delivered: "Доставлено",
|
||||
failed: "Ошибка доставки",
|
||||
rejected: "Отклонено",
|
||||
};
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
|
||||
const app = useApp();
|
||||
const client = useQueryClient();
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||
|
||||
const realtime = useMemo(() => new RealtimeClient(
|
||||
dialogId ? [dialogId] : [],
|
||||
(event) => {
|
||||
if (event.type === "message.new") merge([event.message]);
|
||||
if (event.type === "message.status") {
|
||||
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => old && ({
|
||||
...old,
|
||||
items: old.items.map((item) => item.message_id === event.message_id ? { ...item, safety_status: event.safety_status, delivery_status: event.delivery_status } : item),
|
||||
}));
|
||||
}
|
||||
if (event.type === "dialog.status") void dialog.refetch();
|
||||
},
|
||||
(_, incoming) => merge(incoming),
|
||||
app.setRealtimeState,
|
||||
), [dialogId]);
|
||||
|
||||
function merge(incoming: Message[]) {
|
||||
client.setQueryData(["messages", dialogId], (old: typeof messages.data) =>
|
||||
old ? { ...old, items: reconcileMessages(old.items, incoming) } : { items: incoming, next_cursor: null },
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (app.authStatus === "authenticated") realtime.start();
|
||||
return () => realtime.stop();
|
||||
}, [realtime, app.authStatus]);
|
||||
|
||||
const sendText = async () => {
|
||||
const normalized = text.trim();
|
||||
if (!normalized || !dialogId) return;
|
||||
setSending(true); setError(undefined);
|
||||
try {
|
||||
const message = await dialogApi.sendText(dialogId, normalized, crypto.randomUUID());
|
||||
merge([message]); setText("");
|
||||
} catch (reason) { setError(reason); }
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
const chooseFile = () => {
|
||||
if (Platform.OS !== "web") {
|
||||
setError(new Error("Выбор файла в этой тестовой сборке доступен в web."));
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*,application/pdf";
|
||||
input.onchange = () => { const file = input.files?.[0]; if (file) void sendFile(file); };
|
||||
input.click();
|
||||
};
|
||||
|
||||
const sendFile = async (file: File) => {
|
||||
const limits = config.data?.attachments;
|
||||
const max = (limits?.max_size_mb ?? 5) * 1024 * 1024;
|
||||
const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||
if (file.size > max || !allowed.includes(file.type)) {
|
||||
setError(new Error("Недопустимый тип файла или превышен допустимый размер."));
|
||||
return;
|
||||
}
|
||||
setSending(true); setError(undefined);
|
||||
try {
|
||||
const uploaded = await uploadAttachment(dialogId, file);
|
||||
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, crypto.randomUUID());
|
||||
merge([message]);
|
||||
} catch (reason) { setError(reason); }
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
const downloadAttachment = async (attachmentId: string) => {
|
||||
try {
|
||||
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
||||
if (typeof window !== "undefined") window.location.assign(result.download_url);
|
||||
} catch (reason) { setError(reason); }
|
||||
};
|
||||
|
||||
const closed = dialog.data?.status === "closed";
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
|
||||
{app.authStatus !== "authenticated" && <Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>}
|
||||
{(dialog.isLoading || messages.isLoading) && <Loading />}
|
||||
{(dialog.error || messages.error) && <ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />}
|
||||
<Text style={styles.badge}>Статус: {statusLabel[dialog.data?.status ?? ""] ?? "—"}</Text>
|
||||
<View accessibilityLiveRegion="polite" style={{ gap: 10 }}>
|
||||
{(messages.data?.items ?? []).map((message) => <View key={message.message_id} style={message.sender_type === "client" ? styles.messageClient : styles.messageCompany}>
|
||||
<Text style={styles.text}>{message.content_kind === "file" ? `Файл: ${message.attachments[0]?.file_name ?? "вложение"}` : message.text}</Text>
|
||||
{message.attachments.map((attachment) =>
|
||||
<Button key={attachment.attachment_id} title="Скачать вложение" secondary onPress={() => void downloadAttachment(attachment.attachment_id)} />,
|
||||
)}
|
||||
<Text style={styles.muted}>{message.sender_type === "client" ? "Вы" : "Компания"} · {new Date(message.created_at).toLocaleString("ru-RU")} · {statusLabel[message.delivery_status] ?? message.delivery_status}</Text>
|
||||
</View>)}
|
||||
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
|
||||
</View>
|
||||
{closed ? <Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text> : <View style={styles.card}>
|
||||
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
|
||||
<View style={styles.row}>
|
||||
<Button title={sending ? "Отправка…" : "Отправить"} disabled={sending || !text.trim()} onPress={() => void sendText()} />
|
||||
<Button title="Прикрепить изображение или PDF" secondary disabled={sending} onPress={chooseFile} />
|
||||
</View>
|
||||
{error && <ErrorNotice error={error} />}
|
||||
</View>}
|
||||
</ScrollView>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import React from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { dialogApi } from "../../src/services";
|
||||
import { Button, ErrorNotice, Header, Loading, styles } from "../../src/ui";
|
||||
|
||||
const statusLabels = {
|
||||
open: "Открыт",
|
||||
waiting_for_company: "Ожидает ответа компании",
|
||||
waiting_for_client: "Ожидает вашего ответа",
|
||||
closed: "Закрыт",
|
||||
};
|
||||
|
||||
export default function DialogsScreen() {
|
||||
const app = useApp();
|
||||
const dialogs = useInfiniteQuery({
|
||||
queryKey: ["dialogs"],
|
||||
queryFn: ({ pageParam }) => dialogApi.list(pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||
enabled: app.authStatus === "authenticated",
|
||||
});
|
||||
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
|
||||
<Text style={styles.text}>История доступна после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text>
|
||||
<Link href="/" style={styles.link}>На главную</Link>
|
||||
</ScrollView>;
|
||||
|
||||
const items = dialogs.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
||||
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
|
||||
{dialogs.isLoading && <Loading />}
|
||||
{dialogs.error && <ErrorNotice error={dialogs.error} retry={() => void dialogs.refetch()} />}
|
||||
{!dialogs.isLoading && !items.length && <Text style={styles.muted}>Диалогов пока нет.</Text>}
|
||||
{items.map((dialog) => <View key={dialog.dialog_id} style={styles.card}>
|
||||
<Text style={styles.heading}>{statusLabels[dialog.status]}</Text>
|
||||
<Text style={styles.muted}>Диалог {dialog.dialog_id.slice(0, 8)}…</Text>
|
||||
<Link href={`/dialogs/${dialog.dialog_id}`} style={styles.link}>Открыть диалог</Link>
|
||||
</View>)}
|
||||
{dialogs.hasNextPage && <Button title="Показать ещё" secondary disabled={dialogs.isFetchingNextPage} onPress={() => void dialogs.fetchNextPage()} />}
|
||||
</ScrollView>;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Linking, ScrollView, Switch, Text, View } from "react-native";
|
||||
import { z } from "zod";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { dialogApi, publicApi } from "../src/services";
|
||||
import type { Consents } from "../src/types";
|
||||
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../src/ui";
|
||||
|
||||
const schema = z.object({ text: z.string().trim().min(1, "Введите сообщение").max(4000, "Сообщение слишком длинное") });
|
||||
type Form = z.infer<typeof schema>;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { authStatus, realtimeState, authorize, signOut } = useApp();
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
|
||||
const [consentOpen, setConsentOpen] = useState(false);
|
||||
const [required, setRequired] = useState({ personal: false, agreement: false, marketing: false });
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [sendError, setSendError] = useState<unknown>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const router = useRouter();
|
||||
const { control, handleSubmit, setValue, reset, formState: { errors } } = useForm<Form>({
|
||||
defaultValues: { text: "" }, resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const sendAuthenticated = async (text: string) => {
|
||||
setSending(true);
|
||||
setSendError(undefined);
|
||||
try {
|
||||
const dialog = await dialogApi.create(crypto.randomUUID());
|
||||
await dialogApi.sendText(dialog.dialog_id, text, crypto.randomUUID());
|
||||
reset();
|
||||
router.push(`/dialogs/${dialog.dialog_id}`);
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async (text: string) => {
|
||||
if (authStatus !== "authenticated") {
|
||||
setPending(text);
|
||||
setConsentOpen(true);
|
||||
return;
|
||||
}
|
||||
await sendAuthenticated(text);
|
||||
};
|
||||
|
||||
const accept = async () => {
|
||||
if (!required.personal || !required.agreement) return;
|
||||
const versions = config.data?.consents;
|
||||
const consents: Consents = {
|
||||
personal_data: { accepted: true, version: versions?.personal_data?.version ?? "current" },
|
||||
user_agreement: { accepted: true, version: versions?.user_agreement?.version ?? "current" },
|
||||
marketing: { accepted: required.marketing, version: versions?.marketing?.version ?? "current" },
|
||||
};
|
||||
setConsentOpen(false);
|
||||
try {
|
||||
const authorized = await authorize(consents);
|
||||
if (authorized && pending) await sendAuthenticated(pending);
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const questions = content.data?.popular_questions ?? [];
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={authStatus} realtime={realtimeState} onLogout={authStatus === "authenticated" ? () => void signOut() : undefined} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Помощь мигрантам</Text>
|
||||
<Text style={styles.text}>{content.data?.texts.welcome ?? "Задайте вопрос — оператор ответит в чате."}</Text>
|
||||
{(config.isLoading || content.isLoading) && <Loading />}
|
||||
{(config.error || content.error) && <ErrorNotice error={config.error ?? content.error} retry={() => { void config.refetch(); void content.refetch(); }} />}
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Популярные вопросы</Text>
|
||||
<View style={styles.row}>
|
||||
{questions.map((question, index) => {
|
||||
const text = question.text;
|
||||
return <Button key={question.id ?? index} title={text} secondary onPress={() => { setValue("text", text); void send(text); }} />;
|
||||
})}
|
||||
{!questions.length && <Text style={styles.muted}>Популярные вопросы пока не опубликованы.</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Написать оператору</Text>
|
||||
<Controller control={control} name="text" render={({ field }) =>
|
||||
<Field label="Сообщение" multiline value={field.value} onChangeText={field.onChange} error={errors.text?.message} />
|
||||
} />
|
||||
<Button title={sending ? "Отправляем…" : "Отправить"} disabled={sending} onPress={() => void handleSubmit(({ text }) => send(text))()} />
|
||||
{sendError && <ErrorNotice error={sendError} />}
|
||||
</View>
|
||||
|
||||
{consentOpen && <View accessibilityViewIsModal style={styles.modalBackdrop}>
|
||||
<View style={styles.modal}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
|
||||
<Text style={styles.text}>Для отправки сообщения необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
|
||||
{(["personal_data", "user_agreement", "marketing"] as const).map((key) => {
|
||||
const item = config.data?.consents?.[key];
|
||||
if (!item) return null;
|
||||
return item.document_url ? <Text key={key} accessibilityRole="link" style={styles.link} onPress={() => void Linking.openURL(item.document_url!)}>
|
||||
{key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version}
|
||||
</Text> : null;
|
||||
})}
|
||||
<ConsentRow label="Обработка персональных данных (обязательно)" value={required.personal} onChange={(personal) => setRequired({ ...required, personal })} />
|
||||
<ConsentRow label="Пользовательское соглашение (обязательно)" value={required.agreement} onChange={(agreement) => setRequired({ ...required, agreement })} />
|
||||
<ConsentRow label="Рекламные коммуникации (необязательно)" value={required.marketing} onChange={(marketing) => setRequired({ ...required, marketing })} />
|
||||
<View style={styles.row}>
|
||||
<Button title="Продолжить" disabled={!required.personal || !required.agreement} onPress={() => void accept()} />
|
||||
<Button title="Отмена" secondary onPress={() => { setConsentOpen(false); setPending(null); }} />
|
||||
</View>
|
||||
</View>
|
||||
</View>}
|
||||
</ScrollView>;
|
||||
}
|
||||
|
||||
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (value: boolean) => void }) {
|
||||
return <View style={[styles.row, { justifyContent: "space-between" }]}>
|
||||
<Text style={[styles.text, { flex: 1 }]}>{label}</Text>
|
||||
<Switch accessibilityLabel={label} value={value} onValueChange={onChange} />
|
||||
</View>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { profileApi } from "../src/services";
|
||||
import { Button, ErrorNotice, Header, Loading, styles } from "../src/ui";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const app = useApp();
|
||||
const enabled = app.authStatus === "authenticated";
|
||||
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
|
||||
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
|
||||
const [downloadError, setDownloadError] = useState<unknown>();
|
||||
|
||||
const download = async (id: string) => {
|
||||
try {
|
||||
const result = await profileApi.documentUrl(id);
|
||||
if (typeof window !== "undefined") window.location.assign(result.download_url);
|
||||
} catch (error) { setDownloadError(error); }
|
||||
};
|
||||
|
||||
if (!enabled) return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||
<Text style={styles.text}>Профиль доступен после авторизации.</Text>
|
||||
<Link href="/" style={styles.link}>Перейти в чат для входа</Link>
|
||||
</ScrollView>;
|
||||
|
||||
const personal = profile.data?.profile.personal_data;
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||
{(profile.isLoading || documents.isLoading) && <Loading />}
|
||||
{(profile.error || documents.error) && <ErrorNotice error={profile.error ?? documents.error} retry={() => { void profile.refetch(); void documents.refetch(); }} />}
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Личные данные</Text>
|
||||
<Row label="ФИО" value={personal?.full_name} />
|
||||
<Row label="Гражданство" value={personal?.citizenship} />
|
||||
<Row label="Телефон в РФ" value={personal?.russian_phone} />
|
||||
<Row label="Зарубежный телефон" value={personal?.foreign_phone} />
|
||||
<Row label="Email" value={personal?.email} />
|
||||
<Text style={styles.muted}>Редактирование профиля недоступно. Для изменения данных напишите оператору.</Text>
|
||||
<Link href="/" style={styles.link}>Написать оператору</Link>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Документы</Text>
|
||||
{!documents.data?.items.length && <Text style={styles.muted}>Документов пока нет.</Text>}
|
||||
{documents.data?.items.map((document) => <View key={document.document_id} style={styles.row}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>{document.name}</Text>
|
||||
<Text style={styles.muted}>{new Date(document.sent_at).toLocaleDateString("ru-RU")}</Text>
|
||||
</View>
|
||||
<Button title="Скачать" secondary onPress={() => void download(document.document_id)} />
|
||||
</View>)}
|
||||
{downloadError && <ErrorNotice error={downloadError} />}
|
||||
</View>
|
||||
</ScrollView>;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return <View><Text style={styles.muted}>{label}</Text><Text style={styles.text}>{value || "Не указано"}</Text></View>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="expo/types" />
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "han-frontend-test-site",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"build": "expo export --platform web",
|
||||
"serve": "npx serve dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "57.0.3",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"expo": "57.0.4",
|
||||
"expo-auth-session": "57.0.2",
|
||||
"expo-constants": "57.0.3",
|
||||
"expo-crypto": "57.0.0",
|
||||
"expo-linking": "57.0.2",
|
||||
"expo-router": "57.0.4",
|
||||
"expo-secure-store": "57.0.0",
|
||||
"expo-status-bar": "57.0.0",
|
||||
"expo-web-browser": "57.0.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-hook-form": "7.81.0",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-safe-area-context": "5.8.0",
|
||||
"react-native-screens": "4.26.0",
|
||||
"react-native-web": "0.21.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "29.1.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/react": "19.2.17",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.4",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: "list",
|
||||
use: { baseURL: "http://127.0.0.1:4173", trace: "on-first-retry" },
|
||||
webServer: {
|
||||
command: "npm run build && npx serve dist -l 4173",
|
||||
url: "http://127.0.0.1:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{ name: "webkit-mobile", use: { ...devices["iPhone 13"] } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { env } from "./config";
|
||||
import { getAccessToken, refreshTokens } from "./auth";
|
||||
export { sessionMemory } from "./session";
|
||||
import { sessionMemory } from "./session";
|
||||
|
||||
export type ApiErrorEnvelope = {
|
||||
error: { code: string; message: string; request_id?: string; details?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly requestId?: string,
|
||||
readonly retryAfter?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export type Diagnostic = {
|
||||
at: number;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
export const getDiagnostics = () => [...diagnostics];
|
||||
|
||||
function traceparent() {
|
||||
const traceId = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
||||
const spanId = crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
||||
return `00-${traceId.slice(0, 32)}-${spanId}-01`;
|
||||
}
|
||||
|
||||
function safePath(path: string) {
|
||||
return path.split("?")[0] ?? path;
|
||||
}
|
||||
|
||||
async function parseError(response: Response, requestId: string) {
|
||||
let envelope: ApiErrorEnvelope | undefined;
|
||||
try { envelope = (await response.json()) as ApiErrorEnvelope; } catch { /* intentionally empty */ }
|
||||
const code = envelope?.error?.code ?? `http_${response.status}`;
|
||||
const retry = Number(response.headers.get("Retry-After"));
|
||||
return new ApiError(
|
||||
response.status,
|
||||
code,
|
||||
envelope?.error?.message ?? "Запрос не выполнен",
|
||||
envelope?.error?.request_id ?? requestId,
|
||||
Number.isFinite(retry) ? retry : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
init: RequestInit & { protected?: boolean } = {},
|
||||
replayed = false,
|
||||
): Promise<T> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const isProtected = init.protected ?? false;
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
headers.set("X-Request-ID", requestId);
|
||||
headers.set("traceparent", traceparent());
|
||||
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
if (isProtected) {
|
||||
const token = getAccessToken();
|
||||
if (!token) throw new ApiError(401, "unauthorized", "Требуется авторизация", requestId);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (sessionMemory.id) headers.set("X-Ux-Session-Id", sessionMemory.id);
|
||||
}
|
||||
const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers });
|
||||
diagnostics.unshift({
|
||||
at: Date.now(), method: init.method ?? "GET", path: safePath(path),
|
||||
status: response.status, requestId: response.headers.get("X-Request-ID") ?? requestId,
|
||||
});
|
||||
diagnostics.splice(20);
|
||||
if (response.status === 401 && isProtected && !replayed) {
|
||||
await refreshTokens();
|
||||
return apiRequest<T>(path, init, true);
|
||||
}
|
||||
if (!response.ok) throw await parseError(response, requestId);
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const json = (value: unknown) => JSON.stringify(value);
|
||||
export const idempotencyHeaders = (key: string) => ({ "Idempotency-Key": key });
|
||||
@@ -0,0 +1,121 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
|
||||
import { sessionMemory } from "./api";
|
||||
import { authApi, publicApi } from "./services";
|
||||
import type { Consents } from "./types";
|
||||
|
||||
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
|
||||
type AppContextValue = {
|
||||
authStatus: AuthStatus;
|
||||
realtimeState: string;
|
||||
setRealtimeState: (value: string) => void;
|
||||
authorize: (consents: Consents) => Promise<boolean>;
|
||||
finishCallback: (code: string, state: string, consents: Consents) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
ensureSession: () => Promise<void>;
|
||||
};
|
||||
|
||||
const Context = createContext<AppContextValue | null>(null);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
|
||||
});
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [authStatus, setAuthStatus] = useState<AuthStatus>("guest");
|
||||
const [realtimeState, setRealtimeState] = useState("idle");
|
||||
const [idleTimeoutMs, setIdleTimeoutMs] = useState<number | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const toGuest = useCallback(() => {
|
||||
sessionMemory.clear();
|
||||
setRealtimeState("idle");
|
||||
setAuthStatus("guest");
|
||||
queryClient.clear();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
configureAuthFailure(toGuest);
|
||||
void publicApi.config().then((config) => {
|
||||
const minutes = config.ux.idle_timeout_minutes;
|
||||
if (minutes > 0) setIdleTimeoutMs(minutes * 60_000);
|
||||
}).catch(() => undefined);
|
||||
void refreshTokens()
|
||||
.then(() => {
|
||||
setAuthStatus("authenticated");
|
||||
void authApi.startSession("cold_start").catch(() => undefined);
|
||||
})
|
||||
.catch(() => setAuthStatus("guest"));
|
||||
}, [toGuest]);
|
||||
|
||||
const finishCallback = useCallback(async (code: string, state: string, consents: Consents) => {
|
||||
setAuthStatus("bootstrapping");
|
||||
if (!getAccessToken()) await completeAuthorization(code, state);
|
||||
await authApi.bootstrap(consents);
|
||||
await authApi.startSession("first_launch");
|
||||
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
|
||||
setAuthStatus("authenticated");
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
|
||||
const authorize = useCallback(async (consents: Consents) => {
|
||||
setAuthStatus("authorizing");
|
||||
if (typeof window !== "undefined") window.sessionStorage.setItem("han.pending-consents", JSON.stringify(consents));
|
||||
const result = await beginAuthorization();
|
||||
if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") {
|
||||
setAuthStatus("guest");
|
||||
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
|
||||
return false;
|
||||
}
|
||||
await finishCallback(result.params.code, result.params.state, consents);
|
||||
return true;
|
||||
}, [finishCallback]);
|
||||
|
||||
const ensureSession = useCallback(async () => {
|
||||
if (authStatus !== "authenticated") return;
|
||||
if (!sessionMemory.id) await authApi.startSession("cold_start");
|
||||
else if (idleTimeoutMs !== null && Date.now() - sessionMemory.lastActivityAt > idleTimeoutMs) {
|
||||
await authApi.startSession("idle_timeout");
|
||||
}
|
||||
sessionMemory.touch();
|
||||
}, [authStatus, idleTimeoutMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
const activity = () => sessionMemory.touch();
|
||||
const resume = () => { if (!document.hidden) void ensureSession(); };
|
||||
window.addEventListener("pointerdown", activity);
|
||||
window.addEventListener("keydown", activity);
|
||||
document.addEventListener("visibilitychange", resume);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", activity);
|
||||
window.removeEventListener("keydown", activity);
|
||||
document.removeEventListener("visibilitychange", resume);
|
||||
};
|
||||
}
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") void ensureSession();
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [ensureSession]);
|
||||
|
||||
const value = useMemo<AppContextValue>(() => ({
|
||||
authStatus, realtimeState, setRealtimeState, authorize, finishCallback, ensureSession,
|
||||
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
|
||||
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
|
||||
|
||||
return <QueryClientProvider client={queryClient}><Context.Provider value={value}>{children}</Context.Provider></QueryClientProvider>;
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
const value = useContext(Context);
|
||||
if (!value) throw new Error("AppProvider is missing");
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function resetAuthForTests() {
|
||||
await clearTokens();
|
||||
queryClient.clear();
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as AuthSession from "expo-auth-session";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { Platform } from "react-native";
|
||||
import { env, oidcIssuer } from "./config";
|
||||
import { SingleFlight } from "./single-flight";
|
||||
import type { TokenSet } from "./types";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const REFRESH_KEY = "han.refresh-token";
|
||||
const PKCE_KEY = "han.pkce";
|
||||
const PKCE_TTL_MS = 10 * 60_000;
|
||||
let tokens: TokenSet | null = null;
|
||||
const refreshFlight = new SingleFlight<TokenSet>();
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let authFailure: (() => void) | undefined;
|
||||
|
||||
const browserStore = {
|
||||
async get(key: string) {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(key);
|
||||
},
|
||||
async set(key: string, value: string) {
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(key, value);
|
||||
},
|
||||
async del(key: string) {
|
||||
if (typeof window !== "undefined") window.localStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
|
||||
const secureStore = {
|
||||
get: (key: string) =>
|
||||
Platform.OS === "web" ? browserStore.get(key) : SecureStore.getItemAsync(key),
|
||||
set: (key: string, value: string) =>
|
||||
Platform.OS === "web" ? browserStore.set(key, value) : SecureStore.setItemAsync(key, value),
|
||||
del: (key: string) =>
|
||||
Platform.OS === "web" ? browserStore.del(key) : SecureStore.deleteItemAsync(key),
|
||||
};
|
||||
|
||||
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
|
||||
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
|
||||
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
|
||||
|
||||
export function configureAuthFailure(callback: () => void) {
|
||||
authFailure = callback;
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return tokens?.accessToken ?? null;
|
||||
}
|
||||
|
||||
export function getTokenInfo() {
|
||||
return tokens ? { expiresAt: tokens.expiresAt } : null;
|
||||
}
|
||||
|
||||
async function persist(next: TokenSet) {
|
||||
tokens = next;
|
||||
await secureStore.set(REFRESH_KEY, next.refreshToken);
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
const delay = Math.max(1_000, next.expiresAt - Date.now() - 60_000);
|
||||
refreshTimer = setTimeout(() => void refreshTokens().catch(() => undefined), delay);
|
||||
}
|
||||
|
||||
async function parseTokenResponse(response: Response): Promise<TokenSet> {
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok || typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
|
||||
throw new Error(typeof body.error === "string" ? body.error : "token_exchange_failed");
|
||||
}
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + Number(body.expires_in ?? 300) * 1000,
|
||||
...(typeof body.id_token === "string" ? { idToken: body.id_token } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function beginAuthorization() {
|
||||
const verifier = random();
|
||||
const state = random();
|
||||
const nonce = random();
|
||||
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
|
||||
encoding: Crypto.CryptoEncoding.BASE64,
|
||||
});
|
||||
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
|
||||
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid profile offline_access",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
})}`;
|
||||
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
|
||||
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
|
||||
const callback = new URL(result.url);
|
||||
return {
|
||||
type: "success" as const,
|
||||
params: {
|
||||
code: callback.searchParams.get("code"),
|
||||
state: callback.searchParams.get("state"),
|
||||
error: callback.searchParams.get("error"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeAuthorization(code: string, state: string) {
|
||||
const raw = await secureStore.get(PKCE_KEY);
|
||||
await secureStore.del(PKCE_KEY);
|
||||
if (!raw) throw new Error("pkce_state_missing");
|
||||
const saved = JSON.parse(raw) as { verifier: string; state: string; nonce: string; createdAt: number };
|
||||
if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) {
|
||||
throw new Error("pkce_state_invalid");
|
||||
}
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: env.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
code_verifier: saved.verifier,
|
||||
}).toString(),
|
||||
});
|
||||
const next = await parseTokenResponse(response);
|
||||
if (!next.idToken || readJwtClaim(next.idToken, "nonce") !== saved.nonce) {
|
||||
await clearTokens();
|
||||
throw new Error("oidc_nonce_invalid");
|
||||
}
|
||||
await persist(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function readJwtClaim(token: string, claim: string) {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return undefined;
|
||||
try {
|
||||
const normalized = payload.replaceAll("-", "+").replaceAll("_", "/");
|
||||
const decoded = decodeURIComponent(
|
||||
Array.from(atob(normalized), (character) => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""),
|
||||
);
|
||||
return (JSON.parse(decoded) as Record<string, unknown>)[claim];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshTokens(): Promise<TokenSet> {
|
||||
return refreshFlight.run(async () => {
|
||||
const refreshToken = tokens?.refreshToken ?? (await secureStore.get(REFRESH_KEY));
|
||||
if (!refreshToken) throw new Error("refresh_token_missing");
|
||||
try {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: env.clientId,
|
||||
refresh_token: refreshToken,
|
||||
}).toString(),
|
||||
});
|
||||
const next = await parseTokenResponse(response);
|
||||
await persist(next);
|
||||
return next;
|
||||
} catch (error) {
|
||||
await clearTokens();
|
||||
authFailure?.();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearTokens() {
|
||||
tokens = null;
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
await secureStore.del(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const idToken = tokens?.idToken;
|
||||
await clearTokens();
|
||||
if (idToken) {
|
||||
void fetch(`${oidcIssuer}/protocol/openid-connect/logout`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ client_id: env.clientId, id_token_hint: idToken }).toString(),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const required = (name: string, fallback: string) =>
|
||||
(process.env[name] ?? fallback).replace(/\/$/, "");
|
||||
|
||||
export const env = Object.freeze({
|
||||
apiBaseUrl: required("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000"),
|
||||
authBaseUrl: required("EXPO_PUBLIC_AUTH_BASE_URL", "http://localhost:8080/auth"),
|
||||
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
|
||||
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
|
||||
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
|
||||
});
|
||||
|
||||
export const oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`;
|
||||
export const isProduction = env.appEnv === "production";
|
||||
@@ -0,0 +1,145 @@
|
||||
import { env } from "./config";
|
||||
import { getAccessToken, refreshTokens } from "./auth";
|
||||
import { dialogApi } from "./services";
|
||||
import type { Message } from "./types";
|
||||
export { reconcileMessages } from "./reconcile";
|
||||
|
||||
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
|
||||
export type RealtimeEvent =
|
||||
| { type: "message.new"; dialog_id: string; message: Message; cursor?: string }
|
||||
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
|
||||
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
|
||||
|
||||
const safeCursors = new Map<string, string>();
|
||||
export const getRealtimeDiagnostics = () =>
|
||||
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
|
||||
dialog: `${dialogId.slice(0, 8)}…`,
|
||||
cursor,
|
||||
}));
|
||||
|
||||
export function websocketJwtProtocol(token: string) {
|
||||
const bytes = new TextEncoder().encode(token);
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
||||
return `han.jwt.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
export class RealtimeClient {
|
||||
private socket?: WebSocket;
|
||||
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
private pollingTimer?: ReturnType<typeof setTimeout>;
|
||||
private disconnectedAt = 0;
|
||||
private attempt = 0;
|
||||
private stopped = true;
|
||||
private cursors = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private dialogIds: string[],
|
||||
private readonly onEvent: (event: RealtimeEvent) => void,
|
||||
private readonly onMessages: (dialogId: string, messages: Message[]) => void,
|
||||
private readonly onState: (state: RealtimeState) => void,
|
||||
) {}
|
||||
|
||||
updateDialogs(ids: string[]) {
|
||||
this.dialogIds = [...new Set(ids)];
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.subscribe();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.stopped) return;
|
||||
this.stopped = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||
this.socket?.close();
|
||||
this.onState("idle");
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.stopped) return;
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
this.onState("connecting");
|
||||
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
|
||||
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
|
||||
this.socket.onopen = () => {
|
||||
this.attempt = 0;
|
||||
// Сначала подписываемся, затем читаем REST-gap: события в этом окне
|
||||
// уже попадут в merge, а дубли устраняются по message_id.
|
||||
this.subscribe();
|
||||
void this.reconcileAll().then(() => {
|
||||
this.stopPolling();
|
||||
this.onState("websocket");
|
||||
});
|
||||
};
|
||||
this.socket.onmessage = ({ data }) => {
|
||||
try {
|
||||
const event = JSON.parse(String(data)) as RealtimeEvent | { type: string };
|
||||
if (event.type === "ping") return this.socket?.send(JSON.stringify({ type: "pong" }));
|
||||
if (event.type === "message.new" || event.type === "message.status" || event.type === "dialog.status") {
|
||||
const known = event as RealtimeEvent;
|
||||
if (known.cursor) {
|
||||
this.cursors.set(known.dialog_id, known.cursor);
|
||||
safeCursors.set(known.dialog_id, known.cursor);
|
||||
}
|
||||
this.onEvent(known);
|
||||
}
|
||||
} catch { /* unknown and malformed events are safely ignored */ }
|
||||
};
|
||||
this.socket.onclose = (event) => {
|
||||
if (this.stopped) return;
|
||||
if (!this.disconnectedAt) this.disconnectedAt = Date.now();
|
||||
if (event.code === 4401 || event.code === 1008) {
|
||||
void refreshTokens().finally(() => this.scheduleReconnect());
|
||||
} else {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
this.socket.onerror = () => this.socket?.close();
|
||||
}
|
||||
|
||||
private subscribe() {
|
||||
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: this.dialogIds }));
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.stopped) return;
|
||||
if (Date.now() - this.disconnectedAt >= 30_000) this.startPolling();
|
||||
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
|
||||
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
private async reconcileAll() {
|
||||
await Promise.all(this.dialogIds.map(async (id) => {
|
||||
const page = await dialogApi.messages(id, this.cursors.get(id));
|
||||
if (page.items.length) this.onMessages(id, page.items);
|
||||
if (page.next_cursor) {
|
||||
this.cursors.set(id, page.next_cursor);
|
||||
safeCursors.set(id, page.next_cursor);
|
||||
}
|
||||
}));
|
||||
this.disconnectedAt = 0;
|
||||
}
|
||||
|
||||
private startPolling() {
|
||||
if (this.pollingTimer) return;
|
||||
this.onState("polling");
|
||||
const poll = async () => {
|
||||
if (this.stopped) return;
|
||||
await this.reconcileAll().catch(() => undefined);
|
||||
const delay = typeof document !== "undefined" && document.hidden ? 15_000 : 5_000;
|
||||
this.pollingTimer = setTimeout(poll, delay);
|
||||
};
|
||||
void poll();
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||
this.pollingTimer = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Message } from "./types";
|
||||
|
||||
export function reconcileMessages(current: Message[], incoming: Message[]) {
|
||||
const byId = new Map(current.map((message) => [message.message_id, message]));
|
||||
for (const message of incoming) byId.set(message.message_id, { ...byId.get(message.message_id), ...message });
|
||||
return [...byId.values()].sort((a, b) => a.created_at.localeCompare(b.created_at));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { apiRequest, idempotencyHeaders, json, sessionMemory } from "./api";
|
||||
import { Platform } from "react-native";
|
||||
import type {
|
||||
Consents, Dialog, DocumentItem, Message, Page, Profile, PublicConfig, PublicContent,
|
||||
} from "./types";
|
||||
|
||||
export const publicApi = {
|
||||
config: () => apiRequest<PublicConfig>("/api/v1/public/app-config"),
|
||||
content: () => apiRequest<PublicContent>("/api/v1/public/content"),
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
bootstrap: (consents: Consents) =>
|
||||
apiRequest<{ user_id: string; profile_ready: boolean }>("/api/v1/auth/bootstrap", {
|
||||
method: "POST", protected: true,
|
||||
body: json({ consents, device: deviceMetadata() }),
|
||||
}),
|
||||
startSession: async (reason: "first_launch" | "cold_start" | "idle_timeout") => {
|
||||
const result = await apiRequest<{ ux_session_id: string; started_at: string }>(
|
||||
"/api/v1/analytics/session-start",
|
||||
{ method: "POST", protected: true, body: json({ start_reason: reason, device: deviceMetadata() }) },
|
||||
);
|
||||
sessionMemory.set(result.ux_session_id);
|
||||
return result;
|
||||
},
|
||||
saveConsents: (consents: Consents) =>
|
||||
apiRequest<void>("/api/v1/consents", {
|
||||
method: "POST", protected: true, body: json({ consents }),
|
||||
}),
|
||||
};
|
||||
|
||||
function deviceMetadata() {
|
||||
const platform = Platform.OS;
|
||||
if (platform !== "ios" && platform !== "android" && platform !== "web") {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
return {
|
||||
platform,
|
||||
app_version: "1.0.0",
|
||||
device_id: "frontend-test-site",
|
||||
};
|
||||
}
|
||||
|
||||
export const dialogApi = {
|
||||
list: (cursor?: string) =>
|
||||
apiRequest<Page<Dialog>>(`/api/v1/dialogs${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, { protected: true }),
|
||||
get: (id: string) => apiRequest<Dialog>(`/api/v1/dialogs/${encodeURIComponent(id)}`, { protected: true }),
|
||||
create: (key: string) =>
|
||||
apiRequest<Dialog>("/api/v1/dialogs", {
|
||||
method: "POST", protected: true, headers: idempotencyHeaders(key), body: "{}",
|
||||
}),
|
||||
messages: (id: string, after?: string) =>
|
||||
apiRequest<Page<Message>>(
|
||||
`/api/v1/dialogs/${encodeURIComponent(id)}/messages?limit=50${after ? `&after=${encodeURIComponent(after)}` : ""}`,
|
||||
{ protected: true },
|
||||
),
|
||||
sendText: (id: string, text: string, key: string) =>
|
||||
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
|
||||
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||
body: json({ content_kind: "text", text }),
|
||||
}),
|
||||
sendFile: (id: string, attachmentId: string, checksum: string, key: string) =>
|
||||
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
|
||||
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||
body: json({ content_kind: "file", attachment_id: attachmentId, checksum }),
|
||||
}),
|
||||
};
|
||||
|
||||
export async function uploadAttachment(dialogId: string, file: File, key = crypto.randomUUID()) {
|
||||
const checksum = await sha256(file);
|
||||
const init = await apiRequest<{
|
||||
attachment_id: string;
|
||||
upload_url: string;
|
||||
upload_headers?: Record<string, string>;
|
||||
expires_at: string;
|
||||
}>(`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/init`, {
|
||||
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||
body: json({ file_name: file.name, mime_type: file.type, size_bytes: file.size }),
|
||||
});
|
||||
const upload = await fetch(init.upload_url, {
|
||||
method: "PUT",
|
||||
headers: init.upload_headers ?? { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
|
||||
await apiRequest<void>(
|
||||
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(init.attachment_id)}/complete`,
|
||||
{ method: "POST", protected: true, headers: idempotencyHeaders(key), body: json({ checksum }) },
|
||||
);
|
||||
return { attachmentId: init.attachment_id, checksum };
|
||||
}
|
||||
|
||||
async function sha256(file: Blob) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
return `sha256:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
export const profileApi = {
|
||||
me: () => apiRequest<Profile>("/api/v1/me", { protected: true }),
|
||||
documents: () => apiRequest<Page<DocumentItem>>("/api/v1/me/documents", { protected: true }),
|
||||
documentUrl: (id: string) =>
|
||||
apiRequest<{ download_url: string }>(`/api/v1/documents/${encodeURIComponent(id)}/download-url`, { protected: true }),
|
||||
attachmentUrl: (dialogId: string, id: string) =>
|
||||
apiRequest<{ download_url: string }>(
|
||||
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(id)}/download-url`,
|
||||
{ protected: true },
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
let uxSessionId: string | null = null;
|
||||
let lastActivityAt = Date.now();
|
||||
|
||||
export const sessionMemory = {
|
||||
get id() { return uxSessionId; },
|
||||
get lastActivityAt() { return lastActivityAt; },
|
||||
touch() { lastActivityAt = Date.now(); },
|
||||
set(id: string) { uxSessionId = id; lastActivityAt = Date.now(); },
|
||||
clear() { uxSessionId = null; lastActivityAt = Date.now(); },
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export class SingleFlight<T> {
|
||||
private running: Promise<T> | null = null;
|
||||
|
||||
run(operation: () => Promise<T>): Promise<T> {
|
||||
if (this.running) return this.running;
|
||||
this.running = operation().finally(() => {
|
||||
this.running = null;
|
||||
});
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type Consent = { accepted: boolean; version: string };
|
||||
export type Consents = {
|
||||
personal_data: Consent;
|
||||
user_agreement: Consent;
|
||||
marketing: Consent;
|
||||
};
|
||||
|
||||
export type TokenSet = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
idToken?: string;
|
||||
};
|
||||
|
||||
export type DialogStatus = "open" | "waiting_for_company" | "waiting_for_client" | "closed";
|
||||
export type Dialog = { dialog_id: string; status: DialogStatus; updated_at?: string };
|
||||
export type Attachment = {
|
||||
attachment_id: string;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
scan_status: "pending" | "clean" | "infected" | "failed";
|
||||
};
|
||||
export type Message = {
|
||||
message_id: string;
|
||||
dialog_id: string;
|
||||
sender_type: "client" | "company";
|
||||
content_kind: "text" | "file";
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
safety_status: "pending" | "allowed" | "blocked";
|
||||
delivery_status: "accepted" | "delivered" | "failed" | "rejected";
|
||||
created_at: string;
|
||||
};
|
||||
export type Page<T> = { items: T[]; next_cursor: string | null };
|
||||
|
||||
export type Profile = {
|
||||
user_id: string;
|
||||
profile: {
|
||||
personal_data: {
|
||||
full_name: string | null;
|
||||
citizenship: string | null;
|
||||
russian_phone: string | null;
|
||||
foreign_phone: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
documents: { count: number };
|
||||
};
|
||||
};
|
||||
export type DocumentItem = {
|
||||
document_id: string;
|
||||
name: string;
|
||||
sent_at: string;
|
||||
};
|
||||
|
||||
export type PublicConfig = {
|
||||
auth: { phone_enabled: boolean; password_enabled: boolean };
|
||||
operator: { call_phone: string };
|
||||
consents: Record<string, {
|
||||
required: boolean;
|
||||
document_url: string | null;
|
||||
version: string;
|
||||
}>;
|
||||
attachments: {
|
||||
max_size_mb: number;
|
||||
allowed_extensions: string[];
|
||||
allowed_mime_types: string[];
|
||||
};
|
||||
ux: { idle_timeout_minutes: number };
|
||||
};
|
||||
export type PublicContent = {
|
||||
locale: string;
|
||||
texts: Record<string, string>;
|
||||
popular_questions: Array<{ id: string; mnemonic: string; text: string }>;
|
||||
version: string;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Link } from "expo-router";
|
||||
import React from "react";
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { ApiError } from "./api";
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
page: { flex: 1, width: "100%", maxWidth: 920, alignSelf: "center", padding: 20, gap: 16 },
|
||||
header: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12, paddingBottom: 12, borderBottomWidth: 1, borderColor: "#d7dee8" },
|
||||
title: { fontSize: 28, lineHeight: 34, fontWeight: "700", color: "#12233f" },
|
||||
heading: { fontSize: 20, lineHeight: 26, fontWeight: "700", color: "#12233f" },
|
||||
text: { fontSize: 16, lineHeight: 23, color: "#233653" },
|
||||
muted: { fontSize: 14, lineHeight: 20, color: "#5f6f85" },
|
||||
card: { padding: 16, gap: 10, borderWidth: 1, borderColor: "#d7dee8", borderRadius: 12, backgroundColor: "#fff" },
|
||||
input: { minHeight: 48, borderWidth: 1, borderColor: "#8493a8", borderRadius: 8, padding: 12, fontSize: 16, backgroundColor: "#fff" },
|
||||
textarea: { minHeight: 104, textAlignVertical: "top" },
|
||||
row: { flexDirection: "row", flexWrap: "wrap", gap: 10, alignItems: "center" },
|
||||
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: 8, backgroundColor: "#185abd" },
|
||||
buttonSecondary: { backgroundColor: "#e8eef8" },
|
||||
buttonDanger: { backgroundColor: "#b42318" },
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
buttonText: { color: "#fff", fontWeight: "700", fontSize: 15 },
|
||||
buttonTextSecondary: { color: "#173b70" },
|
||||
link: { color: "#075db7", fontSize: 16, textDecorationLine: "underline", paddingVertical: 10 },
|
||||
badge: { borderRadius: 20, backgroundColor: "#edf2f8", color: "#263b58", paddingHorizontal: 10, paddingVertical: 5, fontSize: 13 },
|
||||
error: { borderLeftWidth: 4, borderColor: "#b42318", backgroundColor: "#fff1f0", padding: 12, color: "#7a271a" },
|
||||
success: { borderLeftWidth: 4, borderColor: "#16803c", backgroundColor: "#edfdf2", padding: 12, color: "#14532d" },
|
||||
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(10,25,45,.45)", alignItems: "center", justifyContent: "center", padding: 20 },
|
||||
modal: { width: "100%", maxWidth: 560, borderRadius: 14, backgroundColor: "#fff", padding: 20, gap: 14 },
|
||||
messageClient: { alignSelf: "flex-end", maxWidth: "82%", backgroundColor: "#e4efff", padding: 12, borderRadius: 12 },
|
||||
messageCompany: { alignSelf: "flex-start", maxWidth: "82%", backgroundColor: "#f0f2f5", padding: 12, borderRadius: 12 },
|
||||
});
|
||||
|
||||
export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) {
|
||||
return <View style={styles.header}>
|
||||
<Link href="/" style={styles.link}>HAN Chat</Link>
|
||||
<Link href="/dialogs" style={styles.link}>Диалоги</Link>
|
||||
<Link href="/profile" style={styles.link}>Профиль</Link>
|
||||
<Link href="/diagnostics" style={styles.link}>Диагностика</Link>
|
||||
<Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text>
|
||||
{onLogout && <Button title="Выйти" secondary onPress={onLogout} />}
|
||||
</View>;
|
||||
}
|
||||
|
||||
export function Button({ title, onPress, disabled, secondary, danger }: {
|
||||
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
|
||||
}) {
|
||||
return <Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ focused }) => [
|
||||
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
|
||||
disabled && styles.buttonDisabled, focused && { borderWidth: 3, borderColor: "#ffbf47" },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
|
||||
</Pressable>;
|
||||
}
|
||||
|
||||
export function Field(props: React.ComponentProps<typeof TextInput> & { label: string; error?: string }) {
|
||||
return <View style={{ gap: 6 }}>
|
||||
<Text style={styles.text}>{props.label}</Text>
|
||||
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} />
|
||||
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
|
||||
</View>;
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator /><Text style={styles.muted}>Загрузка…</Text></View>;
|
||||
}
|
||||
|
||||
export function ErrorNotice({ error, retry }: { error: unknown; retry?: () => void }) {
|
||||
const requestId = error instanceof ApiError ? error.requestId : undefined;
|
||||
return <View style={{ gap: 8 }}>
|
||||
<Text accessibilityRole="alert" style={styles.error}>
|
||||
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
|
||||
{requestId ? `\nКод обращения: ${requestId}` : ""}
|
||||
</Text>
|
||||
{retry && <Button title="Повторить" secondary onPress={retry} />}
|
||||
</View>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/v1/public/app-config", (route) => route.fulfill({
|
||||
json: {
|
||||
auth: { phone_enabled: true, password_enabled: false },
|
||||
operator: { call_phone: "+74950000000" },
|
||||
ux: { idle_timeout_minutes: 15 },
|
||||
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
|
||||
consents: {
|
||||
personal_data: { required: true, version: "2026-07-01", document_url: "https://example.test/personal" },
|
||||
user_agreement: { required: true, version: "2026-07-01", document_url: "https://example.test/agreement" },
|
||||
marketing: { required: false, version: "2026-07-01", document_url: "https://example.test/marketing" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
await page.route("**/api/v1/public/content", (route) => route.fulfill({
|
||||
json: {
|
||||
locale: "ru",
|
||||
texts: { welcome: "Добро пожаловать в HAN Chat" },
|
||||
popular_questions: [{ id: "visa", mnemonic: "visa", text: "Как оформить визу?" }],
|
||||
version: "1",
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
test("гостевой экран загружает публичный контент", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Помощь мигрантам" })).toBeVisible();
|
||||
await expect(page.getByText("Добро пожаловать в HAN Chat")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Как оформить визу?" })).toBeVisible();
|
||||
await expect(page.getByText(/Гость/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("первое сообщение требует обязательные согласия", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("Сообщение").fill("Здравствуйте");
|
||||
await page.getByRole("button", { name: "Отправить" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
|
||||
let protectedCalls = 0;
|
||||
await page.route("**/api/v1/me", (route) => { protectedCalls++; return route.abort(); });
|
||||
await page.goto("/profile");
|
||||
await expect(page.getByText("Профиль доступен после авторизации.")).toBeVisible();
|
||||
expect(protectedCalls).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reconcileMessages } from "../../src/reconcile";
|
||||
import { sessionMemory } from "../../src/session";
|
||||
import { SingleFlight } from "../../src/single-flight";
|
||||
import { websocketJwtProtocol } from "../../src/realtime";
|
||||
import type { Message } from "../../src/types";
|
||||
|
||||
const message = (id: string, createdAt: string, status: Message["delivery_status"] = "accepted"): Message => ({
|
||||
message_id: id,
|
||||
dialog_id: "dialog",
|
||||
sender_type: "client",
|
||||
content_kind: "text",
|
||||
text: "Тест",
|
||||
attachments: [],
|
||||
safety_status: "allowed",
|
||||
delivery_status: status,
|
||||
created_at: createdAt,
|
||||
});
|
||||
|
||||
describe("reconcileMessages", () => {
|
||||
it("устраняет дубли, обновляет статус и сортирует сообщения", () => {
|
||||
const result = reconcileMessages(
|
||||
[message("2", "2026-01-02T00:00:00Z"), message("1", "2026-01-01T00:00:00Z")],
|
||||
[message("2", "2026-01-02T00:00:00Z", "delivered"), message("3", "2026-01-03T00:00:00Z")],
|
||||
);
|
||||
expect(result.map((item) => item.message_id)).toEqual(["1", "2", "3"]);
|
||||
expect(result[1]?.delivery_status).toBe("delivered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UX session memory", () => {
|
||||
it("хранит идентификатор только в памяти и очищает его", () => {
|
||||
sessionMemory.set("ux-test");
|
||||
expect(sessionMemory.id).toBe("ux-test");
|
||||
sessionMemory.clear();
|
||||
expect(sessionMemory.id).toBeNull();
|
||||
expect(localStorage.getItem("ux_session_id")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SingleFlight", () => {
|
||||
it("объединяет параллельные refresh операции", async () => {
|
||||
const flight = new SingleFlight<number>();
|
||||
let calls = 0;
|
||||
const operation = async () => {
|
||||
calls++;
|
||||
await Promise.resolve();
|
||||
return 42;
|
||||
};
|
||||
const [first, second, third] = await Promise.all([
|
||||
flight.run(operation), flight.run(operation), flight.run(operation),
|
||||
]);
|
||||
expect([first, second, third]).toEqual([42, 42, 42]);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket authentication protocol", () => {
|
||||
it("кодирует JWT как canonical han.jwt.<base64url(jwt)>", () => {
|
||||
const protocol = websocketJwtProtocol("header.payload.signature");
|
||||
expect(protocol).toBe("han.jwt.aGVhZGVyLnBheWxvYWQuc2lnbmF0dXJl");
|
||||
expect(protocol).not.toContain("=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] },
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["app", "src", "tests", "app.config.ts", "expo-env.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["tests/unit/**/*.test.{ts,tsx}"],
|
||||
clearMocks: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
x-api-runtime: &api-runtime
|
||||
build:
|
||||
context: ../../api-backend
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
target: /run/secrets/pg-ca.pem
|
||||
read_only: true
|
||||
networks: [backend, observability]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
services:
|
||||
frontend-static:
|
||||
build:
|
||||
context: ../../frontend-test-site
|
||||
target: static
|
||||
args:
|
||||
EXPO_PUBLIC_API_BASE_URL: ${PUBLIC_WEB_URL}
|
||||
EXPO_PUBLIC_AUTH_BASE_URL: ${PUBLIC_AUTH_URL}
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID: han-chat-frontend
|
||||
EXPO_PUBLIC_APP_ENV: ${APP_ENV:-production-like}
|
||||
image: han-chat-frontend-static:${RELEASE_VERSION:-local}
|
||||
volumes:
|
||||
- frontend-static:/output
|
||||
restart: "no"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=8m,mode=1777
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
keycloak:
|
||||
build:
|
||||
context: ../../keycloak
|
||||
image: ${KEYCLOAK_IMAGE:-han-chat-keycloak:local}
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
environment:
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: ${KEYCLOAK_DB_URL}
|
||||
KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME}
|
||||
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
|
||||
KC_PROXY_HEADERS: xforwarded
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HTTP_RELATIVE_PATH: /auth
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_METRICS_ENABLED: "true"
|
||||
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL}
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
|
||||
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false}
|
||||
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?KEYCLOAK_OTP_MOCK_CODE is required}
|
||||
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required}
|
||||
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
|
||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?KEYCLOAK_SETTINGS_BRIDGE_TOKEN is required}
|
||||
command: ["start", "--optimized", "--import-realm"]
|
||||
expose: ["8080", "9000"]
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
target: /run/secrets/pg-ca.pem
|
||||
read_only: true
|
||||
networks: [public, backend, observability]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.0\r\n\r\n' >&3 && grep -q '200 OK' <&3"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
message-safety:
|
||||
build:
|
||||
context: ../../message-safety
|
||||
image: ${MESSAGE_SAFETY_IMAGE:-han-chat-message-safety:local}
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
expose: ["8080"]
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
target: /run/secrets/pg-ca.pem
|
||||
read_only: true
|
||||
networks: [backend, observability]
|
||||
depends_on:
|
||||
redis: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
api-backend:
|
||||
<<: *api-runtime
|
||||
expose: ["8000"]
|
||||
depends_on:
|
||||
redis: {condition: service_healthy}
|
||||
keycloak: {condition: service_healthy}
|
||||
message-safety: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
delivery-worker:
|
||||
<<: *api-runtime
|
||||
entrypoint: []
|
||||
command: ["han-delivery-worker"]
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
bitrix-local-app: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-delivery-worker' in Path('/proc/1/cmdline').read_bytes()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
safety-recovery-worker:
|
||||
<<: *api-runtime
|
||||
entrypoint: []
|
||||
command: ["han-safety-worker"]
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
message-safety: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-safety-worker' in Path('/proc/1/cmdline').read_bytes()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
cleanup-worker:
|
||||
<<: *api-runtime
|
||||
entrypoint: []
|
||||
command: ["han-cleanup-worker"]
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-cleanup-worker' in Path('/proc/1/cmdline').read_bytes()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
bitrix-local-app:
|
||||
build:
|
||||
context: ../../bitrix-local-app
|
||||
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
expose: ["8080"]
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
target: /run/secrets/pg-ca.pem
|
||||
read_only: true
|
||||
networks: [backend, observability]
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
|
||||
bitrix-sync:
|
||||
build:
|
||||
context: ../../bitrix-sync
|
||||
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
|
||||
env_file:
|
||||
- path: ../../.env
|
||||
required: false
|
||||
expose: ["8080"]
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
target: /run/secrets/pg-ca.pem
|
||||
read_only: true
|
||||
networks: [backend, observability]
|
||||
depends_on:
|
||||
redis: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
@@ -0,0 +1,7 @@
|
||||
target
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,17 @@
|
||||
KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth
|
||||
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.internal:6432/han_chat?sslmode=verify-full¤tSchema=keycloak&ApplicationName=keycloak
|
||||
KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=bootstrap-admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-secret
|
||||
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
|
||||
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
|
||||
KEYCLOAK_OTP_TTL_SEC=300
|
||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
|
||||
|
||||
KEYCLOAK_LOG_LEVEL=INFO
|
||||
KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35
|
||||
@@ -0,0 +1,25 @@
|
||||
ARG KEYCLOAK_VERSION=26.1.4
|
||||
|
||||
FROM maven:3.9.9-eclipse-temurin-21 AS provider-build
|
||||
WORKDIR /build
|
||||
COPY pom.xml .
|
||||
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
|
||||
COPY src ./src
|
||||
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
|
||||
|
||||
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
|
||||
COPY --from=provider-build /build/target/han-phone-otp-provider.jar /opt/keycloak/providers/
|
||||
COPY themes/han-phone /opt/keycloak/themes/han-phone
|
||||
ENV KC_HEALTH_ENABLED=true \
|
||||
KC_METRICS_ENABLED=true \
|
||||
KC_DB=postgres \
|
||||
KC_HTTP_RELATIVE_PATH=/auth
|
||||
RUN /opt/keycloak/bin/kc.sh build
|
||||
|
||||
FROM quay.io/keycloak/keycloak:26.1.4
|
||||
COPY --from=keycloak-build --chown=keycloak:keycloak /opt/keycloak/ /opt/keycloak/
|
||||
COPY --chown=keycloak:keycloak realm/han-chat-realm.json /opt/keycloak/data/import/han-chat-realm.json
|
||||
USER 1000
|
||||
EXPOSE 8080 9000
|
||||
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
|
||||
CMD ["start", "--optimized", "--import-realm"]
|
||||
@@ -0,0 +1,88 @@
|
||||
# HAN Chat Keycloak
|
||||
|
||||
Production-like Keycloak 26.1.4 image and realm for OTP-only phone authentication. The module is self-contained and does not publish host ports; root nginx must proxy `/auth/*` to `keycloak:8080`.
|
||||
|
||||
## Security contract
|
||||
|
||||
- Realm `han-chat`; public client `han-chat-frontend`.
|
||||
- Authorization Code flow only, mandatory PKCE S256; implicit, password/direct, device and service-account grants are disabled.
|
||||
- Access tokens contain audience `han-chat-api`, canonical E.164 `phone_number` and boolean `phone_number_verified`.
|
||||
- Access token lifetime is 5 minutes. Refresh token rotation is enabled with max reuse `0`; SSO idle/max are 30/90 days.
|
||||
- Realm brute-force protection uses temporary bounded lockouts.
|
||||
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migration `han-otp-1.0.0` is applied by Keycloak's JPA entity provider.
|
||||
- OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison.
|
||||
- Settings are fetched only from `GET /internal/settings/v1/otp` with `Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}`. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed.
|
||||
- Mock mode is explicit. Startup rejects missing values, code `1234`, codes shorter than six characters, and HMAC keys shorter than 32 bytes. Disabling mock mode without a real delivery provider fails startup.
|
||||
|
||||
## Build and test
|
||||
|
||||
Requires Java 21 and Maven 3.9:
|
||||
|
||||
```bash
|
||||
mvn -B -ntp clean verify
|
||||
docker build -t han-chat/keycloak:26.1.4-otp-1.0.0 .
|
||||
```
|
||||
|
||||
The Maven build shades only libphonenumber into the provider JAR; Keycloak SPI dependencies remain provided by the pinned server image.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy values from `.env.example` into the root backend `.env`; never commit `.env`. Generate independent random values for admin password, mock code, OTP HMAC key and settings bridge token.
|
||||
|
||||
Before production deployment replace the explicit placeholder entries in `realm/han-chat-realm.json`:
|
||||
|
||||
- `https://APP_LINK_HOST.example/auth/callback`
|
||||
- `https://APP_LINK_HOST.example/auth/logout`
|
||||
- `https://APP_WEB_ORIGIN.example`
|
||||
|
||||
Use exact Expo universal/app links and web origins. Do not replace them with wildcards. `https://tohin.ru/auth/callback` and `han-chat://auth/callback` are already allow-listed.
|
||||
|
||||
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`.
|
||||
|
||||
## Runtime
|
||||
|
||||
For standalone validation:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
The service exposes only Docker-network ports:
|
||||
|
||||
- application HTTP: `8080`, relative path `/auth`;
|
||||
- management health and metrics: `9000`;
|
||||
- readiness: `GET http://keycloak:9000/auth/health/ready`;
|
||||
- liveness: `GET http://keycloak:9000/auth/health/live`;
|
||||
- Prometheus metrics: `GET http://keycloak:9000/auth/metrics`.
|
||||
|
||||
Only nginx may publish external ports. Preserve `Host`, `X-Forwarded-Proto=https`, `X-Forwarded-Host`, `X-Forwarded-Port=443` and the trusted client IP chain.
|
||||
|
||||
## Realm lifecycle
|
||||
|
||||
`--import-realm` is suitable for a clean environment. It does not safely reconcile an existing production realm. For changes to a live realm:
|
||||
|
||||
1. take a managed PostgreSQL backup/PITR checkpoint and export the current realm without users/secrets;
|
||||
2. compare the desired safe subset (clients, scopes, flows, token policy);
|
||||
3. apply through a controlled admin job or Admin API procedure;
|
||||
4. verify discovery issuer, JWKS, PKCE login, refresh rotation and logout;
|
||||
5. retain old passive signing keys until all tokens signed by them expire.
|
||||
|
||||
Private signing keys are generated and stored by Keycloak and are absent from the realm JSON.
|
||||
|
||||
## OTP data and operations
|
||||
|
||||
Provider tables:
|
||||
|
||||
- `han_otp_challenge`: expiring, one-time challenges with optimistic version and pessimistic verification lock;
|
||||
- `han_otp_send_counter`: durable 24-hour counter/cooldown per phone HMAC;
|
||||
- `han_otp_security_event`: append-only minimal outcomes without raw phone or OTP.
|
||||
|
||||
Resend marks an earlier active challenge as superseded. Verification locks a challenge row, increments attempts, and atomically consumes a valid challenge, preventing replay and parallel double use.
|
||||
|
||||
Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables.
|
||||
|
||||
## Release and recovery
|
||||
|
||||
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
|
||||
|
||||
Backups must include the full Keycloak schema (realm signing keys and provider tables). After restore verify issuer `https://tohin.ru/auth/realms/han-chat`, JWKS, client redirects, browser flow binding, challenge persistence and refresh revocation before opening traffic.
|
||||
@@ -0,0 +1,61 @@
|
||||
services:
|
||||
keycloak:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: han-chat/keycloak:26.1.4-otp-1.0.0
|
||||
command: ["start", "--optimized", "--import-realm"]
|
||||
environment:
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required}
|
||||
KC_DB_URL_PROPERTIES: ${KC_DB_URL_PROPERTIES:-currentSchema=keycloak}
|
||||
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:-https://tohin.ru/auth}
|
||||
KC_HOSTNAME_STRICT: "true"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HTTP_PORT: "8080"
|
||||
KC_HTTP_RELATIVE_PATH: /auth
|
||||
KC_PROXY_HEADERS: xforwarded
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_METRICS_ENABLED: "true"
|
||||
KC_HTTP_MANAGEMENT_PORT: "9000"
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?bootstrap admin username is required}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}
|
||||
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true}
|
||||
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?mock code is required}
|
||||
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
|
||||
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
|
||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required}
|
||||
KC_LOG_CONSOLE_OUTPUT: json
|
||||
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
|
||||
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
|
||||
expose:
|
||||
- "8080"
|
||||
- "9000"
|
||||
networks:
|
||||
- public
|
||||
- backend
|
||||
- observability
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1770
|
||||
- /opt/keycloak/data/tmp:size=64m,uid=1000,gid=0,mode=0770
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
public:
|
||||
backend:
|
||||
observability:
|
||||
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>ru.han.chat</groupId>
|
||||
<artifactId>han-phone-otp-provider</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<keycloak.version>26.1.4</keycloak.version>
|
||||
<libphonenumber.version>8.13.55</libphonenumber.version>
|
||||
<junit.version>5.11.4</junit.version>
|
||||
<mockito.version>5.15.2</mockito.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
<version>${keycloak.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi-private</artifactId>
|
||||
<version>${keycloak.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-services</artifactId>
|
||||
<version>${keycloak.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-model-jpa</artifactId>
|
||||
<version>${keycloak.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.googlecode.libphonenumber</groupId>
|
||||
<artifactId>libphonenumber</artifactId>
|
||||
<version>${libphonenumber.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>han-phone-otp-provider</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<configuration>
|
||||
<useModulePath>false</useModulePath>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<artifactSet>
|
||||
<includes>
|
||||
<include>com.googlecode.libphonenumber:libphonenumber</include>
|
||||
</includes>
|
||||
</artifactSet>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<shadedArtifactAttached>false</shadedArtifactAttached>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"realm": "han-chat",
|
||||
"enabled": true,
|
||||
"displayName": "HAN Chat",
|
||||
"registrationAllowed": false,
|
||||
"registrationEmailAsUsername": false,
|
||||
"resetPasswordAllowed": false,
|
||||
"editUsernameAllowed": false,
|
||||
"loginWithEmailAllowed": false,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"verifyEmail": false,
|
||||
"rememberMe": true,
|
||||
"sslRequired": "external",
|
||||
"defaultSignatureAlgorithm": "RS256",
|
||||
"accessTokenLifespan": 300,
|
||||
"accessCodeLifespan": 60,
|
||||
"accessCodeLifespanLogin": 300,
|
||||
"ssoSessionIdleTimeout": 2592000,
|
||||
"ssoSessionMaxLifespan": 7776000,
|
||||
"clientSessionIdleTimeout": 2592000,
|
||||
"clientSessionMaxLifespan": 7776000,
|
||||
"revokeRefreshToken": true,
|
||||
"refreshTokenMaxReuse": 0,
|
||||
"offlineSessionMaxLifespanEnabled": true,
|
||||
"offlineSessionMaxLifespan": 0,
|
||||
"bruteForceProtected": true,
|
||||
"permanentLockout": false,
|
||||
"maxTemporaryLockouts": 0,
|
||||
"failureFactor": 5,
|
||||
"waitIncrementSeconds": 60,
|
||||
"quickLoginCheckMilliSeconds": 1000,
|
||||
"minimumQuickLoginWaitSeconds": 60,
|
||||
"maxFailureWaitSeconds": 900,
|
||||
"maxDeltaTimeSeconds": 43200,
|
||||
"eventsEnabled": true,
|
||||
"eventsExpiration": 7776000,
|
||||
"enabledEventTypes": [
|
||||
"LOGIN", "LOGIN_ERROR", "LOGOUT", "LOGOUT_ERROR",
|
||||
"REFRESH_TOKEN", "REFRESH_TOKEN_ERROR", "REVOKE_GRANT", "REVOKE_GRANT_ERROR"
|
||||
],
|
||||
"adminEventsEnabled": true,
|
||||
"adminEventsDetailsEnabled": false,
|
||||
"internationalizationEnabled": true,
|
||||
"supportedLocales": ["ru"],
|
||||
"defaultLocale": "ru",
|
||||
"loginTheme": "han-phone",
|
||||
"browserFlow": "han-phone-otp-browser",
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "han-chat-frontend",
|
||||
"name": "HAN Chat Frontend",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"authorizationServicesEnabled": false,
|
||||
"frontchannelLogout": true,
|
||||
"fullScopeAllowed": false,
|
||||
"redirectUris": [
|
||||
"https://tohin.ru/auth/callback",
|
||||
"han-chat://auth/callback",
|
||||
"https://APP_LINK_HOST.example/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://tohin.ru",
|
||||
"https://APP_WEB_ORIGIN.example"
|
||||
],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256",
|
||||
"post.logout.redirect.uris": "https://tohin.ru/##han-chat://auth/logout##https://APP_LINK_HOST.example/auth/logout",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"client.use.lightweight.access.token.enabled": "false"
|
||||
},
|
||||
"defaultClientScopes": ["openid", "profile", "phone", "han-chat-api"],
|
||||
"optionalClientScopes": ["offline_access"]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
{
|
||||
"name": "phone",
|
||||
"description": "Verified E.164 phone claims",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "phone number",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "phone_number",
|
||||
"claim.name": "phone_number",
|
||||
"jsonType.label": "String",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "phone verified",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "phone_number_verified",
|
||||
"claim.name": "phone_number_verified",
|
||||
"jsonType.label": "boolean",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "han-chat-api",
|
||||
"description": "HAN Chat API audience",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "han-chat-api audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "han-chat-api",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"authenticationFlows": [
|
||||
{
|
||||
"alias": "han-phone-otp-browser",
|
||||
"description": "Cookie SSO or phone OTP only",
|
||||
"providerId": "basic-flow",
|
||||
"topLevel": true,
|
||||
"builtIn": false,
|
||||
"authenticationExecutions": [
|
||||
{
|
||||
"authenticator": "auth-cookie",
|
||||
"requirement": "ALTERNATIVE",
|
||||
"priority": 10,
|
||||
"authenticatorFlow": false
|
||||
},
|
||||
{
|
||||
"flowAlias": "han-phone-otp-forms",
|
||||
"requirement": "ALTERNATIVE",
|
||||
"priority": 20,
|
||||
"authenticatorFlow": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"alias": "han-phone-otp-forms",
|
||||
"description": "Normalize phone, reserve challenge and verify OTP",
|
||||
"providerId": "basic-flow",
|
||||
"topLevel": false,
|
||||
"builtIn": false,
|
||||
"authenticationExecutions": [
|
||||
{
|
||||
"authenticator": "han-phone-identity",
|
||||
"requirement": "REQUIRED",
|
||||
"priority": 10,
|
||||
"authenticatorFlow": false
|
||||
},
|
||||
{
|
||||
"authenticator": "han-phone-otp",
|
||||
"requirement": "REQUIRED",
|
||||
"priority": 20,
|
||||
"authenticatorFlow": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requiredActions": [],
|
||||
"components": {
|
||||
"org.keycloak.keys.KeyProvider": [
|
||||
{
|
||||
"name": "rsa-generated",
|
||||
"providerId": "rsa-generated",
|
||||
"subType": "rsa-generated",
|
||||
"config": {
|
||||
"priority": ["100"],
|
||||
"algorithm": ["RS256"],
|
||||
"keySize": ["2048"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package ru.han.chat.keycloak;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
final class Config {
|
||||
static final boolean MOCK_ENABLED = bool("KEYCLOAK_OTP_MOCK_ENABLED", true);
|
||||
static final String MOCK_CODE = required("KEYCLOAK_OTP_MOCK_CODE");
|
||||
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
static final Duration OTP_TTL = Duration.ofSeconds(integer("KEYCLOAK_OTP_TTL_SEC", 300, 30, 900));
|
||||
static final int MAX_VERIFY_ATTEMPTS = integer("KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS", 5, 1, 10);
|
||||
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
|
||||
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
|
||||
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
|
||||
"http://api-backend:8000/internal/settings/v1/otp"));
|
||||
static final String SETTINGS_TOKEN = required("KEYCLOAK_SETTINGS_BRIDGE_TOKEN");
|
||||
|
||||
static {
|
||||
if (!MOCK_ENABLED) {
|
||||
throw new IllegalStateException("No real OTP delivery provider configured; refusing to start");
|
||||
}
|
||||
if (MOCK_CODE.isBlank() || "1234".equals(MOCK_CODE) || MOCK_CODE.length() < 6) {
|
||||
throw new IllegalStateException("KEYCLOAK_OTP_MOCK_CODE must be a non-default secret of at least 6 characters");
|
||||
}
|
||||
if (HMAC_KEY.length < 32) {
|
||||
throw new IllegalStateException("KEYCLOAK_OTP_HMAC_KEY must contain at least 32 bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private Config() {}
|
||||
|
||||
private static String required(String name) {
|
||||
String value = System.getenv(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException(name + " is required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String env(String name, String fallback) {
|
||||
String value = System.getenv(name);
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
private static boolean bool(String name, boolean fallback) {
|
||||
return Boolean.parseBoolean(env(name, Boolean.toString(fallback)));
|
||||
}
|
||||
|
||||
private static int integer(String name, int fallback, int min, int max) {
|
||||
int value = Integer.parseInt(env(name, Integer.toString(fallback)));
|
||||
if (value < min || value > max) throw new IllegalStateException(name + " is outside allowed range");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user