SHA256
НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений
This commit is contained in:
@@ -61,5 +61,6 @@
|
||||
## Важные замечания
|
||||
|
||||
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
||||
- HTTP endpoints для DM-файлов (`HEAD/GET /f/<hashB64url>` и `POST /upload`) не являются WebSocket `op`, поэтому в таблицу выше не входят; они описаны в `12_Direct_Messages_Push_Calls_API.md`.
|
||||
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
||||
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# API для разработчиков: DM, push и сигналы звонков
|
||||
# API для разработчиков: DM, файлы, push и сигналы звонков
|
||||
|
||||
Документ описывает WebSocket-операции для подписанных личных сообщений, WebPush и realtime-сигналов звонков.
|
||||
Документ описывает публичные операции и endpoints, связанные с личными сообщениями, файлами для DM, WebPush и сигналами звонков.
|
||||
|
||||
Логика личных сообщений дополнительно описана в `Dev_Docs/Personal_Messages/README.md`; этот файл фиксирует именно публичные `op`, поля запросов и поля ответов.
|
||||
Подробная логика DM и бинарного формата: `Dev_Docs/Personal_Messages/README.md`.
|
||||
|
||||
## 1. `UpsertPushToken`
|
||||
|
||||
@@ -40,11 +40,9 @@
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `SendTestWebPush`
|
||||
|
||||
Требует авторизации. Если `login` передан, он должен совпадать с логином текущей сессии.
|
||||
Требует авторизации.
|
||||
|
||||
### Запрос
|
||||
|
||||
@@ -61,65 +59,18 @@
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
## 3. `SendMessagePair` и `ReceiveOutcomingMessage`
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SendTestWebPush",
|
||||
"requestId": "push-test-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"targetLogin": "alice",
|
||||
"attemptedSessions": 1,
|
||||
"sessionsWithPushConfig": 1,
|
||||
"delivered": 1,
|
||||
"failed": 0,
|
||||
"sentAtMs": 1774700000123
|
||||
}
|
||||
}
|
||||
```
|
||||
`ReceiveOutcomingMessage` — алиас `SendMessagePair`.
|
||||
|
||||
---
|
||||
### Назначение
|
||||
|
||||
## 3. `SendDirectMessage`
|
||||
Передаёт пару signed DM-блоков:
|
||||
|
||||
Отправляет один подписанный DM-пакет.
|
||||
- `incomingBlobB64` — блок `type=1` или `type=3`
|
||||
- `outgoingBlobB64` — блок `type=2` или `type=4`
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SendDirectMessage",
|
||||
"requestId": "dm-001",
|
||||
"payload": {
|
||||
"blobB64": "BASE64_SIGNED_DM_PACKET"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SendDirectMessage",
|
||||
"requestId": "dm-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"messageId": "dm-1",
|
||||
"deliveredWsSessions": 1,
|
||||
"deliveredWebPushSessions": 0,
|
||||
"sessionNotFound": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `SendMessagePair` и `ReceiveOutcomingMessage`
|
||||
|
||||
`ReceiveOutcomingMessage` сейчас является алиасом `SendMessagePair` и использует тот же request/handler.
|
||||
Для контентных сообщений `type=1/2` внутри base64 лежит новый бинарный формат `SHiNE_DM`.
|
||||
|
||||
### Запрос
|
||||
|
||||
@@ -143,55 +94,28 @@
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"baseKey": "base-key",
|
||||
"incomingKey": "incoming-key",
|
||||
"outgoingKey": "outgoing-key",
|
||||
"baseKey": "from|to|time|nonce",
|
||||
"incomingKey": "from|to|time|nonce|1",
|
||||
"outgoingKey": "from|to|time|nonce|2",
|
||||
"deliveredWsSessions": 1,
|
||||
"deliveredWebPushSessions": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Ошибки
|
||||
|
||||
## 5. `ReceiveIncomingMessage`
|
||||
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
|
||||
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
|
||||
- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM`
|
||||
- `400 / TOO_MANY_ATTACHMENTS` — больше 12 вложений
|
||||
- `400 / ATTACHMENT_NOT_FOUND` — сообщение ссылается на blob, которого нет на сервере
|
||||
- `404 / USER_NOT_FOUND` — один из логинов не найден
|
||||
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
|
||||
|
||||
Принимает входящий подписанный DM-блок.
|
||||
## 4. `AckSessionDelivery`
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "ReceiveIncomingMessage",
|
||||
"requestId": "dm-in-001",
|
||||
"payload": {
|
||||
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "ReceiveIncomingMessage",
|
||||
"requestId": "dm-in-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"messageKey": "incoming-key",
|
||||
"baseKey": "base-key",
|
||||
"deliveredWsSessions": 1,
|
||||
"deliveredWebPushSessions": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. `AckSessionDelivery`
|
||||
|
||||
Требует авторизации. Подтверждает доставку сообщения в текущую сессию.
|
||||
Требует авторизации. Подтверждает доставку в текущую сессию.
|
||||
|
||||
### Запрос
|
||||
|
||||
@@ -200,107 +124,99 @@
|
||||
"op": "AckSessionDelivery",
|
||||
"requestId": "ack-001",
|
||||
"payload": {
|
||||
"messageKey": "incoming-key"
|
||||
"messageKey": "from|to|time|nonce|1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Событие `SignedMessageArrived`
|
||||
|
||||
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||
|
||||
### Payload события
|
||||
|
||||
```json
|
||||
{
|
||||
"messageKey": "from|to|time|nonce|1",
|
||||
"baseKey": "from|to|time|nonce",
|
||||
"fromLogin": "alice",
|
||||
"toLogin": "bob",
|
||||
"targetLogin": "bob",
|
||||
"messageType": 1,
|
||||
"timeMs": 1774700000123,
|
||||
"nonce": 123456789,
|
||||
"blobB64": "BASE64_SIGNED_BLOCK",
|
||||
"backlog": false
|
||||
}
|
||||
```
|
||||
|
||||
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
|
||||
|
||||
## 6. HTTP `HEAD /f/<hashB64url>`
|
||||
|
||||
Проверка, есть ли ciphertext-файл на сервере.
|
||||
|
||||
### Ответы
|
||||
|
||||
- `200` — файл существует
|
||||
- `404` — файла нет
|
||||
|
||||
## 7. HTTP `GET /f/<hashB64url>`
|
||||
|
||||
Отдаёт ciphertext-файл.
|
||||
|
||||
### Особенности
|
||||
|
||||
- `Content-Type: application/octet-stream`
|
||||
- файл сейчас доступен публично
|
||||
- имя файла на диске и в URL — `base64url(SHA-256(ciphertext))`
|
||||
|
||||
## 8. HTTP `POST /upload?hash=<hashB64url>&size=<bytes>`
|
||||
|
||||
Загружает ciphertext-файл для будущего DM.
|
||||
|
||||
### Тело запроса
|
||||
|
||||
Raw bytes ciphertext-файла.
|
||||
|
||||
### Поведение сервера
|
||||
|
||||
- пересчитывает `SHA-256`
|
||||
- сверяет размер
|
||||
- сохраняет blob в папку `f/`, если его ещё не было
|
||||
- если blob уже есть, не перезаписывает его
|
||||
- создаёт или обновляет запись в `dm_files`
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AckSessionDelivery",
|
||||
"requestId": "ack-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"messageKey": "incoming-key"
|
||||
}
|
||||
"hash": "base64url_sha256",
|
||||
"size": 245120,
|
||||
"alreadyExists": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Ошибки
|
||||
|
||||
## 7. `CallInviteBroadcast`
|
||||
- `400 / bad hash`
|
||||
- `400 / bad size`
|
||||
- `400 / SIZE_MISMATCH`
|
||||
- `400 / HASH_MISMATCH`
|
||||
- `400 / UPLOAD_TOO_LARGE`
|
||||
- `500 / upload_failed`
|
||||
|
||||
Требует авторизации. Отправляет приглашение к звонку на активные сессии пользователя `toLogin`.
|
||||
## 9. `CallInviteBroadcast`
|
||||
|
||||
### Запрос
|
||||
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CallInviteBroadcast",
|
||||
"requestId": "call-invite-001",
|
||||
"payload": {
|
||||
"toLogin": "bob",
|
||||
"callId": "call-1",
|
||||
"type": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
## 10. `CallSignalToSession`
|
||||
|
||||
### Успешный ответ
|
||||
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CallInviteBroadcast",
|
||||
"requestId": "call-invite-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"callId": "call-1",
|
||||
"deliveredWsSessions": 1,
|
||||
"deliveredFcmSessions": 0,
|
||||
"deliveredWebPushSessions": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
## 11. Замечания
|
||||
|
||||
---
|
||||
|
||||
## 8. `CallSignalToSession`
|
||||
|
||||
Требует авторизации. Отправляет сигнал звонка в конкретную сессию получателя.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CallSignalToSession",
|
||||
"requestId": "call-signal-001",
|
||||
"payload": {
|
||||
"toLogin": "bob",
|
||||
"targetSessionId": "SESSION_ID",
|
||||
"callId": "call-1",
|
||||
"type": 101,
|
||||
"data": "{\"sdp\":\"...\"}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "CallSignalToSession",
|
||||
"requestId": "call-signal-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"delivered": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Если целевая сессия не найдена или доставка не удалась, сервер может вернуть `404`.
|
||||
|
||||
## Типовые ошибки
|
||||
|
||||
- `422 / NOT_AUTHENTICATED` — требуется авторизация.
|
||||
- `400 / BAD_FIELDS` — не заполнены обязательные поля.
|
||||
- `404 / USER_NOT_FOUND` — пользователь не найден.
|
||||
- `404 / SESSION_NOT_FOUND` — сессия не найдена.
|
||||
- `422 / BAD_SIGNATURE` — подпись DM не прошла проверку.
|
||||
- `422 / BAD_DEVICE_KEY` — некорректный device key отправителя.
|
||||
- `422 / BAD_TIME_WINDOW` — время подписанного сообщения вне допустимого окна.
|
||||
- `422 / REPLAY` — повторное сообщение заблокировано.
|
||||
- Для нового DM-файла сценарий такой: `HEAD /f/<hash>` → при `404` `POST /upload` → затем `SendMessagePair`.
|
||||
- Сервер хранит только последнюю версию контентного сообщения по `messageKey`.
|
||||
- Удаление сообщения реализуется новой ревизией с пустым телом и нулём вложений.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# DM-вложения, upload и ревизии сообщений
|
||||
|
||||
- краткое описание фичи:
|
||||
Добавлен новый формат контентных DM `SHiNE_DM`, HTTP upload/download ciphertext-файлов, серверный `upsert` последней версии сообщения и UI-скачивание/расшифровка вложений.
|
||||
|
||||
- что проверять:
|
||||
1. Отправка обычного текста без вложений.
|
||||
2. Отправка сообщения с 1-2 вложениями.
|
||||
3. Повторная отправка сообщения с уже загруженным файлом без повторной записи blob.
|
||||
4. Скачивание вложения из UI и корректная расшифровка файла.
|
||||
5. Доставка backlog после переподключения сессии.
|
||||
6. Обновление существующего сообщения той же парой `timeMs+nonce` и большим `revisionTimeMs`.
|
||||
7. Удаление сообщения пустой ревизией (`attachments=0`, `encryptedBodyLen=0`) и исчезновение из UI.
|
||||
|
||||
- ожидаемый результат:
|
||||
Сообщения `type=1/2` приходят в формате `SHiNE_DM`, файлы доступны по `/f/<hash>`, UI показывает вложения кнопками скачивания, сервер хранит только последнюю ревизию по `messageKey`, а пустая ревизия убирает сообщение из интерфейса.
|
||||
|
||||
- статус:
|
||||
pending
|
||||
@@ -1,88 +1,150 @@
|
||||
# Личные сообщения (DM): как это устроено
|
||||
|
||||
## Коротко (для быстрого понимания)
|
||||
## Коротко
|
||||
|
||||
Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`:
|
||||
Личные сообщения в SHiNE теперь работают в двух слоях:
|
||||
|
||||
- тип `1` — входящее сообщение для собеседника;
|
||||
- тип `2` — исходящая копия того же сообщения для автора.
|
||||
- контентные сообщения `type=1/2` идут в новом бинарном формате `SHiNE_DM`;
|
||||
- read-receipt `type=3/4` пока остаются на legacy-формате `SHiNE_dm2`, чтобы не ломать текущую механику подтверждения прочтения.
|
||||
|
||||
Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе.
|
||||
Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`.
|
||||
Одно логическое сообщение по-прежнему отправляется парой блоков:
|
||||
|
||||
Подтверждение прочтения также идёт парой блоков:
|
||||
- `type=1` — входящее сообщение для получателя;
|
||||
- `type=2` — исходящая копия для отправителя.
|
||||
|
||||
- тип `3` — «прочитано» для исходящего сообщения автора;
|
||||
- тип `4` — зеркальная копия для второй стороны.
|
||||
|
||||
UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история.
|
||||
|
||||
---
|
||||
|
||||
## Подробно
|
||||
|
||||
## 1) Общая схема потока
|
||||
|
||||
1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`.
|
||||
2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`).
|
||||
3. Сервер:
|
||||
- парсит оба блока;
|
||||
- валидирует пару;
|
||||
- проверяет существование `from/to` пользователей и подписи;
|
||||
- атомарно сохраняет пару в `signed_messages_v2`.
|
||||
4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`.
|
||||
5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`.
|
||||
6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих.
|
||||
|
||||
## 2) Формат signed DM-блока (`SHiNE_dm2`)
|
||||
|
||||
Префикс: `SHiNE_dm2` (ASCII).
|
||||
|
||||
Далее поля (big-endian):
|
||||
|
||||
1. `toLoginLen` (`u8`) + `toLogin` (ASCII, 1..60);
|
||||
2. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, 1..60);
|
||||
3. `timeMs` (`u64`);
|
||||
4. `nonce` (`u32`);
|
||||
5. `messageType` (`u16`);
|
||||
6. `payloadLen` (`u16`);
|
||||
7. `payloadBytes` (`1..4096`);
|
||||
8. `signature` (`64 bytes`, Ed25519).
|
||||
|
||||
Ограничения:
|
||||
|
||||
- полный пакет: до `8192` байт;
|
||||
- `messageType` сейчас допустим только `1..4`.
|
||||
|
||||
## 3) Типы DM-сообщений
|
||||
|
||||
- `1` (`TYPE_INCOMING_TEXT`) — входящий текст для получателя.
|
||||
- `2` (`TYPE_OUTGOING_COPY`) — исходящая копия в истории автора.
|
||||
- `3` (`TYPE_READ_INCOMING`) — read-receipt (входящий тип для пары квитанции).
|
||||
- `4` (`TYPE_READ_OUTGOING_COPY`) — зеркальная копия read-receipt.
|
||||
|
||||
Правило пары:
|
||||
|
||||
- первый блок должен быть нечётным (`1` или `3`);
|
||||
- второй должен быть ровно `+1` (`2` или `4`);
|
||||
- ключевые поля пары совпадают: `toLogin/fromLogin/timeMs/nonce`.
|
||||
|
||||
## 4) Ключи сообщений
|
||||
Ключ сообщения остаётся прежним:
|
||||
|
||||
- `baseKey = from|to|timeMs|nonce`
|
||||
- `messageKey = baseKey|messageType`
|
||||
|
||||
Эти ключи используются:
|
||||
Теперь `timeMs + nonce` задаются один раз на всё логическое сообщение и не меняются при редактировании.
|
||||
Для новой версии того же письма используется то же `messageKey`, но большее `revisionTimeMs`.
|
||||
Сервер хранит только последнюю версию записи по этому `messageKey` через `upsert`.
|
||||
|
||||
- для дедупликации;
|
||||
- для связи read-receipt с исходным сообщением;
|
||||
- для ACK доставки по сессии.
|
||||
---
|
||||
|
||||
## 5) RPC и события
|
||||
## 1) Общая схема потока
|
||||
|
||||
## `SendMessagePair` (алиас `ReceiveOutcomingMessage`)
|
||||
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
|
||||
{
|
||||
@@ -113,157 +175,190 @@ UI чата строится на этих типах: текстовые соо
|
||||
}
|
||||
```
|
||||
|
||||
## `SignedMessageArrived` (server event)
|
||||
### `SignedMessageArrived`
|
||||
|
||||
Событие в сессию получателя содержит:
|
||||
Событие в сессию всё ещё содержит:
|
||||
|
||||
- `messageKey`, `baseKey`;
|
||||
- `fromLogin`, `toLogin`, `targetLogin`;
|
||||
- `messageType`, `timeMs`, `nonce`;
|
||||
- `blobB64`;
|
||||
- `backlog` (признак догрузки из очереди).
|
||||
- `messageKey`, `baseKey`
|
||||
- `fromLogin`, `toLogin`, `targetLogin`
|
||||
- `messageType`, `timeMs`, `nonce`
|
||||
- `blobB64`
|
||||
- `backlog`
|
||||
|
||||
## `AckSessionDelivery`
|
||||
Новая версия того же письма приходит с тем же `messageKey`, но с более новым `revisionTimeMs` внутри бинарного блока.
|
||||
|
||||
Запрос:
|
||||
### `AckSessionDelivery`
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AckSessionDelivery",
|
||||
"requestId": "ack-1",
|
||||
"payload": {
|
||||
"messageKey": "from|to|time|nonce|1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ответ: `status=200`, echo `messageKey`.
|
||||
|
||||
## 6) Хранение на сервере (SQLite)
|
||||
|
||||
Основные таблицы:
|
||||
|
||||
1. `signed_messages_v2` — сами DM-блоки типов `1/2/3/4`:
|
||||
- `message_key` (PK),
|
||||
- `base_key`,
|
||||
- `target_login`,
|
||||
- `from_login`, `to_login`,
|
||||
- `time_ms`, `nonce`, `message_type`,
|
||||
- `raw_block`,
|
||||
- `source_api`, `origin_session_id`,
|
||||
- `receipt_ref_base_key`, `receipt_ref_type`.
|
||||
2. `signed_message_session_delivery` — доставка по сессиям:
|
||||
- составной PK `(message_key, session_id)`,
|
||||
- `delivered` (0/1),
|
||||
- `delivered_at_ms`, `created_at_ms`.
|
||||
|
||||
Примечание: историческая таблица `signed_direct_messages_history` в БД присутствует как legacy-слой, но текущий рабочий поток DM v2 опирается на `signed_messages_v2` + `signed_message_session_delivery`.
|
||||
|
||||
## 7) Доставка и backlog
|
||||
|
||||
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
||||
- Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`.
|
||||
- При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`:
|
||||
- для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»;
|
||||
- отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`;
|
||||
- лимита на количество сообщений нет — передаётся вся история без ограничений.
|
||||
- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует.
|
||||
- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки.
|
||||
|
||||
## 8) Read-receipt логика
|
||||
|
||||
Когда клиент открывает чат:
|
||||
|
||||
1. ищет входящие `messageType=1` без `readReceiptSent`;
|
||||
2. для каждого отправляет read-receipt как пару `type=3/4`;
|
||||
3. после успешной отправки помечает `readReceiptSent`.
|
||||
|
||||
Сервер для read-receipt хранит ссылку на исходное сообщение:
|
||||
|
||||
- `receipt_ref_base_key`;
|
||||
- `receipt_ref_type`.
|
||||
|
||||
Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`.
|
||||
|
||||
## 9) Логика UI-клиента
|
||||
|
||||
### Хранилище сообщений
|
||||
|
||||
- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу.
|
||||
- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`.
|
||||
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`.
|
||||
|
||||
### Жизненный цикл при старте/подключении
|
||||
|
||||
1. `hydrateMessagesFromStore()` — читает все сообщения из IndexedDB в `state.chats` (до WebSocket-соединения).
|
||||
2. После установки WebSocket-сессии сервер присылает backlog (`SignedMessageArrived(backlog=true)`) для всех недоставленных сообщений.
|
||||
3. Клиент дедублирует через `knownMessageKeys` — уже имеющиеся в IndexedDB игнорируются.
|
||||
4. Новые сообщения в реальном времени приходят теми же WebSocket-событиями.
|
||||
|
||||
### Очистка при выходе и смене пользователя
|
||||
|
||||
- При любом логауте (`terminateCurrentSession`) IndexedDB с сообщениями **удаляется полностью**.
|
||||
- При входе нового пользователя через QR — IndexedDB удаляется явно до вызова `terminateCurrentSession`.
|
||||
- При входе нового пользователя через логин/пароль — IndexedDB удаляется в `registration-keys-view.js` прямо перед `authorizeSession()`.
|
||||
- Это гарантирует: при любом способе входа старые сообщения предыдущего пользователя не попадут к следующему.
|
||||
|
||||
### UI-поведение
|
||||
|
||||
- непрочитанные считаются по `from='in' && unread=true`;
|
||||
- доставка/прочтение исходящих:
|
||||
- `firstTick` — сообщение принято сервером,
|
||||
- `secondTick` — пришло подтверждение прочтения;
|
||||
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
||||
- после отправки нового сообщения UI сразу прокручивает ленту вниз.
|
||||
|
||||
## 10) Синхронизация личных сообщений между серверами
|
||||
|
||||
Когда пользователи зарегистрированы на разных серверах SHiNE, серверы должны синхронизировать DM между собой.
|
||||
|
||||
### Общий принцип
|
||||
|
||||
- Сервер A получает DM-блок, адресованный пользователю на сервере B.
|
||||
- Сервер A пересылает этот блок серверу B (межсерверный relay).
|
||||
- Сервер B сохраняет блок и доставляет его в активные сессии получателя.
|
||||
- Серверы, между которыми идёт синхронизация, задаются списком `sync_servers` в PDA пользователя-сервера.
|
||||
|
||||
### Что синхронизируется
|
||||
|
||||
- Все DM-блоки типов `1/2` (текстовые сообщения) и `3/4` (read-receipt).
|
||||
- Синхронизация двусторонняя: оба сервера должны уметь принимать и пересылать блоки.
|
||||
|
||||
### Идемпотентность
|
||||
|
||||
- Блоки имеют уникальный `message_key` (`from|to|timeMs|nonce|type`).
|
||||
- Повторная доставка одного и того же блока безопасна — дедупликация происходит по `message_key`.
|
||||
|
||||
### Статус реализации
|
||||
|
||||
Межсерверная синхронизация DM **пока не реализована**. Текущая версия работает только в рамках одного сервера. Это задача для следующего этапа.
|
||||
Формат не меняется.
|
||||
|
||||
---
|
||||
|
||||
## 11) Инварианты (обязательно соблюдать при доработках)
|
||||
## 7) HTTP endpoints для файлов
|
||||
|
||||
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
|
||||
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
||||
3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`.
|
||||
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
||||
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
||||
### `HEAD /f/<hashB64url>`
|
||||
|
||||
## 12) Ключевые файлы реализации
|
||||
Проверяет наличие 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/app.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`
|
||||
- Сервер:
|
||||
- `shine-server-net-protocol/.../messages/SignedMessageBlock.java`
|
||||
- `shine-server-net-protocol/.../messages/SignedMessagesCore.java`
|
||||
- `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
|
||||
- `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
|
||||
- `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java`
|
||||
- БД:
|
||||
- `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
|
||||
- `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java`
|
||||
- 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`
|
||||
|
||||
Reference in New Issue
Block a user