SHiNE-server/Dev_Docs/Personal_Messages
2026-06-18 11:46:58 +04:00
..
README.md НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений 2026-06-18 11:46:58 +04:00
TODO_доработка_персональных_сообщений_для_агентов.md Добавил гостевой режим, единые shine-ссылки и пометку о нестабильности мнений 2026-05-20 16:14:59 +03:00

Личные сообщения (DM): как это устроено

Коротко

Личные сообщения в SHiNE теперь работают в двух слоях:

  • контентные сообщения type=1/2 идут в новом бинарном формате SHiNE_DM;
  • read-receipt type=3/4 пока остаются на legacy-формате SHiNE_dm2, чтобы не ломать текущую механику подтверждения прочтения.

Одно логическое сообщение по-прежнему отправляется парой блоков:

  • type=1 — входящее сообщение для получателя;
  • type=2 — исходящая копия для отправителя.

Ключ сообщения остаётся прежним:

  • baseKey = from|to|timeMs|nonce
  • messageKey = baseKey|messageType

Теперь timeMs + nonce задаются один раз на всё логическое сообщение и не меняются при редактировании. Для новой версии того же письма используется то же messageKey, но большее revisionTimeMs. Сервер хранит только последнюю версию записи по этому messageKey через upsert.


1) Общая схема потока

  1. Клиент при необходимости сначала шифрует вложенные файлы локально.
  2. Клиент считает SHA-256(ciphertext) и размер ciphertext.
  3. Клиент проверяет наличие blob через HEAD /f/<hashB64url>.
  4. Если файла нет, клиент загружает ciphertext через POST /upload?hash=<hashB64url>&size=<bytes>.
  5. После загрузки всех файлов клиент строит пару signed DM-блоков type=1/2.
  6. Сервер валидирует подписи, формат, наличие blob-файлов и делает upsert пары.
  7. В одной транзакции сервер:
    • удаляет старые файловые связи этого сообщения;
    • корректирует ref_count;
    • записывает новую версию signed_messages_v2;
    • создаёт новые файловые связи;
    • сбрасывает pending-доставку по сессиям.
  8. Сервер рассылает обновлённые блоки в активные сессии через SignedMessageArrived.
  9. Клиент обновляет существующий пузырь по messageKey; если тело и вложения пустые — сообщение удаляется из UI.

2) Формат signed DM-блока для контентных сообщений (SHiNE_DM)

Префикс: SHiNE_DM (ASCII).

Далее поля, big-endian:

  1. formatVersionMajor (u8) = 1
  2. formatVersionMinor (u8) = 0
  3. toLoginLen (u8) + toLogin (ASCII, 1..60)
  4. fromLoginLen (u8) + fromLogin (ASCII, 1..60)
  5. timeMs (u64)
  6. nonce (u32)
  7. messageType (u16) — только 1 или 2
  8. revisionTimeMs (u64)
  9. attachmentsCount (u8) — 0..12
  10. attachments[]:
  • encFileHashSHA256 (32 bytes)
  • encFileSize (u64)
  1. encryptedBodyLen (u32) — сервер сейчас ограничивает до 16384
  2. encryptedBody (bytes)
  3. signature (64 bytes, Ed25519)

Важные правила

  • messageType не входит в ID логического письма, он только различает сторону пары.
  • ID логического письма = fromLogin + toLogin + timeMs + nonce.
  • У оригинала revisionTimeMs = 0.
  • Для редактирования и удаления timeMs/nonce не меняются, меняется только revisionTimeMs.
  • Чем больше revisionTimeMs, тем новее версия.

3) Legacy read-receipt (SHiNE_dm2)

Пока только блоки type=3/4 остаются в старом формате SHiNE_dm2:

  1. toLoginLen (u8) + toLogin
  2. fromLoginLen (u8) + fromLogin
  3. timeMs (u64)
  4. nonce (u32)
  5. messageType (u16) — 3 или 4
  6. payloadLen (u16)
  7. payloadBytes
  8. signature

Это временный совместимый слой. Контентные сообщения 1/2 в SHiNE_dm2 больше не считаются актуальным форматом.


4) Внешний контейнер вложений

Внешняя часть сообщения содержит только технические ссылки на blob-файлы:

  • attachmentsCount
  • список encFileHashSHA256 + encFileSize

Во внешней части нет:

  • имени файла;
  • MIME;
  • пароля/ключа;
  • nonce/iv;
  • origHash.

Это позволяет серверу хранить и отдавать blob, не зная человеческих метаданных вложения.


5) Внутреннее содержимое encryptedBody

Сейчас encryptedBody содержит текстовый контейнер сообщения, который UI интерпретирует как текст + встроенные метки файлов.

Формат маркера:

<<file:file-format(1.0):type|fileName|origSize|origHashB64u|encHashB64u|encSize|keyB64u|nonceB64u>>

Где:

  • type = photo / video / audio / file
  • fileName — настоящее имя файла, без символов |, :, >, переводов строки
  • origSize — размер исходного файла
  • origHashB64uSHA-256 исходного файла в base64url
  • encHashB64uSHA-256 ciphertext-файла в base64url
  • encSize — размер ciphertext-файла
  • keyB64u — симметричный ключ расшифровки файла
  • nonceB64u — nonce/iv для расшифровки файла

UI:

  • показывает обычный текст без маркеров;
  • заменяет маркеры карточками скачивания;
  • скачивает ciphertext по /f/<encHashB64u>;
  • локально расшифровывает файл и отдаёт пользователю оригинал.

6) RPC и события

SendMessagePair / ReceiveOutcomingMessage

Запрос не меняется: сервер по-прежнему принимает пару incomingBlobB64 + outgoingBlobB64.

{
  "op": "SendMessagePair",
  "requestId": "req-1",
  "payload": {
    "incomingBlobB64": "<base64 signed block type 1 or 3>",
    "outgoingBlobB64": "<base64 signed block type 2 or 4>"
  }
}

Успешный ответ:

{
  "op": "SendMessagePair",
  "requestId": "req-1",
  "status": 200,
  "ok": true,
  "payload": {
    "baseKey": "from|to|time|nonce",
    "incomingKey": "from|to|time|nonce|1",
    "outgoingKey": "from|to|time|nonce|2",
    "deliveredWsSessions": 2,
    "deliveredWebPushSessions": 1
  }
}

SignedMessageArrived

Событие в сессию всё ещё содержит:

  • messageKey, baseKey
  • fromLogin, toLogin, targetLogin
  • messageType, timeMs, nonce
  • blobB64
  • backlog

Новая версия того же письма приходит с тем же messageKey, но с более новым revisionTimeMs внутри бинарного блока.

AckSessionDelivery

Формат не меняется.


7) HTTP endpoints для файлов

HEAD /f/<hashB64url>

Проверяет наличие ciphertext-файла.

  • 200 — файл есть;
  • 404 — файла нет.

GET /f/<hashB64url>

Отдаёт ciphertext-файл как application/octet-stream.

Сейчас доступ публичный, без проверки логина.

POST /upload?hash=<hashB64url>&size=<bytes>

Принимает raw bytes ciphertext-файла.

Сервер:

  • пересчитывает SHA-256;
  • проверяет размер;
  • сохраняет blob в папку f/ под именем <hashB64url>;
  • если файл уже существует, повторно не пишет его на диск;
  • регистрирует строку в dm_files.

8) Хранение на сервере (SQLite)

signed_messages_v2

Основная таблица текущих DM:

  • message_key (PK)
  • base_key
  • target_login
  • from_login, to_login
  • time_ms, nonce
  • message_type
  • revision_time_ms
  • raw_block
  • created_at_ms
  • source_api, origin_session_id
  • receipt_ref_base_key, receipt_ref_type

Для контентных сообщений сервер делает upsert по message_key, поэтому в таблице всегда лежит только последняя версия конкретной стороны пары.

signed_message_session_delivery

Хранит pending/ack по сессиям:

  • (message_key, session_id) — PK
  • delivered
  • delivered_at_ms
  • created_at_ms

При новой ревизии того же сообщения сервер сбрасывает доставку этого message_key обратно в delivered=0.

dm_files

  • file_hash_sha256 (BLOB, PK)
  • file_size
  • ref_count
  • message_key
  • login
  • file_hash_sha256

По этой таблице сервер понимает, какие файловые ссылки нужно снять при редактировании/удалении сообщения.

ref_count считается по числу логических message-side ссылок:

  • у одного письма с вложением обычно две ссылки:
    • получатель (type=1)
    • отправитель (type=2)

Файлы с ref_count = 0 на диске не удаляются автоматически.


9) Доставка, редактирование и удаление

Новое сообщение

  • revisionTimeMs = 0
  • создаётся пара 1/2
  • сервер делает upsert, создаёт файловые связи и доставляет событие

Редактирование

  • используется тот же timeMs + nonce
  • отправляется новая пара 1/2
  • revisionTimeMs больше
  • может измениться и encryptedBody, и список вложений

Удаление

  • тот же timeMs + nonce
  • новая ревизия с большим revisionTimeMs
  • attachmentsCount = 0
  • encryptedBodyLen = 0

UI такое сообщение полностью убирает из чата.


10) Логика UI-клиента

Хранилище сообщений

  • in-memory: state.chats[chatId]
  • IndexedDB: shine-ui-messages-v1, store messages, key = messageKey

Так как messageKey теперь стабилен для всех ревизий одного message-side, клиент делает не append, а update той же записи.

Поведение UI

  • входящая новая ревизия с тем же messageKey обновляет существующий пузырь;
  • пустая ревизия (attachments=0, encryptedBodyLen=0) удаляет пузырь из IndexedDB и из in-memory;
  • вложения показываются кнопками скачивания;
  • ciphertext скачивается с HTTP, затем расшифровывается локально в браузере.

11) Межсерверная синхронизация

Межсерверный relay DM пока не реализован.

Когда он появится, серверы должны будут:

  • синхронизировать только актуальную ревизию message_key;
  • применять правило "больший revisionTimeMs побеждает";
  • одинаково пересчитывать файловые связи и ref_count.

12) Инварианты

  1. Пара 1/2 должна применяться атомарно.
  2. baseKey/messageKey формат не меняется.
  3. Для одного messageKey в signed_messages_v2 хранится только последняя версия.
  4. Все изменения DM и файловых связей применяются одной транзакцией.
  5. Для контентных сообщений обязательна предварительная загрузка blob-файлов.
  6. Для одного сообщения разрешено не больше 12 вложений.
  7. UI не показывает удалённые сообщения.

13) Ключевые файлы реализации

  • UI:
    • shine-UI/js/services/auth-service.js
    • shine-UI/js/services/crypto-utils.js
    • shine-UI/js/state.js
    • shine-UI/js/app.js
    • shine-UI/js/pages/chat-view.js
  • Server:
    • SHiNE-server/shine-server-net-protocol/.../messages/SignedMessageBlock.java
    • SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesCore.java
    • SHiNE-server/shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java
    • SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesRealtime.java
    • SHiNE-server/shine-server-net-protocol/.../messages/DmFileStorage.java
    • SHiNE-server/shine-server-db/.../dao/SignedMessagesV2DAO.java
    • SHiNE-server/src/main/java/server/files/DmFilesServlet.java
    • SHiNE-server/src/main/java/server/files/DmUploadServlet.java