From f854c14c45b5c0cf23c4e5a931ce3ab1134043e8 Mon Sep 17 00:00:00 2001 From: bvn13 Date: Fri, 27 Feb 2026 00:32:28 +0300 Subject: [PATCH] initial --- README.md | 34 + doc/DESIGN.md | 1849 +++++++++++++++++++++ server/.gitignore | 1 + server/README.md | 194 +++ server/docker-compose.yml | 80 + server/prosody.cfg.lua | 45 + server/s3-upload-handler/Dockerfile | 11 + server/s3-upload-handler/main.py | 93 ++ server/s3-upload-handler/requirements.txt | 3 + server/secrets.env.example | 7 + server/snikket.conf | 12 + xmpp_messenger_design_document.md | 297 ++++ 12 files changed, 2626 insertions(+) create mode 100644 README.md create mode 100644 doc/DESIGN.md create mode 100644 server/.gitignore create mode 100644 server/README.md create mode 100644 server/docker-compose.yml create mode 100644 server/prosody.cfg.lua create mode 100644 server/s3-upload-handler/Dockerfile create mode 100644 server/s3-upload-handler/main.py create mode 100644 server/s3-upload-handler/requirements.txt create mode 100644 server/secrets.env.example create mode 100644 server/snikket.conf create mode 100644 xmpp_messenger_design_document.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..38f21d8 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Jabogram + +XMPP-мессенджер для обмена текстовыми и голосовыми сообщениями. + +## Возможности + +- Личные и групповые чаты +- Текстовые сообщения с подтверждением доставки и прочтения +- Голосовые сообщения (запись, отправка, воспроизведение с визуализацией) +- Индикатор набора текста +- Онлайн-статусы контактов +- Синхронизация истории сообщений +- Push-уведомления (APNs / FCM) + +## Стек + +- **Сервер:** ejabberd + PostgreSQL + Nginx (Docker) +- **Клиент:** React Native + TypeScript + stanza.js + Zustand + +## Структура проекта + +``` +jabogram/ +├── doc/ # Документация +│ └── DESIGN.md # Дизайн-документ +├── server/ # Серверная часть (Docker, конфиги ejabberd) +├── react-native-lib/ # Общий код клиентов (компоненты, сервисы, stores) +├── react-native-ios/ # iOS-приложение +└── react-native-android/ # Android-приложение +``` + +## Документация + +- [Дизайн-документ](doc/DESIGN.md) — полное техническое описание архитектуры, протокола, моделей данных, сценариев и развёртывания. diff --git a/doc/DESIGN.md b/doc/DESIGN.md new file mode 100644 index 0000000..ca2382b --- /dev/null +++ b/doc/DESIGN.md @@ -0,0 +1,1849 @@ +# Jabogram: Дизайн-документ + +> Версия: 1.0 +> Дата: 2026-02-10 + +--- + +## Содержание + +1. [Введение](#1-введение) +2. [Глоссарий](#2-глоссарий) +3. [Архитектура системы](#3-архитектура-системы) +4. [Структура проекта](#4-структура-проекта) +5. [Сервер](#5-сервер) +6. [Протокол XMPP: используемые XEP](#6-протокол-xmpp-используемые-xep) +7. [Формат сообщений](#7-формат-сообщений) +8. [Клиентское приложение — общая библиотека](#8-клиентское-приложение--общая-библиотека) +9. [Клиентское приложение — iOS](#9-клиентское-приложение--ios) +10. [Клиентское приложение — Android](#10-клиентское-приложение--android) +11. [Модели данных](#11-модели-данных) +12. [Сценарии взаимодействия](#12-сценарии-взаимодействия) +13. [Безопасность](#13-безопасность) +14. [Развёртывание](#14-развёртывание) +15. [Тестирование](#15-тестирование) +16. [Ограничения и допущения v1](#16-ограничения-и-допущения-v1) + +--- + +## 1. Введение + +### 1.1 Цель + +Jabogram — мессенджер для обмена **текстовыми** и **голосовыми** сообщениями (голос — только предзаписанные аудиофайлы, без реалтайм-стриминга). Протокол — XMPP. + +### 1.2 Основные возможности (scope v1) + +| Функция | Описание | +|---|---| +| Регистрация / вход | XMPP In-Band Registration (XEP-0077) + SASL-аутентификация | +| Личные чаты (1-on-1) | Текст и голосовые сообщения | +| Групповые чаты | Multi-User Chat (XEP-0045) — текст и голосовые сообщения | +| Голосовые сообщения | Запись аудио → загрузка файла → отправка ссылки через XMPP | +| Статусы сообщений | Отправлено / доставлено / прочитано | +| Индикатор набора | «Пользователь печатает…» | +| Список контактов | XMPP Roster + поиск по JID | +| Присутствие | Online / Away / Offline | +| История сообщений | MAM (XEP-0313) + локальный кеш SQLite | +| Push-уведомления | APNs (iOS), FCM (Android) через XEP-0357 | +| Профиль пользователя | Никнейм + аватар (XEP-0084) | + +### 1.3 Вне scope v1 + +- Голосовые/видеозвонки (WebRTC) +- End-to-end шифрование (OMEMO, XEP-0384) +- Передача произвольных файлов (изображения, документы) +- Web-клиент +- Стикеры / реакции + +--- + +## 2. Глоссарий + +| Термин | Определение | +|---|---| +| **JID** | Jabber ID — адрес пользователя в XMPP, формат `user@domain/resource` | +| **Bare JID** | JID без resource-части: `user@domain` | +| **Full JID** | JID с resource: `user@domain/phone` | +| **Roster** | Список контактов пользователя, хранится на сервере | +| **Stanza** | XML-сообщение протокола XMPP (``, ``, ``) | +| **MAM** | Message Archive Management — серверный архив сообщений | +| **MUC** | Multi-User Chat — групповой чат XMPP | +| **XEP** | XMPP Extension Protocol — расширение протокола | +| **OOB** | Out of Band Data — передача URL файла внутри stanza | +| **BOSH** | Bidirectional-streams Over Synchronous HTTP (не используется, только WebSocket) | + +--- + +## 3. Архитектура системы + +### 3.1 Общая схема + +``` +┌──────────────┐ WSS (5443) ┌──────────────────────┐ +│ iOS-клиент │◄─────────────────────────► │ │ +│ (React │ │ ejabberd │ +│ Native) │ │ XMPP-сервер │ +└──────────────┘ │ │ + │ ┌────────────────┐ │ +┌──────────────┐ WSS (5443) │ │ mod_mam │ │ ┌────────────┐ +│ Android- │◄─────────────────────────► │ │ mod_muc │ │◄──►│ PostgreSQL │ +│ клиент │ │ │ mod_http_upload│ │ └────────────┘ +│ (React │ │ │ mod_push │ │ +│ Native) │ │ │ mod_roster │ │ ┌────────────┐ +└──────────────┘ │ │ ... │ │───►│ File │ + │ └────────────────┘ │ │ Storage │ + └──────────┬───────────┘ └────────────┘ + │ + ┌──────────▼───────────┐ + │ Push Gateway │ + │ (APNs / FCM) │ + └──────────────────────┘ +``` + +### 3.2 Технологический стек + +| Компонент | Технология | +|---|---| +| XMPP-сервер | **ejabberd** (latest stable) | +| БД сервера | **PostgreSQL 16+** | +| Файловое хранилище | Локальный диск / S3-совместимое (MinIO) | +| Reverse proxy | **Nginx** (TLS-терминация, проксирование WebSocket) | +| Контейнеризация | **Docker + docker-compose** | +| Клиент: фреймворк | **React Native** (latest stable, New Architecture) | +| Клиент: XMPP-библиотека | **stanza.js** | +| Клиент: навигация | **React Navigation 7+** | +| Клиент: состояние | **Zustand** | +| Клиент: локальная БД | **op-sqlite** (SQLite для React Native) | +| Клиент: аудио | **expo-av** или **react-native-audio-recorder-player** | +| Клиент: push (iOS) | **@react-native-firebase/messaging** + APNs | +| Клиент: push (Android) | **@react-native-firebase/messaging** + FCM | +| Язык клиента | **TypeScript** (strict mode) | + +--- + +## 4. Структура проекта + +Монорепозиторий с npm/yarn workspaces. + +``` +jabogram/ +├── README.md +├── package.json # workspace root +├── doc/ +│ └── DESIGN.md # этот документ +│ +├── server/ +│ ├── docker-compose.yml # ejabberd + postgres + nginx + minio +│ ├── ejabberd/ +│ │ ├── Dockerfile # кастомный образ (если нужны плагины) +│ │ ├── ejabberd.yml # основной конфиг +│ │ └── certs/ # TLS-сертификаты (не в git) +│ ├── nginx/ +│ │ └── nginx.conf # reverse proxy конфиг +│ ├── postgres/ +│ │ └── init.sql # начальная схема (если нужна) +│ └── scripts/ +│ ├── deploy.sh # скрипт развёртывания +│ ├── backup.sh # бекап БД +│ └── create-user.sh # создание пользователя через ejabberdctl +│ +├── react-native-lib/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── src/ +│ │ ├── index.ts # public API библиотеки +│ │ ├── services/ +│ │ │ ├── xmpp/ +│ │ │ │ ├── XMPPClient.ts # подключение, авторизация, реконнект +│ │ │ │ ├── MessageService.ts # отправка/приём сообщений +│ │ │ │ ├── RosterService.ts # управление контактами +│ │ │ │ ├── PresenceService.ts # статусы присутствия +│ │ │ │ ├── MUCService.ts # групповые чаты +│ │ │ │ ├── MAMService.ts # архив сообщений +│ │ │ │ ├── FileUploadService.ts # XEP-0363 HTTP File Upload +│ │ │ │ └── types.ts # XMPP-специфичные типы +│ │ │ ├── audio/ +│ │ │ │ ├── AudioRecorder.ts # запись голосового сообщения +│ │ │ │ └── AudioPlayer.ts # воспроизведение +│ │ │ ├── push/ +│ │ │ │ └── PushService.ts # регистрация push-токена +│ │ │ └── storage/ +│ │ │ ├── Database.ts # инициализация SQLite +│ │ │ ├── MessageRepository.ts # CRUD сообщений +│ │ │ ├── ChatRepository.ts # CRUD чатов +│ │ │ └── ContactRepository.ts # CRUD контактов +│ │ ├── stores/ +│ │ │ ├── useAuthStore.ts +│ │ │ ├── useChatsStore.ts +│ │ │ ├── useContactsStore.ts +│ │ │ ├── usePresenceStore.ts +│ │ │ └── useSettingsStore.ts +│ │ ├── screens/ +│ │ │ ├── auth/ +│ │ │ │ ├── LoginScreen.tsx +│ │ │ │ └── RegisterScreen.tsx +│ │ │ ├── chats/ +│ │ │ │ ├── ChatListScreen.tsx +│ │ │ │ └── ChatScreen.tsx +│ │ │ ├── contacts/ +│ │ │ │ ├── ContactsScreen.tsx +│ │ │ │ └── AddContactScreen.tsx +│ │ │ ├── groups/ +│ │ │ │ ├── CreateGroupScreen.tsx +│ │ │ │ └── GroupInfoScreen.tsx +│ │ │ └── profile/ +│ │ │ └── ProfileScreen.tsx +│ │ ├── components/ +│ │ │ ├── MessageBubble.tsx +│ │ │ ├── VoiceMessageBubble.tsx +│ │ │ ├── ChatInput.tsx +│ │ │ ├── VoiceRecordButton.tsx +│ │ │ ├── VoiceRecordOverlay.tsx +│ │ │ ├── ChatListItem.tsx +│ │ │ ├── ContactListItem.tsx +│ │ │ ├── Avatar.tsx +│ │ │ ├── PresenceDot.tsx +│ │ │ ├── TypingIndicator.tsx +│ │ │ └── MessageStatus.tsx +│ │ ├── navigation/ +│ │ │ └── AppNavigator.tsx +│ │ ├── hooks/ +│ │ │ ├── useXMPP.ts +│ │ │ ├── useAudioRecorder.ts +│ │ │ ├── useAudioPlayer.ts +│ │ │ └── useMessages.ts +│ │ ├── types/ +│ │ │ └── index.ts +│ │ └── utils/ +│ │ ├── jid.ts # парсинг/форматирование JID +│ │ ├── datetime.ts # форматирование дат +│ │ └── audio.ts # утилиты для аудио +│ └── __tests__/ +│ └── ... +│ +├── react-native-ios/ +│ ├── package.json # зависимость на react-native-lib +│ ├── index.js # точка входа (импорт App из lib) +│ ├── app.json +│ ├── metro.config.js # пути к workspace-пакетам +│ ├── babel.config.js +│ ├── Gemfile # CocoaPods +│ └── ios/ +│ ├── Jabogram/ +│ │ ├── AppDelegate.mm +│ │ ├── Info.plist # permissions, URL schemes, background modes +│ │ └── Jabogram.entitlements # push notifications entitlement +│ ├── Jabogram.xcodeproj/ +│ └── Podfile +│ +└── react-native-android/ + ├── package.json # зависимость на react-native-lib + ├── index.js # точка входа (импорт App из lib) + ├── app.json + ├── metro.config.js + ├── babel.config.js + └── android/ + ├── app/ + │ ├── src/main/ + │ │ ├── AndroidManifest.xml # permissions, services + │ │ ├── java/.../ + │ │ │ └── MainActivity.kt + │ │ └── res/ # иконки, цвета, строки + │ └── build.gradle.kts + ├── build.gradle.kts + ├── settings.gradle.kts + └── google-services.json # FCM (не в git) +``` + +### 4.1 Workspace-конфигурация (корневой package.json) + +```jsonc +{ + "name": "jabogram", + "private": true, + "workspaces": [ + "react-native-lib", + "react-native-ios", + "react-native-android" + ] +} +``` + +### 4.2 Metro-конфигурация (react-native-ios и react-native-android) + +Обе платформенные директории должны иметь `metro.config.js`, который умеет резолвить модули из `react-native-lib`: + +```js +const path = require('path'); +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); + +const libPath = path.resolve(__dirname, '../react-native-lib'); + +const config = { + watchFolders: [libPath], + resolver: { + nodeModulesPaths: [ + path.resolve(__dirname, 'node_modules'), + path.resolve(libPath, 'node_modules'), + ], + }, +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), config); +``` + +--- + +## 5. Сервер + +### 5.1 Выбор: ejabberd + +Почему ejabberd: +- Erlang/OTP — высокая производительность и отказоустойчивость +- Поддержка всех необходимых XEP «из коробки» +- Встроенный `mod_http_upload` для загрузки файлов +- Встроенный `mod_push` для push-уведомлений +- REST API для администрирования +- Официальный Docker-образ +- Поддержка PostgreSQL в качестве бэкенда + +### 5.2 docker-compose.yml + +```yaml +version: "3.8" + +services: + ejabberd: + image: ejabberd/ecs:latest + container_name: jabogram-ejabberd + restart: unless-stopped + ports: + - "5222:5222" # XMPP client-to-server (STARTTLS) + - "5443:5443" # XMPP over WebSocket (WSS) + HTTP Upload + - "5280:5280" # Admin web UI + API (только localhost) + volumes: + - ./ejabberd/ejabberd.yml:/home/ejabberd/conf/ejabberd.yml:ro + - ./ejabberd/certs:/home/ejabberd/certs:ro + - ejabberd-database:/home/ejabberd/database + - ejabberd-uploads:/home/ejabberd/uploads + depends_on: + postgres: + condition: service_healthy + environment: + - ERLANG_NODE_ARG=ejabberd@localhost + - CTL_ON_CREATE=register admin jabogram.example.com adminpassword + + postgres: + image: postgres:16-alpine + container_name: jabogram-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ejabberd + POSTGRES_USER: ejabberd + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" # из .env + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ejabberd"] + interval: 5s + timeout: 3s + retries: 5 + + nginx: + image: nginx:alpine + container_name: jabogram-nginx + restart: unless-stopped + ports: + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./ejabberd/certs:/etc/nginx/certs:ro + depends_on: + - ejabberd + +volumes: + postgres-data: + ejabberd-database: + ejabberd-uploads: +``` + +### 5.3 ejabberd.yml — основная конфигурация + +```yaml +hosts: + - jabogram.example.com + +loglevel: info + +certfiles: + - /home/ejabberd/certs/*.pem + +## ─── Listeners ──────────────────────────────────────────── + +listen: + # XMPP client-to-server + - port: 5222 + ip: "::" + module: ejabberd_c2s + max_stanza_size: 262144 + shaper: c2s_shaper + access: c2s + starttls_required: true + + # XMPP over WebSocket + HTTP Upload + BOSH (fallback) + - port: 5443 + ip: "::" + module: ejabberd_http + tls: true + request_handlers: + /ws: ejabberd_http_ws # WebSocket endpoint + /upload: mod_http_upload # HTTP File Upload + /api: mod_http_api # REST API + + # Admin interface (привязан к localhost) + - port: 5280 + ip: "127.0.0.1" + module: ejabberd_http + request_handlers: + /admin: ejabberd_web_admin + /api: mod_http_api + +## ─── Authentication ─────────────────────────────────────── + +auth_method: sql +auth_password_format: scram + +## ─── Database ───────────────────────────────────────────── + +default_db: sql +new_sql_schema: true + +sql_type: pgsql +sql_server: postgres +sql_port: 5432 +sql_database: ejabberd +sql_username: ejabberd +sql_password: "${POSTGRES_PASSWORD}" +sql_pool_size: 10 + +## ─── Modules ────────────────────────────────────────────── + +modules: + ## Roster (список контактов) + mod_roster: + db_type: sql + versioning: true + + ## Presence (онлайн-статус) + mod_presence: {} + + ## Одиночные чаты — карбоны (синхронизация между устройствами) + mod_carboncopy: {} + + ## Статусы доставки сообщений + mod_receipt: {} + + ## Индикатор набора текста + mod_chatstate: {} + + ## Архив сообщений (история) + mod_mam: + db_type: sql + default: always # архивировать все сообщения + request_activates_archiving: false + assume_mam_usage: true + compress_xml: true + + ## Stream Management (надёжная доставка, возобновление сессии) + mod_stream_mgmt: + resend_on_timeout: if_offline + + ## Групповые чаты + mod_muc: + db_type: sql + access_create: muc_create + default_room_options: + persistent: true + mam: true + allow_change_subj: true + max_users: 200 + + ## HTTP File Upload (для голосовых сообщений) + mod_http_upload: + put_url: "https://jabogram.example.com:5443/upload" + get_url: "https://jabogram.example.com:5443/upload" + docroot: /home/ejabberd/uploads + max_size: 10485760 # 10 MB + file_mode: "0640" + dir_mode: "2750" + thumbnail: false + custom_headers: + "Access-Control-Allow-Origin": "*" + "Access-Control-Allow-Headers": "Content-Type" + + ## In-Band Registration (регистрация через XMPP) + mod_register: + access: register + ip_access: trusted_network + welcome_message: + subject: "Welcome to Jabogram!" + body: "Welcome!" + + ## Push-уведомления (XEP-0357) + mod_push: {} + mod_push_keepalive: {} + + ## vCard (профиль пользователя) + mod_vcard: + db_type: sql + + ## Аватар пользователя + mod_avatar: {} + + ## Пинг (keepalive) + mod_ping: + send_pings: true + ping_interval: 60 + ping_ack_timeout: 30 + timeout_action: kill + + ## Блокировка пользователей + mod_blocking: + db_type: sql + + ## Service Discovery + mod_disco: {} + + ## Время последней активности + mod_last: + db_type: sql + + ## Приватное хранилище (для клиентских настроек) + mod_private: + db_type: sql + + ## Версия сервера + mod_version: {} + +## ─── ACL / Access ───────────────────────────────────────── + +acl: + admin: + user: admin@jabogram.example.com + local: + user_regexp: "" + +access_rules: + c2s: + allow: all + register: + allow: all + muc_create: + allow: local + +shaper_rules: + c2s_shaper: + none: admin + normal: all + +shaper: + normal: + rate: 10000 + burst_size: 50000 +``` + +### 5.4 nginx.conf + +```nginx +events { + worker_connections 1024; +} + +http { + upstream ejabberd_ws { + server ejabberd:5443; + } + + server { + listen 443 ssl; + server_name jabogram.example.com; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + # WebSocket proxy + location /ws { + proxy_pass https://ejabberd_ws/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + # HTTP File Upload + location /upload { + proxy_pass https://ejabberd_ws/upload; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 10m; + } + } +} +``` + +### 5.5 Скрипты управления + +**scripts/deploy.sh:** +```bash +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +docker compose up -d --build +docker compose exec ejabberd ejabberdctl status +``` + +**scripts/create-user.sh:** +```bash +#!/usr/bin/env bash +# Использование: ./create-user.sh username password +set -euo pipefail +USER="$1" +PASS="$2" +DOMAIN="jabogram.example.com" +docker compose exec ejabberd ejabberdctl register "$USER" "$DOMAIN" "$PASS" +echo "Created: ${USER}@${DOMAIN}" +``` + +--- + +## 6. Протокол XMPP: используемые XEP + +| XEP | Название | Назначение в Jabogram | +|---|---|---| +| [XEP-0077](https://xmpp.org/extensions/xep-0077.html) | In-Band Registration | Регистрация нового аккаунта прямо через XMPP-соединение | +| [XEP-0030](https://xmpp.org/extensions/xep-0030.html) | Service Discovery | Обнаружение возможностей сервера (поддержка upload, MUC и т.д.) | +| [XEP-0045](https://xmpp.org/extensions/xep-0045.html) | Multi-User Chat | Групповые чаты | +| [XEP-0066](https://xmpp.org/extensions/xep-0066.html) | Out of Band Data | Передача URL голосового файла в теле сообщения | +| [XEP-0084](https://xmpp.org/extensions/xep-0084.html) | User Avatar | Аватары пользователей | +| [XEP-0085](https://xmpp.org/extensions/xep-0085.html) | Chat State Notifications | Индикатор «печатает…» | +| [XEP-0184](https://xmpp.org/extensions/xep-0184.html) | Message Delivery Receipts | Подтверждение доставки (✓✓) | +| [XEP-0191](https://xmpp.org/extensions/xep-0191.html) | Blocking Command | Блокировка пользователей | +| [XEP-0198](https://xmpp.org/extensions/xep-0198.html) | Stream Management | Надёжная доставка stanza, возобновление сессии после обрыва | +| [XEP-0280](https://xmpp.org/extensions/xep-0280.html) | Message Carbons | Синхронизация сообщений между устройствами одного пользователя | +| [XEP-0313](https://xmpp.org/extensions/xep-0313.html) | Message Archive Management | Серверный архив; синхронизация истории при подключении | +| [XEP-0333](https://xmpp.org/extensions/xep-0333.html) | Chat Markers | Статус «прочитано» (displayed marker) | +| [XEP-0352](https://xmpp.org/extensions/xep-0352.html) | Client State Indication | Клиент сообщает серверу, что ушёл в фон (экономия трафика) | +| [XEP-0357](https://xmpp.org/extensions/xep-0357.html) | Push Notifications | Серверные push-уведомления при офлайн-сообщениях | +| [XEP-0363](https://xmpp.org/extensions/xep-0363.html) | HTTP File Upload | Загрузка голосовых файлов на сервер | + +--- + +## 7. Формат сообщений + +### 7.1 Текстовое сообщение (1-on-1) + +```xml + + Привет, как дела? + + + +``` + +### 7.2 Подтверждение доставки (XEP-0184) + +```xml + + + +``` + +### 7.3 Маркер «прочитано» (XEP-0333) + +```xml + + + +``` + +### 7.4 Индикатор набора текста (XEP-0085) + +```xml + + + +``` + +### 7.5 Голосовое сообщение + +Голосовое сообщение отправляется в два этапа: + +**Этап 1 — получение upload-слота (XEP-0363):** + +Запрос: +```xml + + + +``` + +Ответ сервера: +```xml + + + +
Basic ...
+
audio/mp4
+
+ +
+
+``` + +**Этап 2 — загрузка файла по HTTP PUT:** + +``` +PUT /upload/abcdef123/voice-1707580800.m4a HTTP/1.1 +Host: jabogram.example.com:5443 +Content-Type: audio/mp4 +Content-Length: 48320 + + +``` + +**Этап 3 — отправка XMPP-сообщения со ссылкой:** + +```xml + + https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a + + https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a + voice-message + + + + + +``` + +Пояснение полей ``: + +| Атрибут | Тип | Описание | +|---|---|---| +| `duration` | int | Длительность в секундах | +| `mime-type` | string | MIME-тип аудио (`audio/mp4`) | +| `size` | int | Размер файла в байтах | +| `waveform` | string | CSV нормализованных float-значений (0..1) для визуализации волны, ~10-50 точек | + +Клиент-получатель: +1. Видит `` → рисует UI голосового сообщения с waveform +2. По нажатию Play → скачивает файл по URL из `` → воспроизводит +3. Кеширует скачанный файл локально + +Если клиент не поддерживает голосовые сообщения — показывает `` как обычную ссылку (graceful degradation). + +### 7.6 Сообщение в групповой чат (MUC) + +```xml + + Всем привет! + +``` + +--- + +## 8. Клиентское приложение — общая библиотека + +### 8.1 XMPP-клиент (services/xmpp/XMPPClient.ts) + +Обёртка над stanza.js. Основные обязанности: + +```typescript +interface XMPPClientConfig { + domain: string; // jabogram.example.com + wsURL: string; // wss://jabogram.example.com/ws + resource: string; // "ios" | "android" +} + +class XMPPClient { + // Жизненный цикл + connect(jid: string, password: string): Promise; + disconnect(): Promise; + reconnect(): Promise; + + // Stream Management (XEP-0198) + enableStreamManagement(): void; + resumeSession(): Promise; + + // Client State Indication (XEP-0352) + setActive(): void; + setInactive(): void; + + // Низкоуровневый доступ + sendStanza(stanza: Element): void; + onStanza(handler: (stanza: Element) => void): void; + + // События + on(event: 'connected' | 'disconnected' | 'error' | 'stanza', cb): void; +} +``` + +**Стратегия реконнекта:** +1. При потере соединения — попытка resume (XEP-0198) +2. Если resume не удался — полный reconnect +3. Экспоненциальный backoff: 1s → 2s → 4s → 8s → 16s → 30s (max) +4. При переходе приложения из background в foreground — немедленная попытка reconnect + +### 8.2 Сервис сообщений (services/xmpp/MessageService.ts) + +```typescript +class MessageService { + sendTextMessage(to: string, body: string, chatType: 'chat' | 'groupchat'): Message; + sendVoiceMessage(to: string, audioFile: AudioFile, chatType: 'chat' | 'groupchat'): Promise; + sendDeliveryReceipt(to: string, messageId: string): void; + sendDisplayedMarker(to: string, messageId: string): void; + sendChatState(to: string, state: ChatState): void; + + onMessage(handler: (message: Message) => void): void; + onReceipt(handler: (messageId: string) => void): void; + onDisplayed(handler: (messageId: string) => void): void; + onChatState(handler: (jid: string, state: ChatState) => void): void; +} + +type ChatState = 'active' | 'composing' | 'paused' | 'inactive' | 'gone'; +``` + +### 8.3 Сервис загрузки файлов (services/xmpp/FileUploadService.ts) + +```typescript +class FileUploadService { + /** + * 1. Запрашивает upload slot (XEP-0363) + * 2. Загружает файл по HTTP PUT + * 3. Возвращает GET URL для вставки в сообщение + */ + async uploadFile(file: { + uri: string; + name: string; + size: number; + mimeType: string; + }): Promise<{ getUrl: string }>; +} +``` + +### 8.4 Сервис аудио (services/audio/) + +```typescript +// AudioRecorder.ts +class AudioRecorder { + /** Начать запись. Формат: AAC (.m4a) */ + async start(): Promise; + + /** Остановить запись и вернуть файл */ + async stop(): Promise; + + /** Отменить запись */ + async cancel(): Promise; + + /** Текущая амплитуда (для анимации) */ + onAmplitude(handler: (amplitude: number) => void): void; + + /** Текущая длительность записи */ + onDuration(handler: (seconds: number) => void): void; +} + +interface RecordedAudio { + uri: string; // локальный путь к файлу + duration: number; // секунды + size: number; // байты + mimeType: 'audio/mp4'; + waveform: number[]; // нормализованные амплитуды для визуализации +} + +// AudioPlayer.ts +class AudioPlayer { + async play(uri: string): Promise; + async pause(): void; + async stop(): void; + async seekTo(seconds: number): void; + + onProgress(handler: (current: number, total: number) => void): void; + onComplete(handler: () => void): void; +} +``` + +**Формат аудио:** AAC в контейнере M4A. +- Нативная поддержка на iOS и Android без дополнительных кодеков +- Хорошее сжатие (голос 5 сек ≈ 30-50 КБ при 32 kbps) +- Параметры записи: mono, 32 kbps, sample rate 22050 Hz + +### 8.5 Сервис MAM (services/xmpp/MAMService.ts) + +```typescript +class MAMService { + /** + * Запрос архива сообщений. + * При первом подключении — загрузка последних N сообщений для каждого чата. + * При повторном подключении — загрузка сообщений с момента последнего известного. + */ + async fetchHistory(params: { + with?: string; // JID собеседника (опционально) + before?: string; // ID сообщения для пагинации «назад» + after?: string; // ID для пагинации «вперёд» + max?: number; // лимит (по умолчанию 50) + }): Promise; + + /** + * Запрос сообщений, пришедших пока клиент был офлайн. + * Вызывается сразу после успешного connect. + */ + async syncSinceLastMessage(lastKnownId: string): Promise; +} +``` + +### 8.6 Сервис Roster (services/xmpp/RosterService.ts) + +```typescript +class RosterService { + /** Получить текущий список контактов */ + async getRoster(): Promise; + + /** Добавить контакт (отправить subscription request) */ + async addContact(jid: string, name?: string): Promise; + + /** Удалить контакт */ + async removeContact(jid: string): Promise; + + /** Принять входящий запрос на подписку */ + async acceptSubscription(jid: string): Promise; + + /** Отклонить входящий запрос */ + async denySubscription(jid: string): Promise; + + /** Обновление ростера (push от сервера) */ + onRosterUpdate(handler: (contacts: Contact[]) => void): void; + + /** Входящий subscription request */ + onSubscriptionRequest(handler: (jid: string) => void): void; +} +``` + +### 8.7 Локальная база данных (services/storage/) + +SQLite через op-sqlite. Три основные таблицы: + +```sql +-- Контакты +CREATE TABLE contacts ( + jid TEXT PRIMARY KEY, + name TEXT, + avatar_url TEXT, + subscription TEXT DEFAULT 'none', -- none | to | from | both + updated_at INTEGER NOT NULL +); + +-- Чаты (список бесед) +CREATE TABLE chats ( + id TEXT PRIMARY KEY, -- bare JID собеседника или room JID + type TEXT NOT NULL, -- 'chat' | 'groupchat' + title TEXT, -- имя контакта или название группы + last_message_id TEXT, + last_message_text TEXT, + last_message_at INTEGER, + unread_count INTEGER DEFAULT 0, + pinned INTEGER DEFAULT 0, + updated_at INTEGER NOT NULL +); + +-- Сообщения +CREATE TABLE messages ( + id TEXT PRIMARY KEY, -- XMPP stanza id + chat_id TEXT NOT NULL, -- ссылка на chats.id + from_jid TEXT NOT NULL, + to_jid TEXT NOT NULL, + type TEXT NOT NULL, -- 'text' | 'voice' + body TEXT, + -- Голосовое сообщение + voice_url TEXT, + voice_duration INTEGER, + voice_mime_type TEXT, + voice_size INTEGER, + voice_waveform TEXT, -- JSON array + voice_local_uri TEXT, -- кешированный локальный путь + -- Статус + status TEXT DEFAULT 'sent', -- 'sending' | 'sent' | 'delivered' | 'displayed' | 'error' + -- Timestamps + created_at INTEGER NOT NULL, + server_timestamp INTEGER, -- timestamp из MAM + FOREIGN KEY (chat_id) REFERENCES chats(id) +); + +CREATE INDEX idx_messages_chat_id ON messages(chat_id, created_at); +CREATE INDEX idx_messages_status ON messages(status); +``` + +### 8.8 Zustand stores + +#### useAuthStore + +```typescript +interface AuthState { + jid: string | null; + password: string | null; // хранится в Keychain/Keystore, не в памяти + isAuthenticated: boolean; + isConnecting: boolean; + connectionError: string | null; + + login(jid: string, password: string): Promise; + register(username: string, password: string): Promise; + logout(): Promise; +} +``` + +#### useChatsStore + +```typescript +interface ChatsState { + chats: Chat[]; + currentChatId: string | null; + messages: Record; // chatId → messages + + loadChats(): Promise; + loadMessages(chatId: string, before?: string): Promise; + addMessage(message: Message): void; + updateMessageStatus(messageId: string, status: MessageStatus): void; + incrementUnread(chatId: string): void; + resetUnread(chatId: string): void; + setCurrentChat(chatId: string | null): void; +} +``` + +#### useContactsStore + +```typescript +interface ContactsState { + contacts: Contact[]; + pendingRequests: string[]; // входящие запросы на добавление + + loadContacts(): Promise; + addContact(jid: string, name?: string): Promise; + removeContact(jid: string): Promise; + acceptRequest(jid: string): Promise; + denyRequest(jid: string): Promise; +} +``` + +#### usePresenceStore + +```typescript +interface PresenceState { + presences: Record; // bare JID → presence + + setPresence(jid: string, info: PresenceInfo): void; + getPresence(jid: string): PresenceInfo; +} + +interface PresenceInfo { + status: 'online' | 'away' | 'dnd' | 'offline'; + statusText?: string; + lastSeen?: number; // timestamp +} +``` + +### 8.9 Экраны + +#### LoginScreen / RegisterScreen +- Поля: JID (или username при регистрации), пароль +- Адрес сервера — конфигурируемый (по умолчанию `jabogram.example.com`) +- Валидация ввода +- Индикация состояния подключения +- Сохранение credentials в Keychain (iOS) / Keystore (Android) + +#### ChatListScreen +- Список бесед, отсортированный по дате последнего сообщения +- Каждый элемент: аватар, имя, превью последнего сообщения, время, счётчик непрочитанных +- Для голосовых: превью = "🎤 Голосовое сообщение" +- Индикатор онлайн-статуса контакта +- Pull-to-refresh для синхронизации +- FAB-кнопка для создания нового чата + +#### ChatScreen +- Список сообщений (FlatList, inverted) +- Пагинация: подгрузка старых сообщений при скролле вверх (MAM) +- Текстовые сообщения — пузыри (свои справа, чужие слева) +- Голосовые сообщения — специальный виджет с кнопкой Play, waveform, длительностью +- Индикатор «печатает…» над полем ввода +- Статусы сообщений (✓ отправлено, ✓✓ доставлено, синие ✓✓ прочитано) +- Поле ввода текста + кнопка отправки +- Кнопка микрофона: нажать и удерживать → запись; отпустить → отправка; свайп влево → отмена +- При записи: оверлей с таймером, визуализацией амплитуды, подсказкой «свайп для отмены» + +#### ContactsScreen +- Список контактов с онлайн-статусами +- Секция «Входящие запросы» (если есть) — принять / отклонить +- Поиск по имени / JID +- Кнопка добавления нового контакта + +#### AddContactScreen +- Поле ввода JID +- Отправка subscription request + +#### CreateGroupScreen +- Название группы +- Выбор участников из контактов +- Создание MUC-комнаты + +#### ProfileScreen +- Аватар (выбор из галереи, обрезка) +- Никнейм +- Статус-текст +- Кнопка «Выйти» + +### 8.10 Навигация + +``` +AppNavigator (Stack) +├── AuthStack (когда !isAuthenticated) +│ ├── LoginScreen +│ └── RegisterScreen +│ +└── MainStack (когда isAuthenticated) + ├── MainTabs (Bottom Tab) + │ ├── ChatsTab + │ │ └── ChatListScreen + │ ├── ContactsTab + │ │ └── ContactsScreen + │ └── ProfileTab + │ └── ProfileScreen + │ + ├── ChatScreen (modal push) + ├── AddContactScreen (modal push) + ├── CreateGroupScreen (modal push) + └── GroupInfoScreen (modal push) +``` + +### 8.11 Компоненты: детальное описание ключевых + +#### ChatInput + +``` +┌──────────────────────────────────────────────────┐ +│ ┌──────────────────────────────────┐ ┌──────┐ │ +│ │ Введите сообщение... │ │ 🎤 │ │ +│ └──────────────────────────────────┘ └──────┘ │ +└──────────────────────────────────────────────────┘ +``` + +Логика переключения: +- Если текст пуст → показывает кнопку 🎤 (микрофон) +- Если текст не пуст → кнопка заменяется на ➤ (отправить) +- Отправка chat state `composing` при наборе, `paused` через 3 сек неактивности + +#### VoiceRecordOverlay + +``` +┌──────────────────────────────────────────────────┐ +│ │ +│ ◉ 0:05 ← свайп для отмены │ +│ ▂▃▅▇▅▃▂▃▅▇▅▃ (визуализация амплитуды) │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +Появляется при long press на 🎤. Отпускание пальца → отправка. Свайп влево → отмена. + +#### VoiceMessageBubble + +``` +┌───────────────────────────────┐ +│ ▶ ▂▃▅▇▅▃▂▃▅▇▅▃▂ 0:05 │ +│ 12:34 ✓✓ │ +└───────────────────────────────┘ +``` + +- Кнопка play/pause +- Waveform (из данных `voice_waveform`) +- Длительность / текущая позиция при проигрывании +- Прогресс-бар поверх waveform +- Время отправки и статус доставки + +### 8.12 Хуки + +#### useXMPP + +```typescript +function useXMPP() { + // Автоматическое подключение при наличии credentials + // Подписка на события (сообщения, presence, roster updates) + // Реконнект при восстановлении сети + // CSI: active при foreground, inactive при background +} +``` + +#### useAudioRecorder + +```typescript +function useAudioRecorder() { + return { + isRecording: boolean; + duration: number; + amplitude: number; + start: () => Promise; + stop: () => Promise; + cancel: () => Promise; + }; +} +``` + +#### useAudioPlayer + +```typescript +function useAudioPlayer(messageId: string) { + return { + isPlaying: boolean; + progress: number; // 0..1 + currentTime: number; + duration: number; + play: (uri: string) => Promise; + pause: () => void; + seekTo: (seconds: number) => void; + }; +} +``` + +#### useMessages + +```typescript +function useMessages(chatId: string) { + return { + messages: Message[]; + isLoading: boolean; + hasMore: boolean; + loadMore: () => Promise; // подгрузка старых (MAM) + sendText: (body: string) => void; + sendVoice: (audio: RecordedAudio) => Promise; + }; +} +``` + +--- + +## 9. Клиентское приложение — iOS + +### 9.1 Точка входа + +```javascript +// react-native-ios/index.js +import { AppRegistry } from 'react-native'; +import { App } from 'react-native-lib'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); +``` + +### 9.2 Info.plist — требуемые разрешения + +```xml +NSMicrophoneUsageDescription +Jabogram needs microphone access to record voice messages + +NSPhotoLibraryUsageDescription +Jabogram needs photo library access to set your avatar + +NSCameraUsageDescription +Jabogram needs camera access to take a photo for your avatar + +UIBackgroundModes + + remote-notification + fetch + +``` + +### 9.3 Push-уведомления (APNs) + +1. Включить Push Notifications capability в Xcode +2. Настроить `Jabogram.entitlements`: + ```xml + aps-environment + production + ``` +3. Зарегистрировать APNs device token через Firebase Cloud Messaging: + - `@react-native-firebase/messaging` получает FCM token + - FCM-токен регистрируется на XMPP-сервере через XEP-0357 +4. При получении push → показать локальное уведомление, если приложение в фоне + +### 9.4 Хранение credentials + +- **iOS Keychain** через `react-native-keychain` +- Хранить JID и пароль +- Автоматический вход при запуске, если есть сохранённые credentials + +### 9.5 Аудио-сессия + +Настройка AVAudioSession при записи: +- Category: `playAndRecord` +- Mode: `default` +- При воспроизведении: через динамик (не ушной, если телефон не у уха) + +--- + +## 10. Клиентское приложение — Android + +### 10.1 Точка входа + +Аналогично iOS: + +```javascript +// react-native-android/index.js +import { AppRegistry } from 'react-native'; +import { App } from 'react-native-lib'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); +``` + +### 10.2 AndroidManifest.xml — разрешения + +```xml + + + + + + + +``` + +### 10.3 Push-уведомления (FCM) + +1. Файл `google-services.json` в `android/app/` (не в git, добавить в `.gitignore`) +2. `@react-native-firebase/messaging` для получения FCM-токена +3. Регистрация токена на XMPP-сервере (XEP-0357) +4. Notification channels: + ```kotlin + // В MainActivity или Application + val channel = NotificationChannel( + "messages", + "Messages", + NotificationManager.IMPORTANCE_HIGH + ) + ``` + +### 10.4 Хранение credentials + +- **Android Keystore** через `react-native-keychain` +- Тот же API, что и на iOS — библиотека абстрагирует платформу + +### 10.5 Специфика аудиозаписи + +- Runtime permission `RECORD_AUDIO` — запрашивать перед первой записью +- Android 13+: дополнительно `POST_NOTIFICATIONS` +- Формат записи: AAC (.m4a) — поддерживается нативно через MediaRecorder + +--- + +## 11. Модели данных + +### 11.1 TypeScript-типы (react-native-lib/src/types/index.ts) + +```typescript +/** Статус доставки сообщения */ +export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'displayed' | 'error'; + +/** Тип сообщения */ +export type MessageType = 'text' | 'voice'; + +/** Тип чата */ +export type ChatType = 'chat' | 'groupchat'; + +/** Статус присутствия */ +export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline'; + +/** Статус подписки ростера */ +export type SubscriptionStatus = 'none' | 'to' | 'from' | 'both'; + +/** Сообщение */ +export interface Message { + id: string; + chatId: string; + fromJid: string; + toJid: string; + type: MessageType; + body?: string; + + // Голосовое сообщение + voice?: { + url: string; + duration: number; // секунды + mimeType: string; + size: number; // байты + waveform: number[]; // нормализованные значения 0..1 + localUri?: string; // путь к кешированному файлу + }; + + status: MessageStatus; + createdAt: number; // unix timestamp ms + serverTimestamp?: number; // timestamp из MAM +} + +/** Чат / беседа */ +export interface Chat { + id: string; // bare JID или room JID + type: ChatType; + title: string; + avatarUrl?: string; + lastMessage?: { + id: string; + text: string; + timestamp: number; + }; + unreadCount: number; + pinned: boolean; +} + +/** Контакт */ +export interface Contact { + jid: string; // bare JID + name?: string; + avatarUrl?: string; + subscription: SubscriptionStatus; +} + +/** Информация о присутствии */ +export interface PresenceInfo { + status: PresenceStatus; + statusText?: string; + lastSeen?: number; // unix timestamp +} + +/** Записанное аудио */ +export interface RecordedAudio { + uri: string; + duration: number; + size: number; + mimeType: 'audio/mp4'; + waveform: number[]; +} + +/** Данные аутентификации */ +export interface AuthCredentials { + jid: string; + password: string; + server: string; +} +``` + +--- + +## 12. Сценарии взаимодействия + +### 12.1 Регистрация нового пользователя + +``` +Клиент Сервер (ejabberd) + │ │ + │──── WSS connect ─────────────────────►│ + │◄─── stream features (SASL, IBR) ──────│ + │ │ + │──── IQ: register (XEP-0077) ─────────►│ + │ username="alice", password="***" │ + │◄─── IQ result: success ───────────────│ + │ │ + │──── SASL auth (SCRAM-SHA-256) ───────►│ + │◄─── success ──────────────────────────│ + │ │ + │──── bind resource "ios" ─────────────►│ + │◄─── bound: alice@server/ios ──────────│ + │ │ + │──── enable stream mgmt (XEP-0198) ──►│ + │◄─── enabled ──────────────────────────│ + │ │ + │──── enable carbons (XEP-0280) ──────►│ + │◄─── result ───────────────────────────│ + │ │ + │──── initial presence ────────────────►│ + │ │ +``` + +### 12.2 Вход существующего пользователя + +``` +Клиент Сервер + │ │ + │──── WSS connect ─────────────────────►│ + │◄─── stream features ─────────────────│ + │──── SASL auth ───────────────────────►│ + │◄─── success ──────────────────────────│ + │──── bind ────────────────────────────►│ + │◄─── bound ────────────────────────────│ + │──── stream mgmt ─────────────────────►│ + │──── enable carbons ──────────────────►│ + │──── initial presence ────────────────►│ + │ │ + │──── MAM: sync since last known ──────►│ // XEP-0313 + │◄─── archived messages ───────────────│ + │ │ + │──── get roster ──────────────────────►│ + │◄─── roster items ────────────────────│ + │◄─── presence updates ───────────────│ + │ │ +``` + +### 12.3 Отправка текстового сообщения + +``` +Alice (клиент) Сервер Bob (клиент) + │ │ │ + │ 1. UI: ввод текста │ │ + │ 2. chatstate=composing │ │ + │────────────────────────►│─────────────────────►│ 3. «Alice печатает…» + │ │ │ + │ 4. Нажатие «отправить» │ │ + │ 5. Сохранить в SQLite │ │ + │ status='sending' │ │ + │ │ │ + │ 6. + receipt │ │ + │────────────────────────►│ │ + │ │ 7. route message │ + │ │─────────────────────►│ 8. Получено + │ │ │ 9. Сохранить в SQLite + │ │ │ 10. Показать в UI + │ │ │ + │ │◄─────────────────────│ 11. (XEP-0184) + │◄────────────────────────│ │ + │ 12. status='delivered' │ │ + │ │ │ + │ │◄─────────────────────│ 13. (XEP-0333) + │◄────────────────────────│ │ (при открытии чата) + │ 14. status='displayed' │ │ + │ │ │ +``` + +### 12.4 Отправка голосового сообщения + +``` +Alice (клиент) Сервер Bob (клиент) + │ │ │ + │ 1. Long press 🎤 │ │ + │ 2. Начало записи │ │ + │ ...запись... │ │ + │ 3. Отпускание пальца │ │ + │ 4. stop() → RecordedAudio │ + │ 5. Вычисление waveform │ │ + │ │ │ + │ 6. IQ: request upload │ │ + │ slot (XEP-0363) │ │ + │────────────────────────►│ │ + │◄────────────────────────│ 7. PUT url + GET url│ + │ │ │ + │ 8. HTTP PUT (файл) │ │ + │────────────────────────►│ 9. Сохранить файл │ + │◄──── 201 Created ──────│ │ + │ │ │ + │ 10. с OOB │ │ + │ + │ │ + │ + receipt request │ │ + │────────────────────────►│ │ + │ │─────────────────────►│ 11. Получить сообщение + │ │ │ 12. Распознать voice-message + │ │ │ 13. Показать VoiceMessageBubble + │ │ │ 14. По нажатию Play: + │ │ │ GET файл → воспроизвести + │ │ │ +``` + +### 12.5 Получение офлайн-сообщения (push) + +``` +Bob (офлайн) Сервер Push Gateway Телефон Bob + │ │ │ │ + │ │◄── message from Alice │ │ + │ │ │ │ + │ │ mod_push: │ │ + │ │ Bob offline → │ │ + │ │ trigger push │ │ + │ │─────────────────────►│ │ + │ │ │ APNs / FCM │ + │ │ │───────────────────►│ + │ │ │ │ notification + │ │ │ │ + │ Bob открывает │ │ │ + │ приложение │ │ │ + │────── connect ─────────►│ │ │ + │────── MAM sync ────────►│ │ │ + │◄── пропущенные msg ────│ │ │ + │ │ │ │ +``` + +### 12.6 Создание группового чата + +``` +Alice (клиент) Сервер (MUC) + │ │ + │ 1. IQ: create room │ + │ room@conference.jabogram.example.com + │────────────────────────────────────── ►│ + │◄── room created ─────────────────────│ + │ │ + │ 2. IQ: configure room │ + │ (название, persistent, etc.) │ + │──────────────────────────────────────►│ + │◄── configured ───────────────────────│ + │ │ + │ 3. Invite участников │ + │──────────────────────────────────────►│ + │ ──────────────│──► Bob gets invite + │ ──────────────│──► Carol gets invite + │ │ +``` + +### 12.7 Синхронизация истории при подключении + +``` +Клиент Сервер + │ │ + │ 1. Подключение + auth │ + │──────────────────────────────────────►│ + │ │ + │ 2. Прочитать lastKnownMessageId │ + │ из локального SQLite │ + │ │ + │ 3. MAM query: after=lastKnownId │ + │──────────────────────────────────────►│ + │◄── archived messages (порциями) ─────│ + │ │ + │ 4. Для каждого нового сообщения: │ + │ - сохранить в SQLite │ + │ - обновить chat list │ + │ - обновить unread counter │ + │ │ + │ 5. Если lastKnownId отсутствует │ + │ (первый вход): загрузить │ + │ последние 50 сообщений │ + │ │ +``` + +--- + +## 13. Безопасность + +### 13.1 Транспортный уровень + +| Требование | Реализация | +|---|---| +| Шифрование соединения | TLS 1.2+ обязателен. STARTTLS для XMPP C2S (порт 5222), WSS для WebSocket (порт 5443) | +| Сертификаты | Let's Encrypt или корпоративный CA | +| Certificate pinning | Опционально для production (через react-native-ssl-pinning) | + +### 13.2 Аутентификация + +| Требование | Реализация | +|---|---| +| SASL-механизм | SCRAM-SHA-256 (предпочтительно), SCRAM-SHA-1 (fallback). PLAIN запрещён | +| Хранение пароля на сервере | scram-формат (hash + salt + iterations). Никогда plain | +| Хранение пароля на клиенте | iOS Keychain / Android Keystore через react-native-keychain | +| Регистрация | XEP-0077 с rate limiting (ip_access в ejabberd) | + +### 13.3 Загрузка файлов + +| Требование | Реализация | +|---|---| +| Авторизация upload | Только аутентифицированные пользователи получают upload slot | +| Ограничение размера | max_size: 10 MB | +| Допустимые типы | Только audio/mp4, audio/ogg (проверка на клиенте и Content-Type при upload) | +| URL upload-слотов | Случайные, непредсказуемые пути | + +### 13.4 Защита от злоупотреблений + +- Rate limiting на уровне ejabberd (shaper) +- Rate limiting на уровне nginx +- mod_blocking для блокировки пользователей +- Ограничение частоты регистраций с одного IP + +--- + +## 14. Развёртывание + +### 14.1 Серверная инфраструктура + +**Минимальные требования:** +- VPS: 2 vCPU, 4 GB RAM, 40 GB SSD +- Ubuntu 22.04+ или Debian 12+ +- Docker Engine 24+ +- Домен с DNS A-записью + +**Порядок развёртывания:** + +1. Подготовить сервер: + ```bash + apt update && apt install -y docker.io docker-compose-plugin + ``` + +2. Получить TLS-сертификат: + ```bash + apt install -y certbot + certbot certonly --standalone -d jabogram.example.com + cp /etc/letsencrypt/live/jabogram.example.com/fullchain.pem server/ejabberd/certs/ + cp /etc/letsencrypt/live/jabogram.example.com/privkey.pem server/ejabberd/certs/ + ``` + +3. Создать `.env`: + ``` + POSTGRES_PASSWORD= + ``` + +4. Запустить: + ```bash + cd server && docker compose up -d + ``` + +5. Проверить: + ```bash + docker compose exec ejabberd ejabberdctl status + ``` + +### 14.2 Сборка клиентских приложений + +**iOS:** +```bash +cd react-native-ios +npm install +cd ios && pod install && cd .. +npx react-native run-ios --mode Release +# или: xcodebuild -workspace ios/Jabogram.xcworkspace -scheme Jabogram -configuration Release +``` + +**Android:** +```bash +cd react-native-android +npm install +cd android && ./gradlew assembleRelease +# APK: android/app/build/outputs/apk/release/app-release.apk +``` + +### 14.3 Переменные окружения / конфигурация клиента + +Файл `react-native-lib/src/config.ts`: + +```typescript +export const config = { + /** XMPP домен */ + domain: 'jabogram.example.com', + + /** WebSocket URL */ + wsUrl: 'wss://jabogram.example.com/ws', + + /** Максимальная длительность голосового сообщения (секунды) */ + maxVoiceDuration: 300, + + /** Максимальный размер файла (байты) */ + maxFileSize: 10 * 1024 * 1024, + + /** MAM: размер страницы при загрузке истории */ + mamPageSize: 50, + + /** Аудио: битрейт AAC */ + audioBitrate: 32000, + + /** Аудио: sample rate */ + audioSampleRate: 22050, + + /** Количество точек waveform */ + waveformPoints: 40, +}; +``` + +--- + +## 15. Тестирование + +### 15.1 Unit-тесты (react-native-lib) + +| Модуль | Что тестировать | +|---|---| +| `jid.ts` | Парсинг/форматирование JID | +| `MessageService` | Формирование XML stanza (text, voice, receipts, markers) | +| `FileUploadService` | Парсинг upload slot response | +| `MAMService` | Парсинг archived messages | +| `AudioRecorder` | Вычисление waveform из амплитуд | +| Zustand stores | Все мутации состояния | +| `MessageRepository` | CRUD операции с SQLite | +| Компоненты | Snapshot-тесты ключевых компонентов | + +Инструменты: **Jest** + **React Native Testing Library** + +### 15.2 Интеграционные тесты + +| Сценарий | Описание | +|---|---| +| Регистрация → вход | Клиент регистрирует аккаунт, отключается, подключается заново | +| Обмен текстом | Два клиента обмениваются сообщениями, проверка доставки | +| Голосовое сообщение | Upload → отправка → скачивание → проверка файла | +| Офлайн-доставка | Отправка сообщения офлайн-пользователю → подключение → MAM sync | +| Групповой чат | Создание, приглашение, обмен сообщениями | + +Инструменты: **Detox** (E2E для React Native) + тестовый ejabberd в Docker + +### 15.3 Серверные тесты + +- Проверка конфигурации ejabberd при старте +- Healthcheck PostgreSQL +- Проверка доступности WebSocket endpoint +- Проверка работы HTTP File Upload + +--- + +## 16. Ограничения и допущения v1 + +1. **Один сервер** — без федерации (S2S отключён), все пользователи на одном домене +2. **Нет E2E-шифрования** — только transport encryption (TLS). OMEMO — в будущих версиях +3. **Голос только как файлы** — нет VoIP / WebRTC +4. **Нет произвольных файлов** — только голосовые сообщения (audio/mp4) +5. **Нет web-клиента** — только iOS и Android +6. **Нет редактирования/удаления сообщений** — в будущих версиях (XEP-0308, XEP-0424) +7. **Аватар** — только из галереи, без камеры в v1 +8. **Поиск пользователей** — только по точному JID, нет глобального каталога +9. **Push-уведомления** — через Firebase (FCM для обеих платформ, APNs через FCM) +10. **Максимум 200 участников** в групповом чате diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..f24b757 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1 @@ +secrets.env diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..084c00c --- /dev/null +++ b/server/README.md @@ -0,0 +1,194 @@ +# Snikket Server + +XMPP-сервер на базе [Snikket](https://snikket.org/) (обёртка над Prosody с предварительной конфигурацией). + +## Состав сервисов + +| Сервис | Образ | Назначение | +|--------|-------|------------| +| `snikket_server` | `snikket/snikket-server:stable` | Ядро — Prosody XMPP-сервер | +| `snikket_proxy` | `snikket/snikket-web-proxy:stable` | Веб-прокси (nginx) | +| `snikket_certs` | `snikket/snikket-cert-manager:stable` | Автоматическое получение TLS-сертификатов (Let's Encrypt) | +| `snikket_portal` | `snikket/snikket-web-portal:stable` | Веб-портал для управления пользователями и инвайтами | +| `s3_upload_handler` | собирается из `./s3-upload-handler` | Обработчик загрузки файлов — принимает файлы от XMPP-клиентов и сохраняет в S3 | +| `postgres` | `postgres:15` | База данных PostgreSQL для Prosody | + +Все сервисы работают в режиме `network_mode: host`. + +## Требования + +### DNS-записи + +Для домена `chat.example.org` необходимо создать 3 записи: + +| Тип | Имя | Значение | +|-----|-----|----------| +| A | `chat.example.org` | IP-адрес сервера | +| CNAME | `groups.chat.example.org` | `chat.example.org` | +| CNAME | `share.chat.example.org` | `chat.example.org` | + +Опционально: AAAA-запись для IPv6, SRV-запись `_xmpps-client._tcp` для подключения через порт 443. + +### Порты + +| Порт | Протокол | Назначение | +|------|----------|------------| +| 80 | TCP | HTTP (ACME challenges, редирект на HTTPS) | +| 443 | TCP | HTTPS (веб-портал, file sharing) | +| 5222 | TCP | XMPP client-to-server | +| 5269 | TCP | XMPP server-to-server (федерация) | +| 5000 | TCP | Proxy65 (передача файлов) | +| 5050 | TCP | S3 upload handler (HTTP Upload External) | +| 5432 | TCP | PostgreSQL | +| 3478, 3479 | TCP+UDP | STUN/TURN | +| 5349, 5350 | TCP+UDP | STUN/TURN over TLS | +| 49152–65535 | UDP | TURN relay (аудио/видео данные) | + +## Конфигурация + +### prosody.cfg.lua + +Кастомная конфигурация Prosody, монтируется в контейнер `snikket_server` как `/etc/prosody/conf.d/custom.cfg.lua`. Содержит: + +- **PostgreSQL** — `storage = "sql"` с драйвером PostgreSQL (подключение к `127.0.0.1:5432`) +- **HTTP Upload External** — `mod_http_upload_external` для загрузки файлов через внешний S3 upload handler + +### secrets.env + +Файл с секретами, **не коммитится в git** (добавлен в `.gitignore`). Перед первым запуском необходимо создать его и заполнить реальными значениями: + +```bash +cp secrets.env.example secrets.env +# отредактировать secrets.env +``` + +| Переменная | Описание | +|------------|----------| +| `UPLOAD_SECRET` | Shared secret (должен совпадать с `http_upload_external_secret` в `prosody.cfg.lua`) | +| `AWS_ACCESS_KEY_ID` | Ключ доступа AWS/S3 | +| `AWS_SECRET_ACCESS_KEY` | Секретный ключ AWS/S3 | +| `POSTGRES_PASSWORD` | Пароль PostgreSQL | + +### S3 Upload Handler (environment) + +Несекретные параметры задаются в `docker-compose.yml` в секции `environment` сервиса `s3_upload_handler`: + +| Переменная | Описание | +|------------|----------| +| `S3_BUCKET` | Имя S3-бакета | +| `S3_REGION` | Регион S3 | +| `S3_ENDPOINT` | URL S3-совместимого хранилища (MinIO и др.), не задавать для AWS | +| `PRESIGN_EXPIRE` | Время жизни presigned URL для скачивания (секунды, по умолчанию `3600`) | + +### snikket.conf + +### Обязательные параметры + +| Переменная | Описание | +|------------|----------| +| `SNIKKET_DOMAIN` | Домен сервера. **Нельзя изменить после первого запуска.** | +| `SNIKKET_ADMIN_EMAIL` | Email администратора (используется для Let's Encrypt) | + +### Основные параметры + +| Переменная | По умолчанию | Описание | +|------------|-------------|----------| +| `SNIKKET_SITE_NAME` | значение `SNIKKET_DOMAIN` | Человекочитаемое имя сервера | +| `SNIKKET_RETENTION_DAYS` | `7` | Сколько дней хранить сообщения (MAM) и загруженные файлы | +| `SNIKKET_UPLOAD_STORAGE_GB` | без лимита | Лимит дискового пространства для загруженных файлов (напр. `1.5`) | +| `SNIKKET_LOGLEVEL` | `info` | Уровень логирования: `error`, `warn`, `info`, `debug` | +| `SNIKKET_ABUSE_EMAIL` | — | Публичный email для жалоб | +| `SNIKKET_SECURITY_EMAIL` | — | Публичный email для сообщений о безопасности | +| `SNIKKET_UPDATE_CHECK` | `1` | Установить `0` чтобы отключить проверку обновлений | + +### Параметры reverse proxy + +| Переменная | По умолчанию | Описание | +|------------|-------------|----------| +| `SNIKKET_TWEAK_HTTP_PORT` | `80` | HTTP-порт (изменить при работе за reverse proxy) | +| `SNIKKET_TWEAK_HTTPS_PORT` | `443` | HTTPS-порт (изменить при работе за reverse proxy) | + +### Параметры TURN-сервера + +| Переменная | По умолчанию | Описание | +|------------|-------------|----------| +| `SNIKKET_TWEAK_TURNSERVER` | `1` | Установить `0` для отключения встроенного TURN | +| `SNIKKET_TWEAK_TURNSERVER_DOMAIN` | значение `SNIKKET_DOMAIN` | Домен внешнего TURN-сервера | +| `SNIKKET_TWEAK_TURNSERVER_PORT` | `3478` | Порт TURN-сервера | +| `SNIKKET_TWEAK_TURNSERVER_SECRET` | автогенерация | Shared secret для аутентификации TURN | + +### Прочие параметры + +| Переменная | По умолчанию | Описание | +|------------|-------------|----------| +| `SNIKKET_TWEAK_IPV6` | `1` | Установить `0` для отключения IPv6 | +| `SNIKKET_TWEAK_STORAGE` | `files` | Бэкенд хранения: `files` или `sqlite` (превью) | +| `SNIKKET_TWEAK_EXTRA_CONFIG` | — | Путь/glob к дополнительным файлам конфигурации Prosody | +| `SNIKKET_PROXY65_PORT` | `5000` | Порт proxy для передачи файлов | +| `SNIKKET_TWEAK_DNSSEC` | — | Включить поддержку DNSSEC и DANE | + +## Запуск + +```bash +cd server + +# 1. Настроить конфигурацию +# отредактировать snikket.conf значениями вашего домена + +# 2. Заполнить секреты +cp secrets.env.example secrets.env +# отредактировать secrets.env реальными значениями + +# 3. Запустить +docker compose up -d + +# 3. Создать admin-аккаунт +docker exec snikket create-invite --admin --group default +``` + +Инвайт-ссылку открыть в браузере для регистрации администратора. + +## Обновление + +```bash +cd server +docker compose pull +docker compose up -d +``` + +## Хранение данных + +Данные хранятся в Docker volumes: + +| Volume | Содержимое | +|--------|-----------| +| `snikket_data` | Данные сервера: аккаунты, сообщения, TLS-сертификаты | +| `acme_challenges` | Временные файлы ACME (Let's Encrypt) | +| `postgres_data` | Данные PostgreSQL | + +Загруженные файлы хранятся в S3-бакете (не в volumes). + +### Бэкап + +Необходимо сохранить volume `snikket_data` и базу PostgreSQL: + +```bash +# Snikket data +docker run --rm -v jabogram_snikket_data:/data -v $(pwd):/backup alpine \ + tar czf /backup/snikket-backup.tar.gz -C /data . + +# PostgreSQL +docker exec snikket-postgres pg_dump -U snikket snikket | gzip > snikket-pg-backup.sql.gz +``` + +## Хранение файлов (S3) + +Загруженные файлы (HTTP File Upload, XEP-0363) хранятся в **S3-бакете** через кастомный `s3_upload_handler`. + +Схема работы: + +1. XMPP-клиент запрашивает у Prosody слот для загрузки файла +2. Prosody (`mod_http_upload_external`) генерирует подписанный URL, указывающий на `s3_upload_handler` +3. Клиент загружает файл по этому URL +4. `s3_upload_handler` проверяет HMAC-подпись и сохраняет файл в S3 +5. При скачивании — handler генерирует presigned S3 URL и делает redirect diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..027c427 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,80 @@ +version: "3.3" + +services: + snikket_proxy: + container_name: snikket-proxy + image: snikket/snikket-web-proxy:stable + env_file: snikket.conf + network_mode: host + volumes: + - snikket_data:/snikket + - acme_challenges:/var/www/html/.well-known/acme-challenge + restart: "unless-stopped" + depends_on: + - snikket_server + + snikket_certs: + container_name: snikket-certs + image: snikket/snikket-cert-manager:stable + env_file: snikket.conf + network_mode: host + volumes: + - snikket_data:/snikket + - acme_challenges:/var/www/.well-known/acme-challenge + restart: "unless-stopped" + + snikket_portal: + container_name: snikket-portal + image: snikket/snikket-web-portal:stable + env_file: snikket.conf + network_mode: host + restart: "unless-stopped" + depends_on: + - snikket_server + + snikket_server: + container_name: snikket + image: snikket/snikket-server:stable + env_file: + - snikket.conf + - secrets.env + network_mode: host + volumes: + - snikket_data:/snikket + - ./prosody.cfg.lua:/etc/prosody/conf.d/custom.cfg.lua:ro + restart: "unless-stopped" + depends_on: + - postgres + - s3_upload_handler + + s3_upload_handler: + container_name: snikket-s3-upload + build: ./s3-upload-handler + network_mode: host + env_file: + - secrets.env + environment: + S3_BUCKET: "your-snikket-uploads-bucket" + S3_REGION: "us-east-1" + # For MinIO or other S3-compatible storage, uncomment: + # S3_ENDPOINT: "http://127.0.0.1:9000" + PRESIGN_EXPIRE: "3600" + restart: "unless-stopped" + + postgres: + container_name: snikket-postgres + image: postgres:15 + env_file: + - secrets.env + environment: + POSTGRES_DB: snikket + POSTGRES_USER: snikket + volumes: + - postgres_data:/var/lib/postgresql/data + network_mode: host + restart: "unless-stopped" + +volumes: + acme_challenges: + snikket_data: + postgres_data: diff --git a/server/prosody.cfg.lua b/server/prosody.cfg.lua new file mode 100644 index 0000000..901eb40 --- /dev/null +++ b/server/prosody.cfg.lua @@ -0,0 +1,45 @@ +-- Custom Prosody configuration for Snikket +-- This file is included alongside the auto-generated Snikket config. + +---------------------------------------------------------------------- +-- PostgreSQL storage +---------------------------------------------------------------------- +storage = "sql" + +sql = { + driver = "PostgreSQL"; + database = "snikket"; + host = "127.0.0.1"; + port = 5432; + username = "snikket"; + password = os.getenv("POSTGRES_PASSWORD"); +} + +---------------------------------------------------------------------- +-- S3 HTTP Upload (via mod_http_upload_external) +---------------------------------------------------------------------- + +-- Disable built-in upload module, enable external upload +modules_disabled = { + "http_upload"; +} + +modules_enabled = { + "http_upload_external"; +} + +-- URL of the external upload service that handles S3 interaction. +-- This is NOT the S3 bucket URL directly. You need to run a separate +-- upload handler service (e.g. prosody-filer, or a custom Lambda/endpoint) +-- that validates Prosody's HMAC signature and proxies files to/from S3. +http_upload_external_base_url = "https://uploads.chat.example.org/upload/" + +-- Shared secret between Prosody and the upload handler service +-- (must match the secret configured in the upload handler) +http_upload_external_secret = os.getenv("UPLOAD_SECRET") + +-- How long (seconds) the upload URL remains valid +http_upload_external_expire_after = 3600 + +-- Max file size in bytes (10 MB) +http_upload_external_file_size_limit = 10485760 diff --git a/server/s3-upload-handler/Dockerfile b/server/s3-upload-handler/Dockerfile new file mode 100644 index 0000000..938f696 --- /dev/null +++ b/server/s3-upload-handler/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 5050 + +CMD ["gunicorn", "-b", "0.0.0.0:5050", "-w", "2", "--timeout", "120", "main:app"] diff --git a/server/s3-upload-handler/main.py b/server/s3-upload-handler/main.py new file mode 100644 index 0000000..707ea25 --- /dev/null +++ b/server/s3-upload-handler/main.py @@ -0,0 +1,93 @@ +""" +Prosody mod_http_upload_external -> S3 handler. + +Implements the same HMAC protocol as prosody-filer but stores files in S3. + +Flow: + 1. XMPP client asks Prosody for an upload slot + 2. Prosody generates a signed PUT URL pointing here + 3. Client PUTs the file; this handler verifies the HMAC and uploads to S3 + 4. On GET, handler generates a presigned S3 URL and redirects +""" + +import hashlib +import hmac as hmac_mod +import logging +import os + +import boto3 +from flask import Flask, Response, redirect, request + +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("s3-upload-handler") + +SECRET = os.environ["UPLOAD_SECRET"] +S3_BUCKET = os.environ["S3_BUCKET"] +S3_REGION = os.environ.get("S3_REGION", "us-east-1") +S3_ENDPOINT = os.environ.get("S3_ENDPOINT") +PRESIGN_EXPIRE = int(os.environ.get("PRESIGN_EXPIRE", "3600")) + +s3_kwargs = { + "region_name": S3_REGION, +} +if S3_ENDPOINT: + s3_kwargs["endpoint_url"] = S3_ENDPOINT + +s3 = boto3.client("s3", **s3_kwargs) + + +def verify_token(path: str, size: str, content_type: str, token: str) -> bool: + """Verify HMAC-SHA256 token from Prosody mod_http_upload_external.""" + message = f"{path} {size} {content_type}" + expected = hmac_mod.new( + SECRET.encode(), message.encode(), hashlib.sha256 + ).hexdigest() + return hmac_mod.compare_digest(expected, token) + + +@app.route("/upload/", methods=["PUT"]) +def upload(file_path): + token = request.args.get("v", "") + content_type = request.args.get("t", "application/octet-stream") + file_size = request.args.get("s", "0") + + hmac_path = f"upload/{file_path}" + + if not verify_token(hmac_path, file_size, content_type, token): + log.warning("HMAC verification failed for %s", hmac_path) + return Response("Forbidden", status=403) + + body = request.get_data() + + if len(body) != int(file_size): + return Response("Content length mismatch", status=400) + + s3.put_object( + Bucket=S3_BUCKET, + Key=file_path, + Body=body, + ContentType=content_type, + ) + + log.info("Uploaded %s (%s bytes) to s3://%s/%s", file_path, file_size, S3_BUCKET, file_path) + return Response(status=201) + + +@app.route("/upload/", methods=["GET", "HEAD"]) +def download(file_path): + try: + url = s3.generate_presigned_url( + "get_object", + Params={"Bucket": S3_BUCKET, "Key": file_path}, + ExpiresIn=PRESIGN_EXPIRE, + ) + except Exception: + log.exception("Failed to generate presigned URL for %s", file_path) + return Response("Not found", status=404) + + return redirect(url, code=302) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050) diff --git a/server/s3-upload-handler/requirements.txt b/server/s3-upload-handler/requirements.txt new file mode 100644 index 0000000..8b0c72e --- /dev/null +++ b/server/s3-upload-handler/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.* +gunicorn==23.* +boto3==1.36.* diff --git a/server/secrets.env.example b/server/secrets.env.example new file mode 100644 index 0000000..967323c --- /dev/null +++ b/server/secrets.env.example @@ -0,0 +1,7 @@ +# S3 Upload Handler secrets +UPLOAD_SECRET=change-me +AWS_ACCESS_KEY_ID=change-me +AWS_SECRET_ACCESS_KEY=change-me + +# PostgreSQL secrets +POSTGRES_PASSWORD=change-me diff --git a/server/snikket.conf b/server/snikket.conf new file mode 100644 index 0000000..6e415e2 --- /dev/null +++ b/server/snikket.conf @@ -0,0 +1,12 @@ +# Snikket Server Configuration +# Copy this file and fill in your values before deploying. + +# Your XMPP domain (e.g., chat.example.org) +SNIKKET_DOMAIN=chat.example.org + +# Admin email — used for Let's Encrypt certificate registration +SNIKKET_ADMIN_EMAIL=admin@example.org + +# Uncomment the lines below if running behind a reverse proxy (e.g., nginx): +# SNIKKET_TWEAK_HTTP_PORT=5080 +# SNIKKET_TWEAK_HTTPS_PORT=5443 diff --git a/xmpp_messenger_design_document.md b/xmpp_messenger_design_document.md new file mode 100644 index 0000000..67d5244 --- /dev/null +++ b/xmpp_messenger_design_document.md @@ -0,0 +1,297 @@ +# XMPP Messenger (Text + Voice Messages) + +## 1. Цель документа + +Этот документ предназначен как **техническое задание и дизайн‑документ** для разработки XMPP‑мессенджера с поддержкой: +- текстовых сообщений +- голосовых сообщений (аудиофайлы, без стриминга) +- end‑to‑end шифрования +- iOS и Android клиентов на React Native + +Документ рассчитан на использование **Claude Code** как основного помощника в реализации. Поэтому архитектура и формулировки сделаны максимально явными, модульными и детализированными. + +--- + +## 2. Область и ограничения + +### Входит в scope +- One‑to‑one чаты +- Текстовые сообщения +- Голосовые сообщения как файлы +- E2EE (OMEMO) +- Собственная XMPP‑инфраструктура + +### Не входит в scope +- Голосовые/видео звонки +- Групповые чаты (MUC) +- Push‑нотификации (опционально позже) +- Федерация между серверами (пока один домен) + +--- + +## 3. Общая архитектура + +``` +Clients (iOS / Android) + | + | XMPP over WebSocket + | + XMPP Server (Prosody) + | + | HTTP Upload + | + File Storage (local / S3) +``` + +- XMPP используется **только для сигналинга и сообщений** +- Голосовые сообщения передаются как **зашифрованные файлы** через HTTP Upload +- Сервер не имеет доступа к содержимому сообщений + +--- + +## 4. Структура репозитория + +``` +./README.md — описание проекта + +./doc/ + design.md — этот документ + +./server/ + prosody/ + prosody.cfg.lua — конфигурация сервера + modules/ — кастомные модули (если нужны) + deploy/ + docker-compose.yml — развертывание + init.sh — инициализация + +./react-native-lib/ + src/ + xmpp/ — XMPP логика + crypto/ — OMEMO / шифрование + audio/ — запись и воспроизведение + storage/ — работа с файлами + ui/ — общие UI компоненты + types/ — общие типы + +./react-native-ios/ + ios/ — iOS проект + App.tsx — точка входа + +./react-native-android/ + android/ — Android проект + App.tsx — точка входа +``` + +--- + +## 5. Серверная часть + +### 5.1 Выбор сервера + +- **Prosody** (Lua) +- Причины: + - простой + - модульный + - хорошо документирован + - легко дорабатывается + +### 5.2 Основные XEP + +| XEP | Назначение | +|----|-----------| +| XEP-0030 | Service Discovery | +| XEP-0198 | Stream Management | +| XEP-0363 | HTTP File Upload | +| XEP-0384 | OMEMO | +| XEP-0313 | MAM | +| XEP-0280 | Message Carbons | + +### 5.3 HTTP File Upload + +- Хранение файлов: + - локально (MVP) + - либо S3‑совместимое хранилище + +- Ограничения: + - max размер: 10 MB + - max длительность: 5 минут + +### 5.4 Безопасность + +- TLS обязателен +- Сервер **не логирует содержимое сообщений** +- Логи — только технические (connect/disconnect) + +--- + +## 6. Клиентская часть (общая) + +### 6.1 Технологии + +- React Native +- TypeScript +- xmpp.js + +### 6.2 Архитектура клиента + +``` +UI + ↓ +Chat Logic + ↓ +XMPP Service + ↓ +Crypto Layer (OMEMO) +``` + +Каждый слой изолирован. + +--- + +## 7. Текстовые сообщения + +### Flow + +1. Пользователь вводит текст +2. Текст шифруется OMEMO +3. Отправляется XMPP +4. Получатель расшифровывает + +### Формат + +```xml + + + ... + + +``` + +--- + +## 8. Голосовые сообщения + +### 8.1 Аудиоформат + +- Кодек: **Opus** +- Контейнер: + - Android: OGG / WebM + - iOS: AAC (fallback) + +### 8.2 Flow голосового сообщения + +1. Запись аудио +2. Кодирование +3. Шифрование файла +4. Upload через XEP‑0363 +5. Отправка XMPP сообщения со ссылкой + +### 8.3 XMPP сообщение + +```xml + + Voice message + + + + audio/ogg + 12345 + 12 + + + + +``` + +### 8.4 UX требования + +- запись по удержанию кнопки +- отмена свайпом +- отображение длительности +- ручное воспроизведение + +--- + +## 9. Шифрование (OMEMO) + +### Требования + +- Использовать готовую библиотеку +- Один identity key на устройство +- Поддержка multi‑device + +### Поток + +- Файл шифруется симметричным ключом +- Ключ передается через OMEMO + +--- + +## 10. iOS‑специфика + +- Background mode: audio +- Permissions: + - microphone + - network + +- Использовать native modules для записи + +--- + +## 11. Android‑специфика + +- Foreground service при записи +- Runtime permissions +- Ограничения Android 13+ + +--- + +## 12. Ошибки и edge cases + +- потеря сети во время upload +- повторная отправка +- битые ссылки +- несовпадение ключей OMEMO + +--- + +## 13. Нефункциональные требования + +- запуск MVP < 5 секунд +- запись без UI лагов +- батарея: запись ≤ 5% / минута + +--- + +## 14. README.md (кратко) + +README должен содержать: +- описание проекта +- как запустить сервер +- как запустить iOS / Android +- ограничения + +--- + +## 15. Возможные расширения + +- Push notifications +- Groups (MUC) +- Voice calls (Jingle + WebRTC) +- Desktop client + +--- + +## 16. Критерий готовности MVP + +- iOS и Android обмениваются: + - текстом + - голосовыми +- сообщения шифрованы +- сервер не знает содержимое + +--- + +**Этот документ считается достаточным для полной реализации проекта без дополнительных уточнений.** +