SHiNE-server/Dev_Docs/Personal_Messages/README.md

365 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Личные сообщения (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`