This commit is contained in:
bvn13 2026-02-27 00:32:28 +03:00
commit f854c14c45
12 changed files with 2626 additions and 0 deletions

34
README.md Normal file
View File

@ -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) — полное техническое описание архитектуры, протокола, моделей данных, сценариев и развёртывания.

1849
doc/DESIGN.md Normal file

File diff suppressed because it is too large Load Diff

1
server/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
secrets.env

194
server/README.md Normal file
View File

@ -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 |
| 4915265535 | 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

80
server/docker-compose.yml Normal file
View File

@ -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:

45
server/prosody.cfg.lua Normal file
View File

@ -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

View File

@ -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"]

View File

@ -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/<path:file_path>", 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/<path:file_path>", 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)

View File

@ -0,0 +1,3 @@
flask==3.1.*
gunicorn==23.*
boto3==1.36.*

View File

@ -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

12
server/snikket.conf Normal file
View File

@ -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

View File

@ -0,0 +1,297 @@
# XMPP Messenger (Text + Voice Messages)
## 1. Цель документа
Этот документ предназначен как **техническое задание и дизайн‑документ** для разработки XMPPмессенджера с поддержкой:
- текстовых сообщений
- голосовых сообщений (аудиофайлы, без стриминга)
- endtoend шифрования
- iOS и Android клиентов на React Native
Документ рассчитан на использование **Claude Code** как основного помощника в реализации. Поэтому архитектура и формулировки сделаны максимально явными, модульными и детализированными.
---
## 2. Область и ограничения
### Входит в scope
- Onetoone чаты
- Текстовые сообщения
- Голосовые сообщения как файлы
- 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 <message/>
4. Получатель расшифровывает
### Формат
```xml
<message to="jid" type="chat">
<encrypted xmlns="eu.siacs.conversations.axolotl">
...
</encrypted>
</message>
```
---
## 8. Голосовые сообщения
### 8.1 Аудиоформат
- Кодек: **Opus**
- Контейнер:
- Android: OGG / WebM
- iOS: AAC (fallback)
### 8.2 Flow голосового сообщения
1. Запись аудио
2. Кодирование
3. Шифрование файла
4. Upload через XEP0363
5. Отправка XMPP сообщения со ссылкой
### 8.3 XMPP сообщение
```xml
<message>
<body>Voice message</body>
<reference xmlns="urn:xmpp:reference:0" type="data" uri="https://upload/...">
<media-sharing xmlns="urn:xmpp:sims:1">
<file>
<media-type>audio/ogg</media-type>
<size>12345</size>
<duration>12</duration>
</file>
</media-sharing>
</reference>
</message>
```
### 8.4 UX требования
- запись по удержанию кнопки
- отмена свайпом
- отображение длительности
- ручное воспроизведение
---
## 9. Шифрование (OMEMO)
### Требования
- Использовать готовую библиотеку
- Один identity key на устройство
- Поддержка multidevice
### Поток
- Файл шифруется симметричным ключом
- Ключ передается через 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 обмениваются:
- текстом
- голосовыми
- сообщения шифрованы
- сервер не знает содержимое
---
**Этот документ считается достаточным для полной реализации проекта без дополнительных уточнений.**