diff --git a/architectory/arch-02-api-contracts.md b/architectory/arch-02-api-contracts.md index c96a52b..884127a 100644 --- a/architectory/arch-02-api-contracts.md +++ b/architectory/arch-02-api-contracts.md @@ -353,7 +353,7 @@ Keycloak SPI получает product limits OTP из `app_settings` через | Контракт | Владелец | Потребитель | Назначение | Защита | |---|---|---|---|---| -| `GET /internal/settings/v1/otp` | `api-backend` | Keycloak SPI | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, cache metadata | internal network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | +| `GET /internal/settings/v1/otp` | `api-backend` | Keycloak SPI | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, `otp.phone.max_verify_attempts`, cache metadata | internal network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | Ответ не содержит секретов и PII. При недоступности endpoint Keycloak SPI использует последнее валидное cached value; если cache пустой — fail-closed для выдачи OTP. diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index 7b241e1..e877796 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -85,7 +85,7 @@ Managed PostgreSQL **поднимается до** развёртывания п | Группа | Ключи | |---|---| | Auth | `auth.phone.enabled`, `auth.password.enabled` | -| OTP (продукт; потребитель — Keycloak SPI через settings bridge api-backend) | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts` | +| OTP (продукт; потребитель — Keycloak SPI через settings bridge api-backend) | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, `otp.phone.max_verify_attempts` | | Оператор | `operator.call.phone` | | Consent | `consent.personal_data.*`, `consent.user_agreement.*`, `consent.marketing.*` | | Файлы чата | `chat.attachments.*` | @@ -101,6 +101,7 @@ auth.password.enabled=false otp.phone.max_send_attempts_per_24h=3 otp.phone.min_seconds_between_attempts=30 +otp.phone.max_verify_attempts=5 operator.call.phone=+74999591007 @@ -322,7 +323,7 @@ MVP-механизм: 1. `api-backend` читает публичные/служебные настройки из `app_settings` и кэширует их. 2. Для Keycloak SPI доступен internal endpoint `GET /internal/settings/v1/otp` в Docker/VPC-сети, защищённый service token. -3. Keycloak SPI читает `otp.phone.max_send_attempts_per_24h` и `otp.phone.min_seconds_between_attempts` через этот endpoint с локальным cache TTL. +3. Keycloak SPI читает `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts` и `otp.phone.max_verify_attempts` через этот endpoint с локальным cache TTL. 4. При недоступности settings bridge SPI использует последнее валидное cache-значение; если cache пустой — fail-closed и не выдаёт OTP. Счётчики попыток OTP остаются в зоне Keycloak/SPI, не в `api-backend`. diff --git a/backlog.md b/backlog.md index 5d5759f..a7e0243 100644 --- a/backlog.md +++ b/backlog.md @@ -1,9 +1,21 @@ В разработку: 1. Создание нового диалога через UI -2. При создании пользователя номер телефона копировать в профиль Russian_Phone -3. Убрать хеширование устройства клиента в devise_json - надо видеть его параметры. +~~2. При создании пользователя номер телефона копировать в профиль Russian_Phone~~ +~~3. Убрать хеширование устройства клиента в devise_json - хочу видеть его параметры.~~ 4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс. 5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе +6. Кнопка "Войти" для авторизации +7. Если неавторизованный пользователь вводит сообщение, после отправки идет на регистрацию, после окончания регистрации его сообщение пропадает. Надо чтобы сохранялось и отправлялось (по аналогии с нажатием на кнопку из раздела "Популярные вопросы") +~~8. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)~~ +9. Кнопка "Позвонить оператору" (ссылка tel:+74999591007) +10. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts). +11. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. +12. -Выполнено: -1. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession) \ No newline at end of file + +На будущее (после доработки отдельных функциональностей): +1. Разработка message-safety +2. Разработка sync-service +3. Интеграция с СМС-провайдером +3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?) +4. Моделирование профиля клиента/ \ No newline at end of file diff --git a/codebase/backend/.env.example b/codebase/backend/.env.example index 77ae157..b00395b 100644 --- a/codebase/backend/.env.example +++ b/codebase/backend/.env.example @@ -68,7 +68,6 @@ KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false # (openssl rand -hex 32) 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 diff --git a/codebase/backend/api-backend/alembic/versions/0004_device_and_otp_limits.py b/codebase/backend/api-backend/alembic/versions/0004_device_and_otp_limits.py new file mode 100644 index 0000000..9eacb79 --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0004_device_and_otp_limits.py @@ -0,0 +1,40 @@ +"""Store raw device identifiers and add OTP verification limit. + +Revision ID: 0004_device_otp +Revises: 0003_consent_audit +Create Date: 2026-07-21 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0004_device_otp" +down_revision: str | None = "0003_consent_audit" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE han_app.ux_sessions + ADD COLUMN IF NOT EXISTS device_id varchar(255) + """ + ) + op.execute( + """ + INSERT INTO han_app.app_settings + (setting_key, setting_value, value_type, is_public, description, + record_status, updated_at) + VALUES + ('otp.phone.max_verify_attempts', '5', 'integer', false, + 'Maximum failed verification attempts for one OTP challenge', + 'A', now()) + ON CONFLICT (setting_key) DO NOTHING + """ + ) + + +def downgrade() -> None: + raise RuntimeError("Device and OTP limits migration is forward-only") diff --git a/codebase/backend/api-backend/app/db.py b/codebase/backend/api-backend/app/db.py index 4dc0451..a9bb932 100644 --- a/codebase/backend/api-backend/app/db.py +++ b/codebase/backend/api-backend/app/db.py @@ -103,6 +103,7 @@ class UxSession(Common, Base): 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)) + device_id: Mapped[str | None] = mapped_column(String(255)) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index 79ea173..76a5e2f 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -415,7 +415,7 @@ async def ready(request: Request, db: Session): try: await db.execute(text("SELECT 1")) revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1")) - if revision != "0003_consent_audit": + if revision != "0004_device_otp": raise RuntimeError("unexpected database revision") await load_settings(db) components["postgres"] = "ok" @@ -979,6 +979,7 @@ async def otp_settings( 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"), + "max_verify_attempts": settings.integer("otp.phone.max_verify_attempts"), "version": settings.version, "cache_ttl_seconds": 60, }, headers=headers) diff --git a/codebase/backend/api-backend/app/services.py b/codebase/backend/api-backend/app/services.py index 39d538e..a86f6bb 100644 --- a/codebase/backend/api-backend/app/services.py +++ b/codebase/backend/api-backend/app/services.py @@ -63,6 +63,7 @@ REQUIRED_SETTINGS = { "auth.password.enabled", "otp.phone.max_send_attempts_per_24h", "otp.phone.min_seconds_between_attempts", + "otp.phone.max_verify_attempts", "operator.call.phone", "consent.personal_data.required", "consent.personal_data.document_url", @@ -182,9 +183,7 @@ def device_snapshot(device: Any) -> dict[str, str | None]: return { "platform": device.platform, "app_version": device.app_version, - "device_id_hash": ( - hashlib.sha256(device.device_id.encode()).hexdigest() if device.device_id else None - ), + "device_id": device.device_id, } @@ -235,7 +234,7 @@ async def bootstrap( user_id = (await session.execute(statement)).scalar_one() await session.execute( insert(ClientProfile) - .values(id=uuid.uuid4(), user_id=user_id) + .values(id=uuid.uuid4(), user_id=user_id, russian_phone=principal.phone_number) .on_conflict_do_nothing(index_elements=[ClientProfile.user_id]) ) for consent_type in ("personal_data", "user_agreement", "marketing"): @@ -300,7 +299,7 @@ async def record_consents( device = { "platform": ux_session.platform, "app_version": ux_session.app_version, - "device_id_hash": ux_session.device_id_hash, + "device_id": ux_session.device_id, } now = datetime.now(UTC) versions: dict[str, str] = {} @@ -366,7 +365,7 @@ async def start_session( start_reason=body.start_reason, platform=body.device.platform, app_version=body.device.app_version, - device_id_hash=device["device_id_hash"], + device_id=device["device_id"], started_at=now, ) ) diff --git a/codebase/backend/api-backend/tests/unit/test_audit_context.py b/codebase/backend/api-backend/tests/unit/test_audit_context.py index d487227..1cbe47d 100644 --- a/codebase/backend/api-backend/tests/unit/test_audit_context.py +++ b/codebase/backend/api-backend/tests/unit/test_audit_context.py @@ -68,7 +68,7 @@ def test_trace_id_uses_valid_w3c_header_and_generates_fallback() -> None: int(fallback, 16) -def test_user_agent_and_device_are_hashed_without_raw_identifiers() -> None: +def test_user_agent_is_hashed_and_device_parameters_are_preserved() -> None: agent = "Example Browser/1.0" assert user_agent_hash(request(user_agent=agent)) == hashlib.sha256(agent.encode()).hexdigest() snapshot = device_snapshot( @@ -77,9 +77,8 @@ def test_user_agent_and_device_are_hashed_without_raw_identifiers() -> None: assert snapshot == { "platform": "web", "app_version": "1.2.3", - "device_id_hash": hashlib.sha256(b"raw-device-id").hexdigest(), + "device_id": "raw-device-id", } - assert "raw-device-id" not in str(snapshot) def test_audit_copies_request_context_and_bounded_metadata() -> None: diff --git a/codebase/backend/bitrix-local-app/app/main.py b/codebase/backend/bitrix-local-app/app/main.py index 324e0e6..c973dcb 100644 --- a/codebase/backend/bitrix-local-app/app/main.py +++ b/codebase/backend/bitrix-local-app/app/main.py @@ -41,6 +41,11 @@ from app.postgres import create_postgres_engine logger = logging.getLogger("bitrix-local-app") +BITRIX_SENDER_PREFIX = re.compile( + r"^\[b\][^\r\n\[]+:\[/b\]\s*(?:\[br\]\s*)?", + re.IGNORECASE, +) + CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote( '' '' @@ -222,6 +227,10 @@ def first(value: Any, *paths: tuple[str, ...]) -> Any: return None +def strip_bitrix_sender_prefix(text: str) -> str: + return BITRIX_SENDER_PREFIX.sub("", text, count=1) + + def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None: event = str(payload.get("event", "")).upper() if event not in { @@ -257,6 +266,7 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None: text_value = str( first(data, ("MESSAGES", "0", "message", "text"), ("MESSAGE", "TEXT")) or "" ) + text_value = strip_bitrix_sender_prefix(text_value) files_raw = first(data, ("MESSAGES", "0", "message", "files"), ("MESSAGE", "FILES")) or [] if isinstance(files_raw, dict): files_raw = list(files_raw.values()) diff --git a/codebase/backend/bitrix-local-app/tests/test_core.py b/codebase/backend/bitrix-local-app/tests/test_core.py index 6796a34..59f7f90 100644 --- a/codebase/backend/bitrix-local-app/tests/test_core.py +++ b/codebase/backend/bitrix-local-app/tests/test_core.py @@ -58,12 +58,36 @@ def test_normalize_message_and_finish(): assert message["event_type"] == "message.new" assert message["external_chat_id"] == external assert message["bitrix_message_id"] == "86497" + assert message["message"]["text"] == "Ответ" closed = normalize_event( {"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}} ) assert closed["event_type"] == "dialog.closed" +def test_normalize_message_removes_bitrix_sender_prefix(): + external = str(uuid.uuid4()) + message = normalize_event( + { + "event": "ONIMCONNECTORMESSAGEADD", + "data": { + "MESSAGES": [ + { + "im": {"message_id": 86498}, + "chat": {"id": external}, + "message": { + "text": "[b]Антон Пичугин:[/b] [br]опять ты?", + "files": [], + }, + } + ] + }, + } + ) + + assert message["message"]["text"] == "опять ты?" + + def test_normalize_bitrix_file_uses_download_url_and_infers_mime_type(): external = str(uuid.uuid4()) message = normalize_event( diff --git a/codebase/backend/deployment/app-settings.production-like.yaml b/codebase/backend/deployment/app-settings.production-like.yaml index bc694ad..ef031f2 100644 --- a/codebase/backend/deployment/app-settings.production-like.yaml +++ b/codebase/backend/deployment/app-settings.production-like.yaml @@ -4,6 +4,7 @@ settings: 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} + otp.phone.max_verify_attempts: {type: integer, value: 5, 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} diff --git a/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx b/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx index 5dc7dc2..10b5d58 100644 --- a/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx +++ b/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import React, { useEffect, useMemo, useState } from "react"; import { Platform, ScrollView, Text, View } from "react-native"; import { useApp } from "../../src/app-context"; @@ -23,9 +23,11 @@ export default function ChatScreen() { const { dialogId } = useLocalSearchParams<{ dialogId: string }>(); const app = useApp(); const client = useQueryClient(); + const router = useRouter(); const [text, setText] = useState(""); const [error, setError] = useState(); const [sending, setSending] = useState(false); + const [creating, setCreating] = useState(false); const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config }); const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" }); const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" }); @@ -104,6 +106,15 @@ export default function ChatScreen() { } catch (reason) { setError(reason); } }; + const createDialog = async () => { + setCreating(true); setError(undefined); + try { + const created = await dialogApi.create(crypto.randomUUID()); + router.replace(`/dialogs/${created.dialog_id}`); + } catch (reason) { setError(reason); } + finally { setCreating(false); } + }; + const closed = dialog.data?.status === "closed"; return
void app.signOut() : undefined} /> @@ -122,7 +133,11 @@ export default function ChatScreen() { )} {!messages.isLoading && !messages.data?.items.length && Сообщений пока нет.} - {closed ? Диалог закрыт и доступен только для чтения. : + {closed ? + Диалог закрыт и доступен только для чтения. + :
+ + + + meow + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/figma/package.json b/figma/package.json new file mode 100644 index 0000000..fad7042 --- /dev/null +++ b/figma/package.json @@ -0,0 +1,90 @@ +{ + "name": "@figma/my-make-file", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "@emotion/react": "11.14.0", + "@emotion/styled": "11.14.1", + "@mui/icons-material": "7.3.5", + "@mui/material": "7.3.5", + "@popperjs/core": "2.11.8", + "@radix-ui/react-accordion": "1.2.3", + "@radix-ui/react-alert-dialog": "1.1.6", + "@radix-ui/react-aspect-ratio": "1.1.2", + "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "1.1.4", + "@radix-ui/react-collapsible": "1.1.3", + "@radix-ui/react-context-menu": "2.2.6", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "2.1.6", + "@radix-ui/react-hover-card": "1.1.6", + "@radix-ui/react-label": "2.1.2", + "@radix-ui/react-menubar": "1.1.6", + "@radix-ui/react-navigation-menu": "1.2.5", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-progress": "1.1.2", + "@radix-ui/react-radio-group": "1.2.3", + "@radix-ui/react-scroll-area": "1.2.3", + "@radix-ui/react-select": "2.1.6", + "@radix-ui/react-separator": "1.1.2", + "@radix-ui/react-slider": "1.2.3", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-switch": "1.1.3", + "@radix-ui/react-tabs": "1.1.3", + "@radix-ui/react-toggle-group": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-tooltip": "1.1.8", + "canvas-confetti": "1.9.4", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "date-fns": "3.6.0", + "embla-carousel-react": "8.6.0", + "input-otp": "1.4.2", + "lucide-react": "0.487.0", + "motion": "12.23.24", + "next-themes": "0.4.6", + "react-day-picker": "8.10.1", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", + "react-hook-form": "7.55.0", + "react-popper": "2.3.0", + "react-resizable-panels": "2.1.7", + "react-responsive-masonry": "2.7.1", + "react-router": "7.13.0", + "react-slick": "0.31.0", + "recharts": "2.15.2", + "sonner": "2.0.3", + "tailwind-merge": "3.2.0", + "tw-animate-css": "1.3.8", + "vaul": "1.1.2" + }, + "devDependencies": { + "@tailwindcss/vite": "4.1.12", + "@vitejs/plugin-react": "4.7.0", + "tailwindcss": "4.1.12", + "vite": "6.3.5" + }, + "peerDependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + }, + "pnpm": { + "overrides": { + "vite": "6.3.5" + } + } +} \ No newline at end of file diff --git a/figma/pnpm-workspace.yaml b/figma/pnpm-workspace.yaml new file mode 100644 index 0000000..e4aab11 --- /dev/null +++ b/figma/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - '.' \ No newline at end of file diff --git a/figma/postcss.config.mjs b/figma/postcss.config.mjs new file mode 100644 index 0000000..531dbec --- /dev/null +++ b/figma/postcss.config.mjs @@ -0,0 +1,15 @@ +/** + * PostCSS Configuration + * + * Tailwind CSS v4 (via @tailwindcss/vite) automatically sets up all required + * PostCSS plugins — you do NOT need to include `tailwindcss` or `autoprefixer` here. + * + * This file only exists for adding additional PostCSS plugins, if needed. + * For example: + * + * import postcssNested from 'postcss-nested' + * export default { plugins: [postcssNested()] } + * + * Otherwise, you can leave this file empty. + */ +export default {} diff --git a/figma/src/app/App.tsx b/figma/src/app/App.tsx new file mode 100644 index 0000000..2040ce9 --- /dev/null +++ b/figma/src/app/App.tsx @@ -0,0 +1,6 @@ +import { RouterProvider } from 'react-router'; +import { router } from './routes'; + +export default function App() { + return ; +} \ No newline at end of file diff --git a/figma/src/app/components/ChatInput.tsx b/figma/src/app/components/ChatInput.tsx new file mode 100644 index 0000000..6a4d6b1 --- /dev/null +++ b/figma/src/app/components/ChatInput.tsx @@ -0,0 +1,81 @@ +import { Mic, Paperclip, Send } from 'lucide-react'; +import { useState } from 'react'; +import { useNavigate } from 'react-router'; + +export function ChatInput() { + const [message, setMessage] = useState(''); + const [isFocused, setIsFocused] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (message.trim()) { + // Переход в чат при отправке сообщения + navigate('/chat/new'); + setMessage(''); + setIsFocused(false); + } + }; + + return ( +
+
+
+ + +