365 lines
14 KiB
Markdown
365 lines
14 KiB
Markdown
# Личные сообщения (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`)
|
||
11. `encryptedBodyLen` (`u32`) — сервер сейчас ограничивает до `16384`
|
||
12. `encryptedBody` (`bytes`)
|
||
13. `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 интерпретирует как текст + встроенные метки файлов.
|
||
|
||
Формат маркера:
|
||
|
||
```text
|
||
<<file:file-format(1.0):type|fileName|origSize|origHashB64u|encHashB64u|encSize|keyB64u|nonceB64u>>
|
||
```
|
||
|
||
Где:
|
||
|
||
- `type` = `photo` / `video` / `audio` / `file`
|
||
- `fileName` — настоящее имя файла, без символов `|`, `:`, `>`, переводов строки
|
||
- `origSize` — размер исходного файла
|
||
- `origHashB64u` — `SHA-256` исходного файла в `base64url`
|
||
- `encHashB64u` — `SHA-256` ciphertext-файла в `base64url`
|
||
- `encSize` — размер ciphertext-файла
|
||
- `keyB64u` — симметричный ключ расшифровки файла
|
||
- `nonceB64u` — nonce/iv для расшифровки файла
|
||
|
||
UI:
|
||
|
||
- показывает обычный текст без маркеров;
|
||
- заменяет маркеры карточками скачивания;
|
||
- скачивает ciphertext по `/f/<encHashB64u>`;
|
||
- локально расшифровывает файл и отдаёт пользователю оригинал.
|
||
|
||
---
|
||
|
||
## 6) RPC и события
|
||
|
||
### `SendMessagePair` / `ReceiveOutcomingMessage`
|
||
|
||
Запрос не меняется: сервер по-прежнему принимает пару `incomingBlobB64` + `outgoingBlobB64`.
|
||
|
||
```json
|
||
{
|
||
"op": "SendMessagePair",
|
||
"requestId": "req-1",
|
||
"payload": {
|
||
"incomingBlobB64": "<base64 signed block type 1 or 3>",
|
||
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
|
||
}
|
||
}
|
||
```
|
||
|
||
Успешный ответ:
|
||
|
||
```json
|
||
{
|
||
"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`
|
||
|
||
### `dm_message_file_links`
|
||
|
||
- `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`
|