diff --git a/HAN_chat_specification.rar b/HAN_chat_specification.rar deleted file mode 100644 index e7f4c63..0000000 Binary files a/HAN_chat_specification.rar and /dev/null differ diff --git a/codebase/.gitignore b/codebase/.gitignore new file mode 100644 index 0000000..421904f --- /dev/null +++ b/codebase/.gitignore @@ -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 diff --git a/codebase/README.md b/codebase/README.md new file mode 100644 index 0000000..9d1b6e2 --- /dev/null +++ b/codebase/README.md @@ -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`. diff --git a/codebase/backend/.env.example b/codebase/backend/.env.example new file mode 100644 index 0000000..7ce1526 --- /dev/null +++ b/codebase/backend/.env.example @@ -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 diff --git a/codebase/backend/.gitattributes b/codebase/backend/.gitattributes new file mode 100644 index 0000000..98769be --- /dev/null +++ b/codebase/backend/.gitattributes @@ -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 diff --git a/codebase/backend/.gitignore b/codebase/backend/.gitignore new file mode 100644 index 0000000..d4fc25b --- /dev/null +++ b/codebase/backend/.gitignore @@ -0,0 +1,10 @@ +.env +.env.* +!.env.example +*.pem +*.key +*.crt +backups/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/codebase/backend/api-backend/.dockerignore b/codebase/backend/api-backend/.dockerignore new file mode 100644 index 0000000..3c62dd9 --- /dev/null +++ b/codebase/backend/api-backend/.dockerignore @@ -0,0 +1,8 @@ +.env +.git +.mypy_cache +.pytest_cache +.ruff_cache +__pycache__ +*.py[cod] +tests diff --git a/codebase/backend/api-backend/Dockerfile b/codebase/backend/api-backend/Dockerfile new file mode 100644 index 0000000..d0a95d8 --- /dev/null +++ b/codebase/backend/api-backend/Dockerfile @@ -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"] diff --git a/codebase/backend/api-backend/README.md b/codebase/backend/api-backend/README.md new file mode 100644 index 0000000..00577e4 --- /dev/null +++ b/codebase/backend/api-backend/README.md @@ -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. diff --git a/codebase/backend/api-backend/alembic.ini b/codebase/backend/api-backend/alembic.ini new file mode 100644 index 0000000..2788aaa --- /dev/null +++ b/codebase/backend/api-backend/alembic.ini @@ -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 diff --git a/codebase/backend/api-backend/alembic/env.py b/codebase/backend/api-backend/alembic/env.py new file mode 100644 index 0000000..b3a311d --- /dev/null +++ b/codebase/backend/api-backend/alembic/env.py @@ -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()) diff --git a/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py b/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py new file mode 100644 index 0000000..2922ba3 --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py @@ -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") diff --git a/codebase/backend/api-backend/app/__init__.py b/codebase/backend/api-backend/app/__init__.py new file mode 100644 index 0000000..0e0f9f3 --- /dev/null +++ b/codebase/backend/api-backend/app/__init__.py @@ -0,0 +1 @@ +"""HAN Chat API backend.""" diff --git a/codebase/backend/api-backend/app/auth.py b/codebase/backend/api-backend/app/auth.py new file mode 100644 index 0000000..f37196d --- /dev/null +++ b/codebase/backend/api-backend/app/auth.py @@ -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 diff --git a/codebase/backend/api-backend/app/cli/__init__.py b/codebase/backend/api-backend/app/cli/__init__.py new file mode 100644 index 0000000..139c477 --- /dev/null +++ b/codebase/backend/api-backend/app/cli/__init__.py @@ -0,0 +1 @@ +"""Operational command-line entry points.""" diff --git a/codebase/backend/api-backend/app/cli/seed_settings.py b/codebase/backend/api-backend/app/cli/seed_settings.py new file mode 100644 index 0000000..60154cd --- /dev/null +++ b/codebase/backend/api-backend/app/cli/seed_settings.py @@ -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() diff --git a/codebase/backend/api-backend/app/cli/validate_settings.py b/codebase/backend/api-backend/app/cli/validate_settings.py new file mode 100644 index 0000000..59807d4 --- /dev/null +++ b/codebase/backend/api-backend/app/cli/validate_settings.py @@ -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() diff --git a/codebase/backend/api-backend/app/db.py b/codebase/backend/api-backend/app/db.py new file mode 100644 index 0000000..2d539bc --- /dev/null +++ b/codebase/backend/api-backend/app/db.py @@ -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() diff --git a/codebase/backend/api-backend/app/integrations.py b/codebase/backend/api-backend/app/integrations.py new file mode 100644 index 0000000..5fec604 --- /dev/null +++ b/codebase/backend/api-backend/app/integrations.py @@ -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() diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py new file mode 100644 index 0000000..5d83144 --- /dev/null +++ b/codebase/backend/api-backend/app/main.py @@ -0,0 +1,1020 @@ +import asyncio +import hashlib +import hmac +import json +import logging +import time +import uuid +from contextlib import asynccontextmanager, suppress +from datetime import UTC, datetime +from ipaddress import ip_address, ip_network +from typing import Annotated, Any + +import httpx +import redis.asyncio as redis +import structlog +import uvicorn +from fastapi import ( + Depends, + FastAPI, + Header, + Query, + Request, + Response, + WebSocket, + WebSocketDisconnect, +) +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy import and_, desc, func, or_, select, text +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.auth import AuthError, JWKSValidator, Principal +from app.db import ( + Database, + Dialog, + Document, + Message, + MessageAttachment, + PopularQuestion, + TextResource, + UserIdentity, +) +from app.integrations import ( + DependencyFailure, + OpenLinesClient, + RateLimiter, + RedisIdempotency, + S3Client, + SafetyClient, +) +from app.realtime import RealtimeFanout +from app.schemas import ( + AttachmentCompleteRequest, + AttachmentInitRequest, + BootstrapRequest, + ConsentsRequest, + MessageRequest, + OpenLinesInbox, + SessionStartRequest, + decode_cursor, + encode_cursor, +) +from app.services import ( + DomainError, + SettingsSnapshot, + apply_inbox, + audit, + bootstrap, + complete_attachment, + create_dialog, + dialog_dto, + get_profile, + init_attachment, + load_settings, + message_dto, + owned_dialog, + record_consents, + resolve_user, + send_message, + start_session, +) +from app.settings import get_settings + + +def configure_logging(level: str) -> None: + logging.basicConfig(level=level, format="%(message)s") + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"), + structlog.stdlib.add_log_level, + structlog.processors.JSONRenderer(), + ] + ) + + +async def refresh_settings_cache(app: FastAPI) -> None: + while True: + try: + async with app.state.db.sessions() as db: + app.state.snapshot = await load_settings(db) + except Exception: + log.warning("settings.refresh_failed", event="settings.refresh_failed") + await asyncio.sleep(30) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + configure_logging(settings.log_level) + app.state.settings = settings + app.state.db = Database(settings.database_url) + app.state.http = httpx.AsyncClient() + app.state.redis = redis.from_url(settings.redis_url, decode_responses=True) + app.state.redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True) + app.state.jwks = JWKSValidator(settings, app.state.http) + app.state.rate_limiter = RateLimiter(app.state.redis) + app.state.idempotency = RedisIdempotency(app.state.redis) + app.state.safety = SafetyClient(settings, app.state.http) + app.state.openlines = OpenLinesClient(settings, app.state.http) + app.state.s3 = S3Client(settings) + app.state.realtime = RealtimeFanout(app.state.redis_rt) + app.state.snapshot = None + try: + async with app.state.db.sessions() as db: + app.state.snapshot = await load_settings(db) + except Exception: + structlog.get_logger().warning("settings_warmup_failed", event="settings.warmup_failed") + settings_task = asyncio.create_task(refresh_settings_cache(app)) + try: + await app.state.jwks.refresh() + except Exception: + structlog.get_logger().warning("jwks_warmup_failed", event="jwks.warmup_failed") + yield + settings_task.cancel() + with suppress(asyncio.CancelledError): + await settings_task + await app.state.http.aclose() + await app.state.redis.aclose() + await app.state.redis_rt.aclose() + await app.state.db.close() + + +app = FastAPI( + title="HAN Chat API", + version="1.0.0", + openapi_version="3.1.0", + docs_url=None, + redoc_url=None, + lifespan=lifespan, +) +log = structlog.get_logger() + + +def client_ip(request: Request) -> str: + """Trust forwarded client addresses only from configured reverse proxies.""" + peer = request.client.host if request.client else "" + try: + peer_address = ip_address(peer) + trusted = any( + peer_address in ip_network(value.strip(), strict=False) + for value in request.app.state.settings.trusted_proxy_cidrs.split(",") + if value.strip() + ) + except ValueError: + trusted = False + forwarded = request.headers.get("X-Forwarded-For", "") if trusted else "" + candidate = forwarded.split(",", 1)[0].strip() if forwarded else peer + try: + return str(ip_address(candidate)) + except ValueError: + return "unknown" + + +@app.middleware("http") +async def request_context(request: Request, call_next: Any) -> Response: + request_id = request.headers.get("X-Request-ID", "") + try: + request_id = str(uuid.UUID(request_id)) if request_id else str(uuid.uuid4()) + except ValueError: + request_id = str(uuid.uuid4()) + request.state.request_id = request_id + request.state.started_at = time.monotonic() + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars( + request_id=request_id, + ux_session_id=request.headers.get("X-Ux-Session-Id"), + method=request.method, + route=request.url.path, + **{"service.name": "api-backend"}, + ) + origin = request.headers.get("Origin") + cached_settings = request.app.state.snapshot + allowed_origins = ( + cached_settings.strings("security.cors.allowed_origins") if cached_settings else [] + ) + if request.method == "OPTIONS" and origin in allowed_origins: + response = Response(status_code=204) + else: + response = await call_next(request) + if origin in allowed_origins: + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Headers"] = ( + "Authorization,Content-Type,Idempotency-Key,X-Request-ID,X-Ux-Session-Id" + ) + response.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS" + response.headers["Vary"] = "Origin" + response.headers["X-Request-ID"] = request_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Cache-Control"] = response.headers.get("Cache-Control", "no-store") + log.info( + "request.complete", + event="request.complete", + status_code=response.status_code, + duration_ms=round((time.monotonic() - request.state.started_at) * 1000, 2), + ) + return response + + +def error_response(request: Request, code: str, message: str, status: int, details: Any = None): + return JSONResponse( + status_code=status, + content={ + "error": { + "code": code, + "message": message, + "request_id": getattr(request.state, "request_id", str(uuid.uuid4())), + "details": details or {}, + } + }, + ) + + +@app.exception_handler(DomainError) +async def domain_error(request: Request, exc: DomainError): + response = error_response(request, exc.code, exc.message, exc.status, exc.details) + if "retry_after" in exc.details: + response.headers["Retry-After"] = str(exc.details["retry_after"]) + return response + + +@app.exception_handler(AuthError) +async def auth_error(request: Request, exc: AuthError): + return error_response(request, exc.code, "Authentication failed", 401) + + +@app.exception_handler(RequestValidationError) +async def validation_error(request: Request, exc: RequestValidationError): + details = [ + {"field": ".".join(str(part) for part in item["loc"][1:]), "type": item["type"]} + for item in exc.errors() + ] + return error_response(request, "validation_error", "Request validation failed", 400, details) + + +@app.exception_handler(StarletteHTTPException) +async def http_error(request: Request, exc: StarletteHTTPException): + code = "not_found" if exc.status_code == 404 else "forbidden" + return error_response(request, code, "Resource was not found", exc.status_code) + + +@app.exception_handler(Exception) +async def unhandled_error(request: Request, exc: Exception): + log.exception("request.failed", event="request.failed", error_code="internal_error") + return error_response(request, "internal_error", "Internal server error", 500) + + +async def session(request: Request): + async for value in request.app.state.db.session(): + yield value + + +Session = Annotated[AsyncSession, Depends(session)] + + +async def principal( + request: Request, authorization: Annotated[str | None, Header()] = None +) -> Principal: + if not authorization or not authorization.startswith("Bearer "): + raise AuthError() + return await request.app.state.jwks.validate(authorization.removeprefix("Bearer ").strip()) + + +PrincipalDep = Annotated[Principal, Depends(principal)] + + +async def current_user(db: Session, auth: PrincipalDep) -> UserIdentity: + return await resolve_user(db, auth) + + +UserDep = Annotated[UserIdentity, Depends(current_user)] + + +async def snapshot(db: Session) -> SettingsSnapshot: + return await load_settings(db) + + +SnapshotDep = Annotated[SettingsSnapshot, Depends(snapshot)] + + +def ux_id(value: str | None) -> uuid.UUID | None: + try: + return uuid.UUID(value) if value else None + except ValueError: + raise DomainError("validation_error", 400, "X-Ux-Session-Id must be UUID") from None + + +async def service_auth(request: Request, expected: str) -> None: + authorization = request.headers.get("Authorization", "") + supplied = authorization.removeprefix("Bearer ").strip() + if not supplied or not hmac.compare_digest(supplied, expected): + raise AuthError() + + +async def required_idempotency(key: str | None) -> str: + if not key or len(key) > 128: + raise DomainError("validation_error", 400, "Valid Idempotency-Key is required") + return key + + +async def enforce_limit( + request: Request, + identity_type: str, + identity: str, + route: str, + limit_value: tuple[int, int], + *, + fail_closed: bool, +) -> None: + limit, window = limit_value + key = request.app.state.rate_limiter.key(identity_type, identity, route, window) + try: + retry_after = await request.app.state.rate_limiter.consume(key, limit, window) + except DependencyFailure: + if fail_closed: + raise DomainError( + "dependency_unavailable", 503, "Rate limit service is unavailable" + ) from None + return + if retry_after: + raise DomainError( + "rate_limit_exceeded", + 429, + "Rate limit exceeded", + {"retry_after": retry_after}, + ) + + +@app.get("/health/live", tags=["health"]) +async def live() -> dict[str, str]: + return {"status": "live"} + + +@app.get("/health/ready", tags=["health"]) +async def ready(request: Request, db: Session): + components: dict[str, str] = {} + try: + await db.execute(text("SELECT 1")) + revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1")) + if revision != "0001_initial": + raise RuntimeError("unexpected database revision") + await load_settings(db) + components["postgres"] = "ok" + components["settings"] = "ok" + except Exception: + components["postgres"] = "failed" + components["settings"] = "failed" + for name, client in ( + ("redis", request.app.state.redis), + ("redis_realtime", request.app.state.redis_rt), + ): + try: + await client.ping() + components[name] = "ok" + except Exception: + components[name] = "failed" + components["jwks"] = "ok" if request.app.state.jwks._keys else "failed" + try: + safety_response = await request.app.state.http.get( + f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready", + timeout=2, + ) + components["safety"] = "ok" if safety_response.is_success else "failed" + except httpx.HTTPError: + components["safety"] = "failed" + components["openlines"] = "ok" if await request.app.state.openlines.ready() else "degraded" + components["s3"] = "ok" if await request.app.state.s3.ready() else "degraded" + critical = {"postgres", "settings", "redis", "jwks", "safety"} + failed = any(components.get(name) == "failed" for name in critical) + status = ( + "not_ready" if failed else ("degraded" if "degraded" in components.values() else "ready") + ) + return JSONResponse( + {"status": status, "components": components}, status_code=503 if failed else 200 + ) + + +@app.get("/api/v1/public/app-config", tags=["public"]) +async def app_config(request: Request, response: Response, settings: SnapshotDep): + await enforce_limit( + request, + "ip", + client_ip(request), + "public", + settings.limit("rate_limit.public_endpoints.per_ip"), + fail_closed=False, + ) + response.headers["Cache-Control"] = ( + f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}" + ) + response.headers["ETag"] = f'"{settings.version}"' + values = settings.values + return { + "auth": { + "phone_enabled": settings.boolean("auth.phone.enabled"), + "password_enabled": settings.boolean("auth.password.enabled"), + }, + "operator": {"call_phone": values["operator.call.phone"]}, + "consents": { + name: { + "required": settings.boolean(f"consent.{name}.required"), + "document_url": values.get(f"consent.{name}.document_url"), + "version": values[f"consent.{name}.version"], + } + for name in ("personal_data", "user_agreement", "marketing") + }, + "attachments": { + "allowed_extensions": settings.strings("chat.attachments.allowed_extensions"), + "allowed_mime_types": settings.strings("chat.attachments.allowed_mime_types"), + "max_size_mb": settings.integer("chat.attachments.max_size_mb"), + }, + "ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")}, + } + + +@app.get("/api/v1/public/content", tags=["public"]) +async def content( + request: Request, + db: Session, + settings: SnapshotDep, + response: Response, + locale: str = "ru", +): + await enforce_limit( + request, + "ip", + client_ip(request), + "public", + settings.limit("rate_limit.public_endpoints.per_ip"), + fail_closed=False, + ) + locale = "ru" + texts = ( + await db.execute( + select(TextResource) + .where(TextResource.locale == locale, TextResource.record_status == "A") + .order_by(TextResource.sort_order) + ) + ).scalars() + questions = ( + await db.execute( + select(PopularQuestion) + .where(PopularQuestion.locale == locale, PopularQuestion.record_status == "A") + .order_by(PopularQuestion.sort_order) + ) + ).scalars() + response.headers["ETag"] = f'"{settings.version}"' + return { + "locale": locale, + "texts": {item.mnemonic: item.text_value for item in texts}, + "popular_questions": [ + {"id": item.id, "mnemonic": item.mnemonic, "text": item.question_text} + for item in questions + ], + "version": settings.version, + } + + +@app.post("/api/v1/auth/bootstrap", tags=["auth"]) +async def auth_bootstrap( + body: BootstrapRequest, request: Request, db: Session, auth: PrincipalDep, settings: SnapshotDep +): + return await bootstrap(db, auth, body, settings, request.state.request_id) + + +@app.post("/api/v1/consents", status_code=201, tags=["auth"]) +async def consents( + body: ConsentsRequest, + request: Request, + db: Session, + user: UserDep, + settings: SnapshotDep, + x_ux_session_id: Annotated[str | None, Header()] = None, +): + return await record_consents( + db, user, body, settings, request.state.request_id, ux_id(x_ux_session_id) + ) + + +@app.post("/api/v1/analytics/session-start", status_code=201, tags=["analytics"]) +async def analytics_session( + body: SessionStartRequest, request: Request, db: Session, user: UserDep +): + return await start_session(db, user, body, request.state.request_id) + + +@app.get("/api/v1/me", tags=["profile"]) +async def me(db: Session, user: UserDep): + return await get_profile(db, user) + + +@app.get("/api/v1/me/documents", tags=["profile"]) +async def documents(db: Session, user: UserDep, limit: int = Query(50, ge=1, le=100)): + items = ( + ( + await db.execute( + select(Document) + .where(Document.user_id == user.id, Document.record_status == "A") + .order_by(desc(Document.sent_at), desc(Document.id)) + .limit(limit + 1) + ) + ) + .scalars() + .all() + ) + return {"items": [document_dto(item) for item in items[:limit]], "next_cursor": None} + + +def document_dto(item: Document) -> dict[str, Any]: + return { + "document_id": item.id, + "name": item.name, + "mime_type": item.mime_type, + "size_bytes": item.size_bytes, + "checksum": f"sha256:{item.checksum_sha256}", + "sent_at": item.sent_at, + } + + +async def owned_document(db: AsyncSession, user_id: uuid.UUID, document_id: uuid.UUID): + item = ( + await db.execute( + select(Document).where( + Document.id == document_id, + Document.user_id == user_id, + Document.record_status == "A", + ) + ) + ).scalar_one_or_none() + if not item: + raise DomainError("not_found", 404, "Resource was not found") + return item + + +@app.get("/api/v1/documents/{document_id}", tags=["profile"]) +async def document(document_id: uuid.UUID, db: Session, user: UserDep): + return document_dto(await owned_document(db, user.id, document_id)) + + +@app.get("/api/v1/documents/{document_id}/download-url", tags=["profile"]) +async def document_download( + document_id: uuid.UUID, + request: Request, + db: Session, + user: UserDep, + settings: SnapshotDep, + x_ux_session_id: Annotated[str | None, Header()] = None, +): + await enforce_limit( + request, + "user", + str(user.id), + "download_url", + settings.limit("rate_limit.download_url.per_user"), + fail_closed=True, + ) + item = await owned_document(db, user.id, document_id) + db.add( + audit( + "document.download_url_issued", + request.state.request_id, + user.id, + "document", + item.id, + ux_id(x_ux_session_id), + ) + ) + await db.commit() + url = await request.app.state.s3.presign_get(item.storage_bucket, item.object_key) + return {"download_url": url, "expires_at": datetime.now(UTC)} + + +@app.post("/api/v1/dialogs", tags=["dialogs"]) +async def dialogs_create( + request: Request, + db: Session, + user: UserDep, + idempotency_key: Annotated[str | None, Header()] = None, +): + key = await required_idempotency(idempotency_key) + scope = "dialogs.create" + fingerprint = hashlib.sha256(f"POST:/api/v1/dialogs:{user.id}".encode()).hexdigest() + try: + cached = await request.app.state.idempotency.get(scope, user.id, key) + except Exception: + cached = None + if cached: + if cached["fingerprint"] != fingerprint: + raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused") + return JSONResponse(cached["body"], status_code=cached["status"]) + body, status = await create_dialog(db, user, request.state.request_id, key) + try: + await request.app.state.idempotency.put( + scope, + user.id, + key, + { + "fingerprint": fingerprint, + "status": status, + "body": json.loads(json.dumps(body, default=str)), + }, + ) + except Exception: + log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed") + return JSONResponse(json.loads(json.dumps(body, default=str)), status_code=status) + + +@app.get("/api/v1/dialogs", tags=["dialogs"]) +async def dialogs_list( + request: Request, + db: Session, + user: UserDep, + limit: int = Query(50, ge=1, le=100), + cursor: str | None = None, +): + query = select(Dialog).where(Dialog.user_id == user.id, Dialog.record_status == "A") + if cursor: + decoded = decode_cursor( + cursor, request.app.state.settings.cursor_hmac_secret.get_secret_value().encode() + ) + updated, item_id = datetime.fromisoformat(decoded["updated_at"]), uuid.UUID(decoded["id"]) + query = query.where( + or_( + Dialog.updated_at < updated, and_(Dialog.updated_at == updated, Dialog.id < item_id) + ) + ) + items = ( + ( + await db.execute( + query.order_by(desc(Dialog.updated_at), desc(Dialog.id)).limit(limit + 1) + ) + ) + .scalars() + .all() + ) + next_cursor = None + if len(items) > limit: + last = items[limit - 1] + next_cursor = encode_cursor( + {"updated_at": last.updated_at.isoformat(), "id": str(last.id)}, + request.app.state.settings.cursor_hmac_secret.get_secret_value().encode(), + ) + return {"items": [dialog_dto(item) for item in items[:limit]], "next_cursor": next_cursor} + + +@app.get("/api/v1/dialogs/{dialog_id}", tags=["dialogs"]) +async def dialog_get(dialog_id: uuid.UUID, db: Session, user: UserDep): + return dialog_dto(await owned_dialog(db, user.id, dialog_id)) + + +@app.get("/api/v1/dialogs/{dialog_id}/messages", tags=["dialogs"]) +async def messages_list( + request: Request, + dialog_id: uuid.UUID, + db: Session, + user: UserDep, + limit: int = Query(50, ge=1, le=100), + after: str | None = None, +): + await owned_dialog(db, user.id, dialog_id) + query = select(Message).where(Message.dialog_id == dialog_id, Message.record_status == "A") + if after: + decoded = decode_cursor( + after, request.app.state.settings.cursor_hmac_secret.get_secret_value().encode() + ) + created, item_id = datetime.fromisoformat(decoded["created_at"]), uuid.UUID(decoded["id"]) + query = query.where( + or_( + Message.created_at > created, + and_(Message.created_at == created, Message.id > item_id), + ) + ) + items = ( + (await db.execute(query.order_by(Message.created_at, Message.id).limit(limit + 1))) + .scalars() + .all() + ) + page = items[:limit] + attachment_rows = ( + ( + await db.execute( + select(MessageAttachment).where( + MessageAttachment.message_id.in_([item.id for item in page]), + MessageAttachment.record_status == "A", + ) + ) + ) + .scalars() + .all() + if page + else [] + ) + attachments = {item.message_id: item for item in attachment_rows} + next_cursor = None + if len(items) > limit: + last = page[-1] + next_cursor = encode_cursor( + {"created_at": last.created_at.isoformat(), "id": str(last.id)}, + request.app.state.settings.cursor_hmac_secret.get_secret_value().encode(), + ) + return { + "items": [ + message_dto(item, [attachments[item.id]] if item.id in attachments else []) + for item in page + ], + "next_cursor": next_cursor, + } + + +@app.post("/api/v1/dialogs/{dialog_id}/messages", status_code=201, tags=["dialogs"]) +async def message_create( + dialog_id: uuid.UUID, + body: MessageRequest, + request: Request, + db: Session, + user: UserDep, + business: SnapshotDep, + idempotency_key: Annotated[str | None, Header()] = None, +): + key = await required_idempotency(idempotency_key) + scope = f"dialogs.{dialog_id}.messages.create" + fingerprint = hashlib.sha256( + json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + try: + cached = await request.app.state.idempotency.get(scope, user.id, key) + except Exception: + cached = None + if cached: + if cached["fingerprint"] != fingerprint: + raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused") + return cached["body"] + await enforce_limit( + request, + "user", + str(user.id), + "message_send", + business.limit("rate_limit.message_send.per_user"), + fail_closed=True, + ) + await enforce_limit( + request, + "dialog", + str(dialog_id), + "message_send", + business.limit("rate_limit.message_send.per_dialog"), + fail_closed=True, + ) + result = await send_message( + db, + user, + dialog_id, + body, + key, + request.state.request_id, + request.app.state.settings, + request.app.state.safety, + request.app.state.openlines, + request.app.state.s3, + request.app.state.realtime, + ) + try: + await request.app.state.idempotency.put( + scope, + user.id, + key, + { + "fingerprint": fingerprint, + "status": 201, + "body": json.loads(json.dumps(result, default=str)), + }, + ) + except Exception: + log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed") + return result + + +@app.post("/api/v1/dialogs/{dialog_id}/attachments/init", status_code=201, tags=["attachments"]) +async def attachment_init( + dialog_id: uuid.UUID, + body: AttachmentInitRequest, + request: Request, + db: Session, + user: UserDep, + settings: SnapshotDep, +): + await enforce_limit( + request, + "user", + str(user.id), + "attachment_init", + settings.limit("rate_limit.message_send.per_user"), + fail_closed=True, + ) + return await init_attachment( + db, user, dialog_id, body, settings, request.app.state.s3, request.state.request_id + ) + + +@app.post( + "/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete", + tags=["attachments"], +) +async def attachment_complete( + dialog_id: uuid.UUID, + attachment_id: uuid.UUID, + body: AttachmentCompleteRequest, + request: Request, + db: Session, + user: UserDep, +): + return await complete_attachment( + db, user, dialog_id, attachment_id, body, request.app.state.s3, request.state.request_id + ) + + +@app.get( + "/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url", + tags=["attachments"], +) +async def attachment_download( + dialog_id: uuid.UUID, + attachment_id: uuid.UUID, + request: Request, + db: Session, + user: UserDep, + settings: SnapshotDep, + x_ux_session_id: Annotated[str | None, Header()] = None, +): + await enforce_limit( + request, + "user", + str(user.id), + "download_url", + settings.limit("rate_limit.download_url.per_user"), + fail_closed=True, + ) + item = ( + await db.execute( + select(MessageAttachment).where( + MessageAttachment.id == attachment_id, + MessageAttachment.dialog_id == dialog_id, + MessageAttachment.owner_user_id == user.id, + MessageAttachment.record_status == "A", + MessageAttachment.scan_status == "clean", + ) + ) + ).scalar_one_or_none() + if not item: + raise DomainError("not_found", 404, "Resource was not found") + db.add( + audit( + "attachment.download_url_issued", + request.state.request_id, + user.id, + "attachment", + item.id, + ux_id(x_ux_session_id), + ) + ) + await db.commit() + return { + "download_url": await request.app.state.s3.presign_get( + item.storage_bucket, item.object_key + ), + "expires_at": datetime.now(UTC), + } + + +@app.post("/internal/openlines/v1/inbox", tags=["internal"]) +async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings: SnapshotDep): + await service_auth( + request, request.app.state.settings.bitrix_api_inbox_token.get_secret_value() + ) + body, status = await apply_inbox( + db, + event, + request.state.request_id, + settings, + request.app.state.s3, + request.app.state.http, + request.app.state.settings, + request.app.state.realtime, + ) + return JSONResponse(body, status_code=status) + + +@app.get("/internal/settings/v1/otp", tags=["internal"]) +async def otp_settings( + request: Request, + settings: SnapshotDep, + if_none_match: Annotated[str | None, Header()] = None, +): + await service_auth( + request, request.app.state.settings.keycloak_settings_bridge_token.get_secret_value() + ) + etag = f'"{settings.version}"' + headers = {"ETag": etag, "Cache-Control": "private, max-age=60"} + if if_none_match == etag: + return Response(status_code=304, headers=headers) + return JSONResponse({ + "max_send_attempts_per_24h": settings.integer("otp.phone.max_send_attempts_per_24h"), + "min_seconds_between_attempts": settings.integer("otp.phone.min_seconds_between_attempts"), + "version": settings.version, + "cache_ttl_seconds": 60, + }, headers=headers) + + +def websocket_token(websocket: WebSocket) -> tuple[str | None, str | None]: + offered = websocket.headers.get("sec-websocket-protocol", "") + for protocol in (part.strip() for part in offered.split(",")): + if protocol.startswith("han.jwt."): + import base64 + + encoded = protocol.removeprefix("han.jwt.") + try: + token = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode() + return token, protocol + except (ValueError, UnicodeDecodeError): + return None, None + return websocket.query_params.get("access_token"), None + + +@app.websocket("/api/v1/realtime") +async def realtime(websocket: WebSocket): + token, protocol = websocket_token(websocket) + if not token: + await websocket.close(code=4401) + return + try: + auth = await websocket.app.state.jwks.validate(token) + async with websocket.app.state.db.sessions() as db: + user = await resolve_user(db, auth) + await websocket.accept(subprotocol=protocol) + await websocket.send_json( + {"type": "connected", "server_time": datetime.now(UTC).isoformat()} + ) + event_task: asyncio.Task[Any] | None = None + receive_task: asyncio.Task[Any] | None = None + heartbeat_task: asyncio.Task[Any] | None = None + event_stream = None + while True: + receive_task = receive_task or asyncio.create_task(websocket.receive_json()) + heartbeat_task = heartbeat_task or asyncio.create_task(asyncio.sleep(20)) + tasks = {receive_task, heartbeat_task} + if event_task: + tasks.add(event_task) + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + if heartbeat_task in done: + await websocket.send_json( + {"type": "ping", "server_time": datetime.now(UTC).isoformat()} + ) + heartbeat_task = None + if event_task and event_task in done: + await websocket.send_json(event_task.result()) + assert event_stream is not None + event_task = asyncio.create_task(anext(event_stream)) + if receive_task in done: + payload = receive_task.result() + receive_task = None + if payload.get("type") == "pong": + continue + if payload.get("type") != "subscribe": + await websocket.close(code=4400) + return + ids = {uuid.UUID(value) for value in payload.get("dialog_ids", [])[:100]} + count = await db.scalar( + select(func.count(Dialog.id)).where( + Dialog.id.in_(ids), + Dialog.user_id == user.id, + Dialog.record_status == "A", + ) + ) + if count != len(ids): + await websocket.close(code=4404) + return + if event_task: + event_task.cancel() + if event_stream: + await event_stream.aclose() + event_stream = websocket.app.state.realtime.events(ids) + event_task = asyncio.create_task(anext(event_stream)) + await websocket.send_json( + {"type": "subscribed", "dialog_ids": [str(value) for value in ids]} + ) + except (AuthError, DomainError, ValueError): + await websocket.close(code=4401) + except WebSocketDisconnect: + return + + +def run() -> None: + settings = get_settings() + uvicorn.run( + "app.main:app", + host="0.0.0.0", # noqa: S104 - required container listener + port=settings.api_port, + proxy_headers=False, + ) diff --git a/codebase/backend/api-backend/app/realtime.py b/codebase/backend/api-backend/app/realtime.py new file mode 100644 index 0000000..8b8b5ba --- /dev/null +++ b/codebase/backend/api-backend/app/realtime.py @@ -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() diff --git a/codebase/backend/api-backend/app/schemas.py b/codebase/backend/api-backend/app/schemas.py new file mode 100644 index 0000000..2c80763 --- /dev/null +++ b/codebase/backend/api-backend/app/schemas.py @@ -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 diff --git a/codebase/backend/api-backend/app/services.py b/codebase/backend/api-backend/app/services.py new file mode 100644 index 0000000..bcab730 --- /dev/null +++ b/codebase/backend/api-backend/app/services.py @@ -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() diff --git a/codebase/backend/api-backend/app/settings.py b/codebase/backend/api-backend/app/settings.py new file mode 100644 index 0000000..fdf5b4b --- /dev/null +++ b/codebase/backend/api-backend/app/settings.py @@ -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() diff --git a/codebase/backend/api-backend/app/workers.py b/codebase/backend/api-backend/app/workers.py new file mode 100644 index 0000000..bffc2e0 --- /dev/null +++ b/codebase/backend/api-backend/app/workers.py @@ -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")) diff --git a/codebase/backend/api-backend/docker-compose.yml b/codebase/backend/api-backend/docker-compose.yml new file mode 100644 index 0000000..059d5bc --- /dev/null +++ b/codebase/backend/api-backend/docker-compose.yml @@ -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: diff --git a/codebase/backend/api-backend/openapi.yaml b/codebase/backend/api-backend/openapi.yaml new file mode 100644 index 0000000..878cd68 --- /dev/null +++ b/codebase/backend/api-backend/openapi.yaml @@ -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"]} diff --git a/codebase/backend/api-backend/pyproject.toml b/codebase/backend/api-backend/pyproject.toml new file mode 100644 index 0000000..ec20781 --- /dev/null +++ b/codebase/backend/api-backend/pyproject.toml @@ -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/"] diff --git a/codebase/backend/api-backend/tests/contract/test_clients.py b/codebase/backend/api-backend/tests/contract/test_clients.py new file mode 100644 index 0000000..1e28744 --- /dev/null +++ b/codebase/backend/api-backend/tests/contract/test_clients.py @@ -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]) diff --git a/codebase/backend/api-backend/tests/contract/test_openapi.py b/codebase/backend/api-backend/tests/contract/test_openapi.py new file mode 100644 index 0000000..9b5ee49 --- /dev/null +++ b/codebase/backend/api-backend/tests/contract/test_openapi.py @@ -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": "/"}] diff --git a/codebase/backend/api-backend/tests/unit/test_cli_settings.py b/codebase/backend/api-backend/tests/unit/test_cli_settings.py new file mode 100644 index 0000000..8d03140 --- /dev/null +++ b/codebase/backend/api-backend/tests/unit/test_cli_settings.py @@ -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) diff --git a/codebase/backend/api-backend/tests/unit/test_domain.py b/codebase/backend/api-backend/tests/unit/test_domain.py new file mode 100644 index 0000000..ca873d9 --- /dev/null +++ b/codebase/backend/api-backend/tests/unit/test_domain.py @@ -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() diff --git a/codebase/backend/bitrix-local-app/Dockerfile b/codebase/backend/bitrix-local-app/Dockerfile new file mode 100644 index 0000000..899ee5a --- /dev/null +++ b/codebase/backend/bitrix-local-app/Dockerfile @@ -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"] diff --git a/codebase/backend/bitrix-local-app/alembic.ini b/codebase/backend/bitrix-local-app/alembic.ini new file mode 100644 index 0000000..9d3809d --- /dev/null +++ b/codebase/backend/bitrix-local-app/alembic.ini @@ -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 diff --git a/codebase/backend/bitrix-local-app/alembic/env.py b/codebase/backend/bitrix-local-app/alembic/env.py new file mode 100644 index 0000000..2763849 --- /dev/null +++ b/codebase/backend/bitrix-local-app/alembic/env.py @@ -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()) diff --git a/codebase/backend/bitrix-local-app/alembic/versions/0001_bitrix_local_schema.py b/codebase/backend/bitrix-local-app/alembic/versions/0001_bitrix_local_schema.py new file mode 100644 index 0000000..bfb6868 --- /dev/null +++ b/codebase/backend/bitrix-local-app/alembic/versions/0001_bitrix_local_schema.py @@ -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) diff --git a/codebase/backend/bitrix-local-app/app/__init__.py b/codebase/backend/bitrix-local-app/app/__init__.py new file mode 100644 index 0000000..2c9f095 --- /dev/null +++ b/codebase/backend/bitrix-local-app/app/__init__.py @@ -0,0 +1 @@ +"""HAN Bitrix24 Open Lines adapter.""" diff --git a/codebase/backend/bitrix-local-app/app/main.py b/codebase/backend/bitrix-local-app/app/main.py new file mode 100644 index 0000000..efc2ab6 --- /dev/null +++ b/codebase/backend/bitrix-local-app/app/main.py @@ -0,0 +1,1153 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import hmac +import json +import logging +import re +import secrets +import uuid +from contextlib import asynccontextmanager, suppress +from datetime import datetime, timedelta +from typing import Annotated, Any, Literal +from urllib.parse import urlparse + +import httpx +import uvicorn +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from fastapi import Depends, FastAPI, Header, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import HTMLResponse, JSONResponse +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy import func, or_, select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.models import ( + ConnectorSetup, + DeliveryAckOutbox, + DialogSession, + InboxEvent, + InstallRun, + OutboundMessage, + PortalInstallation, + now, +) + +logger = logging.getLogger("bitrix-local-app") + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + app_env: str = "production-like" + bitrix_database_url: str + bitrix_client_id: str + bitrix_client_secret: str + bitrix_connector_id: str = "han_mobile_app" + bitrix_connector_name: str = "HAN Mobile App" + bitrix_open_line_id: str = "8" + bitrix_expected_domain: str = "han0107.bitrix24.ru" + bitrix_public_base_url: str = "https://tohin.ru/bitrix" + bitrix_application_token: str + bitrix_internal_api_token: str = Field(min_length=16) + bitrix_api_forward_url: str + bitrix_api_forward_token: str = Field(min_length=16) + bitrix_token_encryption_key: str + bitrix_token_encryption_key_version: str = "v1" + bitrix_http_timeout_sec: float = Field(default=15, ge=1, le=60) + bitrix_http_max_concurrency: int = Field(default=2, ge=1, le=10) + bitrix_retry_max_attempts: int = Field(default=10, ge=1, le=50) + bitrix_retry_max_delay_sec: int = Field(default=300, ge=1, le=3600) + bitrix_worker_poll_sec: float = Field(default=1, ge=0.05, le=30) + + +class TokenCipher: + def __init__(self, encoded_key: str, version: str) -> None: + try: + key = base64.urlsafe_b64decode(encoded_key + "=" * (-len(encoded_key) % 4)) + except Exception as exc: + raise ValueError("BITRIX_TOKEN_ENCRYPTION_KEY must be urlsafe base64") from exc + if len(key) != 32: + raise ValueError("BITRIX_TOKEN_ENCRYPTION_KEY must decode to 32 bytes") + self.aead = AESGCM(key) + self.version = version + + @staticmethod + def aad(member_id: str, domain: str, token_type: str) -> bytes: + return f"bitrix-local:{member_id}:{domain}:{token_type}".encode() + + def encrypt(self, value: str, member_id: str, domain: str, token_type: str) -> tuple[str, str]: + nonce = secrets.token_bytes(12) + ciphertext = self.aead.encrypt( + nonce, value.encode(), self.aad(member_id, domain, token_type) + ) + return base64.b64encode(ciphertext).decode(), base64.b64encode(nonce).decode() + + def decrypt( + self, ciphertext: str, nonce: str, member_id: str, domain: str, token_type: str + ) -> str: + return self.aead.decrypt( + base64.b64decode(nonce), + base64.b64decode(ciphertext), + self.aad(member_id, domain, token_type), + ).decode() + + +class UserDto(BaseModel): + model_config = ConfigDict(extra="forbid") + id: uuid.UUID + display_name: str = Field(min_length=1, max_length=255) + + +class FileDto(BaseModel): + model_config = ConfigDict(extra="forbid") + attachment_id: uuid.UUID + name: str = Field(min_length=1, max_length=255) + mime_type: str = Field(min_length=1, max_length=255) + size_bytes: int = Field(gt=0, le=5 * 1024 * 1024) + download_url: str = Field(max_length=4096) + + +class MessageDto(BaseModel): + model_config = ConfigDict(extra="forbid") + content_kind: Literal["text", "file"] + text: str = Field(default="", max_length=10000) + files: list[FileDto] = Field(default_factory=list, max_length=1) + + @model_validator(mode="after") + def xor_content(self) -> MessageDto: + if self.content_kind == "text" and (not self.text or self.files): + raise ValueError("text message requires text and no files") + if self.content_kind == "file" and (self.text or len(self.files) != 1): + raise ValueError("file message requires exactly one file and empty text") + return self + + +class OutboundDto(BaseModel): + model_config = ConfigDict(extra="forbid") + message_id: uuid.UUID + external_chat_id: uuid.UUID + occurred_at: datetime + user: UserDto + message: MessageDto + + +def pg_url(value: str) -> str: + return value.replace("postgresql://", "postgresql+asyncpg://", 1) + + +def canonical_fingerprint(value: dict[str, Any]) -> str: + clean = json.loads(json.dumps(value, sort_keys=True, default=str)) + for file in clean.get("message", {}).get("files", []): + parsed = urlparse(file.get("download_url", "")) + file["download_url"] = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + return hashlib.sha256( + json.dumps(clean, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +def retry_delay(attempt: int, maximum: int) -> float: + return secrets.randbelow(max(1, min(2 ** max(0, attempt - 1), maximum) * 1000)) / 1000 + + +def safely_retryable(exc: Exception) -> bool: + return isinstance(exc, httpx.ConnectError) or ( + isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in {429, 502, 503, 504} + ) + + +def safe_error(request_id: str, code: str, message: str) -> dict[str, Any]: + return {"error": {"code": code, "message": message, "request_id": request_id, "details": {}}} + + +def insert_nested(target: dict[str, Any], key: str, value: Any) -> None: + parts = [part for part in re.split(r"\[|\]", key) if part] + cursor = target + for part in parts[:-1]: + cursor = cursor.setdefault(part, {}) + cursor[parts[-1]] = value + + +async def parse_callback(request: Request) -> dict[str, Any]: + content_type = request.headers.get("content-type", "").split(";")[0].lower() + if content_type == "application/json": + value = await request.json() + if not isinstance(value, dict): + raise ValueError("object expected") + return value + if content_type in {"application/x-www-form-urlencoded", "multipart/form-data"}: + result: dict[str, Any] = {} + form = await request.form() + if len(form) > 500: + raise ValueError("too many fields") + for key, value in form.multi_items(): + insert_nested(result, key, str(value)) + return result + raise ValueError("unsupported content type") + + +def validate_portal(domain: str, endpoint: str, expected: str) -> None: + parsed = urlparse(endpoint) + if domain.lower() != expected.lower(): + raise ValueError("unexpected portal") + if parsed.scheme != "https" or parsed.hostname != expected.lower(): + raise ValueError("invalid client endpoint") + + +def first(value: Any, *paths: tuple[str, ...]) -> Any: + for path in paths: + current = value + for part in path: + if isinstance(current, list) and part.isdigit(): + index = int(part) + current = current[index] if index < len(current) else None + elif isinstance(current, list): + current = current[0].get(part) if current and isinstance(current[0], dict) else None + elif isinstance(current, dict): + current = current.get(part) + else: + current = None + if current is None: + break + if current not in (None, ""): + return current + return None + + +def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None: + event = str(payload.get("event", "")).upper() + if event not in { + "ONIMCONNECTORMESSAGEADD", + "ONIMCONNECTORDIALOGSTART", + "ONIMCONNECTORDIALOGFINISH", + }: + return None + data = payload.get("data") or {} + external = first( + data, + ("MESSAGES", "0", "chat", "id"), + ("MESSAGES", "0", "chat", "external_chat_id"), + ("CHAT", "ID"), + ("external_chat_id",), + ) + try: + external_id = str(uuid.UUID(str(external))) + except (ValueError, TypeError): + raise ValueError("external_chat_id is missing or invalid") + bitrix_message_id = first( + data, ("MESSAGES", "0", "message", "id"), ("MESSAGE", "ID"), ("message_id",) + ) + if event == "ONIMCONNECTORDIALOGFINISH": + event_type = "dialog.closed" + message = None + else: + event_type = "message.new" + text_value = str( + first(data, ("MESSAGES", "0", "message", "text"), ("MESSAGE", "TEXT")) or "" + ) + files_raw = first(data, ("MESSAGES", "0", "message", "files"), ("MESSAGE", "FILES")) or [] + if isinstance(files_raw, dict): + files_raw = list(files_raw.values()) + files = [ + { + "name": str(item.get("name") or item.get("NAME") or "attachment"), + "mime_type": str( + item.get("type") or item.get("TYPE") or "application/octet-stream" + ), + "size_bytes": int(item.get("size") or item.get("SIZE") or 0), + "download_url": str(item.get("url") or item.get("URL") or ""), + } + for item in files_raw + if isinstance(item, dict) + ] + if not text_value and not files: + raise ValueError("empty message") + message = {"text": text_value, "files": files} + stable = f"{event}:{external_id}:{bitrix_message_id or ''}" + raw_event_id = first(payload, ("event_id",), ("ts",)) + event_id = str(raw_event_id) if raw_event_id else hashlib.sha256(stable.encode()).hexdigest() + return { + "event_id": event_id, + "event_type": event_type, + "external_chat_id": external_id, + "bitrix_message_id": str(bitrix_message_id) if bitrix_message_id else None, + "occurred_at": now().isoformat().replace("+00:00", "Z"), + "message": message, + } + + +class BitrixClient: + def __init__( + self, + client: httpx.AsyncClient, + settings: Settings, + cipher: TokenCipher, + sessions: async_sessionmaker[AsyncSession], + ) -> None: + self.http = client + self.settings = settings + self.cipher = cipher + self.sessions = sessions + self.limit = asyncio.Semaphore(settings.bitrix_http_max_concurrency) + self.refresh_lock = asyncio.Lock() + + async def ensure_fresh(self, portal: PortalInstallation) -> None: + if portal.expires_at and portal.expires_at > now() + timedelta(seconds=60): + return + async with self.refresh_lock: + async with self.sessions() as session: + lock_key = f"bitrix-oauth:{portal.member_id}" + await session.execute( + text("SELECT pg_advisory_lock(hashtext(:key))"), {"key": lock_key} + ) + await session.commit() + try: + current = await session.get(PortalInstallation, portal.id) + if current.expires_at and current.expires_at > now() + timedelta(seconds=60): + portal.access_ciphertext = current.access_ciphertext + portal.access_nonce = current.access_nonce + portal.expires_at = current.expires_at + await session.commit() + return + refresh = self.cipher.decrypt( + current.refresh_ciphertext, + current.refresh_nonce, + current.member_id, + current.domain, + "refresh", + ) + member_id, domain = current.member_id, current.domain + await session.commit() + # No database transaction is held during the OAuth HTTP request. + response = await self.http.post( + "https://oauth.bitrix.info/oauth/token/", + data={ + "grant_type": "refresh_token", + "client_id": self.settings.bitrix_client_id, + "client_secret": self.settings.bitrix_client_secret, + "refresh_token": refresh, + }, + follow_redirects=False, + ) + response.raise_for_status() + body = response.json() + current = await session.get(PortalInstallation, portal.id, with_for_update=True) + if body.get("error"): + current.install_status = "reauth_required" + current.last_error_code = "oauth_invalid_grant" + await session.commit() + raise RuntimeError("oauth_refresh_failed") + access_pair = self.cipher.encrypt( + body["access_token"], member_id, domain, "access" + ) + refresh_pair = self.cipher.encrypt( + body.get("refresh_token", refresh), member_id, domain, "refresh" + ) + current.access_ciphertext, current.access_nonce = access_pair + current.refresh_ciphertext, current.refresh_nonce = refresh_pair + current.expires_at = now() + timedelta(seconds=int(body.get("expires", 3600))) + current.last_refresh_at = now() + await session.commit() + portal.access_ciphertext = current.access_ciphertext + portal.access_nonce = current.access_nonce + portal.expires_at = current.expires_at + finally: + await session.execute( + text("SELECT pg_advisory_unlock(hashtext(:key))"), {"key": lock_key} + ) + await session.commit() + + async def call(self, portal: PortalInstallation, method: str, fields: dict[str, Any]) -> dict: + await self.ensure_fresh(portal) + token = self.cipher.decrypt( + portal.access_ciphertext, + portal.access_nonce, + portal.member_id, + portal.domain, + "access", + ) + url = f"https://{portal.domain}/rest/{method}.json" + async with self.limit: + response = await self.http.post( + url, data={**fields, "auth": token}, follow_redirects=False + ) + response.raise_for_status() + body = response.json() + if body.get("error"): + raise RuntimeError(str(body["error"])[:64]) + result = body.get("result", body) + return result if isinstance(result, dict) else {"value": result} + + async def setup(self, portal: PortalInstallation) -> dict[str, bool]: + s = self.settings + await self.call( + portal, + "imconnector.register", + { + "ID": s.bitrix_connector_id, + "NAME": s.bitrix_connector_name, + "PLACEMENT_HANDLER": f"{s.bitrix_public_base_url}/placement", + }, + ) + await self.call( + portal, + "imconnector.activate", + {"CONNECTOR": s.bitrix_connector_id, "LINE": s.bitrix_open_line_id, "ACTIVE": "1"}, + ) + for event in ( + "OnImConnectorMessageAdd", + "OnImConnectorDialogStart", + "OnImConnectorDialogFinish", + ): + await self.call( + portal, + "event.bind", + {"event": event, "handler": f"{s.bitrix_public_base_url}/handler"}, + ) + return {"registered": True, "activated": True, "bindings": True} + + +def create_app(settings: Settings | None = None) -> FastAPI: + cfg = settings or Settings() + cipher = TokenCipher(cfg.bitrix_token_encryption_key, cfg.bitrix_token_encryption_key_version) + + @asynccontextmanager + async def lifespan(app: FastAPI): + engine = create_async_engine( + pg_url(cfg.bitrix_database_url), + pool_size=5, + max_overflow=0, + pool_pre_ping=True, + ) + app.state.engine = engine + app.state.sessions = async_sessionmaker(engine, expire_on_commit=False) + app.state.http = httpx.AsyncClient( + timeout=httpx.Timeout(cfg.bitrix_http_timeout_sec, connect=3), + limits=httpx.Limits(max_connections=20, max_keepalive_connections=10), + ) + app.state.bitrix = BitrixClient(app.state.http, cfg, cipher, app.state.sessions) + app.state.stop = asyncio.Event() + app.state.workers = [ + asyncio.create_task(worker_loop(app, "inbox"), name="bitrix-inbox"), + asyncio.create_task(worker_loop(app, "ack"), name="bitrix-ack"), + asyncio.create_task(worker_loop(app, "outbound"), name="bitrix-outbox"), + asyncio.create_task(worker_loop(app, "setup"), name="bitrix-setup"), + ] + yield + app.state.stop.set() + for task in app.state.workers: + task.cancel() + for task in app.state.workers: + with suppress(asyncio.CancelledError): + await task + await app.state.http.aclose() + await engine.dispose() + + app = FastAPI( + title="HAN Bitrix24 Local App", + version="1.0.0", + lifespan=lifespan, + docs_url=None if cfg.app_env != "test" else "/docs", + ) + app.state.settings = cfg + app.state.cipher = cipher + + @app.middleware("http") + async def request_context(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.exception_handler(RequestValidationError) + async def validation_error(request: Request, _: RequestValidationError): + return JSONResponse( + safe_error(request.state.request_id, "validation_error", "Request is invalid"), + status_code=400, + ) + + def internal_auth( + request: Request, authorization: Annotated[str | None, Header()] = None + ) -> None: + candidate = ( + authorization[7:] if authorization and authorization.startswith("Bearer ") else "" + ) + if not hmac.compare_digest(candidate, cfg.bitrix_internal_api_token): + raise HTTPException( + 401, + safe_error( + request.state.request_id, "service_unauthorized", "Authentication failed" + ), + ) + + @app.get("/health/live") + async def live(): + return {"status": "live"} + + @app.get("/health/ready") + async def ready(request: Request): + try: + async with request.app.state.sessions() as session: + portal = await active_portal(session) + await session.scalar(select(func.now())) + worker_ok = all(not task.done() for task in request.app.state.workers) + if portal and portal.install_status == "installed" and worker_ok: + return {"status": "ready", "portal": "installed", "workers": "running"} + return JSONResponse( + {"status": "not_ready", "reason": "portal_not_installed"}, status_code=503 + ) + except Exception: + return JSONResponse( + {"status": "not_ready", "reason": "database_unavailable"}, status_code=503 + ) + + @app.get("/bitrix/install") + @app.get("/bitrix/handler") + async def callback_probe(): + return {"status": "ok"} + + @app.post("/bitrix/install") + async def install(request: Request): + try: + payload = await parse_callback(request) + return await install_payload(request.app, payload) + except ValueError: + raise HTTPException( + 400, safe_error(request.state.request_id, "validation_error", "Invalid callback") + ) + + @app.post("/bitrix/handler") + async def handler(request: Request): + try: + payload = await parse_callback(request) + event = str(payload.get("event", "")).upper() + if event == "ONAPPINSTALL": + return await install_payload(request.app, payload) + if event == "ONAPPUNINSTALL": + await uninstall_payload(request.app, payload) + return {"status": "uninstalled"} + auth = payload.get("auth") or {} + token = str(auth.get("application_token") or "") + if not hmac.compare_digest(token, cfg.bitrix_application_token): + raise HTTPException( + 403, + safe_error(request.state.request_id, "callback_forbidden", "Invalid callback"), + ) + domain = str(auth.get("domain") or "").lower() + data = payload.get("data") or {} + connector = first( + data, + ("CONNECTOR",), + ("connector",), + ("MESSAGES", "0", "connector"), + ) + line = first(data, ("LINE",), ("line",), ("MESSAGES", "0", "line")) + if ( + (domain and domain != cfg.bitrix_expected_domain.lower()) + or (connector and str(connector) != cfg.bitrix_connector_id) + or (line and str(line) != cfg.bitrix_open_line_id) + ): + raise HTTPException( + 403, + safe_error(request.state.request_id, "callback_forbidden", "Invalid callback"), + ) + member_id = str(auth.get("member_id") or "") + if member_id: + async with request.app.state.sessions() as session: + portal = await active_portal(session) + if not portal or not hmac.compare_digest(member_id, portal.member_id): + raise HTTPException( + 403, + safe_error( + request.state.request_id, + "callback_forbidden", + "Invalid callback", + ), + ) + normalized = normalize_event(payload) + if normalized is None: + return {"status": "ignored"} + created = await save_inbox(request.app, normalized) + return JSONResponse( + {"status": "accepted" if created else "duplicate"}, + status_code=202 if created else 200, + ) + except HTTPException: + raise + except (ValueError, TypeError): + raise HTTPException( + 400, safe_error(request.state.request_id, "validation_error", "Invalid callback") + ) + + @app.get("/bitrix/placement", response_class=HTMLResponse) + async def placement(): + return HTMLResponse( + "
HAN Mobile App connector is managed automatically.
", + headers={ + "Content-Security-Policy": ( + f"default-src 'none'; style-src 'unsafe-inline'; " + f"frame-ancestors https://{cfg.bitrix_expected_domain}" + ) + }, + ) + + @app.post( + "/internal/openlines/v1/messages", + dependencies=[Depends(internal_auth)], + ) + async def send_message( + dto: OutboundDto, + request: Request, + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, + ): + if idempotency_key != str(dto.message_id): + raise HTTPException( + 400, + safe_error( + request.state.request_id, + "validation_error", + "Idempotency-Key must equal message_id", + ), + ) + body = dto.model_dump(mode="json") + fp = canonical_fingerprint(body) + async with request.app.state.sessions() as session: + row = await session.scalar( + select(OutboundMessage).where(OutboundMessage.message_id == dto.message_id) + ) + if row: + if row.request_fingerprint != fp: + raise HTTPException( + 409, + safe_error( + request.state.request_id, + "idempotency_key_reused", + "Idempotency key was reused", + ), + ) + if row.status == "delivered": + return row.response_json + if row.status in {"sending", "ambiguous"}: + raise HTTPException( + 503, + safe_error( + request.state.request_id, + "delivery_in_progress", + "Delivery state requires reconciliation", + ), + ) + else: + row = OutboundMessage( + message_id=dto.message_id, + external_chat_id=dto.external_chat_id, + request_fingerprint=fp, + payload_json=body, + status="sending", + ) + session.add(row) + try: + await session.commit() + except IntegrityError: + await session.rollback() + row = await session.scalar( + select(OutboundMessage).where(OutboundMessage.message_id == dto.message_id) + ) + if not row or row.request_fingerprint != fp: + raise HTTPException( + 409, + safe_error( + request.state.request_id, + "idempotency_key_reused", + "Idempotency key was reused", + ), + ) + if row.status == "delivered": + return row.response_json + raise HTTPException( + 503, + safe_error( + request.state.request_id, + "delivery_in_progress", + "Delivery is already in progress", + ), + ) + try: + result = await deliver_outbound(request.app, row.id) + except Exception as exc: + async with request.app.state.sessions() as session: + current = await session.get(OutboundMessage, row.id, with_for_update=True) + if current: + if safely_retryable(exc): + current.status = "retry" + current.next_attempt_at = now() + timedelta( + seconds=retry_delay( + max(1, current.attempt_count), + cfg.bitrix_retry_max_delay_sec, + ) + ) + current.last_error_code = "bitrix_delivery_retry" + else: + current.status = "ambiguous" + current.last_error_code = "bitrix_delivery_ambiguous" + await session.commit() + raise HTTPException( + 503, + safe_error( + request.state.request_id, "dependency_unavailable", "Bitrix is unavailable" + ), + ) + return JSONResponse(result, status_code=201) + + @app.get( + "/internal/openlines/v1/dialogs/{external_chat_id}", + dependencies=[Depends(internal_auth)], + ) + async def dialog(external_chat_id: uuid.UUID, request: Request): + async with request.app.state.sessions() as session: + row = await session.scalar( + select(DialogSession).where( + DialogSession.external_chat_id == external_chat_id, + DialogSession.record_status == "A", + ) + ) + if not row: + raise HTTPException( + 404, safe_error(request.state.request_id, "dialog_not_found", "Dialog not found") + ) + return dialog_json(row) + + @app.get("/internal/openlines/v1/status", dependencies=[Depends(internal_auth)]) + async def status(request: Request): + async with request.app.state.sessions() as session: + portal = await active_portal(session) + inbox = await session.scalar( + select(func.count()) + .select_from(InboxEvent) + .where(InboxEvent.status.in_(["received", "retry", "forwarding"])) + ) + dead = await session.scalar( + select(func.count()) + .select_from(InboxEvent) + .where(InboxEvent.status == "dead_letter") + ) + return { + "status": "ok" if portal else "degraded", + "portal": portal.install_status if portal else "not_installed", + "setup": portal.setup_status if portal else "not_started", + "workers": {"running": all(not task.done() for task in request.app.state.workers)}, + "backlog": {"inbox": inbox or 0, "dead_letter": dead or 0}, + } + + @app.post("/internal/openlines/v1/setup/retry", dependencies=[Depends(internal_auth)]) + async def setup_retry(request: Request): + result = await reconcile_setup(request.app) + return {"status": "completed" if all(result.values()) else "partial", "steps": result} + + return app + + +async def active_portal(session: AsyncSession) -> PortalInstallation | None: + return await session.scalar( + select(PortalInstallation).where( + PortalInstallation.record_status == "A", + PortalInstallation.install_status == "installed", + ) + ) + + +async def install_payload(app: FastAPI, payload: dict[str, Any]) -> dict[str, str]: + auth = payload.get("auth") or {} + domain = str(auth.get("domain") or auth.get("DOMAIN") or "").lower() + endpoint = str(auth.get("client_endpoint") or auth.get("CLIENT_ENDPOINT") or "") + member_id = str(auth.get("member_id") or auth.get("MEMBER_ID") or "") + access = str(auth.get("access_token") or auth.get("ACCESS_TOKEN") or "") + refresh = str(auth.get("refresh_token") or auth.get("REFRESH_TOKEN") or "") + application = str( + auth.get("application_token") + or auth.get("APPLICATION_TOKEN") + or app.state.settings.bitrix_application_token + ) + if not all((member_id, access, refresh, application)): + raise ValueError("missing auth") + validate_portal(domain, endpoint, app.state.settings.bitrix_expected_domain) + cipher: TokenCipher = app.state.cipher + encrypted = [ + cipher.encrypt(value, member_id, domain, token_type) + for value, token_type in ( + (access, "access"), + (refresh, "refresh"), + (application, "application"), + ) + ] + expires = int(auth.get("expires") or auth.get("EXPIRES") or 3600) + async with app.state.sessions() as session: + portal = await session.scalar( + select(PortalInstallation).where(PortalInstallation.member_id == member_id) + ) + values = { + "domain": domain, + "client_endpoint": endpoint, + "access_ciphertext": encrypted[0][0], + "access_nonce": encrypted[0][1], + "refresh_ciphertext": encrypted[1][0], + "refresh_nonce": encrypted[1][1], + "application_ciphertext": encrypted[2][0], + "application_nonce": encrypted[2][1], + "key_version": cipher.version, + "expires_at": now() + timedelta(seconds=expires), + "scope": str(auth.get("scope") or ""), + "install_status": "installed", + "setup_status": "pending", + "record_status": "A", + } + if portal: + for key, value in values.items(): + setattr(portal, key, value) + else: + portal = PortalInstallation(member_id=member_id, **values) + session.add(portal) + await session.flush() + session.add(InstallRun(portal_id=portal.id, status="tokens_saved", result_json={})) + await session.commit() + try: + result = await reconcile_setup(app) + return {"status": "installed" if all(result.values()) else "installed_with_errors"} + except Exception: + return {"status": "installed_with_errors"} + + +async def uninstall_payload(app: FastAPI, payload: dict[str, Any]) -> None: + auth = payload.get("auth") or {} + member_id = str(auth.get("member_id") or auth.get("MEMBER_ID") or "") + async with app.state.sessions() as session: + portal = await session.scalar( + select(PortalInstallation).where(PortalInstallation.member_id == member_id) + ) + if portal: + portal.install_status = "uninstalled" + portal.record_status = "D" + portal.status_changed_at = now() + portal.status_change_reason = "ONAPPUNINSTALL" + await session.commit() + + +async def save_inbox(app: FastAPI, normalized: dict[str, Any]) -> bool: + fingerprint = hashlib.sha256( + json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + row = InboxEvent( + event_id=normalized["event_id"], + event_type=normalized["event_type"], + external_chat_id=uuid.UUID(normalized["external_chat_id"]), + bitrix_message_id=normalized["bitrix_message_id"], + payload_fingerprint=fingerprint, + normalized_json=normalized, + ) + async with app.state.sessions() as session: + session.add(row) + try: + await session.commit() + return True + except IntegrityError: + await session.rollback() + return False + + +def extract_delivery(result: dict[str, Any]) -> tuple[int | None, str | None, str | None]: + chat = first(result, ("CHAT_ID",), ("chat", "id"), ("DATA", "CHAT_ID")) + session_id = first(result, ("ID",), ("SESSION_ID",), ("session", "id")) + message_id = first(result, ("MESSAGE_ID",), ("message", "id")) + return ( + int(chat) if chat not in (None, "") else None, + str(session_id) if session_id else None, + str(message_id) if message_id else None, + ) + + +async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]: + async with app.state.sessions() as session: + row = await session.get(OutboundMessage, row_id) + portal = await active_portal(session) + if not row or not portal: + raise RuntimeError("portal_not_installed") + payload = row.payload_json + message = payload["message"] + fields: dict[str, Any] = { + "CONNECTOR": app.state.settings.bitrix_connector_id, + "LINE": app.state.settings.bitrix_open_line_id, + "MESSAGES[0][user][id]": payload["user"]["id"], + "MESSAGES[0][user][name]": payload["user"]["display_name"], + "MESSAGES[0][message][id]": payload["message_id"], + "MESSAGES[0][message][date]": payload["occurred_at"], + "MESSAGES[0][message][text]": message["text"], + "MESSAGES[0][chat][id]": payload["external_chat_id"], + } + if message["files"]: + file = message["files"][0] + fields["MESSAGES[0][message][files][0][url]"] = file["download_url"] + fields["MESSAGES[0][message][files][0][name]"] = file["name"] + result = await app.state.bitrix.call(portal, "imconnector.send.messages", fields) + chat_id, session_id, bitrix_message_id = extract_delivery(result) + response = { + "status": "delivered", + "message_id": payload["message_id"], + "external_chat_id": payload["external_chat_id"], + "bitrix_message_id": bitrix_message_id, + "dialog_session": {"bitrix_chat_id": chat_id, "session_id": session_id}, + } + async with app.state.sessions() as session: + current = await session.get(OutboundMessage, row_id, with_for_update=True) + current.status = "delivered" + current.bitrix_message_id = bitrix_message_id + current.response_json = response + mapping = await session.scalar( + select(DialogSession).where( + DialogSession.external_chat_id == uuid.UUID(payload["external_chat_id"]), + DialogSession.record_status == "A", + ) + ) + if mapping: + mapping.bitrix_chat_id = chat_id + mapping.session_id = session_id + mapping.status = "open" + else: + session.add( + DialogSession( + external_chat_id=uuid.UUID(payload["external_chat_id"]), + bitrix_chat_id=chat_id, + session_id=session_id, + portal_id=portal.id, + status="open", + ) + ) + await session.commit() + return response + + +def dialog_json(row: DialogSession) -> dict[str, Any]: + return { + "external_chat_id": str(row.external_chat_id), + "bitrix_chat_id": row.bitrix_chat_id, + "session_id": row.session_id, + "status": row.status, + "updated_at": row.updated_at.isoformat().replace("+00:00", "Z"), + } + + +async def reconcile_setup(app: FastAPI) -> dict[str, bool]: + async with app.state.sessions() as session: + portal = await active_portal(session) + if not portal: + return {"registered": False, "activated": False, "bindings": False} + try: + result = await app.state.bitrix.setup(portal) + except Exception: + result = {"registered": False, "activated": False, "bindings": False} + async with app.state.sessions() as session: + setup = await session.scalar( + select(ConnectorSetup).where( + ConnectorSetup.portal_id == portal.id, + ConnectorSetup.connector_id == app.state.settings.bitrix_connector_id, + ConnectorSetup.line_id == app.state.settings.bitrix_open_line_id, + ) + ) + if not setup: + setup = ConnectorSetup( + portal_id=portal.id, + connector_id=app.state.settings.bitrix_connector_id, + line_id=app.state.settings.bitrix_open_line_id, + ) + session.add(setup) + setup.registered = result["registered"] + setup.activated = result["activated"] + setup.bindings_json = {"complete": result["bindings"]} + setup.observed_at = now() + setup.attempt_count += 1 + setup.last_error_code = None if all(result.values()) else "connector_setup_failed" + setup.next_retry_at = ( + None + if all(result.values()) + else now() + + timedelta( + seconds=retry_delay( + setup.attempt_count, app.state.settings.bitrix_retry_max_delay_sec + ) + ) + ) + current_portal = await session.get(PortalInstallation, portal.id) + if all(result.values()): + current_portal.setup_status = "ready" + elif setup.attempt_count >= app.state.settings.bitrix_retry_max_attempts: + current_portal.setup_status = "dead_letter" + else: + current_portal.setup_status = "retry" + await session.commit() + return result + + +async def claim_one(session: AsyncSession, model, statuses: list[str]): + row = await session.scalar( + select(model) + .where( + model.status.in_(statuses), + model.next_attempt_at <= now(), + or_(model.lease_until.is_(None), model.lease_until < now()), + ) + .order_by(model.next_attempt_at) + .with_for_update(skip_locked=True) + .limit(1) + ) + if row: + row.lease_until = now() + timedelta(seconds=30) + row.attempt_count += 1 + await session.commit() + return row + + +async def worker_loop(app: FastAPI, kind: str) -> None: + while not app.state.stop.is_set(): + try: + if kind == "inbox": + await process_inbox(app) + elif kind == "ack": + await process_ack(app) + elif kind == "outbound": + await process_outbound(app) + else: + await process_setup(app) + except Exception: + logger.exception("worker iteration failed", extra={"worker_kind": kind}) + try: + await asyncio.wait_for(app.state.stop.wait(), app.state.settings.bitrix_worker_poll_sec) + except TimeoutError: + continue + + +async def process_inbox(app: FastAPI) -> None: + async with app.state.sessions() as session: + row = await claim_one(session, InboxEvent, ["received", "retry"]) + if not row: + return + try: + response = await app.state.http.post( + app.state.settings.bitrix_api_forward_url, + json=row.normalized_json, + headers={ + "Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}", + "X-Request-ID": str(uuid.uuid4()), + }, + follow_redirects=False, + ) + duplicate = response.status_code in {200, 204} + if response.status_code != 201 and not duplicate: + response.raise_for_status() + async with app.state.sessions() as session: + current = await session.get(InboxEvent, row.id, with_for_update=True) + current.status = "ack_pending" + current.api_ack_status = "duplicate" if duplicate else "created" + current.lease_until = None + session.add( + DeliveryAckOutbox( + inbox_event_id=current.id, + payload_json={ + "external_chat_id": str(current.external_chat_id), + "bitrix_message_id": current.bitrix_message_id, + }, + ) + ) + await session.commit() + except Exception: + await mark_retry(app, InboxEvent, row.id, "api_forward_failed") + + +async def process_ack(app: FastAPI) -> None: + async with app.state.sessions() as session: + row = await claim_one(session, DeliveryAckOutbox, ["pending", "retry"]) + portal = await active_portal(session) + if not row or not portal: + return + try: + await app.state.bitrix.call( + portal, + "imconnector.send.status.delivery", + { + "CONNECTOR": app.state.settings.bitrix_connector_id, + "LINE": app.state.settings.bitrix_open_line_id, + "MESSAGES[0][im][chat_id]": row.payload_json["external_chat_id"], + "MESSAGES[0][message][id]": row.payload_json["bitrix_message_id"] or "", + }, + ) + async with app.state.sessions() as session: + current = await session.get(DeliveryAckOutbox, row.id, with_for_update=True) + event = await session.get(InboxEvent, current.inbox_event_id, with_for_update=True) + current.status = "completed" + current.lease_until = None + event.status = "completed" + event.delivery_ack_status = "sent" + await session.commit() + except Exception: + await mark_retry(app, DeliveryAckOutbox, row.id, "delivery_ack_failed") + + +async def process_outbound(app: FastAPI) -> None: + async with app.state.sessions() as session: + row = await claim_one(session, OutboundMessage, ["retry"]) + if not row: + return + try: + await deliver_outbound(app, row.id) + except Exception: + await mark_retry(app, OutboundMessage, row.id, "bitrix_delivery_failed") + + +async def process_setup(app: FastAPI) -> None: + async with app.state.sessions() as session: + portal = await active_portal(session) + setup = ( + await session.scalar( + select(ConnectorSetup).where( + ConnectorSetup.portal_id == portal.id, + ConnectorSetup.connector_id == app.state.settings.bitrix_connector_id, + ConnectorSetup.line_id == app.state.settings.bitrix_open_line_id, + ) + ) + if portal + else None + ) + due = setup is None or setup.next_retry_at is None or setup.next_retry_at <= now() + if portal and portal.setup_status in {"pending", "retry"} and due: + await reconcile_setup(app) + + +async def mark_retry(app: FastAPI, model, row_id: uuid.UUID, error_code: str) -> None: + async with app.state.sessions() as session: + row = await session.get(model, row_id, with_for_update=True) + if not row: + return + row.last_error_code = error_code + row.lease_until = None + if row.attempt_count >= app.state.settings.bitrix_retry_max_attempts: + row.status = "dead_letter" + else: + row.status = "retry" + row.next_attempt_at = now() + timedelta( + seconds=retry_delay( + row.attempt_count, app.state.settings.bitrix_retry_max_delay_sec + ) + ) + await session.commit() + + +app = create_app() + + +def run() -> None: + uvicorn.run("app.main:app", host="0.0.0.0", port=8080) diff --git a/codebase/backend/bitrix-local-app/app/models.py b/codebase/backend/bitrix-local-app/app/models.py new file mode 100644 index 0000000..b62e55d --- /dev/null +++ b/codebase/backend/bitrix-local-app/app/models.py @@ -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) diff --git a/codebase/backend/bitrix-local-app/openapi.yaml b/codebase/backend/bitrix-local-app/openapi.yaml new file mode 100644 index 0000000..2d93dee --- /dev/null +++ b/codebase/backend/bitrix-local-app/openapi.yaml @@ -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"} diff --git a/codebase/backend/bitrix-local-app/pyproject.toml b/codebase/backend/bitrix-local-app/pyproject.toml new file mode 100644 index 0000000..a1550d8 --- /dev/null +++ b/codebase/backend/bitrix-local-app/pyproject.toml @@ -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 diff --git a/codebase/backend/bitrix-local-app/tests/test_core.py b/codebase/backend/bitrix-local-app/tests/test_core.py new file mode 100644 index 0000000..0ad0512 --- /dev/null +++ b/codebase/backend/bitrix-local-app/tests/test_core.py @@ -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 diff --git a/codebase/backend/bitrix-sync/Dockerfile b/codebase/backend/bitrix-sync/Dockerfile new file mode 100644 index 0000000..899ee5a --- /dev/null +++ b/codebase/backend/bitrix-sync/Dockerfile @@ -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"] diff --git a/codebase/backend/bitrix-sync/alembic.ini b/codebase/backend/bitrix-sync/alembic.ini new file mode 100644 index 0000000..9d3809d --- /dev/null +++ b/codebase/backend/bitrix-sync/alembic.ini @@ -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 diff --git a/codebase/backend/bitrix-sync/alembic/env.py b/codebase/backend/bitrix-sync/alembic/env.py new file mode 100644 index 0000000..16b454e --- /dev/null +++ b/codebase/backend/bitrix-sync/alembic/env.py @@ -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()) diff --git a/codebase/backend/bitrix-sync/alembic/versions/0001_connectivity_stub_baseline.py b/codebase/backend/bitrix-sync/alembic/versions/0001_connectivity_stub_baseline.py new file mode 100644 index 0000000..1362468 --- /dev/null +++ b/codebase/backend/bitrix-sync/alembic/versions/0001_connectivity_stub_baseline.py @@ -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 diff --git a/codebase/backend/bitrix-sync/app/__init__.py b/codebase/backend/bitrix-sync/app/__init__.py new file mode 100644 index 0000000..7e2076d --- /dev/null +++ b/codebase/backend/bitrix-sync/app/__init__.py @@ -0,0 +1 @@ +"""HAN bitrix-sync DB connectivity stub.""" diff --git a/codebase/backend/bitrix-sync/app/main.py b/codebase/backend/bitrix-sync/app/main.py new file mode 100644 index 0000000..890407b --- /dev/null +++ b/codebase/backend/bitrix-sync/app/main.py @@ -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) diff --git a/codebase/backend/bitrix-sync/openapi.yaml b/codebase/backend/bitrix-sync/openapi.yaml new file mode 100644 index 0000000..6f97f58 --- /dev/null +++ b/codebase/backend/bitrix-sync/openapi.yaml @@ -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"]} diff --git a/codebase/backend/bitrix-sync/pyproject.toml b/codebase/backend/bitrix-sync/pyproject.toml new file mode 100644 index 0000000..a8a42a5 --- /dev/null +++ b/codebase/backend/bitrix-sync/pyproject.toml @@ -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 diff --git a/codebase/backend/bitrix-sync/tests/test_service.py b/codebase/backend/bitrix-sync/tests/test_service.py new file mode 100644 index 0000000..a0f6009 --- /dev/null +++ b/codebase/backend/bitrix-sync/tests/test_service.py @@ -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 diff --git a/codebase/backend/deployment/RUNBOOK.md b/codebase/backend/deployment/RUNBOOK.md new file mode 100644 index 0000000..682a403 --- /dev/null +++ b/codebase/backend/deployment/RUNBOOK.md @@ -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. diff --git a/codebase/backend/deployment/app-settings.production-like.yaml b/codebase/backend/deployment/app-settings.production-like.yaml new file mode 100644 index 0000000..bc694ad --- /dev/null +++ b/codebase/backend/deployment/app-settings.production-like.yaml @@ -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} diff --git a/codebase/backend/deployment/docker-compose.jobs.yml b/codebase/backend/deployment/docker-compose.jobs.yml new file mode 100644 index 0000000..805c9bb --- /dev/null +++ b/codebase/backend/deployment/docker-compose.jobs.yml @@ -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"] diff --git a/codebase/backend/deployment/scripts/backup.sh b/codebase/backend/deployment/scripts/backup.sh new file mode 100644 index 0000000..5aba26a --- /dev/null +++ b/codebase/backend/deployment/scripts/backup.sh @@ -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." diff --git a/codebase/backend/deployment/scripts/migrate.sh b/codebase/backend/deployment/scripts/migrate.sh new file mode 100644 index 0000000..ab37e40 --- /dev/null +++ b/codebase/backend/deployment/scripts/migrate.sh @@ -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." diff --git a/codebase/backend/deployment/scripts/rollback.sh b/codebase/backend/deployment/scripts/rollback.sh new file mode 100644 index 0000000..05cc645 --- /dev/null +++ b/codebase/backend/deployment/scripts/rollback.sh @@ -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." diff --git a/codebase/backend/deployment/scripts/seed.sh b/codebase/backend/deployment/scripts/seed.sh new file mode 100644 index 0000000..8e3c4d7 --- /dev/null +++ b/codebase/backend/deployment/scripts/seed.sh @@ -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." diff --git a/codebase/backend/deployment/scripts/smoke.sh b/codebase/backend/deployment/scripts/smoke.sh new file mode 100644 index 0000000..119f10c --- /dev/null +++ b/codebase/backend/deployment/scripts/smoke.sh @@ -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 \ + | openssl x509 -noout -checkend 604800 +echo "Public edge smoke passed." diff --git a/codebase/backend/deployment/scripts/ssl-renew.sh b/codebase/backend/deployment/scripts/ssl-renew.sh new file mode 100644 index 0000000..fe22832 --- /dev/null +++ b/codebase/backend/deployment/scripts/ssl-renew.sh @@ -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)\"}" diff --git a/codebase/backend/docker-compose.yml b/codebase/backend/docker-compose.yml new file mode 100644 index 0000000..440addc --- /dev/null +++ b/codebase/backend/docker-compose.yml @@ -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: diff --git a/codebase/backend/frontend-test-site/.dockerignore b/codebase/backend/frontend-test-site/.dockerignore new file mode 100644 index 0000000..9d1c846 --- /dev/null +++ b/codebase/backend/frontend-test-site/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.expo +.git +.env* +playwright-report +test-results diff --git a/codebase/backend/frontend-test-site/.env.example b/codebase/backend/frontend-test-site/.env.example new file mode 100644 index 0000000..6732a88 --- /dev/null +++ b/codebase/backend/frontend-test-site/.env.example @@ -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-код запрещены. diff --git a/codebase/backend/frontend-test-site/.gitignore b/codebase/backend/frontend-test-site/.gitignore new file mode 100644 index 0000000..5cde369 --- /dev/null +++ b/codebase/backend/frontend-test-site/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.expo/ +playwright-report/ +test-results/ +.env +.env.local +*.log diff --git a/codebase/backend/frontend-test-site/Dockerfile b/codebase/backend/frontend-test-site/Dockerfile new file mode 100644 index 0000000..bd24e0f --- /dev/null +++ b/codebase/backend/frontend-test-site/Dockerfile @@ -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/"] diff --git a/codebase/backend/frontend-test-site/README.md b/codebase/backend/frontend-test-site/README.md new file mode 100644 index 0000000..147eed4 --- /dev/null +++ b/codebase/backend/frontend-test-site/README.md @@ -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.