From 8c7b4074c4b1d8adee376be1df03eb3ab1a2e26c Mon Sep 17 00:00:00 2001 From: mi Date: Fri, 10 Jul 2026 18:06:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B2=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HAN_chat_specification.rar | Bin 53652 -> 0 bytes codebase/.gitignore | 33 + codebase/README.md | 7 + codebase/backend/.env.example | 132 ++ codebase/backend/.gitattributes | 7 + codebase/backend/.gitignore | 10 + codebase/backend/api-backend/.dockerignore | 8 + codebase/backend/api-backend/Dockerfile | 19 + codebase/backend/api-backend/README.md | 68 + codebase/backend/api-backend/alembic.ini | 37 + codebase/backend/api-backend/alembic/env.py | 52 + .../alembic/versions/0001_initial_han_app.py | 153 +++ codebase/backend/api-backend/app/__init__.py | 1 + codebase/backend/api-backend/app/auth.py | 106 ++ .../backend/api-backend/app/cli/__init__.py | 1 + .../api-backend/app/cli/seed_settings.py | 115 ++ .../api-backend/app/cli/validate_settings.py | 40 + codebase/backend/api-backend/app/db.py | 358 +++++ .../backend/api-backend/app/integrations.py | 338 +++++ codebase/backend/api-backend/app/main.py | 1020 +++++++++++++++ codebase/backend/api-backend/app/realtime.py | 65 + codebase/backend/api-backend/app/schemas.py | 148 +++ codebase/backend/api-backend/app/services.py | 930 +++++++++++++ codebase/backend/api-backend/app/settings.py | 78 ++ codebase/backend/api-backend/app/workers.py | 234 ++++ .../backend/api-backend/docker-compose.yml | 56 + codebase/backend/api-backend/openapi.yaml | 314 +++++ codebase/backend/api-backend/pyproject.toml | 67 + .../tests/contract/test_clients.py | 155 +++ .../tests/contract/test_openapi.py | 58 + .../tests/unit/test_cli_settings.py | 26 + .../api-backend/tests/unit/test_domain.py | 80 ++ codebase/backend/bitrix-local-app/Dockerfile | 12 + codebase/backend/bitrix-local-app/alembic.ini | 30 + .../backend/bitrix-local-app/alembic/env.py | 42 + .../versions/0001_bitrix_local_schema.py | 20 + .../backend/bitrix-local-app/app/__init__.py | 1 + codebase/backend/bitrix-local-app/app/main.py | 1153 +++++++++++++++++ .../backend/bitrix-local-app/app/models.py | 188 +++ .../backend/bitrix-local-app/openapi.yaml | 104 ++ .../backend/bitrix-local-app/pyproject.toml | 33 + .../bitrix-local-app/tests/test_core.py | 70 + codebase/backend/bitrix-sync/Dockerfile | 12 + codebase/backend/bitrix-sync/alembic.ini | 30 + codebase/backend/bitrix-sync/alembic/env.py | 34 + .../0001_connectivity_stub_baseline.py | 15 + codebase/backend/bitrix-sync/app/__init__.py | 1 + codebase/backend/bitrix-sync/app/main.py | 319 +++++ codebase/backend/bitrix-sync/openapi.yaml | 46 + codebase/backend/bitrix-sync/pyproject.toml | 30 + .../backend/bitrix-sync/tests/test_service.py | 65 + codebase/backend/deployment/RUNBOOK.md | 207 +++ .../app-settings.production-like.yaml | 31 + .../deployment/docker-compose.jobs.yml | 72 + codebase/backend/deployment/scripts/backup.sh | 24 + .../backend/deployment/scripts/migrate.sh | 18 + .../backend/deployment/scripts/rollback.sh | 21 + codebase/backend/deployment/scripts/seed.sh | 7 + codebase/backend/deployment/scripts/smoke.sh | 30 + .../backend/deployment/scripts/ssl-renew.sh | 13 + codebase/backend/docker-compose.yml | 26 + .../backend/frontend-test-site/.dockerignore | 7 + .../backend/frontend-test-site/.env.example | 7 + .../backend/frontend-test-site/.gitignore | 8 + .../backend/frontend-test-site/Dockerfile | 23 + codebase/backend/frontend-test-site/README.md | 31 + .../backend/frontend-test-site/app.config.ts | 15 + .../frontend-test-site/app/_layout.tsx | 16 + .../frontend-test-site/app/auth/callback.tsx | 40 + .../frontend-test-site/app/diagnostics.tsx | 46 + .../app/dialogs/[dialogId].tsx | 134 ++ .../frontend-test-site/app/dialogs/index.tsx | 46 + .../backend/frontend-test-site/app/index.tsx | 127 ++ .../frontend-test-site/app/profile.tsx | 63 + .../backend/frontend-test-site/expo-env.d.ts | 1 + .../backend/frontend-test-site/package.json | 48 + .../frontend-test-site/playwright.config.ts | 20 + .../backend/frontend-test-site/src/api.ts | 93 ++ .../frontend-test-site/src/app-context.tsx | 121 ++ .../backend/frontend-test-site/src/auth.ts | 194 +++ .../backend/frontend-test-site/src/config.ts | 13 + .../frontend-test-site/src/realtime.ts | 145 +++ .../frontend-test-site/src/reconcile.ts | 7 + .../frontend-test-site/src/services.ts | 108 ++ .../backend/frontend-test-site/src/session.ts | 10 + .../frontend-test-site/src/single-flight.ts | 11 + .../backend/frontend-test-site/src/types.ts | 76 ++ .../backend/frontend-test-site/src/ui.tsx | 81 ++ .../tests/e2e/smoke.spec.ts | 49 + .../tests/unit/core.test.ts | 64 + .../backend/frontend-test-site/tsconfig.json | 14 + .../frontend-test-site/vitest.config.ts | 11 + .../backend/infra/compose/application.yml | 229 ++++ codebase/backend/keycloak/.dockerignore | 7 + codebase/backend/keycloak/.env.example | 17 + codebase/backend/keycloak/Dockerfile | 25 + codebase/backend/keycloak/README.md | 88 ++ codebase/backend/keycloak/docker-compose.yml | 61 + codebase/backend/keycloak/pom.xml | 102 ++ .../keycloak/realm/han-chat-realm.json | 205 +++ .../java/ru/han/chat/keycloak/Config.java | 54 + .../java/ru/han/chat/keycloak/Crypto.java | 35 + .../java/ru/han/chat/keycloak/OtpStore.java | 110 ++ .../keycloak/PhoneIdentityAuthenticator.java | 53 + .../PhoneIdentityAuthenticatorFactory.java | 31 + .../ru/han/chat/keycloak/PhoneNormalizer.java | 38 + .../chat/keycloak/PhoneOtpAuthenticator.java | 69 + .../PhoneOtpAuthenticatorFactory.java | 35 + .../ru/han/chat/keycloak/SettingsBridge.java | 81 ++ .../keycloak/entity/OtpChallengeEntity.java | 26 + .../entity/OtpSecurityEventEntity.java | 19 + .../keycloak/entity/OtpSendCounterEntity.java | 19 + .../persistence/HanJpaEntityProvider.java | 27 + .../HanJpaEntityProviderFactory.java | 17 + .../resources/META-INF/han-otp-changelog.xml | 53 + ...ycloak.authentication.AuthenticatorFactory | 2 + ...pa.entityprovider.JpaEntityProviderFactory | 1 + .../java/ru/han/chat/keycloak/CryptoTest.java | 22 + .../chat/keycloak/PhoneNormalizerTest.java | 34 + .../han/chat/keycloak/RealmContractTest.java | 47 + .../login/messages/messages_ru.properties | 13 + .../keycloak/themes/han-phone/login/otp.ftl | 16 + .../keycloak/themes/han-phone/login/phone.ftl | 16 + .../login/resources/css/han-login.css | 18 + .../themes/han-phone/login/theme.properties | 4 + codebase/backend/message-safety/Dockerfile | 11 + .../backend/message-safety/app/__init__.py | 1 + codebase/backend/message-safety/app/main.py | 334 +++++ codebase/backend/message-safety/openapi.yaml | 58 + .../backend/message-safety/pyproject.toml | 28 + .../message-safety/tests/test_service.py | 103 ++ codebase/backend/nginx/Dockerfile | 14 + codebase/backend/nginx/docker-compose.yml | 71 + codebase/backend/nginx/nginx.conf.template | 65 + codebase/backend/nginx/scripts/entrypoint.sh | 56 + .../backend/nginx/snippets/proxy-common.conf | 16 + .../nginx/snippets/security-headers.conf | 5 + .../templates/frontend-dev.conf.template | 8 + .../templates/frontend-static.conf.template | 20 + .../templates/security-headers.conf.template | 7 + .../templates/site-bootstrap.conf.template | 16 + .../nginx/templates/site-tls.conf.template | 137 ++ .../backend/observability/docker-compose.yml | 63 + .../observability/otel-collector.local.yaml | 45 + .../backend/observability/otel-collector.yaml | 90 ++ codebase/backend/redis/Dockerfile | 11 + codebase/backend/redis/docker-compose.yml | 34 + codebase/backend/redis/redis.conf | 32 + codebase/backend/redis/scripts/entrypoint.sh | 19 + codebase/backend/redis/users.acl.template | 4 + codebase/backend/scripts/validate-env | 136 ++ codebase/backend/tests/test_config.py | 141 ++ .../module-01-api-backend.md | 0 .../module-02-frontend-test-site.md | 0 {architectory => modules}/module-03-nginx.md | 0 {architectory => modules}/module-04-redis.md | 0 .../module-05-message-safety.md | 0 .../module-06-bitrix-local-app.md | 0 .../module-07-bitrix-sync.md | 0 .../module-08-keycloak.md | 0 .../module-09-observability.md | 0 .../module-10-deployment-runbook.md | 96 +- 162 files changed, 12178 insertions(+), 16 deletions(-) delete mode 100644 HAN_chat_specification.rar create mode 100644 codebase/.gitignore create mode 100644 codebase/README.md create mode 100644 codebase/backend/.env.example create mode 100644 codebase/backend/.gitattributes create mode 100644 codebase/backend/.gitignore create mode 100644 codebase/backend/api-backend/.dockerignore create mode 100644 codebase/backend/api-backend/Dockerfile create mode 100644 codebase/backend/api-backend/README.md create mode 100644 codebase/backend/api-backend/alembic.ini create mode 100644 codebase/backend/api-backend/alembic/env.py create mode 100644 codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py create mode 100644 codebase/backend/api-backend/app/__init__.py create mode 100644 codebase/backend/api-backend/app/auth.py create mode 100644 codebase/backend/api-backend/app/cli/__init__.py create mode 100644 codebase/backend/api-backend/app/cli/seed_settings.py create mode 100644 codebase/backend/api-backend/app/cli/validate_settings.py create mode 100644 codebase/backend/api-backend/app/db.py create mode 100644 codebase/backend/api-backend/app/integrations.py create mode 100644 codebase/backend/api-backend/app/main.py create mode 100644 codebase/backend/api-backend/app/realtime.py create mode 100644 codebase/backend/api-backend/app/schemas.py create mode 100644 codebase/backend/api-backend/app/services.py create mode 100644 codebase/backend/api-backend/app/settings.py create mode 100644 codebase/backend/api-backend/app/workers.py create mode 100644 codebase/backend/api-backend/docker-compose.yml create mode 100644 codebase/backend/api-backend/openapi.yaml create mode 100644 codebase/backend/api-backend/pyproject.toml create mode 100644 codebase/backend/api-backend/tests/contract/test_clients.py create mode 100644 codebase/backend/api-backend/tests/contract/test_openapi.py create mode 100644 codebase/backend/api-backend/tests/unit/test_cli_settings.py create mode 100644 codebase/backend/api-backend/tests/unit/test_domain.py create mode 100644 codebase/backend/bitrix-local-app/Dockerfile create mode 100644 codebase/backend/bitrix-local-app/alembic.ini create mode 100644 codebase/backend/bitrix-local-app/alembic/env.py create mode 100644 codebase/backend/bitrix-local-app/alembic/versions/0001_bitrix_local_schema.py create mode 100644 codebase/backend/bitrix-local-app/app/__init__.py create mode 100644 codebase/backend/bitrix-local-app/app/main.py create mode 100644 codebase/backend/bitrix-local-app/app/models.py create mode 100644 codebase/backend/bitrix-local-app/openapi.yaml create mode 100644 codebase/backend/bitrix-local-app/pyproject.toml create mode 100644 codebase/backend/bitrix-local-app/tests/test_core.py create mode 100644 codebase/backend/bitrix-sync/Dockerfile create mode 100644 codebase/backend/bitrix-sync/alembic.ini create mode 100644 codebase/backend/bitrix-sync/alembic/env.py create mode 100644 codebase/backend/bitrix-sync/alembic/versions/0001_connectivity_stub_baseline.py create mode 100644 codebase/backend/bitrix-sync/app/__init__.py create mode 100644 codebase/backend/bitrix-sync/app/main.py create mode 100644 codebase/backend/bitrix-sync/openapi.yaml create mode 100644 codebase/backend/bitrix-sync/pyproject.toml create mode 100644 codebase/backend/bitrix-sync/tests/test_service.py create mode 100644 codebase/backend/deployment/RUNBOOK.md create mode 100644 codebase/backend/deployment/app-settings.production-like.yaml create mode 100644 codebase/backend/deployment/docker-compose.jobs.yml create mode 100644 codebase/backend/deployment/scripts/backup.sh create mode 100644 codebase/backend/deployment/scripts/migrate.sh create mode 100644 codebase/backend/deployment/scripts/rollback.sh create mode 100644 codebase/backend/deployment/scripts/seed.sh create mode 100644 codebase/backend/deployment/scripts/smoke.sh create mode 100644 codebase/backend/deployment/scripts/ssl-renew.sh create mode 100644 codebase/backend/docker-compose.yml create mode 100644 codebase/backend/frontend-test-site/.dockerignore create mode 100644 codebase/backend/frontend-test-site/.env.example create mode 100644 codebase/backend/frontend-test-site/.gitignore create mode 100644 codebase/backend/frontend-test-site/Dockerfile create mode 100644 codebase/backend/frontend-test-site/README.md create mode 100644 codebase/backend/frontend-test-site/app.config.ts create mode 100644 codebase/backend/frontend-test-site/app/_layout.tsx create mode 100644 codebase/backend/frontend-test-site/app/auth/callback.tsx create mode 100644 codebase/backend/frontend-test-site/app/diagnostics.tsx create mode 100644 codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx create mode 100644 codebase/backend/frontend-test-site/app/dialogs/index.tsx create mode 100644 codebase/backend/frontend-test-site/app/index.tsx create mode 100644 codebase/backend/frontend-test-site/app/profile.tsx create mode 100644 codebase/backend/frontend-test-site/expo-env.d.ts create mode 100644 codebase/backend/frontend-test-site/package.json create mode 100644 codebase/backend/frontend-test-site/playwright.config.ts create mode 100644 codebase/backend/frontend-test-site/src/api.ts create mode 100644 codebase/backend/frontend-test-site/src/app-context.tsx create mode 100644 codebase/backend/frontend-test-site/src/auth.ts create mode 100644 codebase/backend/frontend-test-site/src/config.ts create mode 100644 codebase/backend/frontend-test-site/src/realtime.ts create mode 100644 codebase/backend/frontend-test-site/src/reconcile.ts create mode 100644 codebase/backend/frontend-test-site/src/services.ts create mode 100644 codebase/backend/frontend-test-site/src/session.ts create mode 100644 codebase/backend/frontend-test-site/src/single-flight.ts create mode 100644 codebase/backend/frontend-test-site/src/types.ts create mode 100644 codebase/backend/frontend-test-site/src/ui.tsx create mode 100644 codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts create mode 100644 codebase/backend/frontend-test-site/tests/unit/core.test.ts create mode 100644 codebase/backend/frontend-test-site/tsconfig.json create mode 100644 codebase/backend/frontend-test-site/vitest.config.ts create mode 100644 codebase/backend/infra/compose/application.yml create mode 100644 codebase/backend/keycloak/.dockerignore create mode 100644 codebase/backend/keycloak/.env.example create mode 100644 codebase/backend/keycloak/Dockerfile create mode 100644 codebase/backend/keycloak/README.md create mode 100644 codebase/backend/keycloak/docker-compose.yml create mode 100644 codebase/backend/keycloak/pom.xml create mode 100644 codebase/backend/keycloak/realm/han-chat-realm.json create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Config.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/Crypto.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneIdentityAuthenticator.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneIdentityAuthenticatorFactory.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneNormalizer.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticatorFactory.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/SettingsBridge.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpChallengeEntity.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpSecurityEventEntity.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/entity/OtpSendCounterEntity.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/persistence/HanJpaEntityProvider.java create mode 100644 codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/persistence/HanJpaEntityProviderFactory.java create mode 100644 codebase/backend/keycloak/src/main/resources/META-INF/han-otp-changelog.xml create mode 100644 codebase/backend/keycloak/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory create mode 100644 codebase/backend/keycloak/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory create mode 100644 codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/CryptoTest.java create mode 100644 codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/PhoneNormalizerTest.java create mode 100644 codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java create mode 100644 codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties create mode 100644 codebase/backend/keycloak/themes/han-phone/login/otp.ftl create mode 100644 codebase/backend/keycloak/themes/han-phone/login/phone.ftl create mode 100644 codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css create mode 100644 codebase/backend/keycloak/themes/han-phone/login/theme.properties create mode 100644 codebase/backend/message-safety/Dockerfile create mode 100644 codebase/backend/message-safety/app/__init__.py create mode 100644 codebase/backend/message-safety/app/main.py create mode 100644 codebase/backend/message-safety/openapi.yaml create mode 100644 codebase/backend/message-safety/pyproject.toml create mode 100644 codebase/backend/message-safety/tests/test_service.py create mode 100644 codebase/backend/nginx/Dockerfile create mode 100644 codebase/backend/nginx/docker-compose.yml create mode 100644 codebase/backend/nginx/nginx.conf.template create mode 100644 codebase/backend/nginx/scripts/entrypoint.sh create mode 100644 codebase/backend/nginx/snippets/proxy-common.conf create mode 100644 codebase/backend/nginx/snippets/security-headers.conf create mode 100644 codebase/backend/nginx/templates/frontend-dev.conf.template create mode 100644 codebase/backend/nginx/templates/frontend-static.conf.template create mode 100644 codebase/backend/nginx/templates/security-headers.conf.template create mode 100644 codebase/backend/nginx/templates/site-bootstrap.conf.template create mode 100644 codebase/backend/nginx/templates/site-tls.conf.template create mode 100644 codebase/backend/observability/docker-compose.yml create mode 100644 codebase/backend/observability/otel-collector.local.yaml create mode 100644 codebase/backend/observability/otel-collector.yaml create mode 100644 codebase/backend/redis/Dockerfile create mode 100644 codebase/backend/redis/docker-compose.yml create mode 100644 codebase/backend/redis/redis.conf create mode 100644 codebase/backend/redis/scripts/entrypoint.sh create mode 100644 codebase/backend/redis/users.acl.template create mode 100644 codebase/backend/scripts/validate-env create mode 100644 codebase/backend/tests/test_config.py rename {architectory => modules}/module-01-api-backend.md (100%) rename {architectory => modules}/module-02-frontend-test-site.md (100%) rename {architectory => modules}/module-03-nginx.md (100%) rename {architectory => modules}/module-04-redis.md (100%) rename {architectory => modules}/module-05-message-safety.md (100%) rename {architectory => modules}/module-06-bitrix-local-app.md (100%) rename {architectory => modules}/module-07-bitrix-sync.md (100%) rename {architectory => modules}/module-08-keycloak.md (100%) rename {architectory => modules}/module-09-observability.md (100%) rename {architectory => modules}/module-10-deployment-runbook.md (86%) diff --git a/HAN_chat_specification.rar b/HAN_chat_specification.rar deleted file mode 100644 index e7f4c63a22699f69eb6345ade242c0e314f96f79..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53652 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 ayzVlcKPq@_DhmIT<-s0ctWQWxg!dm#S73<% 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" + "

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.`. Они должны совпадать с реализацией api-backend. Query-token намеренно не используется. diff --git a/codebase/backend/frontend-test-site/app.config.ts b/codebase/backend/frontend-test-site/app.config.ts new file mode 100644 index 0000000..900b6bc --- /dev/null +++ b/codebase/backend/frontend-test-site/app.config.ts @@ -0,0 +1,15 @@ +import type { ExpoConfig } from "expo/config"; + +const config: ExpoConfig = { + name: "HAN Chat Test", + slug: "han-chat-test", + version: "1.0.0", + scheme: "han-chat", + orientation: "portrait", + userInterfaceStyle: "light", + experiments: { typedRoutes: true }, + plugins: ["expo-router", "expo-secure-store"], + web: { bundler: "metro", output: "static" }, +}; + +export default config; diff --git a/codebase/backend/frontend-test-site/app/_layout.tsx b/codebase/backend/frontend-test-site/app/_layout.tsx new file mode 100644 index 0000000..210f608 --- /dev/null +++ b/codebase/backend/frontend-test-site/app/_layout.tsx @@ -0,0 +1,16 @@ +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import React from "react"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { AppProvider } from "../src/app-context"; + +export default function RootLayout() { + return + + + + + + + ; +} diff --git a/codebase/backend/frontend-test-site/app/auth/callback.tsx b/codebase/backend/frontend-test-site/app/auth/callback.tsx new file mode 100644 index 0000000..9b5e2ef --- /dev/null +++ b/codebase/backend/frontend-test-site/app/auth/callback.tsx @@ -0,0 +1,40 @@ +import { useLocalSearchParams } from "expo-router"; +import React, { useEffect, useState } from "react"; +import { Text, View } from "react-native"; +import { useApp } from "../../src/app-context"; +import type { Consents } from "../../src/types"; +import { Button, ErrorNotice, Loading, styles } from "../../src/ui"; + +export default function AuthCallbackScreen() { + const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>(); + const app = useApp(); + const [error, setError] = useState(); + const retry = () => { + if (!params.code || !params.state || typeof window === "undefined") return; + const raw = window.sessionStorage.getItem("han.pending-consents"); + if (!raw) return; + setError(undefined); + void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError); + }; + useEffect(() => { + if (params.error) { + setError(new Error("Авторизация отменена или отклонена.")); + return; + } + if (!params.code || !params.state) return; + const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null; + if (!raw) { + setError(new Error("Не найдены локально принятые согласия. Начните вход заново.")); + return; + } + const consents = JSON.parse(raw) as Consents; + void app.finishCallback(params.code, params.state, consents) + .then(() => window.sessionStorage.removeItem("han.pending-consents")) + .catch(setError); + }, [params.code, params.state, params.error]); + return + Завершение входа + {!error && } + {error && <> + + + diff --git a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl new file mode 100644 index 0000000..0e9c257 --- /dev/null +++ b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl @@ -0,0 +1,16 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=true; section> + <#if section = "header">${msg("phoneTitle")} + <#elseif section = "form"> +
+
+ + +
+

${msg("phoneHelp")}

+ +
+ + diff --git a/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css b/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css new file mode 100644 index 0000000..f98e492 --- /dev/null +++ b/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css @@ -0,0 +1,18 @@ +:root { + --han-primary: #246bfd; + --han-text: #172033; +} + +body { color: var(--han-text); } +.pf-c-button.pf-m-primary { background: var(--han-primary); border-radius: 10px; min-height: 44px; } +input[type="tel"], input[name="otp"] { + box-sizing: border-box; + width: 100%; + min-height: 44px; + margin-top: 8px; + padding: 10px 12px; + border: 1px solid #9aa6bd; + border-radius: 8px; +} +.han-help { color: #596780; font-size: .9rem; } +.han-test-mode { color: #7a4b00; background: #fff3cd; padding: 8px 10px; border-radius: 8px; } diff --git a/codebase/backend/keycloak/themes/han-phone/login/theme.properties b/codebase/backend/keycloak/themes/han-phone/login/theme.properties new file mode 100644 index 0000000..185b09d --- /dev/null +++ b/codebase/backend/keycloak/themes/han-phone/login/theme.properties @@ -0,0 +1,4 @@ +parent=keycloak.v2 +import=common/keycloak +styles=css/han-login.css +locales=ru diff --git a/codebase/backend/message-safety/Dockerfile b/codebase/backend/message-safety/Dockerfile new file mode 100644 index 0000000..d23e3ec --- /dev/null +++ b/codebase/backend/message-safety/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim AS runtime +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +RUN addgroup --system app && adduser --system --ingroup app app +WORKDIR /service +COPY app ./app +COPY 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/message-safety/app/__init__.py b/codebase/backend/message-safety/app/__init__.py new file mode 100644 index 0000000..890e9c9 --- /dev/null +++ b/codebase/backend/message-safety/app/__init__.py @@ -0,0 +1 @@ +"""HAN message-safety service.""" diff --git a/codebase/backend/message-safety/app/main.py b/codebase/backend/message-safety/app/main.py new file mode 100644 index 0000000..22ad52e --- /dev/null +++ b/codebase/backend/message-safety/app/main.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import random +import unicodedata +import uuid +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from typing import Annotated, Any, Literal, Protocol + +import redis.asyncio as redis +import uvicorn +from fastapi import Depends, FastAPI, Header, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + app_env: str = "production-like" + message_safety_redis_url: str = "redis://redis:6379/2" + message_safety_service_token: str = Field(min_length=16) + message_safety_rules_version: str = "2026-01-01" + message_safety_task_ttl_sec: int = Field(default=900, ge=330) + message_safety_poll_after_ms: int = Field(default=2000, ge=100, le=30000) + safety_stub_worker_mode: Literal["emulated_on_poll"] = "emulated_on_poll" + safety_stub_rng_seed: int | None = None + + @model_validator(mode="after") + def forbid_seed_outside_tests(self) -> Settings: + if self.safety_stub_rng_seed is not None and self.app_env != "test": + raise ValueError("SAFETY_STUB_RNG_SEED is allowed only when APP_ENV=test") + return self + + +class Attachment(BaseModel): + model_config = ConfigDict(extra="forbid") + attachment_id: uuid.UUID + quarantine_object_key: str = Field(min_length=1, max_length=1024) + mime_type: str = Field(min_length=1, max_length=255) + size_bytes: int = Field(ge=0, le=10 * 1024 * 1024) + checksum: str = Field(pattern=r"^sha256:[0-9a-fA-F]{64}$") + + +class CheckRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + message_id: uuid.UUID + content_kind: Literal["text", "file"] + text: str = Field(default="", max_length=10000) + attachment: Attachment | None = None + + @model_validator(mode="after") + def validate_kind(self) -> CheckRequest: + if self.content_kind == "file" and self.attachment is None: + raise ValueError("attachment is required for file content") + if self.content_kind == "text" and self.attachment is not None: + raise ValueError("attachment is forbidden for text content") + return self + + +class TaskStore(Protocol): + async def reserve(self, message_id: str, fingerprint: str, task_id: str, ttl: int) -> str: ... + async def poll(self, task_id: str) -> int | None: ... + async def ready(self) -> bool: ... + async def close(self) -> None: ... + + +class RedisTaskStore: + _reserve_lua = """ +local prior = redis.call('GET', KEYS[1]) +if prior then + local sep = string.find(prior, '|', 1, true) + local old_fp = string.sub(prior, 1, sep - 1) + if old_fp ~= ARGV[1] then return {'conflict'} end + return {'existing', string.sub(prior, sep + 1)} +end +redis.call('HSET', KEYS[2], 'schema_version', '1', 'message_id', ARGV[2], + 'created_at_ms', ARGV[3], 'poll_count', '0', 'rules_version', ARGV[5]) +redis.call('EXPIRE', KEYS[2], ARGV[4]) +redis.call('SET', KEYS[1], ARGV[1] .. '|' .. ARGV[6], 'EX', ARGV[4]) +return {'created', ARGV[6]} +""" + _poll_lua = """ +if redis.call('EXISTS', KEYS[1]) == 0 then return nil end +return redis.call('HINCRBY', KEYS[1], 'poll_count', 1) +""" + + def __init__(self, client: redis.Redis, rules_version: str) -> None: + self.client = client + self.rules_version = rules_version + + async def reserve(self, message_id: str, fingerprint: str, task_id: str, ttl: int) -> str: + result = await self.client.eval( + self._reserve_lua, + 2, + f"han:safety:task-by-message:{message_id}", + f"han:safety:task:{task_id}", + fingerprint, + message_id, + str(int(datetime.now(UTC).timestamp() * 1000)), + str(ttl), + self.rules_version, + task_id, + ) + status = _decode(result[0]) + if status == "conflict": + raise ValueError("conflict") + return _decode(result[1]) + + async def poll(self, task_id: str) -> int | None: + key = f"han:safety:task:{task_id}" + count = await self.client.eval(self._poll_lua, 1, key) + return int(count) if count is not None else None + + async def ready(self) -> bool: + key = f"han:safety:ready:{uuid.uuid4()}" + try: + await self.client.set(key, "1", ex=5) + return await self.client.get(key) == b"1" + finally: + await self.client.delete(key) + + async def close(self) -> None: + await self.client.aclose() + + +def _decode(value: Any) -> str: + return value.decode() if isinstance(value, bytes) else str(value) + + +def normalize(text: str) -> str: + return unicodedata.normalize("NFKC", text.replace("\r\n", "\n").replace("\r", "\n")).lstrip() + + +def fingerprint(dto: CheckRequest) -> str: + body = dto.model_dump(mode="json") + body["text"] = normalize(dto.text) + encoded = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def error(code: str, message: str, request_id: str, details: dict[str, Any] | None = None) -> dict: + return { + "error": { + "code": code, + "message": message, + "request_id": request_id, + "details": details or {}, + } + } + + +def create_app( + settings: Settings | None = None, + store: TaskStore | None = None, + rng: random.Random | None = None, +) -> FastAPI: + cfg = settings or Settings() + verdict_rng = rng or random.Random(cfg.safety_stub_rng_seed) + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.store = store or RedisTaskStore( + redis.from_url(cfg.message_safety_redis_url, decode_responses=False), + cfg.message_safety_rules_version, + ) + yield + await app.state.store.close() + + app = FastAPI( + title="HAN Message Safety", + 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_middleware(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 + + def authorize( + request: Request, + token: Annotated[str | None, Header(alias="X-Service-Token")] = None, + ) -> None: + if token is None or not hmac.compare_digest(token, cfg.message_safety_service_token): + raise HTTPException( + 401, + error( + "service_unauthorized", + "Service authentication failed", + request.state.request_id, + ), + ) + + @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( + error("validation_error", "Request is invalid", request.state.request_id), + status_code=400, + ) + + @app.get("/health/live") + async def live() -> dict[str, str]: + return {"status": "live"} + + @app.get("/health/ready") + async def ready(request: Request): + try: + ok = await request.app.state.store.ready() + except Exception: + ok = False + body = { + "status": "ready" if ok else "not_ready", + "components": {"redis": "ok" if ok else "down"}, + } + return JSONResponse(body, status_code=200 if ok else 503) + + @app.post("/internal/safety/v1/messages/check", dependencies=[Depends(authorize)]) + async def check(dto: CheckRequest, request: Request): + text = normalize(dto.text) + common = {"rules_version": cfg.message_safety_rules_version} + if text and text[0] in {"ф", "Ф"}: + return JSONResponse( + { + "verdict": "deny", + "rule_id": "stub.starts_with_cyrillic_ef", + "reason_code": "stub_blocked", + **common, + }, + status_code=403, + ) + if text and unicodedata.category(text[0]) == "Nd": + task_id = str(uuid.uuid4()) + try: + task_id = await request.app.state.store.reserve( + str(dto.message_id), fingerprint(dto), task_id, cfg.message_safety_task_ttl_sec + ) + except ValueError: + return JSONResponse( + error( + "safety_request_conflict", "message_id was reused", request.state.request_id + ), + status_code=409, + ) + except Exception: + return JSONResponse( + error( + "redis_unavailable", "Task storage is unavailable", request.state.request_id + ), + status_code=503, + ) + return JSONResponse( + { + "verdict": "pending", + "task_id": task_id, + "poll_after_ms": cfg.message_safety_poll_after_ms, + "expires_at": ( + datetime.now(UTC) + timedelta(seconds=cfg.message_safety_task_ttl_sec) + ) + .isoformat() + .replace("+00:00", "Z"), + **common, + }, + status_code=203, + ) + return {"verdict": "allow", "rule_id": "stub.default_allow", **common} + + @app.get("/internal/safety/v1/messages/tasks/{task_id}", dependencies=[Depends(authorize)]) + async def task(task_id: str, request: Request): + try: + parsed = str(uuid.UUID(task_id)) + except ValueError: + return JSONResponse( + error("validation_error", "Request is invalid", request.state.request_id), + status_code=400, + ) + try: + count = await request.app.state.store.poll(parsed) + except Exception: + return JSONResponse( + error("redis_unavailable", "Task storage is unavailable", request.state.request_id), + status_code=503, + ) + if count is None: + return JSONResponse( + error("task_not_found", "Task was not found", request.state.request_id), + status_code=404, + ) + outcome = verdict_rng.choice(("pending", "allow", "final_error")) + if outcome == "pending": + return JSONResponse( + { + "verdict": "pending", + "task_id": parsed, + "poll_after_ms": cfg.message_safety_poll_after_ms, + }, + status_code=203, + ) + if outcome == "allow": + return {"verdict": "allow", "task_id": parsed, "rule_id": "stub.random_allow"} + return JSONResponse( + { + "verdict": "deny", + "task_id": parsed, + **error( + "stub_final_error", + "Stub task returned a final negative verdict", + request.state.request_id, + {"terminal": True}, + ), + }, + status_code=400, + ) + + return app + + +app = create_app() + + +def run() -> None: + uvicorn.run("app.main:app", host="0.0.0.0", port=8080) diff --git a/codebase/backend/message-safety/openapi.yaml b/codebase/backend/message-safety/openapi.yaml new file mode 100644 index 0000000..917f282 --- /dev/null +++ b/codebase/backend/message-safety/openapi.yaml @@ -0,0 +1,58 @@ +openapi: 3.1.0 +info: {title: HAN Message Safety, version: 1.0.0} +paths: + /health/live: + get: {responses: {"200": {description: Live}}} + /health/ready: + get: {responses: {"200": {description: Ready}, "503": {description: Redis unavailable}}} + /internal/safety/v1/messages/check: + post: + security: [{ServiceToken: []}] + parameters: [{$ref: "#/components/parameters/RequestId"}] + requestBody: + required: true + content: + application/json: + schema: {$ref: "#/components/schemas/CheckRequest"} + responses: + "200": {description: Allow} + "203": {description: Pending} + "403": {description: Deny} + "409": {description: Conflicting message id} + "503": {description: Redis unavailable} + /internal/safety/v1/messages/tasks/{task_id}: + get: + security: [{ServiceToken: []}] + parameters: + - {name: task_id, in: path, required: true, schema: {type: string, format: uuid}} + - {$ref: "#/components/parameters/RequestId"} + responses: + "200": {description: Allow} + "203": {description: Pending} + "400": {description: Validation error or terminal stub rejection} + "404": {description: Task not found} +components: + securitySchemes: + ServiceToken: {type: apiKey, in: header, name: X-Service-Token} + parameters: + RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}} + schemas: + Attachment: + type: object + additionalProperties: false + required: [attachment_id, quarantine_object_key, mime_type, size_bytes, checksum] + properties: + attachment_id: {type: string, format: uuid} + quarantine_object_key: {type: string, maxLength: 1024} + mime_type: {type: string, maxLength: 255} + size_bytes: {type: integer, minimum: 0} + checksum: {type: string, pattern: "^sha256:[0-9a-fA-F]{64}$"} + CheckRequest: + type: object + additionalProperties: false + required: [message_id, content_kind] + properties: + message_id: {type: string, format: uuid} + content_kind: {type: string, enum: [text, file]} + text: {type: string, maxLength: 10000, default: ""} + attachment: {anyOf: [{$ref: "#/components/schemas/Attachment"}, {type: "null"}]} diff --git a/codebase/backend/message-safety/pyproject.toml b/codebase/backend/message-safety/pyproject.toml new file mode 100644 index 0000000..c811770 --- /dev/null +++ b/codebase/backend/message-safety/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "han-message-safety" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.116,<1", + "pydantic-settings>=2.10,<3", + "redis>=6,<7", + "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/message-safety/tests/test_service.py b/codebase/backend/message-safety/tests/test_service.py new file mode 100644 index 0000000..8a029b0 --- /dev/null +++ b/codebase/backend/message-safety/tests/test_service.py @@ -0,0 +1,103 @@ +import os +import random +import uuid + +os.environ.setdefault("MESSAGE_SAFETY_SERVICE_TOKEN", "test-service-token-32-characters") + +import httpx +import pytest + +from app.main import Settings, create_app, normalize + + +class Store: + def __init__(self): + self.tasks = {} + + async def reserve(self, message_id, fingerprint, task_id, ttl): + prior = self.tasks.get(message_id) + if prior and prior[0] != fingerprint: + raise ValueError("conflict") + if prior: + return prior[1] + self.tasks[message_id] = (fingerprint, task_id) + return task_id + + async def poll(self, task_id): + return 1 if any(value[1] == task_id for value in self.tasks.values()) else None + + async def ready(self): + return True + + async def close(self): + pass + + +class SequenceRandom(random.Random): + def __init__(self): + self.values = iter(("pending", "allow", "final_error")) + + def choice(self, _): + return next(self.values) + + +@pytest.mark.asyncio +async def test_rules_auth_and_independent_poll(): + settings = Settings( + app_env="test", + message_safety_service_token="test-service-token-32-characters", + ) + app = create_app(settings, Store(), SequenceRandom()) + headers = {"X-Service-Token": settings.message_safety_service_token} + message_id = str(uuid.uuid4()) + 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.post( + "/internal/safety/v1/messages/check", + json={ + "message_id": str(uuid.uuid4()), + "content_kind": "text", + "text": " Файл", + }, + headers=headers, + ) + ).status_code == 403 + pending = await client.post( + "/internal/safety/v1/messages/check", + json={"message_id": message_id, "content_kind": "text", "text": "\u00a07 дней"}, + headers=headers, + ) + assert pending.status_code == 203 + task_id = pending.json()["task_id"] + assert [ + ( + await client.get( + f"/internal/safety/v1/messages/tasks/{task_id}", headers=headers + ) + ).status_code + for _ in range(3) + ] == [203, 200, 400] + assert ( + await client.post( + "/internal/safety/v1/messages/check", + json={ + "message_id": str(uuid.uuid4()), + "content_kind": "text", + "text": "документ", + }, + headers=headers, + ) + ).status_code == 200 + assert ( + await client.post( + "/internal/safety/v1/messages/check", + json={"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "ok"}, + ) + ).status_code == 401 + + +def test_normalization(): + assert normalize("\r\n\u00a07 дней") == "7 дней" diff --git a/codebase/backend/nginx/Dockerfile b/codebase/backend/nginx/Dockerfile new file mode 100644 index 0000000..8f3123c --- /dev/null +++ b/codebase/backend/nginx/Dockerfile @@ -0,0 +1,14 @@ +FROM nginx:1.27.4-bookworm +RUN apt-get update \ + && apt-get install -y --no-install-recommends gettext-base curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /etc/nginx/generated /var/www/certbot /usr/share/nginx/html \ + && chown -R nginx:nginx /etc/nginx/generated /var/cache/nginx /var/run +COPY nginx.conf.template /etc/nginx/templates-src/nginx.conf.template +COPY templates /etc/nginx/templates-src/sites +COPY snippets /etc/nginx/snippets +COPY scripts/entrypoint.sh /usr/local/bin/han-nginx-entrypoint +RUN sed -i 's/\r$//' /usr/local/bin/han-nginx-entrypoint \ + && chmod 0555 /usr/local/bin/han-nginx-entrypoint \ + && find /etc/nginx/templates-src /etc/nginx/snippets -type f -exec chmod 0444 {} + +ENTRYPOINT ["/usr/local/bin/han-nginx-entrypoint"] diff --git a/codebase/backend/nginx/docker-compose.yml b/codebase/backend/nginx/docker-compose.yml new file mode 100644 index 0000000..ce39b94 --- /dev/null +++ b/codebase/backend/nginx/docker-compose.yml @@ -0,0 +1,71 @@ +services: + nginx: + build: + context: . + image: han-chat-nginx:${RELEASE_VERSION:-local} + environment: + APP_ENV: ${APP_ENV:-production-like} + PUBLIC_HOST: ${PUBLIC_HOST} + NGINX_TLS_ENABLED: ${NGINX_TLS_ENABLED:-true} + NGINX_TLS_CERTIFICATE: ${NGINX_TLS_CERTIFICATE} + NGINX_TLS_CERTIFICATE_KEY: ${NGINX_TLS_CERTIFICATE_KEY} + NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-0} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-8m} + NGINX_RATE_LIMIT_API: ${NGINX_RATE_LIMIT_API:-60r/m} + NGINX_RATE_LIMIT_AUTH: ${NGINX_RATE_LIMIT_AUTH:-10r/m} + NGINX_RATE_LIMIT_PUBLIC: ${NGINX_RATE_LIMIT_PUBLIC:-60r/m} + NGINX_RATE_LIMIT_POLLING: ${NGINX_RATE_LIMIT_POLLING:-60r/m} + NGINX_RATE_LIMIT_DOWNLOADS: ${NGINX_RATE_LIMIT_DOWNLOADS:-30r/m} + NGINX_RATE_LIMIT_BITRIX: ${NGINX_RATE_LIMIT_BITRIX:-120r/m} + NGINX_RATE_LIMIT_WS: ${NGINX_RATE_LIMIT_WS:-30r/m} + NGINX_MESSAGE_READ_TIMEOUT_SEC: ${NGINX_MESSAGE_READ_TIMEOUT_SEC:-330} + FRONTEND_DEV_PROXY_ENABLED: ${FRONTEND_DEV_PROXY_ENABLED:-false} + EXPO_DEV_SERVER_URL: ${EXPO_DEV_SERVER_URL:-http://host.docker.internal:8081} + BITRIX_FRAME_ANCESTORS: ${BITRIX_FRAME_ANCESTORS:-https://*.bitrix24.ru} + S3_CONNECT_SRC: ${S3_CONNECT_SRC:-https://s3.storage.selcloud.ru} + ports: + - "${NGINX_HTTP_PORT:-80}:80" + - "${NGINX_HTTPS_PORT:-443}:443" + expose: ["8080"] + volumes: + - nginx-certs:/etc/letsencrypt:ro + - nginx-acme-webroot:/var/www/certbot:ro + - frontend-static:/usr/share/nginx/html:ro + - nginx-cache:/var/cache/nginx + networks: [public, backend] + depends_on: + frontend-static: {condition: service_completed_successfully} + api-backend: {condition: service_healthy} + keycloak: {condition: service_healthy} + bitrix-local-app: {condition: service_healthy} + bitrix-sync: {condition: service_healthy} + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/nginx-health/live || curl -fsS http://127.0.0.1/nginx-health/live"] + interval: 10s + timeout: 3s + retries: 10 + restart: unless-stopped + read_only: true + tmpfs: + - /tmp:size=128m,mode=1777 + - /etc/nginx/generated:size=4m,mode=0755,uid=101,gid=101 + cap_drop: ["ALL"] + cap_add: ["NET_BIND_SERVICE", "SETUID", "SETGID"] + security_opt: ["no-new-privileges:true"] + extra_hosts: + - "host.docker.internal:host-gateway" + ulimits: + nofile: {soft: 65536, hard: 65536} + logging: + driver: json-file + options: {max-size: "50m", max-file: "5"} + + certbot: + image: certbot/certbot:v3.1.0 + profiles: ["certbot"] + volumes: + - nginx-certs:/etc/letsencrypt + - nginx-acme-webroot:/var/www/certbot + networks: [public] + security_opt: ["no-new-privileges:true"] + cap_drop: ["ALL"] diff --git a/codebase/backend/nginx/nginx.conf.template b/codebase/backend/nginx/nginx.conf.template new file mode 100644 index 0000000..ea1cf7c --- /dev/null +++ b/codebase/backend/nginx/nginx.conf.template @@ -0,0 +1,65 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 4096; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + server_tokens off; + charset utf-8; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + map $http_x_request_id $edge_request_id { + "~*^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" $http_x_request_id; + "~*^[0-9A-HJKMNP-TV-Z]{26}$" $http_x_request_id; + default $request_id; + } + map $http_traceparent $trace_id { + "~^[0-9a-f]{2}-(?[0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$" $valid_trace_id; + default ""; + } + map $request_method $polling_key { + GET $binary_remote_addr; + default ""; + } + + log_format json_combined escape=json + '{"timestamp":"$time_iso8601","service.name":"nginx","request_id":"$edge_request_id",' + '"trace_id":"$trace_id","remote_addr":"$remote_addr","host":"$host","method":"$request_method",' + '"uri":"$uri","status":$status,"bytes":$body_bytes_sent,"duration":$request_time,' + '"upstream_status":"$upstream_status","upstream_time":"$upstream_response_time",' + '"cache":"$upstream_cache_status","tls":"$ssl_protocol","user_agent":"$http_user_agent"}'; + access_log /dev/stdout json_combined; + + limit_req_status 429; + limit_conn_status 429; + limit_req_zone $binary_remote_addr zone=api:10m rate=${NGINX_RATE_LIMIT_API}; + limit_req_zone $binary_remote_addr zone=auth:10m rate=${NGINX_RATE_LIMIT_AUTH}; + limit_req_zone $binary_remote_addr zone=public:10m rate=${NGINX_RATE_LIMIT_PUBLIC}; + limit_req_zone $polling_key zone=polling:10m rate=${NGINX_RATE_LIMIT_POLLING}; + limit_req_zone $binary_remote_addr zone=downloads:10m rate=${NGINX_RATE_LIMIT_DOWNLOADS}; + limit_req_zone $binary_remote_addr zone=bitrix_callbacks:10m rate=${NGINX_RATE_LIMIT_BITRIX}; + limit_req_zone $binary_remote_addr zone=ws_connect:10m rate=${NGINX_RATE_LIMIT_WS}; + limit_conn_zone $binary_remote_addr zone=connections:10m; + + proxy_cache_path /var/cache/nginx/public levels=1:2 keys_zone=public_cache:20m + max_size=256m inactive=1h use_temp_path=off; + client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE}; + client_body_temp_path /tmp/client_body; + proxy_temp_path /tmp/proxy; + + upstream api_backend { server api-backend:8000; keepalive 32; } + upstream keycloak_upstream { server keycloak:8080; keepalive 16; } + upstream bitrix_local { server bitrix-local-app:8080; keepalive 16; } + upstream bitrix_sync_upstream { server bitrix-sync:8080; keepalive 8; } + upstream frontend_dev { server ${EXPO_DEV_SERVER_HOSTPORT}; keepalive 8; } + + include /etc/nginx/generated/site.conf; +} diff --git a/codebase/backend/nginx/scripts/entrypoint.sh b/codebase/backend/nginx/scripts/entrypoint.sh new file mode 100644 index 0000000..29ef0e0 --- /dev/null +++ b/codebase/backend/nginx/scripts/entrypoint.sh @@ -0,0 +1,56 @@ +#!/bin/sh +set -eu + +required="PUBLIC_HOST NGINX_RATE_LIMIT_API NGINX_RATE_LIMIT_AUTH NGINX_RATE_LIMIT_PUBLIC NGINX_RATE_LIMIT_POLLING NGINX_RATE_LIMIT_DOWNLOADS NGINX_RATE_LIMIT_BITRIX NGINX_RATE_LIMIT_WS NGINX_CLIENT_MAX_BODY_SIZE NGINX_MESSAGE_READ_TIMEOUT_SEC" +for name in $required; do + eval "value=\${$name:-}" + if [ -z "$value" ]; then + echo "nginx bootstrap: missing $name" >&2 + exit 64 + fi +done + +case "${EXPO_DEV_SERVER_URL:-http://host.docker.internal:8081}" in + http://*/*) echo "nginx bootstrap: EXPO_DEV_SERVER_URL must not contain a path" >&2; exit 64 ;; + http://*) EXPO_DEV_SERVER_HOSTPORT=${EXPO_DEV_SERVER_URL#http://} ;; + *) echo "nginx bootstrap: EXPO_DEV_SERVER_URL must use http:// Docker/host DNS" >&2; exit 64 ;; +esac +export EXPO_DEV_SERVER_HOSTPORT + +if [ "${FRONTEND_DEV_PROXY_ENABLED:-false}" = "true" ] \ + && { [ "${APP_ENV:-}" = "production" ] || [ "${APP_ENV:-}" = "production-like" ]; }; then + echo "nginx bootstrap: frontend dev proxy is forbidden in $APP_ENV" >&2 + exit 64 +fi + +umask 027 +common_vars='${NGINX_RATE_LIMIT_API} ${NGINX_RATE_LIMIT_AUTH} ${NGINX_RATE_LIMIT_PUBLIC} ${NGINX_RATE_LIMIT_POLLING} ${NGINX_RATE_LIMIT_DOWNLOADS} ${NGINX_RATE_LIMIT_BITRIX} ${NGINX_RATE_LIMIT_WS} ${NGINX_CLIENT_MAX_BODY_SIZE} ${EXPO_DEV_SERVER_HOSTPORT}' +site_vars='${PUBLIC_HOST} ${NGINX_TLS_CERTIFICATE} ${NGINX_TLS_CERTIFICATE_KEY} ${NGINX_MESSAGE_READ_TIMEOUT_SEC} ${BITRIX_FRAME_ANCESTORS}' +security_vars='${NGINX_HSTS_MAX_AGE} ${S3_CONNECT_SRC}' + +envsubst "$common_vars" < /etc/nginx/templates-src/nginx.conf.template > /tmp/nginx.conf +envsubst "$security_vars" < /etc/nginx/templates-src/sites/security-headers.conf.template \ + > /etc/nginx/generated/security-headers.conf + +if [ "${FRONTEND_DEV_PROXY_ENABLED:-false}" = "true" ]; then + cp /etc/nginx/templates-src/sites/frontend-dev.conf.template /etc/nginx/generated/frontend-location.conf +else + cp /etc/nginx/templates-src/sites/frontend-static.conf.template /etc/nginx/generated/frontend-location.conf +fi + +if [ "${NGINX_TLS_ENABLED:-true}" = "true" ]; then + for certificate in "${NGINX_TLS_CERTIFICATE:-}" "${NGINX_TLS_CERTIFICATE_KEY:-}"; do + if [ ! -r "$certificate" ]; then + echo "nginx bootstrap: TLS file is not readable: $certificate" >&2 + exit 78 + fi + done + envsubst "$site_vars" < /etc/nginx/templates-src/sites/site-tls.conf.template \ + > /etc/nginx/generated/site.conf +else + envsubst '${PUBLIC_HOST}' < /etc/nginx/templates-src/sites/site-bootstrap.conf.template \ + > /etc/nginx/generated/site.conf +fi + +nginx -t -c /tmp/nginx.conf +exec nginx -c /tmp/nginx.conf -g 'daemon off;' diff --git a/codebase/backend/nginx/snippets/proxy-common.conf b/codebase/backend/nginx/snippets/proxy-common.conf new file mode 100644 index 0000000..b5f7229 --- /dev/null +++ b/codebase/backend/nginx/snippets/proxy-common.conf @@ -0,0 +1,16 @@ +proxy_http_version 1.1; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $remote_addr; +proxy_set_header X-Forwarded-Proto https; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Port 443; +proxy_set_header X-Request-ID $edge_request_id; +proxy_set_header traceparent $http_traceparent; +proxy_hide_header X-Powered-By; +proxy_hide_header Server; +add_header X-Request-ID $edge_request_id always; +include /etc/nginx/generated/security-headers.conf; +proxy_connect_timeout 3s; +proxy_send_timeout 30s; +proxy_read_timeout 30s; diff --git a/codebase/backend/nginx/snippets/security-headers.conf b/codebase/backend/nginx/snippets/security-headers.conf new file mode 100644 index 0000000..44bacf6 --- /dev/null +++ b/codebase/backend/nginx/snippets/security-headers.conf @@ -0,0 +1,5 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header Cross-Origin-Opener-Policy "same-origin" always; +add_header X-Frame-Options "DENY" always; diff --git a/codebase/backend/nginx/templates/frontend-dev.conf.template b/codebase/backend/nginx/templates/frontend-dev.conf.template new file mode 100644 index 0000000..2831dff --- /dev/null +++ b/codebase/backend/nginx/templates/frontend-dev.conf.template @@ -0,0 +1,8 @@ +location / { + include /etc/nginx/snippets/proxy-common.conf; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_buffering off; + proxy_read_timeout 90s; + proxy_pass http://frontend_dev; +} diff --git a/codebase/backend/nginx/templates/frontend-static.conf.template b/codebase/backend/nginx/templates/frontend-static.conf.template new file mode 100644 index 0000000..2fae6e9 --- /dev/null +++ b/codebase/backend/nginx/templates/frontend-static.conf.template @@ -0,0 +1,20 @@ +location ~* \.(?:css|js|png|jpe?g|gif|webp|svg|ico|woff2?)$ { + root /usr/share/nginx/html; + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable"; + include /etc/nginx/generated/security-headers.conf; +} +location = /index.html { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache"; + include /etc/nginx/generated/security-headers.conf; +} +location = /service-worker.js { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache"; + include /etc/nginx/generated/security-headers.conf; +} +location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; +} diff --git a/codebase/backend/nginx/templates/security-headers.conf.template b/codebase/backend/nginx/templates/security-headers.conf.template new file mode 100644 index 0000000..8d9a870 --- /dev/null +++ b/codebase/backend/nginx/templates/security-headers.conf.template @@ -0,0 +1,7 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header Cross-Origin-Opener-Policy "same-origin" always; +add_header X-Frame-Options "DENY" always; +add_header Strict-Transport-Security "max-age=${NGINX_HSTS_MAX_AGE}" always; +add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; connect-src 'self' https: wss: ${S3_CONNECT_SRC}; img-src 'self' data: blob: https:; script-src 'self'; style-src 'self' 'unsafe-inline'" always; diff --git a/codebase/backend/nginx/templates/site-bootstrap.conf.template b/codebase/backend/nginx/templates/site-bootstrap.conf.template new file mode 100644 index 0000000..4d0e0db --- /dev/null +++ b/codebase/backend/nginx/templates/site-bootstrap.conf.template @@ -0,0 +1,16 @@ +server { + listen 80; + server_name ${PUBLIC_HOST}; + + location = /nginx-health/live { + access_log off; + return 200 "ok\n"; + } + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type text/plain; + } + location ^~ /internal/ { return 404; } + location ^~ /_internal/ { return 404; } + location / { return 308 https://$host$request_uri; } +} diff --git a/codebase/backend/nginx/templates/site-tls.conf.template b/codebase/backend/nginx/templates/site-tls.conf.template new file mode 100644 index 0000000..c136ca4 --- /dev/null +++ b/codebase/backend/nginx/templates/site-tls.conf.template @@ -0,0 +1,137 @@ +server { + listen 8080; + server_name _; + location = /nginx-health/live { access_log off; return 200 "ok\n"; } +} + +server { + listen 80; + server_name ${PUBLIC_HOST}; + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type text/plain; + } + location / { return 308 https://$host$request_uri; } +} + +server { + listen 443 ssl; + http2 on; + server_name ${PUBLIC_HOST}; + ssl_certificate ${NGINX_TLS_CERTIFICATE}; + ssl_certificate_key ${NGINX_TLS_CERTIFICATE_KEY}; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5:!3DES; + ssl_session_cache shared:TLS:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + include /etc/nginx/generated/security-headers.conf; + error_page 429 = @rate_limited; + + location @rate_limited { + add_header Retry-After "1" always; + add_header X-Request-ID $edge_request_id always; + include /etc/nginx/generated/security-headers.conf; + return 429; + } + + location = /nginx-health/live { access_log off; return 200 "ok\n"; } + location ^~ /internal/ { return 404; } + location ^~ /_internal/ { return 404; } + location = /metrics { return 404; } + location = /nginx_status { return 404; } + location ~* /(?:\.env|docker-compose|config\.(?:json|ya?ml))$ { return 404; } + location ~ /\. { return 404; } + + location = /api/v1/realtime { + limit_req zone=ws_connect burst=5 nodelay; + limit_conn connections 10; + include /etc/nginx/snippets/proxy-common.conf; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 90s; + proxy_send_timeout 30s; + proxy_pass http://api_backend; + } + + location ~ ^/api/v1/dialogs/[0-9a-fA-F-]+/messages$ { + limit_req zone=api burst=20 nodelay; + limit_req zone=polling burst=20 nodelay; + include /etc/nginx/snippets/proxy-common.conf; + proxy_read_timeout ${NGINX_MESSAGE_READ_TIMEOUT_SEC}s; + proxy_pass http://api_backend; + } + + location = /api/v1/public/app-config { + limit_req zone=public burst=20 nodelay; + include /etc/nginx/snippets/proxy-common.conf; + proxy_cache public_cache; + proxy_cache_methods GET HEAD; + proxy_cache_bypass $http_authorization; + proxy_no_cache $http_authorization $upstream_http_set_cookie; + proxy_cache_valid 200 1h; + proxy_pass http://api_backend; + } + location = /api/v1/public/content { + limit_req zone=public burst=20 nodelay; + include /etc/nginx/snippets/proxy-common.conf; + proxy_cache public_cache; + proxy_cache_methods GET HEAD; + proxy_cache_bypass $http_authorization; + proxy_no_cache $http_authorization $upstream_http_set_cookie; + proxy_cache_valid 200 1h; + proxy_pass http://api_backend; + } + location ~ ^/api/v1/(?:documents|dialogs)/.+/download-url$ { + limit_req zone=downloads burst=10 nodelay; + include /etc/nginx/snippets/proxy-common.conf; + proxy_pass http://api_backend; + } + location ^~ /api/ { + limit_req zone=api burst=30 nodelay; + include /etc/nginx/snippets/proxy-common.conf; + proxy_pass http://api_backend; + } + + location ^~ /auth/ { + limit_req zone=auth burst=10; + include /etc/nginx/snippets/proxy-common.conf; + proxy_read_timeout 60s; + proxy_pass http://keycloak_upstream; + } + + location = /bitrix/handler { + limit_req zone=bitrix_callbacks burst=60 nodelay; + include /etc/nginx/snippets/proxy-common.conf; + proxy_read_timeout 60s; + proxy_buffering off; + proxy_pass http://bitrix_local; + } + location = /bitrix/install { + limit_req zone=bitrix_callbacks burst=30 nodelay; + include /etc/nginx/snippets/proxy-common.conf; + proxy_read_timeout 60s; + proxy_pass http://bitrix_local; + } + location = /bitrix/placement { + limit_req zone=bitrix_callbacks burst=30 nodelay; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Request-ID $edge_request_id; + add_header X-Request-ID $edge_request_id always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; frame-ancestors ${BITRIX_FRAME_ANCESTORS}" always; + proxy_pass http://bitrix_local; + } + location ^~ /bitrix/ { return 404; } + + include /etc/nginx/generated/frontend-location.conf; +} diff --git a/codebase/backend/observability/docker-compose.yml b/codebase/backend/observability/docker-compose.yml new file mode 100644 index 0000000..dd5b65b --- /dev/null +++ b/codebase/backend/observability/docker-compose.yml @@ -0,0 +1,63 @@ +services: + otel-queue-init: + image: alpine:3.21.2 + command: ["sh", "-c", "chown 10001:10001 /queue && chmod 0700 /queue"] + volumes: + - otel-queue:/queue + networks: [observability] + restart: "no" + read_only: true + cap_drop: ["ALL"] + cap_add: ["CHOWN", "FOWNER"] + security_opt: ["no-new-privileges:true"] + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.117.0 + command: ["--config=/etc/otelcol/config.yaml"] + environment: + APP_ENV: ${APP_ENV:-production-like} + RELEASE_VERSION: ${RELEASE_VERSION:-unknown} + OTEL_REMOTE_ENDPOINT: ${OTEL_REMOTE_ENDPOINT} + OTEL_REMOTE_AUTH_HEADER: ${OTEL_REMOTE_AUTH_HEADER} + expose: ["4317", "4318", "13133", "8888"] + volumes: + - ./otel-collector.yaml:/etc/otelcol/config.yaml:ro + - otel-queue:/var/lib/otelcol/queue + networks: [observability, backend] + depends_on: + otel-queue-init: {condition: service_completed_successfully} + healthcheck: + test: ["CMD", "/otelcol-contrib", "validate", "--config=/etc/otelcol/config.yaml"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + read_only: true + tmpfs: ["/tmp:size=32m,mode=1777"] + user: "10001:10001" + cap_drop: ["ALL"] + security_opt: ["no-new-privileges:true"] + mem_limit: 512m + cpus: 0.5 + logging: + driver: json-file + options: {max-size: "50m", max-file: "5"} + + otel-collector-local: + image: otel/opentelemetry-collector-contrib:0.117.0 + profiles: ["observability-local"] + command: ["--config=/etc/otelcol/config.yaml"] + expose: ["4317", "4318", "13133"] + volumes: + - ./otel-collector.local.yaml:/etc/otelcol/config.yaml:ro + networks: [observability] + healthcheck: + test: ["CMD", "/otelcol-contrib", "validate", "--config=/etc/otelcol/config.yaml"] + interval: 30s + timeout: 10s + retries: 3 + read_only: true + tmpfs: ["/tmp:size=16m,mode=1777"] + user: "10001:10001" + cap_drop: ["ALL"] + security_opt: ["no-new-privileges:true"] diff --git a/codebase/backend/observability/otel-collector.local.yaml b/codebase/backend/observability/otel-collector.local.yaml new file mode 100644 index 0000000..426c1a3 --- /dev/null +++ b/codebase/backend/observability/otel-collector.local.yaml @@ -0,0 +1,45 @@ +extensions: + health_check: {endpoint: 0.0.0.0:13133} + +receivers: + otlp: + protocols: + grpc: {endpoint: 0.0.0.0:4317} + http: {endpoint: 0.0.0.0:4318} + +processors: + memory_limiter: {check_interval: 1s, limit_mib: 192, spike_limit_mib: 48} + resource/common: + attributes: + - {key: service.namespace, value: han-chat, action: upsert} + - {key: deployment.environment, value: local, action: upsert} + attributes/redact: + actions: + - {key: http.request.header.authorization, action: delete} + - {key: http.request.header.cookie, action: delete} + - {key: url.query, action: delete} + - {key: db.statement, action: delete} + - {key: messaging.message.body, action: delete} + batch: {timeout: 5s} + +exporters: + debug: + verbosity: normal + sampling_initial: 2 + sampling_thereafter: 500 + +service: + extensions: [health_check] + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, resource/common, attributes/redact, batch] + exporters: [debug] + metrics: + receivers: [otlp] + processors: [memory_limiter, resource/common, attributes/redact, batch] + exporters: [debug] + logs: + receivers: [otlp] + processors: [memory_limiter, resource/common, attributes/redact, batch] + exporters: [debug] diff --git a/codebase/backend/observability/otel-collector.yaml b/codebase/backend/observability/otel-collector.yaml new file mode 100644 index 0000000..452fcf9 --- /dev/null +++ b/codebase/backend/observability/otel-collector.yaml @@ -0,0 +1,90 @@ +extensions: + health_check: + endpoint: 0.0.0.0:13133 + file_storage: + directory: /var/lib/otelcol/queue + timeout: 10s + +receivers: + otlp: + protocols: + grpc: {endpoint: 0.0.0.0:4317} + http: {endpoint: 0.0.0.0:4318} + prometheus: + config: + scrape_configs: + - job_name: otel-collector + static_configs: + - targets: ["127.0.0.1:8888"] + +processors: + memory_limiter: + check_interval: 1s + limit_mib: 384 + spike_limit_mib: 96 + resource/common: + attributes: + - {key: service.namespace, value: han-chat, action: upsert} + - {key: deployment.environment, value: "${env:APP_ENV}", action: upsert} + - {key: service.version, value: "${env:RELEASE_VERSION}", action: upsert} + attributes/redact: + actions: + - {key: http.request.header.authorization, action: delete} + - {key: http.request.header.cookie, action: delete} + - {key: http.response.header.set-cookie, action: delete} + - {key: url.query, action: delete} + - {key: db.statement, action: delete} + - {key: enduser.id, action: delete} + - {key: user.phone, action: delete} + - {key: user.email, action: delete} + - {key: messaging.message.body, action: delete} + filter/noise: + error_mode: ignore + traces: + span: + - 'attributes["http.route"] == "/health/live"' + - 'attributes["http.route"] == "/nginx-health/live"' + probabilistic_sampler: + sampling_percentage: 10 + batch: + timeout: 5s + send_batch_size: 1024 + send_batch_max_size: 2048 + +exporters: + otlp/remote: + endpoint: "${env:OTEL_REMOTE_ENDPOINT}" + headers: + authorization: "${env:OTEL_REMOTE_AUTH_HEADER}" + tls: + insecure: false + sending_queue: + enabled: true + num_consumers: 4 + queue_size: 10000 + storage: file_storage + retry_on_failure: + enabled: true + initial_interval: 5s + max_interval: 30s + max_elapsed_time: 0s + +service: + extensions: [health_check, file_storage] + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, resource/common, attributes/redact, filter/noise, probabilistic_sampler, batch] + exporters: [otlp/remote] + metrics: + receivers: [otlp, prometheus] + processors: [memory_limiter, resource/common, attributes/redact, batch] + exporters: [otlp/remote] + logs: + receivers: [otlp] + processors: [memory_limiter, resource/common, attributes/redact, batch] + exporters: [otlp/remote] + telemetry: + metrics: + address: 0.0.0.0:8888 + level: normal diff --git a/codebase/backend/redis/Dockerfile b/codebase/backend/redis/Dockerfile new file mode 100644 index 0000000..e7a0964 --- /dev/null +++ b/codebase/backend/redis/Dockerfile @@ -0,0 +1,11 @@ +FROM redis:7.4.2-alpine +RUN apk add --no-cache gettext \ + && mkdir -p /etc/han-redis /usr/local/lib/han +COPY redis.conf /etc/han-redis/redis.conf +COPY users.acl.template /etc/han-redis/users.acl.template +COPY scripts/entrypoint.sh /usr/local/lib/han/entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/lib/han/entrypoint.sh \ + && chmod 0555 /usr/local/lib/han/entrypoint.sh \ + && chmod 0444 /etc/han-redis/* +USER redis +ENTRYPOINT ["/usr/local/lib/han/entrypoint.sh"] diff --git a/codebase/backend/redis/docker-compose.yml b/codebase/backend/redis/docker-compose.yml new file mode 100644 index 0000000..01fecd6 --- /dev/null +++ b/codebase/backend/redis/docker-compose.yml @@ -0,0 +1,34 @@ +services: + redis: + build: + context: . + image: han-chat-redis:${RELEASE_VERSION:-local} + environment: + REDIS_API_PASSWORD: ${REDIS_API_PASSWORD} + REDIS_SAFETY_PASSWORD: ${REDIS_SAFETY_PASSWORD} + REDIS_HEALTH_PASSWORD: ${REDIS_HEALTH_PASSWORD} + REDIS_MAXMEMORY: ${REDIS_MAXMEMORY:-384mb} + REDIS_EVICTION_POLICY: ${REDIS_EVICTION_POLICY:-volatile-lru} + expose: ["6379"] + volumes: + - redis-data:/data + networks: [backend] + healthcheck: + test: ["CMD-SHELL", "redis-cli --no-auth-warning --user ops_health --pass \"$$REDIS_HEALTH_PASSWORD\" PING | grep -qx PONG"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + restart: unless-stopped + stop_grace_period: 30s + read_only: true + tmpfs: + - /tmp:size=8m,mode=1777 + security_opt: ["no-new-privileges:true"] + cap_drop: ["ALL"] + mem_limit: 512m + ulimits: + nofile: {soft: 65536, hard: 65536} + logging: + driver: json-file + options: {max-size: "50m", max-file: "5"} diff --git a/codebase/backend/redis/redis.conf b/codebase/backend/redis/redis.conf new file mode 100644 index 0000000..82a72a0 --- /dev/null +++ b/codebase/backend/redis/redis.conf @@ -0,0 +1,32 @@ +bind 0.0.0.0 +port 6379 +protected-mode yes +daemonize no +supervised no +dir /data +dbfilename dump.rdb +appendonly yes +appendfilename appendonly.aof +appendfsync everysec +aof-use-rdb-preamble yes +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb +save 900 1 +save 300 100 +save 60 10000 +stop-writes-on-bgsave-error yes +maxmemory-policy volatile-lru +timeout 0 +tcp-keepalive 300 +databases 3 +loglevel notice +logfile "" +latency-monitor-threshold 100 +slowlog-log-slower-than 10000 +slowlog-max-len 128 +client-output-buffer-limit pubsub 32mb 8mb 60 +rename-command FLUSHALL "" +rename-command FLUSHDB "" +rename-command CONFIG "" +rename-command MODULE "" +rename-command KEYS "" diff --git a/codebase/backend/redis/scripts/entrypoint.sh b/codebase/backend/redis/scripts/entrypoint.sh new file mode 100644 index 0000000..53791ba --- /dev/null +++ b/codebase/backend/redis/scripts/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +for name in REDIS_API_PASSWORD REDIS_SAFETY_PASSWORD REDIS_HEALTH_PASSWORD; do + eval "value=\${$name:-}" + if [ "${#value}" -lt 16 ] || printf '%s' "$value" | grep -Eq '[[:space:]]'; then + echo "redis bootstrap: $name must be at least 16 characters without whitespace" >&2 + exit 64 + fi +done + +umask 077 +envsubst '${REDIS_API_PASSWORD} ${REDIS_SAFETY_PASSWORD} ${REDIS_HEALTH_PASSWORD}' \ + < /etc/han-redis/users.acl.template > /tmp/users.acl + +exec redis-server /etc/han-redis/redis.conf \ + --aclfile /tmp/users.acl \ + --maxmemory "${REDIS_MAXMEMORY:-384mb}" \ + --maxmemory-policy "${REDIS_EVICTION_POLICY:-volatile-lru}" diff --git a/codebase/backend/redis/users.acl.template b/codebase/backend/redis/users.acl.template new file mode 100644 index 0000000..e6723ec --- /dev/null +++ b/codebase/backend/redis/users.acl.template @@ -0,0 +1,4 @@ +user default off +user api_backend on >${REDIS_API_PASSWORD} ~han:api:* ~han:rt:* ~han:coord:* &han:rt:* +@read +@write +@connection +@scripting +publish +subscribe +psubscribe -flushall -flushdb -config -module -keys +user message_safety on >${REDIS_SAFETY_PASSWORD} ~han:safety:* +@read +@write +@connection +@scripting -flushall -flushdb -config -module -keys -publish -subscribe -psubscribe +user ops_health on >${REDIS_HEALTH_PASSWORD} +ping +info +client|id -select diff --git a/codebase/backend/scripts/validate-env b/codebase/backend/scripts/validate-env new file mode 100644 index 0000000..1f1bede --- /dev/null +++ b/codebase/backend/scripts/validate-env @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Fail-fast validation for the single HAN Chat deployment environment.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path +from urllib.parse import urlparse + +REQUIRED = { + "APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "DATABASE_URL", + "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL", + "MESSAGE_SAFETY_DATABASE_URL", "KEYCLOAK_DB_URL", "PUBLIC_HOST", + "PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", + "KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "REDIS_URL", + "REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL", + "MESSAGE_SAFETY_SERVICE_TOKEN", "BITRIX_LOCAL_APP_INTERNAL_TOKEN", + "BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN", + "BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN", + "KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY", + "KEYCLOAK_ADMIN", "KEYCLOAK_ADMIN_PASSWORD", "CURSOR_HMAC_SECRET", + "BITRIX_TOKEN_ENCRYPTION_KEY", + "SELECTEL_S3_ENDPOINT_URL", + "SELECTEL_S3_ACCESS_KEY", "SELECTEL_S3_SECRET_KEY", + "SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY", + "SELECTEL_S3_QUARANTINE_READ_SECRET_KEY", "REDIS_API_PASSWORD", + "REDIS_SAFETY_PASSWORD", "REDIS_HEALTH_PASSWORD", +} +SECRET_KEYS = { + key for key in REQUIRED + if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET_KEY", "ACCESS_KEY")) +} | {"KEYCLOAK_OTP_MOCK_CODE", "BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN"} +PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I) + + +def load(path: Path) -> tuple[dict[str, str], list[str]]: + values: dict[str, str] = {} + errors: list[str] = [] + for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + errors.append(f"строка {number}: ожидается KEY=VALUE") + continue + key, value = line.split("=", 1) + key = key.strip() + if key in values: + errors.append(f"строка {number}: повтор ключа {key}") + values[key] = value.strip() + return values, errors + + +def main() -> int: + path = Path(sys.argv[1] if len(sys.argv) > 1 else ".env") + if not path.is_file(): + print(f"ERROR: файл не найден: {path}", file=sys.stderr) + return 2 + env, errors = load(path) + for key in sorted(REQUIRED): + if not env.get(key): + errors.append(f"{key}: обязательное значение отсутствует") + for key in sorted(SECRET_KEYS): + value = env.get(key, "") + if value and (len(value) < 16 or PLACEHOLDER.search(value)): + errors.append(f"{key}: секрет должен быть непустым, уникальным и длиной >=16") + + production = env.get("APP_ENV") in {"production-like", "production"} + if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false": + errors.append("FRONTEND_DEV_PROXY_ENABLED: production-like/production требует false") + if production and env.get("NGINX_TLS_ENABLED", "").lower() != "true": + errors.append("NGINX_TLS_ENABLED: production-like/production требует true") + for key in ("PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL"): + if env.get(key) and urlparse(env[key]).scheme != "https": + errors.append(f"{key}: публичный URL должен использовать https") + for key in ("KEYCLOAK_INTERNAL_URL", "MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL"): + parsed = urlparse(env.get(key, "")) + if parsed.scheme != "http" or "." in (parsed.hostname or ""): + errors.append(f"{key}: ожидается http URL с Docker DNS service name") + if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"): + errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth") + redis_contract = ( + ("REDIS_URL", "api_backend", "REDIS_API_PASSWORD", "/0"), + ("REDIS_REALTIME_URL", "api_backend", "REDIS_API_PASSWORD", "/1"), + ("MESSAGE_SAFETY_REDIS_URL", "message_safety", "REDIS_SAFETY_PASSWORD", "/2"), + ) + for key, username, password_key, database in redis_contract: + parsed = urlparse(env.get(key, "")) + if ( + parsed.scheme != "redis" + or parsed.hostname != "redis" + or parsed.username != username + or parsed.password != env.get(password_key) + or parsed.path != database + ): + errors.append(f"{key}: ACL user/password/host/DB не согласованы с {password_key}") + for key in ( + "DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL", + "MESSAGE_SAFETY_DATABASE_URL", "KEYCLOAK_DB_URL", + ): + value = env.get(key, "") + if "sslmode=verify-full" not in value or "sslrootcert=" not in value: + errors.append(f"{key}: требуется sslmode=verify-full и sslrootcert") + pairs = ( + ("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"), + ("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"), + ) + for left, right in pairs: + if env.get(left) != env.get(right): + errors.append(f"{left} должен совпадать с {right}") + try: + poll = int(env.get("MESSAGE_SAFETY_TASK_POLL_MAX_SEC", "0")) + nginx = int(env.get("NGINX_MESSAGE_READ_TIMEOUT_SEC", "0")) + ttl = int(env.get("MESSAGE_SAFETY_TASK_TTL_SEC", "0")) + if nginx < poll + 30: + errors.append("NGINX_MESSAGE_READ_TIMEOUT_SEC должен быть >= poll max + 30") + if ttl <= poll + 60: + errors.append("MESSAGE_SAFETY_TASK_TTL_SEC должен превышать poll max + recovery margin") + except ValueError: + errors.append("Safety/NGINX timeout values должны быть целыми числами") + if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true": + if env.get("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED", "").lower() != "true": + errors.append("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true обязателен для mock OTP") + if env.get("HAN_PG_HOST") in {"localhost", "127.0.0.1", "postgres", "db"}: + errors.append("HAN_PG_HOST: PostgreSQL должен быть внешним managed endpoint") + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print(f"OK: {path} прошёл проверку ({len(env)} переменных)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py new file mode 100644 index 0000000..01934eb --- /dev/null +++ b/codebase/backend/tests/test_config.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class InfrastructureConfigTests(unittest.TestCase): + def test_root_compose_uses_only_infra_fragments(self) -> None: + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + for fragment in ( + "infra/compose/application.yml", + "nginx/docker-compose.yml", + "redis/docker-compose.yml", + "observability/docker-compose.yml", + "deployment/docker-compose.jobs.yml", + ): + self.assertIn(fragment, compose) + self.assertNotIn("postgres:", compose.lower()) + + def test_only_nginx_fragment_publishes_ports(self) -> None: + forbidden = ( + ROOT / "infra/compose/application.yml", + ROOT / "redis/docker-compose.yml", + ROOT / "observability/docker-compose.yml", + ROOT / "deployment/docker-compose.jobs.yml", + ) + for path in forbidden: + self.assertNotIn("\n ports:", path.read_text(encoding="utf-8"), path) + nginx = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") + self.assertEqual(nginx.count("\n ports:"), 1) + self.assertIn('NGINX_HTTP_PORT:-80}:80', nginx) + self.assertIn('NGINX_HTTPS_PORT:-443}:443', nginx) + + def test_nginx_internal_denies_precede_spa(self) -> None: + site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8") + internal = site.index("location ^~ /internal/") + api = site.index("location ^~ /api/") + frontend = site.index("include /etc/nginx/generated/frontend-location.conf") + self.assertLess(internal, api) + self.assertLess(api, frontend) + self.assertIn("location = /api/v1/realtime", site) + self.assertNotIn("message-safety:", site) + + def test_redis_persistence_acl_and_no_host_port(self) -> None: + config = (ROOT / "redis/redis.conf").read_text(encoding="utf-8") + acl = (ROOT / "redis/users.acl.template").read_text(encoding="utf-8") + self.assertIn("appendonly yes", config) + self.assertIn("appendfsync everysec", config) + self.assertIn("save 900 1", config) + self.assertIn("user default off", acl) + self.assertIn("~han:api:*", acl) + self.assertIn("~han:safety:*", acl) + + def test_otel_has_redaction_and_persistent_queue(self) -> None: + config = (ROOT / "observability/otel-collector.yaml").read_text(encoding="utf-8") + for forbidden_attribute in ( + "http.request.header.authorization", + "http.request.header.cookie", + "url.query", + "db.statement", + "messaging.message.body", + ): + self.assertIn(forbidden_attribute, config) + self.assertIn("storage: file_storage", config) + self.assertIn("retry_on_failure:", config) + + def test_settings_cli_and_workers_are_deployable(self) -> None: + cli = ROOT / "api-backend/app/cli" + self.assertTrue((cli / "__init__.py").is_file()) + self.assertTrue((cli / "seed_settings.py").is_file()) + self.assertTrue((cli / "validate_settings.py").is_file()) + + jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8") + self.assertIn("python -m app.cli.seed_settings", jobs) + self.assertIn("python -m app.cli.validate_settings", jobs) + + application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") + self.assertIn("\n frontend-static:", application) + self.assertIn("frontend-test-site", application) + self.assertIn("frontend-static:/output", application) + for service, command in ( + ("delivery-worker:", "han-delivery-worker"), + ("safety-recovery-worker:", "han-safety-worker"), + ("cleanup-worker:", "han-cleanup-worker"), + ): + self.assertIn(f"\n {service}", application) + self.assertIn(f'command: ["{command}"]', application) + self.assertNotIn("\n ports:", application) + nginx = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") + self.assertIn("frontend-static: {condition: service_completed_successfully}", nginx) + + def test_keycloak_management_health_and_bridge_environment(self) -> None: + standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8") + self.assertIn("GET /health/ready", standalone) + self.assertNotIn("GET /auth/health/ready", standalone) + + application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") + for variable in ( + "KEYCLOAK_OTP_MOCK_ENABLED", + "KEYCLOAK_OTP_MOCK_CODE", + "KEYCLOAK_OTP_HMAC_KEY", + "KEYCLOAK_OTP_TTL_SEC", + "KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS", + "KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", + "KEYCLOAK_SETTINGS_BRIDGE_URL", + "KEYCLOAK_SETTINGS_BRIDGE_TOKEN", + ): + self.assertIn(f" {variable}:", application) + + def test_env_validator_accepts_materialized_example(self) -> None: + example = (ROOT / ".env.example").read_text(encoding="utf-8") + for required in ( + "CURSOR_HMAC_SECRET=", + "BITRIX_TOKEN_ENCRYPTION_KEY=", + "KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth", + ): + self.assertIn(required, example) + materialized = example.replace("change-me", "0123456789abcdef0123456789abcdef") + materialized = materialized.replace( + "KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false", + "KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true", + ) + with tempfile.TemporaryDirectory() as directory: + env_file = Path(directory) / ".env" + env_file.write_text(materialized, encoding="utf-8") + result = subprocess.run( + [sys.executable, str(ROOT / "scripts/validate-env"), str(env_file)], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/architectory/module-01-api-backend.md b/modules/module-01-api-backend.md similarity index 100% rename from architectory/module-01-api-backend.md rename to modules/module-01-api-backend.md diff --git a/architectory/module-02-frontend-test-site.md b/modules/module-02-frontend-test-site.md similarity index 100% rename from architectory/module-02-frontend-test-site.md rename to modules/module-02-frontend-test-site.md diff --git a/architectory/module-03-nginx.md b/modules/module-03-nginx.md similarity index 100% rename from architectory/module-03-nginx.md rename to modules/module-03-nginx.md diff --git a/architectory/module-04-redis.md b/modules/module-04-redis.md similarity index 100% rename from architectory/module-04-redis.md rename to modules/module-04-redis.md diff --git a/architectory/module-05-message-safety.md b/modules/module-05-message-safety.md similarity index 100% rename from architectory/module-05-message-safety.md rename to modules/module-05-message-safety.md diff --git a/architectory/module-06-bitrix-local-app.md b/modules/module-06-bitrix-local-app.md similarity index 100% rename from architectory/module-06-bitrix-local-app.md rename to modules/module-06-bitrix-local-app.md diff --git a/architectory/module-07-bitrix-sync.md b/modules/module-07-bitrix-sync.md similarity index 100% rename from architectory/module-07-bitrix-sync.md rename to modules/module-07-bitrix-sync.md diff --git a/architectory/module-08-keycloak.md b/modules/module-08-keycloak.md similarity index 100% rename from architectory/module-08-keycloak.md rename to modules/module-08-keycloak.md diff --git a/architectory/module-09-observability.md b/modules/module-09-observability.md similarity index 100% rename from architectory/module-09-observability.md rename to modules/module-09-observability.md diff --git a/architectory/module-10-deployment-runbook.md b/modules/module-10-deployment-runbook.md similarity index 86% rename from architectory/module-10-deployment-runbook.md rename to modules/module-10-deployment-runbook.md index ca9d00f..5f66ad8 100644 --- a/architectory/module-10-deployment-runbook.md +++ b/modules/module-10-deployment-runbook.md @@ -172,18 +172,20 @@ free -h ## 6. Stage 3 — managed PostgreSQL -### 6.1. Backups и TLS +### 6.1. Защита и восстановление managed PostgreSQL -До схем включить: +До создания схем включить и проверить настройки managed PostgreSQL: -- daily backup; -- PITR; -- encryption at rest; -- TLS certificate/CA; -- alerts storage/connections/backup failure; -- deletion protection. +- **Daily backup** — автоматический полный снимок БД не реже одного раза в сутки. Нужно задать срок хранения, например 7–14 дней, и убедиться, что резервные копии размещаются отдельно от вычислительного узла БД. Наличие backup без периодической проверки восстановления не считается достаточным. +- **PITR (Point-in-Time Recovery)** — восстановление состояния БД на выбранный момент времени между полными backup за счёт архивации WAL. Это позволяет откатиться, например, к состоянию непосредственно перед ошибочной миграцией или удалением данных. Период доступного восстановления должен соответствовать принятому RPO. +- **Encryption at rest** — шифрование дисков, backup и WAL на стороне провайдера managed PostgreSQL. Ключ управляется провайдером либо KMS организации; пароль пользователя БД не заменяет это шифрование. +- **TLS для соединения с PostgreSQL** — шифрование трафика между контейнерами на VM и managed PostgreSQL с обязательной проверкой имени сервера. CA-сертификат обычно выдаёт провайдер PostgreSQL. Это **не** публичный сертификат сайта и не сертификат Let's Encrypt. +- **Alerts** — уведомления как минимум о нехватке диска, исчерпании подключений, высокой загрузке CPU/IO, replication lag (если есть реплики), неуспешном backup и истечении/замене CA. +- **Deletion protection** — запрет удаления кластера обычной командой/API. Отключение защиты должно быть отдельным подтверждаемым действием администратора. Эта настройка не защищает от `DROP TABLE`, поэтому least privilege и backup всё равно обязательны. -Скачать CA в защищённый путь VM, например `/opt/han-chat/secrets/pg/ca.pem`, mode 0444/0400 по policy. Все DSN используют `verify-full`/эквивалент. +CA managed PostgreSQL скачать из панели или документации провайдера в защищённый путь VM, например `/opt/han-chat/secrets/pg/ca.pem`, с владельцем `root:deploy` и mode `0440` (либо `0400`, если файл читает один пользователь). Все DSN используют `sslmode=verify-full` и `sslrootcert=/run/secrets/pg-ca.pem` либо эквивалент драйвера. Режимы `disable`, `allow`, `prefer` и `require` без проверки CA для production запрещены. + +Публичный HTTPS-сертификат `` выпускается отдельно через **Let's Encrypt** на Stage 9. Он устанавливается в корневой nginx, а не в PostgreSQL. ### 6.2. Роли @@ -508,17 +510,27 @@ Redis: ACL, AOF everysec, RDB, maxmemory, volume, no host port. OTEL: config rea 2. Запустить nginx с bootstrap config: только `/.well-known/acme-challenge/` и redirect; TLS block не требует отсутствующий cert. 3. Запустить Certbot profile: +Сначала проверить процесс через Let's Encrypt staging CA, добавив `--staging`. Staging-сертификат не является доверенным браузерами и нужен только для проверки DNS, firewall, ACME webroot и конфигурации. После успешной проверки удалить staging lineage либо выпустить production-сертификат с отдельным `--cert-name `. + +Production-выпуск: + ```bash cd docker compose --profile tls-bootstrap up -d nginx docker compose --profile certbot run --rm certbot certonly \ --webroot -w /var/www/certbot \ -d \ + --cert-name \ --email \ --agree-tos --no-eff-email --non-interactive ``` -Сначала rehearsal с Let's Encrypt staging CA. +Каталоги `/etc/letsencrypt` и `/var/www/certbot` должны быть общими named volumes для контейнеров `certbot` и `nginx`. Сертификат и закрытый ключ не копируются в репозиторий или image. TLS-конфигурация nginx использует: + +```text +/etc/letsencrypt/live//fullchain.pem +/etc/letsencrypt/live//privkey.pem +``` ### Phase B: TLS activation @@ -531,13 +543,64 @@ docker compose exec -T nginx nginx -s reload HSTS пока не включать. Проверить chain/hostname/redirect, затем включить HSTS без preload. -Renewal: systemd timer предпочтительнее weekly cron. Запуск минимум дважды в сутки: +### Автоматическое обновление Let's Encrypt -1. `certbot renew --webroot`; -2. если cert изменился — `nginx -t`; -3. reload; -4. emit metric/log; -5. alert expiry. +Сертификаты Let's Encrypt действуют 90 дней. Проверку обновления выполнять **дважды в сутки**; Certbot сам обновляет сертификат только при приближении срока истечения. Для VM предпочтителен systemd timer, который вызывает репозиторный скрипт root Compose. + +Скрипт `/deploy/ssl-renew.sh` должен: + +1. взять `flock`, чтобы исключить параллельные запуски; +2. выполнить `docker compose --profile certbot run --rm certbot renew --webroot -w /var/www/certbot --quiet`; +3. при успешном обновлении проверить `docker compose exec -T nginx nginx -t`; +4. только после успешной проверки выполнить `docker compose exec -T nginx nginx -s reload`; +5. записать структурированный результат и метрику времени до истечения; +6. вернуть ненулевой exit code при ошибке, чтобы сработал alert; +7. не удалять действующий сертификат при неуспешном renew. + +Пример unit `/etc/systemd/system/han-chat-cert-renew.service`: + +```ini +[Unit] +Description=Renew HAN Chat Let's Encrypt certificate +Requires=docker.service +After=docker.service network-online.target + +[Service] +Type=oneshot +User=deploy +WorkingDirectory= +ExecStart=/deploy/ssl-renew.sh +``` + +Пример timer `/etc/systemd/system/han-chat-cert-renew.timer`: + +```ini +[Unit] +Description=Check HAN Chat certificate renewal twice daily + +[Timer] +OnCalendar=*-*-* 03,15:20:00 +RandomizedDelaySec=30m +Persistent=true +Unit=han-chat-cert-renew.service + +[Install] +WantedBy=timers.target +``` + +Установка и обязательная проверка расписания: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now han-chat-cert-renew.timer +sudo systemctl list-timers han-chat-cert-renew.timer +cd +docker compose --profile certbot run --rm certbot renew --dry-run +sudo systemctl start han-chat-cert-renew.service +sudo systemctl status han-chat-cert-renew.service --no-pager +``` + +Дополнительно настроить alert при остатке менее 21 дня и критический alert менее 7 дней. Проверять срок можно synthetic probe снаружи и метрикой exporter/скрипта на VM. Ошибка одного запуска renew не должна останавливать nginx. Прототипные [`ssl-common.sh`](../../HAN_chat/bitrix-local-app/deploy/ssl-common.sh), [`ssl-renew.sh`](../../HAN_chat/bitrix-local-app/deploy/ssl-renew.sh) полезны концептуально, но должны работать с **root Compose**, не service compose. @@ -548,6 +611,7 @@ Renewal: systemd timer предпочтительнее weekly cron. Запус - [ ] HTTP только ACME + 308. - [ ] TLS 1.0/1.1 rejected; 1.2/1.3 accepted. - [ ] Renewal dry-run и safe reload успешны. +- [ ] `han-chat-cert-renew.timer` включён, имеет следующий запуск и переживает reboot. - [ ] Alert expiry настроен. ## 13. Stage 10 — миграции и seed