Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Tiny Messenger

Небольшой личный мессенджер для веба и устройства с экраном 0.96″ 80×160 px и тремя кнопками. Пользователю не нужна регистрация: сервер выдаёт UUID и секретный токен, который одновременно служит паролем и API-ключом.

Что уже работает

  • создание профиля и восстановление на другом браузере по токену;
  • смена короткого имени и ротация токена;
  • единый поиск контактов по имени, нику или UUID с актуальными данными профиля;
  • обычные сообщения и вопросы с ответами left / right;
  • реакции любым эмодзи на сообщения в личных и групповых чатах;
  • статусы sent → delivered → read → answered;
  • входящие и ответы из веб-интерфейса;
  • установка как PWA и Web Push-уведомления о сообщениях и ответах;
  • голосовые WebRTC-комнаты 1:1 с повторным входом, reconnect и выбором аудиоустройств;
  • presence контактов: «В сети» и сохранённое время последнего подключения;
  • REST polling для ESP и веба, а также WebSocket device adapter на общем HTTP-сервере;
  • SQLite через Prisma с миграциями и непрерывным бэкапом Litestream в S3, общий minute-window rate limit и отдельная защита от слишком быстрой отправки сообщений;
  • Готовый официальный Node.js образ с единым Next.js custom server для VPS.

Текущие серверные defaults:

Поле Максимум
Имя на устройстве 14 символов
Сообщение 96 символов
Подпись каждой из двух кнопок 5 символов

Они возвращаются через GET /api/config и могут измениться, в том числе стать пользовательскими настройками. Клиентам не следует считать эти числа неизменной частью протокола. Сервер считает Unicode code points, а не байты UTF-8; прошивке всё равно нужно учитывать реальную ширину глифов, перенос и возможную прокрутку.

Локальный запуск

Нужны Node.js 22.12+ в ветке 22.x и pnpm 9.15. Требование к Node учитывает Prisma 7 и нативный модуль better-sqlite3; в production используется Node 22.

pnpm install
pnpm dev

Откройте http://localhost:3000. pnpm dev сам генерирует Prisma-клиент и накатывает миграции; база появится в data/messenger.db и не попадёт в git.

Проверки (smoke-тест запускается против уже работающего локального сервера):

pnpm typecheck
pnpm build
TEST_BASE_URL=http://127.0.0.1:3000 pnpm test:reactions

База данных и бэкап

Данные лежат в одном файле SQLite (DATABASE_URL, по умолчанию file:./data/messenger.db), приложение работает с ним напрямую через Prisma и better-sqlite3 в режиме WAL. Метаданные голосовых комнат и presence остаются в data/realtime.json.

Схема описана в prisma/schema.prisma, история миграций в prisma/migrations. После изменения схемы создайте миграцию локально и закоммитьте её:

pnpm db:migrate --name describe_change

В production миграции применяются автоматически при старте контейнера (prisma migrate deploy в docker-entrypoint.sh), отдельно запускать ничего не нужно. pnpm db:studio открывает Prisma Studio для просмотра данных.

Переезд с JSON-хранилища

Если у вас ещё есть data/store.json от предыдущих версий, импортируйте его один раз в пустую базу:

pnpm db:deploy
pnpm db:import-json

В Docker то же самое делается внутри контейнера после первого запуска новой версии (миграции к этому моменту уже применены):

docker compose --env-file .env --env-file deploy/release.env exec messenger node scripts/import-json-store.mjs

Compose выполняет exec от uid/gid 1001:1001. Не запускайте импорт от root: созданные им файлы WAL приложение потом не сможет открыть на запись.

Скрипт отказывается работать, если в базе уже есть пользователи. UUID, токены и история сохраняются; сам store.json после импорта можно удалить.

JSON + gzip в Telegram

scripts/run-telegram-backup.sh экспортирует структуру и все таблицы SQLite (включая миграции, реакции и внутренние таблицы) из одной согласованной WAL-транзакции. Записи и загрузка gzip в Telegram идут потоково; целые архивы не держатся в RAM. Целые числа SQLite кодируются как {"$sqliteInteger":"..."}, BLOB — как {"$sqliteBlob":"base64..."}. После даты в подписи документа идут две строки:

users: 21, messages: 361
всего: 463 записей

Числа берутся из снимка базы; messages — сумма личных и групповых сообщений. SHA256 сохраняется в серверном журнале.

На сервере секреты лежат отдельно от Git в /root/.config/tiny-messenger/telegram-backup.env, каталог 0700, файл 0600:

TELEGRAM_BOT_TOKEN=...
TELEGRAM_ADMIN_CHAT_ID=...

Образ и зависимости берутся из уже установленного deploy/release.env. Сборка, установка пакетов и перезапуск Messenger для бэкапа не нужны. Контейнер бэкапа ограничен 128 MiB RAM, без swap и 0.25 CPU; таймаут — 10 минут. При доступной памяти меньше 256 MiB задача отказывается стартовать. flock исключает параллельные экспорты. Локальные архивы имеют права 0600, хранятся в backups/telegram 14 дней; очистка выполняется только после успешной отправки. Ошибка экспорта/отправки попадает в журнал и по возможности отправляется админу без звука. Отказы до запуска контейнера записываются в журнал systemd. Архивы свыше 49 MiB остаются локально: для них нужно отдельно настроить доставку частями. Запрос с неопределённым исходом автоматически не повторяется.

Установка на сервере (пути в unit-файлах рассчитаны на /root/tiny-messenger):

sudo install -m 0644 deploy/tiny-messenger-backup.service deploy/tiny-messenger-backup-bot.service /etc/systemd/system/
sudo install -m 0644 deploy/telegram-backup.cron /etc/cron.d/tiny-messenger-telegram-backup
sudo systemctl daemon-reload
sudo systemctl enable --now tiny-messenger-backup-bot.service
sudo systemctl start tiny-messenger-backup.service

Cron запускает бэкап ежедневно в 00:00 по часовому поясу сервера Europe/Berlin (полночь совпадает с Europe/Belgrade). Не меняйте часовой пояс сервера без обновления расписания. Бот дополнительно принимает /backup или /backup@TinyMessengerBackupBot только от админа в его личном чате, проверяя одновременно from.id и chat.id. Документ отправляется с disable_notification=true, без отдельного сообщения о запуске. Повторная команда во время работы получает тихий ответ о текущем запуске. Позиция Telegram updates сохраняется между перезапусками; команды других пользователей игнорируются. Команда добавляется в меню Telegram только для личного чата админа. Бот ограничен 48 MiB RAM / 0.1 CPU, управляющий процесс задачи — 64 MiB / 0.1 CPU.

Журналы и ручной запуск:

sudo journalctl -u tiny-messenger-backup.service -u tiny-messenger-backup-bot.service
sudo systemctl start tiny-messenger-backup.service

Разовое сравнение JSON и SQLite из одного снимка базы (оба архива придут в Telegram):

sudo bash scripts/run-telegram-backup.sh --compare-sqlite

SQLite копируется через online backup API с учётом WAL, затем сжимается gzip с тем же уровнем 6, что JSON. Файл .db.gz после распаковки открывается обычным SQLite; режим сравнения сохраняет лимиты ресурсов и блокировку параллельных запусков. Ежедневный cron и /backup продолжают отправлять JSON.

Проверка восстановления на отдельной машине с установленными зависимостями:

node scripts/restore-json-backup.mjs tiny-messenger-DATE.json.gz recovered.db

Скрипт создаёт только новый файл, восстанавливает схему, индексы, триггеры, представления, все записи и autoincrement, затем проверяет integrity_check и внешние ключи. Для замены рабочей базы сначала остановите Messenger и Litestream и сохраните существующие .db, -wal, -shm отдельно; восстановленной базе верните владельца 1001:1001. Этот формат отличается от старого store.json.

Litestream

Сервис litestream в docker-compose.yml непрерывно реплицирует базу в любой S3-совместимый бакет (Yandex Object Storage, Backblaze B2, Cloudflare R2): изменения уходят пачками раз в секунду, полный снимок раз в сутки, хранится неделя истории. Сервис включается профилем backup, чтобы без настроенного бакета он не запускался. Настройки в deploy/litestream.yml, доступы и профиль в .env:

COMPOSE_PROFILES=backup
LITESTREAM_ACCESS_KEY_ID=...
LITESTREAM_SECRET_ACCESS_KEY=...
LITESTREAM_BUCKET=my-messenger-backup
LITESTREAM_ENDPOINT=https://storage.yandexcloud.net
LITESTREAM_REGION=ru-central1

После этого docker compose --env-file .env --env-file deploy/release.env up -d поднимет и Litestream.

Бакет должен быть приватным: в базе лежат хеши токенов и ключи push-подписок, а Litestream ничего не шифрует сам.

Перед восстановлением остановите Messenger и Litestream, если они уже запущены. Сохраняйте существующую базу вместе с её служебными файлами отдельно; в каталоге назначения не должно быть messenger.db, messenger.db-wal и messenger.db-shm. Каталог должен принадлежать uid/gid 1001:1001. Команды выполняются из корня серверного checkout. Если уже подготовлены каталоги релиза и deploy/release.env, восстановите базу без запуска зависимого Messenger:

docker compose --env-file .env --env-file deploy/release.env stop messenger litestream
docker compose --env-file .env --env-file deploy/release.env run --rm --no-deps litestream restore -config /etc/litestream.yml -o /app/data/messenger.db /app/data/messenger.db

На новой машине без deploy/release.env используйте готовый образ Litestream напрямую, до первой установки приложения. Создайте .env по .env.example и заполните все LITESTREAM_*, включая путь реплики LITESTREAM_PATH:

mkdir -p data
chown 1001:1001 data
docker run --rm --user 1001:1001 --env-file .env \
  --mount "type=bind,src=$(pwd)/data,dst=/app/data" \
  --mount "type=bind,src=$(pwd)/deploy/litestream.yml,dst=/etc/litestream.yml,readonly" \
  litestream/litestream:0.5.17 restore -config /etc/litestream.yml \
  -o /app/data/messenger.db /app/data/messenger.db

После восстановления проверьте целостность базы, затем установите релиз через scripts/install-release.sh. Репликацию включайте после успешного старта Messenger. Litestream сохраняет SQLite, но не data/realtime.json с метаданными голосовых комнат и presence.

Флаг -timestamp восстанавливает состояние на момент внутри окна хранения. Посмотреть, что лежит в реплике, можно командой docker compose --env-file .env --env-file deploy/release.env exec litestream litestream ltx -config /etc/litestream.yml /app/data/messenger.db, а сводку по репликации даёт litestream status -config /etc/litestream.yml.

Smoke-тесты создают пользователей, сообщения и группы и изменяют их состояние; тестовые профили остаются в базе. Запускайте их на локальном или отдельном тестовом экземпляре, а не как проверку production после деплоя. test:api ожидает уже запущенный сервер на порту 3107 (либо адрес в TEST_BASE_URL):

TEST_BASE_URL=http://127.0.0.1:3107 pnpm test:api

WebSocket endpoint работает на том же порту, что и REST, и использует API этого же экземпляра как единственную реализацию операций:

PORT=3107 pnpm start
TEST_BASE_URL=http://127.0.0.1:3107 TEST_WS_URL=ws://127.0.0.1:3107/ws pnpm test:ws

Голосовые комнаты и presence

Веб-клиент открывает тот же /ws, первым кадром отправляет токен и получает protocol 2. Устройство продолжает использовать protocol 1 с токеном в заголовке. Голосовая сессия привязана к паре пользователей: первый участник создаёт комнату, второй принимает приглашение один раз, после чего оба могут выходить и возвращаться, пока в комнате остаётся собеседник. Потеря WebSocket не считается выходом — сервер держит владение вкладки 45 секунд и позволяет восстановить сигналинг без нового принятия. Метаданные комнат и lastSeenAt лежат в data/realtime.json; SDP, ICE, медиапотоки и resume-токены на диск не записываются. Служебный MESSENGER_INTERNAL_TOKEN для вызова push-маршрута генерируется при запуске общего процесса и в поддерживаемой однопроцессной схеме настройки не требует.

Перед созданием комнаты интерфейс запрашивает микрофон и показывает выбор входа и выхода. В браузерах без setSinkId (в том числе старых iOS) аудиовыход выбирается системой. Для связи за NAT настройте coturn и RTC_TURN_URLS + RTC_TURN_SECRET; сервер выдаёт часовые HMAC credentials через авторизованный GET /api/rtc/config. Один STUN не гарантирует установку связи в мобильных и корпоративных сетях.

В production compose уже включён coturn на 3478/tcp+udp с relay-диапазоном 49160-49200/tcp+udp. Перед запуском заведите DNS-запись для TURN, например turn.example.com, откройте эти порты у провайдера и задайте в .env один общий секрет и URL:

RTC_TURN_SECRET=<случайный секрет длиной не менее 32 байт>
RTC_TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp

Тот же домен укажите в realm внутри deploy/turnserver.conf: он участвует в HMAC-подписи временных credentials, поэтому значения по обе стороны должны совпадать.

Конфигурация рассчитана на сервер с публичным IP непосредственно на сетевом интерфейсе: coturn сам выбирает relay-адрес. Если TURN окажется за NAT, явно задайте соответствие публичного и приватного адресов через external-ip.

Не добавляйте RTC_TURN_SECRET в git: это одновременно ключ coturn и ключ, которым приложение подписывает временные TURN credentials.

На iOS PWA звонок поддерживается в foreground. После приостановки или выгрузки приложения push только открывает комнату, а микрофон включается повторным действием пользователя — веб-приложение не обещает поведение нативного CallKit в фоне.

Авторизация REST API

Во всех закрытых ручках передавайте один из заголовков:

Authorization: Bearer msg_ваш_токен

или более удобный для микроконтроллера:

X-API-Key: msg_ваш_токен

Все ответы имеют одну форму:

{ "ok": true, "data": {} }

или:

{ "ok": false, "error": { "code": "invalid_token", "message": "..." } }

Профиль

Метод и путь Назначение
POST /api/auth/register Создать профиль, тело { "name": "Аня" }; токен возвращается только здесь
POST /api/auth/session Восстановить профиль, тело { "token": "msg_..." }
GET /api/me Текущий пользователь
PATCH /api/me Поменять имя и аватарку, тело { "name": "Аня", "avatarUrl": "https://example.com/avatar.jpg", "avatarBackground": "#DCEBFA" }; пустые avatarUrl и avatarBackground удаляют изображение и выбранный фон
POST /api/me/token Сбросить токен и получить новый сгенерированный сервером; старый сразу перестаёт работать
GET /api/users/:uuid Найти публичное имя и аватарку пользователя

PWA и уведомления

Приложение содержит web app manifest, адаптивные иконки и service worker. Его можно установить из браузера; сохранённая оболочка открывается и без сети, но для загрузки диалогов по-прежнему требуется соединение с сервером.

Для фоновых уведомлений создайте VAPID-ключи:

pnpm push:keys

Перенесите выведенные ключи в .env или окружение VPS как WEB_PUSH_VAPID_PUBLIC_KEY и WEB_PUSH_VAPID_PRIVATE_KEY. В WEB_PUSH_VAPID_SUBJECT укажите контактный mailto: или HTTPS-адрес. Без этих переменных PWA продолжит работать, а в настройках будет указано, что push на сервере пока не настроен.

Метод и путь Назначение
GET /api/push Проверить серверную настройку push и получить публичный VAPID-ключ
POST /api/push Сохранить браузерную подписку: { "subscription": PushSubscriptionJSON }
DELETE /api/push Удалить подписку текущего браузера: { "endpoint": "https://…" }

Подписки хранятся в базе. Сервер автоматически удаляет недействительные endpoint-ы после ответов push-сервиса 404 или 410. При выходе из браузера клиент также удаляет свою подписку.

Сервер хранит только SHA-256 хеш токена. После потери токена восстановить доступ без прямого редактирования базы нельзя.

Контакты

Метод и путь Назначение
GET /api/contacts Список контактов текущего пользователя
POST /api/contacts Добавить: { "userId": "uuid" }
DELETE /api/contacts/:uuid Удалить только из своего списка
POST /api/sync Один web-запрос: контакты, история, скрытые диалоги и отметка доставки
DELETE /api/conversations/:uuid Скрыть диалог ({ "mode": "hide" }) или удалить историю (`{ "mode": "delete_history", "scope": "me"

Сообщения

Обычное сообщение:

POST /api/messages
X-API-Key: msg_sender_token
Content-Type: application/json

{
  "toUserId": "UUID получателя",
  "kind": "text",
  "text": "Я уже рядом"
}

Вопрос для физических кнопок:

{
  "toUserId": "UUID получателя",
  "kind": "choice",
  "text": "Будешь чай?",
  "options": [
    { "id": "1", "label": "Да" },
    { "id": "2", "label": "Нет" }
  ]
}

ID варианта задаёт отправляющий клиент и должен быть уникальным внутри сообщения. Он не описывает кнопку или положение варианта в интерфейсе.

Метод и путь Назначение
GET /api/messages?box=all&contactId=UUID&limit=50 История без изменения статусов; box: all, inbox, sent
GET /api/messages/:uuid Одно доступное отправителю или получателю сообщение
DELETE /api/messages/:uuid Удалить одно сообщение: `{ "scope": "me"
DELETE /api/messages Удалить несколько сообщений: `{ "ids": ["uuid"], "scope": "me"
POST /api/messages/poll Получить непрочитанные входящие и атомарно поставить delivered
PATCH /api/messages/:uuid/status Тело { "status": "read" } или { "status": "delivered" }
POST /api/messages/:uuid/answer Выбрать вариант: { "id": "2" }
PUT /api/messages/:uuid/reaction Поставить или заменить свою реакцию: { "emoji": "👍" }; снять: { "emoji": null }

answer автоматически проставляет также delivered и read. Повтор того же ID идемпотентен, смена уже данного ответа возвращает 409.

Каждый участник может оставить одну реакцию на сообщение, включая собственное или вопрос. Принимается один Unicode-эмодзи, включая оттенки кожи, флаги и составные эмодзи. Обычный текст или несколько эмодзи возвращают 422. Повтор PUT с тем же эмодзи или null идемпотентен. Ответ содержит reactions: [{ "userId": "uuid", "emoji": "👍" }]; этот же массив возвращается внутри сообщений в истории, poll и sync (без реакций — []). Реакция не меняет статус доставки или прочтения. Реагировать может только участник чата на доступное ему сообщение. Удаление сообщения у всех удаляет и его реакции. В вебе меню открывается кнопкой со смайликом, правым кликом или долгим нажатием; нажатие на свою реакцию снимает её, на другую — заменяет. Быстрые реакции доступны сразу, стрелка раскрывает популярные эмодзи и полный каталог по категориям с выбором оттенка кожи. В поиске можно найти эмодзи по названию или вставить любой эмодзи и отправить его нажатием Enter. Каталог Emoji Mart загружается отдельным локальным чанком при раскрытии меню.

Статусы delivered и read — не поля сообщения, а watermark на пару «читатель + чат» (readStates в хранилище): сообщение считается доставленным или прочитанным, если его sentAt не позже курсора. PATCH /api/messages/:uuid/status сдвигает курсор до этого сообщения, то есть помечает и всё более раннее от того же собеседника; POST /api/messages/poll и POST /api/sync сдвигают курсор доставки автоматически. Веб-клиент отмечает чат прочитанным при открытии. POST /api/sync возвращает собственные курсоры пользователя в readStates.

Группы

Групповые чаты доступны только в веб-клиенте: устройство и /api/messages/poll работают с личными сообщениями. Группа создаётся с участниками, которые уже зарегистрированы; сообщения в группе — только текстовые.

Метод и путь Назначение
GET /api/groups Группы, в которых состоит пользователь
POST /api/groups Создать: { "name": "Проект", "memberIds": ["uuid"] }; создатель становится владельцем
GET /api/groups/:uuid Группа с участниками (только для участника)
PATCH /api/groups/:uuid Владелец меняет name, avatarUrl, avatarBackground
DELETE /api/groups/:uuid Выйти из группы; владение переходит следующему участнику, последний вышедший удаляет группу и её сообщения
POST /api/groups/:uuid/members Любой участник добавляет { "userId": "uuid" }
DELETE /api/groups/:uuid/members Владелец исключает { "userId": "uuid" }; себя — через выход из группы
GET /api/group-messages?groupId=UUID&limit=50 История группы
POST /api/group-messages Отправить { "groupId": "uuid", "text": "..." }
PATCH /api/group-messages/:uuid Автор меняет текст
DELETE /api/group-messages/:uuid, DELETE /api/group-messages Удалить: scope: "me" доступен любому участнику, scope: "everyone" — только автору
PATCH /api/group-messages/:uuid/status Сдвинуть свой курсор в группе: { "status": "read" } или "delivered"
PUT /api/group-messages/:uuid/reaction Поставить, заменить или снять свою реакцию, как в личном чате

Статус группового сообщения для отправителя — самый слабый из курсоров остальных участников: read, когда прочитали все, delivered, когда все получили. Курсор новичка начинается с момента вступления, поэтому старые сообщения не становятся для него «непрочитанными». POST /api/sync возвращает groups и groupMessages рядом с личными сообщениями.

При scope: "me" сообщение остаётся доступно второму участнику, но больше не возвращается текущему пользователю ни в истории, ни в device poll. При scope: "everyone" запись удаляется из общего хранилища. Ответ на вопрос хранится внутри записи вопроса и удаляется вместе с ним.

Скрытие диалога не удаляет контакт или сообщения. POST /api/sync возвращает UUID скрытых собеседников в hiddenPeerIds; новое сообщение между участниками снова открывает диалог. Удаление всей истории дополнительно скрывает пустой диалог: только у текущего пользователя при scope: "me" и у обоих при scope: "everyone".

WebSocket device adapter

WebSocket — единый транспорт устройства для сообщений, погоды и валют. Подключение к /ws на общем порту приложения авторизуется заголовком X-API-Key (также поддерживается Authorization: Bearer ...). Токен не передаётся в URL. REST остаётся доступным и является единственной реализацией чтения и изменения состояния; адаптер не хранит собственную копию сообщений. Погода wttr.in и курсы fxapi запрашиваются уже с VPS, поэтому ESP8266 не создаёт отдельные TLS-соединения к внешним сервисам. Для wttr.in используется равноценный резервный домен wttr.is, а Open‑Meteo остаётся аварийным источником погоды и дополняет четвёртый день прогноза, которого нет в ответе wttr.in.

Сервер отправляет:

  • {"type":"ready","protocol":1,"userId":"..."} после проверки токена;
  • {"type":"inbox_snapshot","ok":true,"data":{"messages": [...]}} при подключении и при изменении непрочитанных сообщений;
  • {"type":"ack","requestId":"...","action":"read","ok":true} после команды устройства;
  • {"type":"dashboard_snapshot","requestId":"...","ok":true,"weather":{...},"rates":{...}} с нормализованной погодой и курсами;
  • {"type":"error","error":{"code":"...","message":"..."}} при ошибке фоновой синхронизации.

Устройство может отправить:

{ "type": "refresh" }
{ "type": "read", "requestId": "1", "messageId": "message-uuid" }
{ "type": "answer", "requestId": "2", "messageId": "message-uuid", "id": "option-id" }
{ "type": "reaction", "requestId": "reaction-1", "messageId": "message-uuid", "emoji": "👍" }
{ "type": "send", "requestId": "3", "toUserId": "user-uuid", "text": "Хорошо" }
{ "type": "dashboard_refresh", "requestId": "4", "weather": true, "rates": true, "latitude": 44.8176, "longitude": 20.4633 }

Для готового короткого ответа поле readMessageId в событии send позволяет после отправки отметить исходное входящее прочитанным. В answer.id по-прежнему передаётся настоящий ID серверной опции, а не кнопка, индекс или подпись. Команда reaction поддерживает любой эмодзи и null для снятия; после сохранения приходит ack. Компактный inbox_snapshot устройства сохраняет прежний набор полей, а для получения реакций используется REST история или poll. Максимальный входящий WS-кадр адаптера — 16 КБ; heartbeat разрывает зависшие соединения. Внутренний poll по умолчанию выполняется раз в 2 секунды и отправляет полный inbox-снимок только когда набор ожидающих сообщений изменился. Погода кешируется адаптером на 10 минут по округлённым координатам, курсы — на один час. Несколько устройств или повторные refresh поэтому не умножают число внешних запросов. Координаты проверяются адаптером; токен по-прежнему не попадает ни в URL внешних сервисов, ни в логи.

Рекомендуемый цикл ESP

  1. Раз в 2–5 секунд вызвать POST /api/messages/poll с телом {} и X-API-Key.
  2. Отобразить полученный список или выбранный элемент. Устройство не обязано сохранять сообщения и статусы во flash: источником состояния остаётся сервер, а доступ восстанавливается по токену. Poll возвращает актуальные непрочитанные входящие; историю можно заново получить через GET /api/messages?box=inbox.
  3. После явного действия средней кнопкой отправить PATCH .../status со статусом read.
  4. Для kind: "choice" клиент сам сопоставляет элементы массива своим кнопкам или меню и отправляет строковый ID выбранной опции через POST .../answer. Сейчас сервер принимает ровно две опции; веб-клиент назначает им компактные ID "1" и "2" в порядке массива.
  5. Если массив пуст, оставить экран ожидания.

Пример poll:

curl -sS -X POST https://example.com/api/messages/poll \
  -H 'X-API-Key: msg_your_token' \
  -H 'Content-Type: application/json' \
  -d '{"limit":10}'

VPS через Docker

Свой Docker-образ не собирается. Сервер запускает готовый официальный node:22-bookworm, закреплённый digest из локальной сборки. В нём уже есть OpenSSL для миграций Prisma; в slim-варианте этой библиотеки нет. Приложение и Linux-зависимости подключаются из каталогов релиза. Зависимости переиспользуются пока не меняется lockfile или digest образов Node; обычные изменения JS/TS требуют передачи только app.tar.gz и небольших файлов метаданных.

На Mac сначала запустите Colima с поддержкой x86_64 через Rosetta:

colima start tm-release --arch aarch64 --vm-type vz --cpu 4 --memory 6 --disk 20 --vz-rosetta --activate=false
DOCKER_CONTEXT=colima-tm-release bash scripts/build-release.sh

Скрипт собирает закоммиченный HEAD в Linux amd64, используя официальный node:<точная версия>-bookworm. Контейнер сборки ограничен 4 CPU, 4 ГБ RAM без swap, Node heap — 2 ГБ, Next.js — одним worker. Лимиты регулируются BUILD_CPUS, BUILD_MEMORY_MB и BUILD_NODE_HEAP_MB. Нативные SQLite-модули собираются для Linux, а не копируются из macOS. Docker volume кеширует установленные зависимости. Перед сборкой проверяется доступная память Docker VM: нужен лимит сборки плюс 256 МиБ запаса. По окончании работы профиль можно остановить командой colima stop tm-release; кеши и готовые релизы сохранятся.

Результат — releases/artifacts/<commit>/: app.tar.gz, dependencies.tar.gz, runtime-image, dependencies-key, revision, SHA256SUMS. На VPS загрузите эти файлы в отдельный входящий каталог; dependencies.tar.gz можно пропустить, если releases/dependencies/<dependencies-key>/.complete уже существует. Затем на VPS:

bash scripts/install-release.sh /path/to/uploaded-artifacts

Либо отправьте готовый релиз одним вызовом с локальной машины (коммит должен быть отправлен в remote):

bash scripts/deploy-release.sh HOST /root/tiny-messenger releases/artifacts/COMMIT

Этот скрипт сам сохраняет конфигурацию старого контейнера при первом переходе, передаёт только необходимые архивы, обновляет серверный checkout через git pull --ff-only и запускает установку. Не запускайте сборку на VPS. Версия приложения берётся из выбранного архива; серверный checkout содержит актуальные инструменты деплоя. Архив может соответствовать более раннему коммиту main, например при повторной установке уже проверенного релиза.

Перед первым переходом со старого образа, до обновления compose-файла, сохраните конфигурацию отката:

mkdir -p releases
(umask 077; docker compose config > releases/legacy-compose.yml)

Она может содержать секреты: храните её только на сервере, с правами 600. Для последующих релизов предыдущие пути сохраняются в deploy/release.env.previous автоматически.

Установка проверяет контрольные суммы и запас памяти, затем пересоздаёт только Messenger, без сборки и перезапуска TURN. Работающий Litestream временно останавливается на время миграций, чтобы не блокировать SQLite, и запускается снова при завершении установки, включая откат. При аварийном завершении установщика отдельно проверьте, что репликация возобновилась.

Проверка памяти требует MemAvailable + RSS процессов старого Messenger ≥ 384 МиБ + DEPLOY_RESERVE_MB. Резерв по умолчанию — 128 МиБ, итого 512 МиБ. При отказе проверки работающий контейнер остаётся на месте. Устраняйте причину нехватки памяти перед повтором, учитывая остальные сервисы на VPS.

Контейнер приложения ограничен 384 МиБ RAM без swap и 0,75 CPU, Node heap — 192 МиБ. Установщик ожидает healthcheck до 180 секунд; если запуск не удался, восстанавливает предыдущий релиз или сохранённую конфигурацию старого образа. Обновление обычно пересоздаёт контейнер с новыми путями релиза; готовый Node-образ переиспользуется, а при отсутствии нужного digest скачивается один раз.

Для ручного отката найдите предыдущий релиз по пути в deploy/release.env.previous и повторно установите его сохранённый архив:

bash scripts/install-release.sh releases/incoming/PREVIOUS_COMMIT

Это повторяет проверку памяти, остановку Litestream на время миграций и ожидание healthcheck. Простой перезапуск Messenger при работающем Litestream может закончиться ошибкой Prisma database is locked даже без новых миграций. Сохраняйте архив предыдущего релиза и его зависимости до завершения проверки новой версии.

Для остальных команд Compose (логи, exec, backup) также указывайте оба --env-file: первый содержит настройки сервисов, второй — пути текущего релиза и закреплённый образ. Пользователь nextjs в готовом образе не создаётся; контейнер уже работает с числовым uid/gid 1001:1001.

База лежит в папке ./data рядом с docker-compose.yml (messenger.db плюс служебные -wal и -shm). Каталог должен принадлежать uid/gid 1001:1001; приложение и Litestream работают с этим uid. Entrypoint применяет миграции. Перед обновлением проверьте успешную репликацию Litestream либо снимите консистентную копию командой sqlite3 data/messenger.db "VACUUM INTO 'data/backup-before-release.db'" (файл назначения ещё не должен существовать). Копировать .db вручную во время работы нельзя. Откат приложения не откатывает миграции, поэтому они должны быть совместимы с предыдущей версией.

До и после установки сравните целостность базы и количество основных записей (команда требует установленного на VPS sqlite3):

sqlite3 -readonly data/messenger.db <<'SQL'
PRAGMA integrity_check;
PRAGMA foreign_key_check;
SELECT 'User', COUNT(*) FROM "User"
UNION ALL SELECT 'Message', COUNT(*) FROM "Message"
UNION ALL SELECT 'Group', COUNT(*) FROM "Group"
UNION ALL SELECT 'GroupMessage', COUNT(*) FROM "GroupMessage";
SQL

Ожидается ok от integrity_check и отсутствие строк от foreign_key_check. Учитывайте записи, которые могли добавить пользователи между проверками. После деплоя также проверьте статус контейнеров и свежие логи репликации:

docker compose --env-file .env --env-file deploy/release.env ps
docker compose --env-file .env --env-file deploy/release.env logs --since 5m litestream
curl --fail --silent --show-error https://example.com/api/health

/api/health проверяет доступность приложения, но не обращается к БД и не подтверждает свежесть S3-бэкапа. Репликацию проверяйте отдельно по успешным операциям Litestream; без профиля backup этот сервис не запускается.

За HTTPS и реальные IP клиентов должен отвечать reverse proxy (Caddy или nginx), который передаёт X-Forwarded-For.

Compose публикует приложение только на 127.0.0.1:3000, чтобы порт нельзя было обойти снаружи. Контейнерный reverse proxy может подключаться к сети <compose-project>_default и обращаться к сервису как messenger:3000.

WebSocket endpoint опубликован вместе с приложением на 127.0.0.1:3000. Для nginx добавьте к существующему HTTPS virtual host:

location /ws {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
}

TLS завершается на reverse proxy, поэтому устройство подключается к wss://example.com/ws, а приложение внутри VPS принимает обычный WebSocket. После установки проверьте загрузку страницы и JS/CSS в браузере, а Upgrade публичного WebSocket — без создания тестовых пользователей:

curl --http1.1 --include --no-buffer --max-time 8 \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  https://example.com/ws

Ожидается 101 Switching Protocols; без авторизации сервер затем закрывает соединение. Curl может завершиться по таймауту с кодом 28 после успешного Upgrade. Полную функциональную проверку pnpm test:ws выполняйте на тестовом экземпляре: TEST_BASE_URL и TEST_WS_URL должны указывать REST и WebSocket одного сервера.

Настраиваемые переменные находятся в .env.example. Общие ограничения частоты хранятся в памяти и рассчитаны на один процесс: по умолчанию 120 публичных и 480 авторизованных запросов в минуту на комбинацию IP/токена. Для отправки сообщений действует отдельный лимит на пользователя: 6 сообщений за 10 секунд и 30 сообщений в минуту. При превышении POST /api/messages возвращает 429, код message_rate_limited и заголовок Retry-After. Это защищает от случайного зажатого Enter, но не является DDoS-защитой.

Границы текущего MVP

  • Основные данные хранятся в локальной SQLite, метаданные realtime — в JSON. Поддерживается один экземпляр Node.js; для горизонтального масштабирования нужны общая БД и внешний pub/sub для realtime.
  • Веб раз в 5 секунд делает POST /api/sync для сообщений и профилей. Presence и голосовой сигналинг идут через общий WebSocket; сообщения пока сохраняют совместимый REST polling.
  • Нет восстановления утерянного токена, блокировок пользователей, антиспама и сквозного шифрования.
  • Нет горизонтального масштабирования realtime без внешней БД/pub-sub; background-звонки на iOS работают только как push-to-open.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages