jabogram/doc/DESIGN.md
2026-02-27 00:32:28 +03:00

72 KiB
Raw Blame History

Jabogram: Дизайн-документ

Версия: 1.0 Дата: 2026-02-10


Содержание

  1. Введение
  2. Глоссарий
  3. Архитектура системы
  4. Структура проекта
  5. Сервер
  6. Протокол XMPP: используемые XEP
  7. Формат сообщений
  8. Клиентское приложение — общая библиотека
  9. Клиентское приложение — iOS
  10. Клиентское приложение — Android
  11. Модели данных
  12. Сценарии взаимодействия
  13. Безопасность
  14. Развёртывание
  15. Тестирование
  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 (<message>, <presence>, <iq>)
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)

{
  "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:

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

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 — основная конфигурация

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

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:

#!/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:

#!/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 In-Band Registration Регистрация нового аккаунта прямо через XMPP-соединение
XEP-0030 Service Discovery Обнаружение возможностей сервера (поддержка upload, MUC и т.д.)
XEP-0045 Multi-User Chat Групповые чаты
XEP-0066 Out of Band Data Передача URL голосового файла в теле сообщения
XEP-0084 User Avatar Аватары пользователей
XEP-0085 Chat State Notifications Индикатор «печатает…»
XEP-0184 Message Delivery Receipts Подтверждение доставки (✓✓)
XEP-0191 Blocking Command Блокировка пользователей
XEP-0198 Stream Management Надёжная доставка stanza, возобновление сессии после обрыва
XEP-0280 Message Carbons Синхронизация сообщений между устройствами одного пользователя
XEP-0313 Message Archive Management Серверный архив; синхронизация истории при подключении
XEP-0333 Chat Markers Статус «прочитано» (displayed marker)
XEP-0352 Client State Indication Клиент сообщает серверу, что ушёл в фон (экономия трафика)
XEP-0357 Push Notifications Серверные push-уведомления при офлайн-сообщениях
XEP-0363 HTTP File Upload Загрузка голосовых файлов на сервер

7. Формат сообщений

7.1 Текстовое сообщение (1-on-1)

<message
    from="alice@jabogram.example.com/phone"
    to="bob@jabogram.example.com"
    type="chat"
    id="msg-uuid-001">
  <body>Привет, как дела?</body>
  <request xmlns="urn:xmpp:receipts"/>
  <markable xmlns="urn:xmpp:chat-markers:0"/>
</message>

7.2 Подтверждение доставки (XEP-0184)

<message
    from="bob@jabogram.example.com/phone"
    to="alice@jabogram.example.com"
    type="chat"
    id="receipt-001">
  <received xmlns="urn:xmpp:receipts" id="msg-uuid-001"/>
</message>

7.3 Маркер «прочитано» (XEP-0333)

<message
    from="bob@jabogram.example.com/phone"
    to="alice@jabogram.example.com"
    type="chat"
    id="marker-001">
  <displayed xmlns="urn:xmpp:chat-markers:0" id="msg-uuid-001"/>
</message>

7.4 Индикатор набора текста (XEP-0085)

<message
    from="alice@jabogram.example.com/phone"
    to="bob@jabogram.example.com"
    type="chat">
  <composing xmlns="http://jabber.org/protocol/chatstates"/>
</message>

7.5 Голосовое сообщение

Голосовое сообщение отправляется в два этапа:

Этап 1 — получение upload-слота (XEP-0363):

Запрос:

<iq from="alice@jabogram.example.com/phone"
    to="jabogram.example.com"
    type="get"
    id="upload-001">
  <request xmlns="urn:xmpp:http:upload:0"
      filename="voice-1707580800.m4a"
      size="48320"
      content-type="audio/mp4"/>
</iq>

Ответ сервера:

<iq from="jabogram.example.com"
    to="alice@jabogram.example.com/phone"
    type="result"
    id="upload-001">
  <slot xmlns="urn:xmpp:http:upload:0">
    <put url="https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a">
      <header name="Authorization">Basic ...</header>
      <header name="Content-Type">audio/mp4</header>
    </put>
    <get url="https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a"/>
  </slot>
</iq>

Этап 2 — загрузка файла по HTTP PUT:

PUT /upload/abcdef123/voice-1707580800.m4a HTTP/1.1
Host: jabogram.example.com:5443
Content-Type: audio/mp4
Content-Length: 48320

<binary audio data>

Этап 3 — отправка XMPP-сообщения со ссылкой:

<message
    from="alice@jabogram.example.com/phone"
    to="bob@jabogram.example.com"
    type="chat"
    id="msg-uuid-002">
  <body>https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a</body>
  <x xmlns="jabber:x:oob">
    <url>https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a</url>
    <desc>voice-message</desc>
  </x>
  <voice-message xmlns="urn:jabogram:voice:0"
      duration="5"
      mime-type="audio/mp4"
      size="48320"
      waveform="0.1,0.3,0.7,0.9,0.5,0.2,0.4,0.8,0.6,0.3"/>
  <request xmlns="urn:xmpp:receipts"/>
  <markable xmlns="urn:xmpp:chat-markers:0"/>
</message>

Пояснение полей <voice-message>:

Атрибут Тип Описание
duration int Длительность в секундах
mime-type string MIME-тип аудио (audio/mp4)
size int Размер файла в байтах
waveform string CSV нормализованных float-значений (0..1) для визуализации волны, ~10-50 точек

Клиент-получатель:

  1. Видит <voice-message> → рисует UI голосового сообщения с waveform
  2. По нажатию Play → скачивает файл по URL из <x xmlns="jabber:x:oob"><url> → воспроизводит
  3. Кеширует скачанный файл локально

Если клиент не поддерживает голосовые сообщения — показывает <body> как обычную ссылку (graceful degradation).

7.6 Сообщение в групповой чат (MUC)

<message
    from="alice@jabogram.example.com/phone"
    to="developers@conference.jabogram.example.com"
    type="groupchat"
    id="msg-uuid-003">
  <body>Всем привет!</body>
</message>

8. Клиентское приложение — общая библиотека

8.1 XMPP-клиент (services/xmpp/XMPPClient.ts)

Обёртка над stanza.js. Основные обязанности:

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<void>;
  disconnect(): Promise<void>;
  reconnect(): Promise<void>;

  // Stream Management (XEP-0198)
  enableStreamManagement(): void;
  resumeSession(): Promise<boolean>;

  // 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)

class MessageService {
  sendTextMessage(to: string, body: string, chatType: 'chat' | 'groupchat'): Message;
  sendVoiceMessage(to: string, audioFile: AudioFile, chatType: 'chat' | 'groupchat'): Promise<Message>;
  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)

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/)

// AudioRecorder.ts
class AudioRecorder {
  /** Начать запись. Формат: AAC (.m4a) */
  async start(): Promise<void>;

  /** Остановить запись и вернуть файл */
  async stop(): Promise<RecordedAudio>;

  /** Отменить запись */
  async cancel(): Promise<void>;

  /** Текущая амплитуда (для анимации) */
  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<void>;
  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)

class MAMService {
  /**
   * Запрос архива сообщений.
   * При первом подключении — загрузка последних N сообщений для каждого чата.
   * При повторном подключении — загрузка сообщений с момента последнего известного.
   */
  async fetchHistory(params: {
    with?: string;           // JID собеседника (опционально)
    before?: string;         // ID сообщения для пагинации «назад»
    after?: string;          // ID для пагинации «вперёд»
    max?: number;            // лимит (по умолчанию 50)
  }): Promise<ArchivedMessage[]>;

  /**
   * Запрос сообщений, пришедших пока клиент был офлайн.
   * Вызывается сразу после успешного connect.
   */
  async syncSinceLastMessage(lastKnownId: string): Promise<ArchivedMessage[]>;
}

8.6 Сервис Roster (services/xmpp/RosterService.ts)

class RosterService {
  /** Получить текущий список контактов */
  async getRoster(): Promise<Contact[]>;

  /** Добавить контакт (отправить subscription request) */
  async addContact(jid: string, name?: string): Promise<void>;

  /** Удалить контакт */
  async removeContact(jid: string): Promise<void>;

  /** Принять входящий запрос на подписку */
  async acceptSubscription(jid: string): Promise<void>;

  /** Отклонить входящий запрос */
  async denySubscription(jid: string): Promise<void>;

  /** Обновление ростера (push от сервера) */
  onRosterUpdate(handler: (contacts: Contact[]) => void): void;

  /** Входящий subscription request */
  onSubscriptionRequest(handler: (jid: string) => void): void;
}

8.7 Локальная база данных (services/storage/)

SQLite через op-sqlite. Три основные таблицы:

-- Контакты
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

interface AuthState {
  jid: string | null;
  password: string | null;     // хранится в Keychain/Keystore, не в памяти
  isAuthenticated: boolean;
  isConnecting: boolean;
  connectionError: string | null;

  login(jid: string, password: string): Promise<void>;
  register(username: string, password: string): Promise<void>;
  logout(): Promise<void>;
}

useChatsStore

interface ChatsState {
  chats: Chat[];
  currentChatId: string | null;
  messages: Record<string, Message[]>;  // chatId → messages

  loadChats(): Promise<void>;
  loadMessages(chatId: string, before?: string): Promise<void>;
  addMessage(message: Message): void;
  updateMessageStatus(messageId: string, status: MessageStatus): void;
  incrementUnread(chatId: string): void;
  resetUnread(chatId: string): void;
  setCurrentChat(chatId: string | null): void;
}

useContactsStore

interface ContactsState {
  contacts: Contact[];
  pendingRequests: string[];     // входящие запросы на добавление

  loadContacts(): Promise<void>;
  addContact(jid: string, name?: string): Promise<void>;
  removeContact(jid: string): Promise<void>;
  acceptRequest(jid: string): Promise<void>;
  denyRequest(jid: string): Promise<void>;
}

usePresenceStore

interface PresenceState {
  presences: Record<string, PresenceInfo>;  // 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

function useXMPP() {
  // Автоматическое подключение при наличии credentials
  // Подписка на события (сообщения, presence, roster updates)
  // Реконнект при восстановлении сети
  // CSI: active при foreground, inactive при background
}

useAudioRecorder

function useAudioRecorder() {
  return {
    isRecording: boolean;
    duration: number;
    amplitude: number;
    start: () => Promise<void>;
    stop: () => Promise<RecordedAudio>;
    cancel: () => Promise<void>;
  };
}

useAudioPlayer

function useAudioPlayer(messageId: string) {
  return {
    isPlaying: boolean;
    progress: number;       // 0..1
    currentTime: number;
    duration: number;
    play: (uri: string) => Promise<void>;
    pause: () => void;
    seekTo: (seconds: number) => void;
  };
}

useMessages

function useMessages(chatId: string) {
  return {
    messages: Message[];
    isLoading: boolean;
    hasMore: boolean;
    loadMore: () => Promise<void>;   // подгрузка старых (MAM)
    sendText: (body: string) => void;
    sendVoice: (audio: RecordedAudio) => Promise<void>;
  };
}

9. Клиентское приложение — iOS

9.1 Точка входа

// 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 — требуемые разрешения

<key>NSMicrophoneUsageDescription</key>
<string>Jabogram needs microphone access to record voice messages</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>Jabogram needs photo library access to set your avatar</string>

<key>NSCameraUsageDescription</key>
<string>Jabogram needs camera access to take a photo for your avatar</string>

<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
    <string>fetch</string>
</array>

9.3 Push-уведомления (APNs)

  1. Включить Push Notifications capability в Xcode
  2. Настроить Jabogram.entitlements:
    <key>aps-environment</key>
    <string>production</string>
    
  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:

// 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 — разрешения

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

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:
    // В 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)

/** Статус доставки сообщения */
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. <message> + receipt │                      │
  │────────────────────────►│                      │
  │                         │  7. route message    │
  │                         │─────────────────────►│  8. Получено
  │                         │                      │  9. Сохранить в SQLite
  │                         │                      │  10. Показать в UI
  │                         │                      │
  │                         │◄─────────────────────│  11. <received> (XEP-0184)
  │◄────────────────────────│                      │
  │  12. status='delivered' │                      │
  │                         │                      │
  │                         │◄─────────────────────│  13. <displayed> (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. <message> с OOB    │                      │
  │      + <voice-message>  │                      │
  │      + 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. Подготовить сервер:

    apt update && apt install -y docker.io docker-compose-plugin
    
  2. Получить TLS-сертификат:

    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=<secure random password>
    
  4. Запустить:

    cd server && docker compose up -d
    
  5. Проверить:

    docker compose exec ejabberd ejabberdctl status
    

14.2 Сборка клиентских приложений

iOS:

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:

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:

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 участников в групповом чате