НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений
This commit is contained in:
parent
2225c2d173
commit
92fd315505
@ -61,5 +61,6 @@
|
|||||||
## Важные замечания
|
## Важные замечания
|
||||||
|
|
||||||
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
- `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 не входит.
|
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
||||||
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
- 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`
|
## 1. `UpsertPushToken`
|
||||||
|
|
||||||
@ -40,11 +40,9 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `SendTestWebPush`
|
## 2. `SendTestWebPush`
|
||||||
|
|
||||||
Требует авторизации. Если `login` передан, он должен совпадать с логином текущей сессии.
|
Требует авторизации.
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -61,65 +59,18 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Успешный ответ
|
## 3. `SendMessagePair` и `ReceiveOutcomingMessage`
|
||||||
|
|
||||||
```json
|
`ReceiveOutcomingMessage` — алиас `SendMessagePair`.
|
||||||
{
|
|
||||||
"op": "SendTestWebPush",
|
|
||||||
"requestId": "push-test-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"targetLogin": "alice",
|
|
||||||
"attemptedSessions": 1,
|
|
||||||
"sessionsWithPushConfig": 1,
|
|
||||||
"delivered": 1,
|
|
||||||
"failed": 0,
|
|
||||||
"sentAtMs": 1774700000123
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
### Назначение
|
||||||
|
|
||||||
## 3. `SendDirectMessage`
|
Передаёт пару signed DM-блоков:
|
||||||
|
|
||||||
Отправляет один подписанный DM-пакет.
|
- `incomingBlobB64` — блок `type=1` или `type=3`
|
||||||
|
- `outgoingBlobB64` — блок `type=2` или `type=4`
|
||||||
|
|
||||||
### Запрос
|
Для контентных сообщений `type=1/2` внутри base64 лежит новый бинарный формат `SHiNE_DM`.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -143,55 +94,28 @@
|
|||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"payload": {
|
"payload": {
|
||||||
"baseKey": "base-key",
|
"baseKey": "from|to|time|nonce",
|
||||||
"incomingKey": "incoming-key",
|
"incomingKey": "from|to|time|nonce|1",
|
||||||
"outgoingKey": "outgoing-key",
|
"outgoingKey": "from|to|time|nonce|2",
|
||||||
"deliveredWsSessions": 1,
|
"deliveredWsSessions": 1,
|
||||||
"deliveredWebPushSessions": 0
|
"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",
|
"op": "AckSessionDelivery",
|
||||||
"requestId": "ack-001",
|
"requestId": "ack-001",
|
||||||
"payload": {
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"op": "AckSessionDelivery",
|
|
||||||
"requestId": "ack-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"payload": {
|
"hash": "base64url_sha256",
|
||||||
"messageKey": "incoming-key"
|
"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
|
## 10. `CallSignalToSession`
|
||||||
{
|
|
||||||
"op": "CallInviteBroadcast",
|
|
||||||
"requestId": "call-invite-001",
|
|
||||||
"payload": {
|
|
||||||
"toLogin": "bob",
|
|
||||||
"callId": "call-1",
|
|
||||||
"type": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Успешный ответ
|
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
||||||
|
|
||||||
```json
|
## 11. Замечания
|
||||||
{
|
|
||||||
"op": "CallInviteBroadcast",
|
|
||||||
"requestId": "call-invite-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"callId": "call-1",
|
|
||||||
"deliveredWsSessions": 1,
|
|
||||||
"deliveredFcmSessions": 0,
|
|
||||||
"deliveredWebPushSessions": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
- Для нового DM-файла сценарий такой: `HEAD /f/<hash>` → при `404` `POST /upload` → затем `SendMessagePair`.
|
||||||
|
- Сервер хранит только последнюю версию контентного сообщения по `messageKey`.
|
||||||
## 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` — повторное сообщение заблокировано.
|
|
||||||
|
|||||||
@ -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): как это устроено
|
# Личные сообщения (DM): как это устроено
|
||||||
|
|
||||||
## Коротко (для быстрого понимания)
|
## Коротко
|
||||||
|
|
||||||
Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`:
|
Личные сообщения в SHiNE теперь работают в двух слоях:
|
||||||
|
|
||||||
- тип `1` — входящее сообщение для собеседника;
|
- контентные сообщения `type=1/2` идут в новом бинарном формате `SHiNE_DM`;
|
||||||
- тип `2` — исходящая копия того же сообщения для автора.
|
- 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`
|
- `baseKey = from|to|timeMs|nonce`
|
||||||
- `messageKey = baseKey|messageType`
|
- `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
|
```json
|
||||||
{
|
{
|
||||||
@ -113,157 +175,190 @@ UI чата строится на этих типах: текстовые соо
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## `SignedMessageArrived` (server event)
|
### `SignedMessageArrived`
|
||||||
|
|
||||||
Событие в сессию получателя содержит:
|
Событие в сессию всё ещё содержит:
|
||||||
|
|
||||||
- `messageKey`, `baseKey`;
|
- `messageKey`, `baseKey`
|
||||||
- `fromLogin`, `toLogin`, `targetLogin`;
|
- `fromLogin`, `toLogin`, `targetLogin`
|
||||||
- `messageType`, `timeMs`, `nonce`;
|
- `messageType`, `timeMs`, `nonce`
|
||||||
- `blobB64`;
|
- `blobB64`
|
||||||
- `backlog` (признак догрузки из очереди).
|
- `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) должна оставаться атомарной.
|
### `HEAD /f/<hashB64url>`
|
||||||
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
|
||||||
3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`.
|
|
||||||
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
|
||||||
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
|
||||||
|
|
||||||
## 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:
|
- UI:
|
||||||
- `shine-UI/js/services/auth-service.js`
|
- `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/state.js`
|
||||||
|
- `shine-UI/js/app.js`
|
||||||
- `shine-UI/js/pages/chat-view.js`
|
- `shine-UI/js/pages/chat-view.js`
|
||||||
- Сервер:
|
- Server:
|
||||||
- `shine-server-net-protocol/.../messages/SignedMessageBlock.java`
|
- `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessageBlock.java`
|
||||||
- `shine-server-net-protocol/.../messages/SignedMessagesCore.java`
|
- `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesCore.java`
|
||||||
- `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
|
- `SHiNE-server/shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
|
||||||
- `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
|
- `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
|
||||||
- `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java`
|
- `SHiNE-server/shine-server-net-protocol/.../messages/DmFileStorage.java`
|
||||||
- БД:
|
- `SHiNE-server/shine-server-db/.../dao/SignedMessagesV2DAO.java`
|
||||||
- `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
|
- `SHiNE-server/src/main/java/server/files/DmFilesServlet.java`
|
||||||
- `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java`
|
- `SHiNE-server/src/main/java/server/files/DmUploadServlet.java`
|
||||||
|
|||||||
@ -618,6 +618,7 @@ public final class DatabaseInitializer {
|
|||||||
time_ms INTEGER NOT NULL,
|
time_ms INTEGER NOT NULL,
|
||||||
nonce INTEGER NOT NULL,
|
nonce INTEGER NOT NULL,
|
||||||
message_type INTEGER NOT NULL,
|
message_type INTEGER NOT NULL,
|
||||||
|
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
raw_block BLOB NOT NULL,
|
raw_block BLOB NOT NULL,
|
||||||
created_at_ms INTEGER NOT NULL,
|
created_at_ms INTEGER NOT NULL,
|
||||||
source_api TEXT NOT NULL,
|
source_api TEXT NOT NULL,
|
||||||
@ -639,6 +640,36 @@ public final class DatabaseInitializer {
|
|||||||
ON signed_messages_v2 (base_key, message_type);
|
ON signed_messages_v2 (base_key, message_type);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_files (
|
||||||
|
file_hash_sha256 BLOB NOT NULL PRIMARY KEY,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
ref_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_message_file_links (
|
||||||
|
message_key TEXT NOT NULL,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
file_hash_sha256 BLOB NOT NULL,
|
||||||
|
PRIMARY KEY (message_key, login, file_hash_sha256),
|
||||||
|
FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key),
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||||
|
FOREIGN KEY (file_hash_sha256) REFERENCES dm_files(file_hash_sha256)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_login
|
||||||
|
ON dm_message_file_links (login, file_hash_sha256);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_message
|
||||||
|
ON dm_message_file_links (message_key);
|
||||||
|
""");
|
||||||
|
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
||||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import java.sql.Statement;
|
|||||||
public final class SqliteDbController {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
private static volatile SqliteDbController instance;
|
||||||
private static final int LATEST_SCHEMA_VERSION = 5;
|
private static final int LATEST_SCHEMA_VERSION = 6;
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@ -88,6 +88,7 @@ public final class SqliteDbController {
|
|||||||
case 3 -> migrateToV3();
|
case 3 -> migrateToV3();
|
||||||
case 4 -> migrateToV4();
|
case 4 -> migrateToV4();
|
||||||
case 5 -> migrateToV5();
|
case 5 -> migrateToV5();
|
||||||
|
case 6 -> migrateToV6();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,6 +210,26 @@ public final class SqliteDbController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void migrateToV6() {
|
||||||
|
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement st = c.createStatement()) {
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
ensureSignedMessagesRevisionColumn(c, st);
|
||||||
|
ensureDmFileTables(st);
|
||||||
|
setSchemaVersion(c, 6);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw new RuntimeException("DB migration to v6 failed", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("DB migration to v6 failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||||
@ -329,6 +350,45 @@ public final class SqliteDbController {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException {
|
||||||
|
if (!tableExists(c, "signed_messages_v2")) return;
|
||||||
|
if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) {
|
||||||
|
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureDmFileTables(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_files (
|
||||||
|
file_hash_sha256 BLOB NOT NULL PRIMARY KEY,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
ref_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_message_file_links (
|
||||||
|
message_key TEXT NOT NULL,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
file_hash_sha256 BLOB NOT NULL,
|
||||||
|
PRIMARY KEY (message_key, login, file_hash_sha256),
|
||||||
|
FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key),
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||||
|
FOREIGN KEY (file_hash_sha256) REFERENCES dm_files(file_hash_sha256)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_login
|
||||||
|
ON dm_message_file_links (login, file_hash_sha256);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_message
|
||||||
|
ON dm_message_file_links (message_key);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
|
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
|
||||||
try (Statement probe = c.createStatement();
|
try (Statement probe = c.createStatement();
|
||||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.SqliteDbController;
|
||||||
|
import shine.db.entities.DmFileRef;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@ -8,7 +9,10 @@ import java.sql.PreparedStatement;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public final class SignedMessagesV2DAO {
|
public final class SignedMessagesV2DAO {
|
||||||
private static volatile SignedMessagesV2DAO instance;
|
private static volatile SignedMessagesV2DAO instance;
|
||||||
@ -30,35 +34,19 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
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
|
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, e.getMessageKey());
|
bindSignedMessage(ps, e);
|
||||||
ps.setString(2, e.getBaseKey());
|
|
||||||
ps.setString(3, e.getTargetLogin());
|
|
||||||
ps.setString(4, e.getFromLogin());
|
|
||||||
ps.setString(5, e.getToLogin());
|
|
||||||
ps.setLong(6, e.getTimeMs());
|
|
||||||
ps.setLong(7, e.getNonce());
|
|
||||||
ps.setInt(8, e.getMessageType());
|
|
||||||
ps.setBytes(9, e.getRawBlock());
|
|
||||||
ps.setLong(10, e.getCreatedAtMs());
|
|
||||||
ps.setString(11, e.getSourceApi());
|
|
||||||
ps.setString(12, e.getOriginSessionId());
|
|
||||||
ps.setString(13, e.getReceiptRefBaseKey());
|
|
||||||
if (e.getReceiptRefType() == null) ps.setObject(14, null);
|
|
||||||
else ps.setInt(14, e.getReceiptRefType());
|
|
||||||
return ps.executeUpdate() > 0;
|
return ps.executeUpdate() > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Атомарная вставка пары блоков: либо вставляются оба, либо не вставляется ни один.
|
* Атомарная вставка пары блоков legacy/read-receipt: либо вставляются оба, либо не вставляется ни один.
|
||||||
* Возвращает true только если обе записи добавлены в БД.
|
|
||||||
* Если хотя бы одна запись уже существует (или конфликтует по уникальности), возвращает false.
|
|
||||||
*/
|
*/
|
||||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
@ -85,37 +73,97 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
/**
|
||||||
String sql = """
|
* Атомарный upsert пары контентных DM с полной заменой файловых связей.
|
||||||
INSERT INTO signed_messages_v2 (
|
* Возвращает true, только если ревизия применена. Более старая или идентичная ревизия игнорируется.
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
*/
|
||||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
public boolean upsertContentPairReplaceFiles(
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
SignedMessageV2Entry incoming,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
List<DmFileRef> incomingFiles,
|
||||||
""";
|
SignedMessageV2Entry outgoing,
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
List<DmFileRef> outgoingFiles
|
||||||
ps.setString(1, e.getMessageKey());
|
) throws Exception {
|
||||||
ps.setString(2, e.getBaseKey());
|
try (Connection c = db.getConnection()) {
|
||||||
ps.setString(3, e.getTargetLogin());
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
ps.setString(4, e.getFromLogin());
|
c.setAutoCommit(false);
|
||||||
ps.setString(5, e.getToLogin());
|
try {
|
||||||
ps.setLong(6, e.getTimeMs());
|
if (!allFilesExist(c, incomingFiles) || !allFilesExist(c, outgoingFiles)) {
|
||||||
ps.setLong(7, e.getNonce());
|
c.rollback();
|
||||||
ps.setInt(8, e.getMessageType());
|
return false;
|
||||||
ps.setBytes(9, e.getRawBlock());
|
}
|
||||||
ps.setLong(10, e.getCreatedAtMs());
|
|
||||||
ps.setString(11, e.getSourceApi());
|
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
|
||||||
ps.setString(12, e.getOriginSessionId());
|
Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey());
|
||||||
ps.setString(13, e.getReceiptRefBaseKey());
|
long currentRevision = Math.max(
|
||||||
if (e.getReceiptRefType() == null) ps.setObject(14, null);
|
currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE,
|
||||||
else ps.setInt(14, e.getReceiptRefType());
|
currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE
|
||||||
return ps.executeUpdate();
|
);
|
||||||
|
long nextRevision = incoming.getRevisionTimeMs();
|
||||||
|
|
||||||
|
if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) {
|
||||||
|
c.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentRevision != Long.MIN_VALUE
|
||||||
|
&& nextRevision == currentRevision
|
||||||
|
&& hasSameRawBlock(c, incoming)
|
||||||
|
&& hasSameRawBlock(c, outgoing)) {
|
||||||
|
c.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceFileLinks(c, incoming.getMessageKey(), incoming.getTargetLogin(), incomingFiles);
|
||||||
|
replaceFileLinks(c, outgoing.getMessageKey(), outgoing.getTargetLogin(), outgoingFiles);
|
||||||
|
|
||||||
|
upsertMessage(c, incoming);
|
||||||
|
upsertMessage(c, outgoing);
|
||||||
|
|
||||||
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
|
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
return true;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isConstraintViolation(SQLException ex) {
|
public boolean fileExists(byte[] fileHash, long fileSize) throws Exception {
|
||||||
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
try (Connection c = db.getConnection()) {
|
||||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
return fileExists(c, fileHash, fileSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean fileExistsByHash(byte[] fileHash) throws Exception {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
String sql = "SELECT 1 FROM dm_files WHERE file_hash_sha256 = ? LIMIT 1";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setBytes(1, fileHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void registerFileIfAbsent(byte[] fileHash, long fileSize) throws Exception {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO dm_files (file_hash_sha256, file_size, ref_count)
|
||||||
|
VALUES (?, ?, 0)
|
||||||
|
ON CONFLICT(file_hash_sha256) DO UPDATE SET
|
||||||
|
file_size = excluded.file_size
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setBytes(1, fileHash);
|
||||||
|
ps.setLong(2, fileSize);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||||
@ -123,7 +171,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
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
|
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||||
FROM signed_messages_v2
|
FROM signed_messages_v2
|
||||||
WHERE message_key = ?
|
WHERE message_key = ?
|
||||||
@ -203,13 +251,13 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||||
m.time_ms, m.nonce, m.message_type, m.raw_block, m.created_at_ms,
|
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms,
|
||||||
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
|
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
|
||||||
FROM signed_messages_v2 m
|
FROM signed_messages_v2 m
|
||||||
JOIN signed_message_session_delivery d
|
JOIN signed_message_session_delivery d
|
||||||
ON d.message_key = m.message_key
|
ON d.message_key = m.message_key
|
||||||
WHERE d.session_id = ? AND d.delivered = 0
|
WHERE d.session_id = ? AND d.delivered = 0
|
||||||
ORDER BY m.time_ms ASC, m.created_at_ms ASC
|
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC
|
||||||
""";
|
""";
|
||||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@ -222,6 +270,206 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO signed_messages_v2 (
|
||||||
|
message_key, 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
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(message_key) DO UPDATE SET
|
||||||
|
base_key = excluded.base_key,
|
||||||
|
target_login = excluded.target_login,
|
||||||
|
from_login = excluded.from_login,
|
||||||
|
to_login = excluded.to_login,
|
||||||
|
time_ms = excluded.time_ms,
|
||||||
|
nonce = excluded.nonce,
|
||||||
|
message_type = excluded.message_type,
|
||||||
|
revision_time_ms = excluded.revision_time_ms,
|
||||||
|
raw_block = excluded.raw_block,
|
||||||
|
created_at_ms = excluded.created_at_ms,
|
||||||
|
source_api = excluded.source_api,
|
||||||
|
origin_session_id = excluded.origin_session_id,
|
||||||
|
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
||||||
|
receipt_ref_type = excluded.receipt_ref_type
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
bindSignedMessage(ps, e);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replaceFileLinks(Connection c, String messageKey, String login, List<DmFileRef> nextFiles) throws SQLException {
|
||||||
|
List<byte[]> oldHashes = listLinkedFileHashes(c, messageKey, login);
|
||||||
|
for (byte[] oldHash : oldHashes) {
|
||||||
|
adjustRefCount(c, oldHash, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
DELETE FROM dm_message_file_links
|
||||||
|
WHERE message_key = ? AND login = ? COLLATE NOCASE
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, messageKey);
|
||||||
|
ps.setString(2, login);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextFiles == null || nextFiles.isEmpty()) return;
|
||||||
|
|
||||||
|
Set<String> dedup = new HashSet<>();
|
||||||
|
for (DmFileRef ref : nextFiles) {
|
||||||
|
if (ref == null || ref.getFileHash() == null) continue;
|
||||||
|
String dedupKey = Arrays.toString(ref.getFileHash());
|
||||||
|
if (!dedup.add(dedupKey)) continue;
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT OR IGNORE INTO dm_message_file_links (
|
||||||
|
message_key, login, file_hash_sha256
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, messageKey);
|
||||||
|
ps.setString(2, login);
|
||||||
|
ps.setBytes(3, ref.getFileHash());
|
||||||
|
int inserted = ps.executeUpdate();
|
||||||
|
if (inserted > 0) {
|
||||||
|
adjustRefCount(c, ref.getFileHash(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<byte[]> listLinkedFileHashes(Connection c, String messageKey, String login) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT file_hash_sha256
|
||||||
|
FROM dm_message_file_links
|
||||||
|
WHERE message_key = ? AND login = ? COLLATE NOCASE
|
||||||
|
""";
|
||||||
|
List<byte[]> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, messageKey);
|
||||||
|
ps.setString(2, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
out.add(rs.getBytes(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void adjustRefCount(Connection c, byte[] fileHash, int delta) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE dm_files
|
||||||
|
SET ref_count = CASE
|
||||||
|
WHEN ref_count + ? < 0 THEN 0
|
||||||
|
ELSE ref_count + ?
|
||||||
|
END
|
||||||
|
WHERE file_hash_sha256 = ?
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setInt(1, delta);
|
||||||
|
ps.setInt(2, delta);
|
||||||
|
ps.setBytes(3, fileHash);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean allFilesExist(Connection c, List<DmFileRef> refs) throws SQLException {
|
||||||
|
if (refs == null) return true;
|
||||||
|
for (DmFileRef ref : refs) {
|
||||||
|
if (ref == null || ref.getFileHash() == null) return false;
|
||||||
|
if (!fileExists(c, ref.getFileHash(), ref.getFileSize())) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean fileExists(Connection c, byte[] fileHash, long fileSize) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT 1
|
||||||
|
FROM dm_files
|
||||||
|
WHERE file_hash_sha256 = ? AND file_size = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setBytes(1, fileHash);
|
||||||
|
ps.setLong(2, fileSize);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException {
|
||||||
|
String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, messageKey);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return rs.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException {
|
||||||
|
String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, entry.getMessageKey());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return false;
|
||||||
|
return Arrays.equals(rs.getBytes(1), entry.getRawBlock());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE signed_message_session_delivery
|
||||||
|
SET delivered = 0, delivered_at_ms = NULL
|
||||||
|
WHERE message_key = ?
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, messageKey);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO signed_messages_v2 (
|
||||||
|
message_key, 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
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
bindSignedMessage(ps, e);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException {
|
||||||
|
ps.setString(1, e.getMessageKey());
|
||||||
|
ps.setString(2, e.getBaseKey());
|
||||||
|
ps.setString(3, e.getTargetLogin());
|
||||||
|
ps.setString(4, e.getFromLogin());
|
||||||
|
ps.setString(5, e.getToLogin());
|
||||||
|
ps.setLong(6, e.getTimeMs());
|
||||||
|
ps.setLong(7, e.getNonce());
|
||||||
|
ps.setInt(8, e.getMessageType());
|
||||||
|
ps.setLong(9, e.getRevisionTimeMs());
|
||||||
|
ps.setBytes(10, e.getRawBlock());
|
||||||
|
ps.setLong(11, e.getCreatedAtMs());
|
||||||
|
ps.setString(12, e.getSourceApi());
|
||||||
|
ps.setString(13, e.getOriginSessionId());
|
||||||
|
ps.setString(14, e.getReceiptRefBaseKey());
|
||||||
|
if (e.getReceiptRefType() == null) ps.setObject(15, null);
|
||||||
|
else ps.setInt(15, e.getReceiptRefType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isConstraintViolation(SQLException ex) {
|
||||||
|
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
||||||
|
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||||
|
}
|
||||||
|
|
||||||
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
||||||
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
||||||
e.setMessageKey(rs.getString("message_key"));
|
e.setMessageKey(rs.getString("message_key"));
|
||||||
@ -232,6 +480,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
e.setTimeMs(rs.getLong("time_ms"));
|
e.setTimeMs(rs.getLong("time_ms"));
|
||||||
e.setNonce(rs.getLong("nonce"));
|
e.setNonce(rs.getLong("nonce"));
|
||||||
e.setMessageType(rs.getInt("message_type"));
|
e.setMessageType(rs.getInt("message_type"));
|
||||||
|
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
||||||
e.setRawBlock(rs.getBytes("raw_block"));
|
e.setRawBlock(rs.getBytes("raw_block"));
|
||||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
e.setSourceApi(rs.getString("source_api"));
|
e.setSourceApi(rs.getString("source_api"));
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class DmFileRef {
|
||||||
|
private byte[] fileHash;
|
||||||
|
private long fileSize;
|
||||||
|
|
||||||
|
public DmFileRef() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmFileRef(byte[] fileHash, long fileSize) {
|
||||||
|
this.fileHash = fileHash;
|
||||||
|
this.fileSize = fileSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getFileHash() { return fileHash; }
|
||||||
|
public void setFileHash(byte[] fileHash) { this.fileHash = fileHash; }
|
||||||
|
public long getFileSize() { return fileSize; }
|
||||||
|
public void setFileSize(long fileSize) { this.fileSize = fileSize; }
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ public class SignedMessageV2Entry {
|
|||||||
private long timeMs;
|
private long timeMs;
|
||||||
private long nonce;
|
private long nonce;
|
||||||
private int messageType;
|
private int messageType;
|
||||||
|
private long revisionTimeMs;
|
||||||
private byte[] rawBlock;
|
private byte[] rawBlock;
|
||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
private String sourceApi;
|
private String sourceApi;
|
||||||
@ -32,6 +33,8 @@ public class SignedMessageV2Entry {
|
|||||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||||
public int getMessageType() { return messageType; }
|
public int getMessageType() { return messageType; }
|
||||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||||
|
public long getRevisionTimeMs() { return revisionTimeMs; }
|
||||||
|
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; }
|
||||||
public byte[] getRawBlock() { return rawBlock; }
|
public byte[] getRawBlock() { return rawBlock; }
|
||||||
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
||||||
public long getCreatedAtMs() { return createdAtMs; }
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
|||||||
@ -0,0 +1,122 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public final class DmFileStorage {
|
||||||
|
private static final String CFG_FILES_DIR = "dm.files.dir";
|
||||||
|
private static final String CFG_MAX_UPLOAD_BYTES = "dm.upload.maxBytes";
|
||||||
|
private static final long DEFAULT_MAX_UPLOAD_BYTES = 100L * 1024L * 1024L;
|
||||||
|
|
||||||
|
private DmFileStorage() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path rootDir() {
|
||||||
|
String configured = AppConfig.getInstance().getStringOrEmpty(CFG_FILES_DIR).trim();
|
||||||
|
if (configured.isEmpty()) configured = "f";
|
||||||
|
return Path.of(configured).toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ensureRootDir() throws IOException {
|
||||||
|
Files.createDirectories(rootDir());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long maxUploadBytes() {
|
||||||
|
String raw = AppConfig.getInstance().getStringOrEmpty(CFG_MAX_UPLOAD_BYTES).trim();
|
||||||
|
if (raw.isEmpty()) return DEFAULT_MAX_UPLOAD_BYTES;
|
||||||
|
try {
|
||||||
|
long parsed = Long.parseLong(raw);
|
||||||
|
return parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_BYTES;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return DEFAULT_MAX_UPLOAD_BYTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path resolvePathByHashB64Url(String hashB64Url) {
|
||||||
|
return rootDir().resolve(hashB64Url).normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String hashToBase64Url(byte[] hashBytes) {
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] base64UrlToHash(String value) {
|
||||||
|
try {
|
||||||
|
byte[] decoded = Base64.getUrlDecoder().decode(value);
|
||||||
|
if (decoded.length != 32) {
|
||||||
|
throw new IllegalArgumentException("BAD_HASH_LEN");
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
throw new IllegalArgumentException("BAD_HASH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StoreResult storeCiphertext(InputStream in, String expectedHashB64Url, long expectedSize) throws Exception {
|
||||||
|
if (expectedSize < 0) throw new IllegalArgumentException("BAD_SIZE");
|
||||||
|
ensureRootDir();
|
||||||
|
|
||||||
|
byte[] expectedHash = base64UrlToHash(expectedHashB64Url);
|
||||||
|
Path target = resolvePathByHashB64Url(expectedHashB64Url);
|
||||||
|
if (Files.exists(target)) {
|
||||||
|
long existingSize = Files.size(target);
|
||||||
|
return new StoreResult(expectedHashB64Url, existingSize, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
long maxBytes = maxUploadBytes();
|
||||||
|
Path tmp = Files.createTempFile(rootDir(), "upload-", ".tmp");
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
long written = 0;
|
||||||
|
try (OutputStream out = Files.newOutputStream(tmp)) {
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
while (true) {
|
||||||
|
int read = in.read(buf);
|
||||||
|
if (read < 0) break;
|
||||||
|
if (read == 0) continue;
|
||||||
|
written += read;
|
||||||
|
if (written > maxBytes) {
|
||||||
|
throw new IllegalArgumentException("UPLOAD_TOO_LARGE");
|
||||||
|
}
|
||||||
|
digest.update(buf, 0, read);
|
||||||
|
out.write(buf, 0, read);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
try { Files.deleteIfExists(tmp); } catch (Exception ignored) {}
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (written != expectedSize) {
|
||||||
|
Files.deleteIfExists(tmp);
|
||||||
|
throw new IllegalArgumentException("SIZE_MISMATCH");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] actualHash = digest.digest();
|
||||||
|
String actualHashB64Url = hashToBase64Url(actualHash);
|
||||||
|
if (!MessageDigest.isEqual(expectedHash, actualHash)) {
|
||||||
|
Files.deleteIfExists(tmp);
|
||||||
|
throw new IllegalArgumentException("HASH_MISMATCH");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
return new StoreResult(actualHashB64Url, written, false);
|
||||||
|
} catch (IOException moveError) {
|
||||||
|
Files.deleteIfExists(tmp);
|
||||||
|
if (Files.exists(target)) {
|
||||||
|
return new StoreResult(actualHashB64Url, Files.size(target), true);
|
||||||
|
}
|
||||||
|
throw moveError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StoreResult(String hashB64Url, long sizeBytes, boolean alreadyExists) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,9 +8,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque
|
|||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.entities.DmFileRef;
|
||||||
import shine.db.dao.SignedMessagesV2DAO;
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
@ -32,9 +35,13 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
try {
|
try {
|
||||||
SignedMessagesCore.verifyUsersAndSignature(incoming);
|
SignedMessagesCore.verifyUsersAndSignature(incoming);
|
||||||
SignedMessagesCore.verifyUsersAndSignature(outgoing);
|
SignedMessagesCore.verifyUsersAndSignature(outgoing);
|
||||||
|
SignedMessagesCore.ensureAllFilesExist(incoming);
|
||||||
|
SignedMessagesCore.ensureAllFilesExist(outgoing);
|
||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
String code = ex.getMessage();
|
String code = ex.getMessage();
|
||||||
int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED;
|
int status = "USER_NOT_FOUND".equals(code)
|
||||||
|
? 404
|
||||||
|
: ("ATTACHMENT_NOT_FOUND".equals(code) ? WireCodes.Status.BAD_REQUEST : WireCodes.Status.UNVERIFIED);
|
||||||
return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
|
return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,11 +56,20 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
|
boolean pairInserted;
|
||||||
|
if (incoming.isContentType()) {
|
||||||
|
List<DmFileRef> incomingFiles = SignedMessagesCore.attachmentRefs(incoming);
|
||||||
|
List<DmFileRef> outgoingFiles = SignedMessagesCore.attachmentRefs(outgoing);
|
||||||
|
pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPairReplaceFiles(
|
||||||
|
incomingEntry, incomingFiles, outgoingEntry, outgoingFiles
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
|
||||||
|
}
|
||||||
|
|
||||||
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairInserted) {
|
if (pairInserted) {
|
||||||
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null);
|
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming);
|
||||||
}
|
}
|
||||||
|
|
||||||
String excludeSessionId = null;
|
String excludeSessionId = null;
|
||||||
@ -62,7 +78,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairInserted) {
|
if (pairInserted) {
|
||||||
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId);
|
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
||||||
|
|||||||
@ -1,26 +1,39 @@
|
|||||||
package server.logic.ws_protocol.JSON.messages;
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import shine.db.entities.DmFileRef;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
final class SignedMessageBlock {
|
final class SignedMessageBlock {
|
||||||
static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
||||||
static final int TYPE_INCOMING_TEXT = 1;
|
static final int TYPE_INCOMING_TEXT = 1;
|
||||||
static final int TYPE_OUTGOING_COPY = 2;
|
static final int TYPE_OUTGOING_COPY = 2;
|
||||||
static final int TYPE_READ_INCOMING = 3;
|
static final int TYPE_READ_INCOMING = 3;
|
||||||
static final int TYPE_READ_OUTGOING_COPY = 4;
|
static final int TYPE_READ_OUTGOING_COPY = 4;
|
||||||
|
static final int MAX_ATTACHMENTS = 12;
|
||||||
|
|
||||||
final String toLogin;
|
final String toLogin;
|
||||||
final String fromLogin;
|
final String fromLogin;
|
||||||
final long timeMs;
|
final long timeMs;
|
||||||
final long nonce;
|
final long nonce;
|
||||||
final int messageType;
|
final int messageType;
|
||||||
|
final long revisionTimeMs;
|
||||||
|
final int formatVersionMajor;
|
||||||
|
final int formatVersionMinor;
|
||||||
final byte[] payloadBytes;
|
final byte[] payloadBytes;
|
||||||
|
final byte[] encryptedBodyBytes;
|
||||||
|
final List<DmFileRef> attachments;
|
||||||
final byte[] signedBody;
|
final byte[] signedBody;
|
||||||
final byte[] signature64;
|
final byte[] signature64;
|
||||||
final byte[] rawPacket;
|
final byte[] rawPacket;
|
||||||
|
final boolean legacyFormat;
|
||||||
|
|
||||||
private SignedMessageBlock(
|
private SignedMessageBlock(
|
||||||
String toLogin,
|
String toLogin,
|
||||||
@ -28,36 +41,54 @@ final class SignedMessageBlock {
|
|||||||
long timeMs,
|
long timeMs,
|
||||||
long nonce,
|
long nonce,
|
||||||
int messageType,
|
int messageType,
|
||||||
|
long revisionTimeMs,
|
||||||
|
int formatVersionMajor,
|
||||||
|
int formatVersionMinor,
|
||||||
byte[] payloadBytes,
|
byte[] payloadBytes,
|
||||||
|
byte[] encryptedBodyBytes,
|
||||||
|
List<DmFileRef> attachments,
|
||||||
byte[] signedBody,
|
byte[] signedBody,
|
||||||
byte[] signature64,
|
byte[] signature64,
|
||||||
byte[] rawPacket
|
byte[] rawPacket,
|
||||||
|
boolean legacyFormat
|
||||||
) {
|
) {
|
||||||
this.toLogin = toLogin;
|
this.toLogin = toLogin;
|
||||||
this.fromLogin = fromLogin;
|
this.fromLogin = fromLogin;
|
||||||
this.timeMs = timeMs;
|
this.timeMs = timeMs;
|
||||||
this.nonce = nonce;
|
this.nonce = nonce;
|
||||||
this.messageType = messageType;
|
this.messageType = messageType;
|
||||||
|
this.revisionTimeMs = revisionTimeMs;
|
||||||
|
this.formatVersionMajor = formatVersionMajor;
|
||||||
|
this.formatVersionMinor = formatVersionMinor;
|
||||||
this.payloadBytes = payloadBytes;
|
this.payloadBytes = payloadBytes;
|
||||||
|
this.encryptedBodyBytes = encryptedBodyBytes;
|
||||||
|
this.attachments = attachments;
|
||||||
this.signedBody = signedBody;
|
this.signedBody = signedBody;
|
||||||
this.signature64 = signature64;
|
this.signature64 = signature64;
|
||||||
this.rawPacket = rawPacket;
|
this.rawPacket = rawPacket;
|
||||||
|
this.legacyFormat = legacyFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
static SignedMessageBlock parse(byte[] raw, int maxPayloadBytes) {
|
static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) {
|
||||||
if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
|
if (raw == null || raw.length < 64) {
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
}
|
}
|
||||||
if (raw.length > 8192) {
|
|
||||||
throw new IllegalArgumentException("PAYLOAD_TOO_LARGE");
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
if (startsWith(raw, LEGACY_PREFIX)) {
|
||||||
byte[] prefix = new byte[PREFIX.length];
|
return parseLegacy(raw, maxEncryptedBodyBytes);
|
||||||
bb.get(prefix);
|
|
||||||
if (!Arrays.equals(prefix, PREFIX)) {
|
|
||||||
throw new IllegalArgumentException("BAD_PREFIX");
|
|
||||||
}
|
}
|
||||||
|
if (startsWith(raw, V1_PREFIX)) {
|
||||||
|
return parseV1(raw, maxEncryptedBodyBytes);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("BAD_PREFIX");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) {
|
||||||
|
if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
|
||||||
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
}
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.position(LEGACY_PREFIX.length);
|
||||||
|
|
||||||
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
||||||
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
||||||
@ -67,9 +98,7 @@ final class SignedMessageBlock {
|
|||||||
|
|
||||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
int messageType = Short.toUnsignedInt(bb.getShort());
|
||||||
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
|
ensureMessageType(messageType);
|
||||||
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
|
||||||
}
|
|
||||||
|
|
||||||
int payloadLen = Short.toUnsignedInt(bb.getShort());
|
int payloadLen = Short.toUnsignedInt(bb.getShort());
|
||||||
if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
|
if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
|
||||||
@ -86,7 +115,95 @@ final class SignedMessageBlock {
|
|||||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||||
|
|
||||||
return new SignedMessageBlock(
|
return new SignedMessageBlock(
|
||||||
toLogin, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw
|
toLogin,
|
||||||
|
fromLogin,
|
||||||
|
timeMs,
|
||||||
|
nonce,
|
||||||
|
messageType,
|
||||||
|
0L,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
payload,
|
||||||
|
payload,
|
||||||
|
List.of(),
|
||||||
|
signedBody,
|
||||||
|
signature64,
|
||||||
|
raw,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) {
|
||||||
|
if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) {
|
||||||
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
}
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.position(V1_PREFIX.length);
|
||||||
|
|
||||||
|
int major = Byte.toUnsignedInt(bb.get());
|
||||||
|
int minor = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (major != 1 || minor != 0) {
|
||||||
|
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||||
|
}
|
||||||
|
|
||||||
|
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
||||||
|
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
||||||
|
long timeMs = bb.getLong();
|
||||||
|
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
||||||
|
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||||
|
int messageType = Short.toUnsignedInt(bb.getShort());
|
||||||
|
ensureMessageType(messageType);
|
||||||
|
long revisionTimeMs = bb.getLong();
|
||||||
|
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||||
|
|
||||||
|
int attachmentsCount = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (attachmentsCount > MAX_ATTACHMENTS) {
|
||||||
|
throw new IllegalArgumentException("TOO_MANY_ATTACHMENTS");
|
||||||
|
}
|
||||||
|
List<DmFileRef> attachments = new ArrayList<>(attachmentsCount);
|
||||||
|
for (int i = 0; i < attachmentsCount; i++) {
|
||||||
|
if (bb.remaining() < 32 + 8 + 4 + 64) {
|
||||||
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
}
|
||||||
|
byte[] hash = new byte[32];
|
||||||
|
bb.get(hash);
|
||||||
|
long size = bb.getLong();
|
||||||
|
if (size < 0) throw new IllegalArgumentException("BAD_ATTACHMENT_SIZE");
|
||||||
|
attachments.add(new DmFileRef(hash, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bb.remaining() < 4 + 64) {
|
||||||
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
}
|
||||||
|
long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt());
|
||||||
|
if (encryptedBodyLen > maxEncryptedBodyBytes) {
|
||||||
|
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||||
|
}
|
||||||
|
if (bb.remaining() != encryptedBodyLen + 64) {
|
||||||
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
}
|
||||||
|
byte[] encryptedBody = new byte[(int) encryptedBodyLen];
|
||||||
|
bb.get(encryptedBody);
|
||||||
|
byte[] signature64 = new byte[64];
|
||||||
|
bb.get(signature64);
|
||||||
|
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||||
|
|
||||||
|
return new SignedMessageBlock(
|
||||||
|
toLogin,
|
||||||
|
fromLogin,
|
||||||
|
timeMs,
|
||||||
|
nonce,
|
||||||
|
messageType,
|
||||||
|
revisionTimeMs,
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
encryptedBody,
|
||||||
|
encryptedBody,
|
||||||
|
Collections.unmodifiableList(attachments),
|
||||||
|
signedBody,
|
||||||
|
signature64,
|
||||||
|
raw,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,10 +215,36 @@ final class SignedMessageBlock {
|
|||||||
return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
|
return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean isContentType() {
|
||||||
|
return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isReadReceiptType() {
|
||||||
|
return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isDeletedContent() {
|
||||||
|
return isContentType() && !legacyFormat && attachments.isEmpty() && encryptedBodyBytes.length == 0;
|
||||||
|
}
|
||||||
|
|
||||||
String targetLogin() {
|
String targetLogin() {
|
||||||
return isIncomingType() ? toLogin : fromLogin;
|
return isIncomingType() ? toLogin : fromLogin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureMessageType(int messageType) {
|
||||||
|
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
|
||||||
|
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean startsWith(byte[] raw, byte[] prefix) {
|
||||||
|
if (raw.length < prefix.length) return false;
|
||||||
|
for (int i = 0; i < prefix.length; i++) {
|
||||||
|
if (raw[i] != prefix[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
|
private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
|
||||||
if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
|
if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
|
||||||
int len = Byte.toUnsignedInt(bb.get());
|
int len = Byte.toUnsignedInt(bb.get());
|
||||||
|
|||||||
@ -2,21 +2,27 @@ package server.logic.ws_protocol.JSON.messages;
|
|||||||
|
|
||||||
import shine.db.dao.SignedMessagesV2DAO;
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
import shine.db.dao.SolanaUsersDAO;
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.entities.DmFileRef;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.entities.SolanaUserEntry;
|
||||||
import utils.crypto.Ed25519Util;
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
final class SignedMessagesCore {
|
final class SignedMessagesCore {
|
||||||
private static final int MAX_PAYLOAD_BYTES = 4096;
|
private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
|
||||||
|
|
||||||
private SignedMessagesCore() {}
|
private SignedMessagesCore() {}
|
||||||
|
|
||||||
static SignedMessageBlock parseFromB64(String blobB64) {
|
static SignedMessageBlock parseFromB64(String blobB64) {
|
||||||
try {
|
try {
|
||||||
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
|
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
|
||||||
return SignedMessageBlock.parse(raw, MAX_PAYLOAD_BYTES);
|
return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
||||||
}
|
}
|
||||||
@ -42,7 +48,7 @@ final class SignedMessagesCore {
|
|||||||
if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||||
if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||||
|
|
||||||
if (incoming.messageType == SignedMessageBlock.TYPE_READ_INCOMING) {
|
if (incoming.isReadReceiptType()) {
|
||||||
ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes);
|
ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes);
|
||||||
ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
|
ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
|
||||||
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
||||||
@ -52,6 +58,63 @@ final class SignedMessagesCore {
|
|||||||
|| inRef.refType != outRef.refType) {
|
|| inRef.refType != outRef.refType) {
|
||||||
throw new IllegalArgumentException("BAD_RECEIPT_REF");
|
throw new IllegalArgumentException("BAD_RECEIPT_REF");
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.legacyFormat || outgoing.legacyFormat) {
|
||||||
|
throw new IllegalArgumentException("BAD_CONTENT_FORMAT");
|
||||||
|
}
|
||||||
|
if (incoming.revisionTimeMs != outgoing.revisionTimeMs) {
|
||||||
|
throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||||
|
}
|
||||||
|
if (incoming.formatVersionMajor != outgoing.formatVersionMajor
|
||||||
|
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) {
|
||||||
|
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||||
|
}
|
||||||
|
if (incoming.attachments.size() != outgoing.attachments.size()) {
|
||||||
|
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> seenIncoming = new HashSet<>();
|
||||||
|
Set<String> seenOutgoing = new HashSet<>();
|
||||||
|
for (int i = 0; i < incoming.attachments.size(); i++) {
|
||||||
|
DmFileRef left = incoming.attachments.get(i);
|
||||||
|
DmFileRef right = outgoing.attachments.get(i);
|
||||||
|
if (left.getFileSize() != right.getFileSize()) {
|
||||||
|
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||||
|
}
|
||||||
|
if (left.getFileHash() == null || right.getFileHash() == null) {
|
||||||
|
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||||
|
}
|
||||||
|
if (left.getFileHash().length != 32 || right.getFileHash().length != 32) {
|
||||||
|
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||||
|
}
|
||||||
|
String inDedup = Base64.getEncoder().encodeToString(left.getFileHash());
|
||||||
|
String outDedup = Base64.getEncoder().encodeToString(right.getFileHash());
|
||||||
|
if (!seenIncoming.add(inDedup) || !seenOutgoing.add(outDedup)) {
|
||||||
|
throw new IllegalArgumentException("DUPLICATE_ATTACHMENTS");
|
||||||
|
}
|
||||||
|
if (!inDedup.equals(outDedup)) {
|
||||||
|
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) {
|
||||||
|
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) {
|
||||||
|
if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) {
|
||||||
|
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensureAllFilesExist(SignedMessageBlock block) throws Exception {
|
||||||
|
if (!block.isContentType()) return;
|
||||||
|
for (DmFileRef ref : block.attachments) {
|
||||||
|
if (!SignedMessagesV2DAO.getInstance().fileExists(ref.getFileHash(), ref.getFileSize())) {
|
||||||
|
throw new IllegalArgumentException("ATTACHMENT_NOT_FOUND");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +131,7 @@ final class SignedMessagesCore {
|
|||||||
entry.setTimeMs(block.timeMs);
|
entry.setTimeMs(block.timeMs);
|
||||||
entry.setNonce(block.nonce);
|
entry.setNonce(block.nonce);
|
||||||
entry.setMessageType(block.messageType);
|
entry.setMessageType(block.messageType);
|
||||||
|
entry.setRevisionTimeMs(block.revisionTimeMs);
|
||||||
entry.setRawBlock(block.rawPacket);
|
entry.setRawBlock(block.rawPacket);
|
||||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||||
entry.setSourceApi(sourceApi);
|
entry.setSourceApi(sourceApi);
|
||||||
@ -83,6 +147,17 @@ final class SignedMessagesCore {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<DmFileRef> attachmentRefs(SignedMessageBlock block) {
|
||||||
|
return new ArrayList<>(block.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String previewTextForPush(SignedMessageBlock block) {
|
||||||
|
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
|
static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
|
||||||
return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,8 +21,13 @@ public final class SignedMessagesRealtime {
|
|||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private SignedMessagesRealtime() {}
|
private SignedMessagesRealtime() {}
|
||||||
|
|
||||||
|
static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
||||||
|
return deliverToTargetSessions(message, block, null);
|
||||||
|
}
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(
|
static DeliveryCounters deliverToTargetSessions(
|
||||||
SignedMessageV2Entry message,
|
SignedMessageV2Entry message,
|
||||||
|
SignedMessageBlock block,
|
||||||
String excludeSessionId
|
String excludeSessionId
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
DeliveryCounters counters = new DeliveryCounters();
|
DeliveryCounters counters = new DeliveryCounters();
|
||||||
@ -39,8 +44,11 @@ public final class SignedMessagesRealtime {
|
|||||||
counters.wsDelivered++;
|
counters.wsDelivered++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) {
|
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||||
boolean pushed = pushNewMessageNotification(s, message);
|
&& block != null
|
||||||
|
&& block.revisionTimeMs == 0
|
||||||
|
&& !block.isDeletedContent()) {
|
||||||
|
boolean pushed = pushNewMessageNotification(s, message, block);
|
||||||
if (pushed) counters.pushDelivered++;
|
if (pushed) counters.pushDelivered++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,13 +97,21 @@ public final class SignedMessagesRealtime {
|
|||||||
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
|
private static boolean pushNewMessageNotification(
|
||||||
|
ActiveSessionEntry session,
|
||||||
|
SignedMessageV2Entry message,
|
||||||
|
SignedMessageBlock block
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (session == null) return false;
|
if (session == null) return false;
|
||||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения.";
|
String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim();
|
||||||
|
if (preview.length() > 80) preview = preview.substring(0, 80) + "...";
|
||||||
|
String text = preview.isBlank()
|
||||||
|
? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."
|
||||||
|
: preview;
|
||||||
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
||||||
return WebPushSender.sendBase64Payload(
|
return WebPushSender.sendBase64Payload(
|
||||||
session.getPushEndpoint(),
|
session.getPushEndpoint(),
|
||||||
|
|||||||
66
SHiNE-server/src/main/java/server/files/DmFilesServlet.java
Normal file
66
SHiNE-server/src/main/java/server/files/DmFilesServlet.java
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package server.files;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServlet;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.DmFileStorage;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public class DmFilesServlet extends HttpServlet {
|
||||||
|
@Override
|
||||||
|
protected void doOptions(HttpServletRequest req, HttpServletResponse resp) {
|
||||||
|
applyCors(resp);
|
||||||
|
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||||
|
handleRead(req, resp, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||||
|
handleRead(req, resp, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRead(HttpServletRequest req, HttpServletResponse resp, boolean headOnly) throws IOException {
|
||||||
|
applyCors(resp);
|
||||||
|
String pathInfo = String.valueOf(req.getPathInfo() == null ? "" : req.getPathInfo()).trim();
|
||||||
|
if (pathInfo.startsWith("/")) pathInfo = pathInfo.substring(1);
|
||||||
|
if (pathInfo.isBlank()) {
|
||||||
|
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "hash is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DmFileStorage.base64UrlToHash(pathInfo);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "bad hash");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Path target = DmFileStorage.resolvePathByHashB64Url(pathInfo);
|
||||||
|
if (!Files.exists(target) || !Files.isRegularFile(target)) {
|
||||||
|
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
resp.setContentType("application/octet-stream");
|
||||||
|
resp.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
resp.setHeader("Content-Disposition", "inline; filename=\"" + pathInfo + "\"");
|
||||||
|
long size = Files.size(target);
|
||||||
|
resp.setContentLengthLong(size);
|
||||||
|
if (headOnly) return;
|
||||||
|
Files.copy(target, resp.getOutputStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCors(HttpServletResponse resp) {
|
||||||
|
resp.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
resp.setHeader("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS");
|
||||||
|
resp.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
}
|
||||||
|
}
|
||||||
69
SHiNE-server/src/main/java/server/files/DmUploadServlet.java
Normal file
69
SHiNE-server/src/main/java/server/files/DmUploadServlet.java
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package server.files;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import jakarta.servlet.http.HttpServlet;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.DmFileStorage;
|
||||||
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class DmUploadServlet extends HttpServlet {
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doOptions(HttpServletRequest req, HttpServletResponse resp) {
|
||||||
|
applyCors(resp);
|
||||||
|
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||||
|
applyCors(resp);
|
||||||
|
String hash = String.valueOf(req.getParameter("hash")).trim();
|
||||||
|
String sizeRaw = String.valueOf(req.getParameter("size")).trim();
|
||||||
|
if (hash.isEmpty() || sizeRaw.isEmpty()) {
|
||||||
|
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "hash and size are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long expectedSize;
|
||||||
|
try {
|
||||||
|
expectedSize = Long.parseLong(sizeRaw);
|
||||||
|
if (expectedSize < 0) throw new NumberFormatException("negative");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "bad size");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DmFileStorage.StoreResult result = DmFileStorage.storeCiphertext(req.getInputStream(), hash, expectedSize);
|
||||||
|
SignedMessagesV2DAO.getInstance().registerFileIfAbsent(
|
||||||
|
DmFileStorage.base64UrlToHash(result.hashB64Url()),
|
||||||
|
result.sizeBytes()
|
||||||
|
);
|
||||||
|
|
||||||
|
ObjectNode payload = MAPPER.createObjectNode();
|
||||||
|
payload.put("ok", true);
|
||||||
|
payload.put("hash", result.hashB64Url());
|
||||||
|
payload.put("size", result.sizeBytes());
|
||||||
|
payload.put("alreadyExists", result.alreadyExists());
|
||||||
|
|
||||||
|
resp.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
resp.setContentType("application/json; charset=UTF-8");
|
||||||
|
MAPPER.writeValue(resp.getOutputStream(), payload);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "upload_failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCors(HttpServletResponse resp) {
|
||||||
|
resp.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
resp.setHeader("Access-Control-Allow-Methods", "POST,OPTIONS");
|
||||||
|
resp.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,14 @@
|
|||||||
package server.ws;
|
package server.ws;
|
||||||
|
|
||||||
import org.eclipse.jetty.server.Server;
|
import org.eclipse.jetty.server.Server;
|
||||||
|
import org.eclipse.jetty.servlet.ServletHolder;
|
||||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||||
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import server.debug.DebugApiConfigurator;
|
import server.debug.DebugApiConfigurator;
|
||||||
|
import server.files.DmFilesServlet;
|
||||||
|
import server.files.DmUploadServlet;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@ -61,6 +64,8 @@ public final class WsServer {
|
|||||||
|
|
||||||
// HTTP debug API
|
// HTTP debug API
|
||||||
DebugApiConfigurator.register(context);
|
DebugApiConfigurator.register(context);
|
||||||
|
context.addServlet(new ServletHolder(new DmFilesServlet()), "/f/*");
|
||||||
|
context.addServlet(new ServletHolder(new DmUploadServlet()), "/upload");
|
||||||
|
|
||||||
// Инициализация контейнера WebSocket
|
// Инициализация контейнера WebSocket
|
||||||
JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> {
|
JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> {
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
server.1port=7070
|
server.1port=7070
|
||||||
db.path=data/shine.sqlite
|
db.path=data/shine.sqlite
|
||||||
|
dm.files.dir=f
|
||||||
|
dm.upload.maxBytes=104857600
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Server public info
|
# Server public info
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.207
|
client.version=1.2.208
|
||||||
server.version=1.2.196
|
server.version=1.2.197
|
||||||
|
|||||||
@ -900,7 +900,7 @@ async function init() {
|
|||||||
const messageType = Number(parsed.messageType || 0);
|
const messageType = Number(parsed.messageType || 0);
|
||||||
const chatId = messageType === 2 ? toLogin : fromLogin;
|
const chatId = messageType === 2 ? toLogin : fromLogin;
|
||||||
const text = (messageType === 1 || messageType === 2)
|
const text = (messageType === 1 || messageType === 2)
|
||||||
? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0))
|
? String(parsed.text || '')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
let shouldRefreshToolbarUnread = false;
|
let shouldRefreshToolbarUnread = false;
|
||||||
@ -916,15 +916,18 @@ async function init() {
|
|||||||
messageType,
|
messageType,
|
||||||
unread: isIncomingForCurrent,
|
unread: isIncomingForCurrent,
|
||||||
rawBlobB64: blobB64,
|
rawBlobB64: blobB64,
|
||||||
|
revisionTimeMs: Number(parsed.revisionTimeMs || 0),
|
||||||
|
attachments: Array.isArray(parsed.bodyAttachments) ? parsed.bodyAttachments : [],
|
||||||
|
deleted: Boolean(parsed.deleted),
|
||||||
});
|
});
|
||||||
if (added) {
|
if (added) {
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'signed-dm',
|
source: 'signed-dm',
|
||||||
message: isIncomingForCurrent
|
message: isIncomingForCurrent
|
||||||
? `Новое входящее сообщение от ${fromLogin}`
|
? `Обновлено входящее сообщение от ${fromLogin}`
|
||||||
: `Синхронизирована исходящая копия в чате ${chatId}`,
|
: `Синхронизирована исходящая копия в чате ${chatId}`,
|
||||||
details: { messageKey, baseKey: parsed.baseKey, messageType },
|
details: { messageKey, baseKey: parsed.baseKey, messageType, revisionTimeMs: parsed.revisionTimeMs || 0, deleted: !!parsed.deleted },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (added && isIncomingForCurrent) {
|
if (added && isIncomingForCurrent) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { directMessages } from '../mock-data.js';
|
|||||||
import {
|
import {
|
||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
addChatMessage,
|
addChatMessage,
|
||||||
|
addSignedMessageToChat,
|
||||||
addSystemChatMessage,
|
addSystemChatMessage,
|
||||||
addOutgoingPendingMessage,
|
addOutgoingPendingMessage,
|
||||||
getChatMessages,
|
getChatMessages,
|
||||||
@ -165,6 +166,35 @@ function resolveDeliveryStatus(msg) {
|
|||||||
return '…';
|
return '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
const value = Number(bytes || 0);
|
||||||
|
if (!Number.isFinite(value) || value < 1024) return `${Math.max(0, Math.trunc(value))} B`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagePlainText(msg) {
|
||||||
|
return String(msg?.text || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAttachment(attachment) {
|
||||||
|
const fileName = String(attachment?.fileName || 'file.bin');
|
||||||
|
const mime = String(attachment?.mime || 'application/octet-stream');
|
||||||
|
const plainBytes = await authService.downloadAndDecryptDmAttachment(attachment, state.entrySettings.shineServerHttp);
|
||||||
|
const blob = new Blob([plainBytes], { type: mime });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToLatestMessage(list) {
|
function scrollToLatestMessage(list) {
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
@ -198,9 +228,35 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
|
|||||||
const bubbleKind = String(msg?.kind || '').trim();
|
const bubbleKind = String(msg?.kind || '').trim();
|
||||||
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
|
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
|
||||||
|
|
||||||
const textNode = document.createElement('div');
|
const plainText = messagePlainText(msg);
|
||||||
textNode.className = 'bubble-text';
|
if (plainText) {
|
||||||
textNode.textContent = msg.text || '';
|
const textNode = document.createElement('div');
|
||||||
|
textNode.className = 'bubble-text';
|
||||||
|
textNode.textContent = plainText;
|
||||||
|
bubble.append(textNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = Array.isArray(msg?.attachments) ? msg.attachments : [];
|
||||||
|
if (attachments.length) {
|
||||||
|
const attachmentsNode = document.createElement('div');
|
||||||
|
attachmentsNode.className = 'stack';
|
||||||
|
attachments.forEach((attachment) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'secondary-btn';
|
||||||
|
btn.textContent = `${attachment?.fileName || 'file'} • ${formatFileSize(attachment?.origSize || attachment?.encSize || 0)}`;
|
||||||
|
btn.addEventListener('click', async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
await downloadAttachment(attachment);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Не удалось скачать файл: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
attachmentsNode.append(btn);
|
||||||
|
});
|
||||||
|
bubble.append(attachmentsNode);
|
||||||
|
}
|
||||||
|
|
||||||
const metaNode = document.createElement('div');
|
const metaNode = document.createElement('div');
|
||||||
metaNode.className = 'bubble-meta';
|
metaNode.className = 'bubble-meta';
|
||||||
@ -218,7 +274,7 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
|
|||||||
metaNode.append(statusNode);
|
metaNode.append(statusNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
bubble.append(textNode, metaNode);
|
bubble.append(metaNode);
|
||||||
bubble.addEventListener('click', () => {
|
bubble.addEventListener('click', () => {
|
||||||
if (typeof onOpenActions === 'function') onOpenActions(msg);
|
if (typeof onOpenActions === 'function') onOpenActions(msg);
|
||||||
});
|
});
|
||||||
@ -332,17 +388,64 @@ export function render({ navigate, route }) {
|
|||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.className = 'chat-input dm-chat-input';
|
form.className = 'chat-input dm-chat-input';
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="2000"></textarea>
|
<input type="file" id="chat-file-input" multiple hidden />
|
||||||
|
<div class="stack" id="chat-attachments-preview"></div>
|
||||||
|
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
|
||||||
<div class="dm-actions-col">
|
<div class="dm-actions-col">
|
||||||
|
<button class="ghost-btn dm-voice-btn" type="button" id="chat-file-pick" title="Вложить файлы">📎</button>
|
||||||
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
|
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
|
||||||
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const fileInput = form.querySelector('#chat-file-input');
|
||||||
|
const attachmentsPreview = form.querySelector('#chat-attachments-preview');
|
||||||
|
let pendingFiles = [];
|
||||||
|
|
||||||
|
const renderPendingFiles = () => {
|
||||||
|
if (!attachmentsPreview) return;
|
||||||
|
attachmentsPreview.innerHTML = '';
|
||||||
|
pendingFiles.forEach((file, index) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'meta-muted';
|
||||||
|
row.textContent = `${file.name} • ${formatFileSize(file.size)}`;
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
pendingFiles = pendingFiles.filter((_, current) => current !== index);
|
||||||
|
renderPendingFiles();
|
||||||
|
});
|
||||||
|
attachmentsPreview.append(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMessagePayloadText = (plainText, preparedAttachments) => {
|
||||||
|
const parts = [];
|
||||||
|
const text = String(plainText || '').trim();
|
||||||
|
if (text) parts.push(text);
|
||||||
|
preparedAttachments.forEach((item) => {
|
||||||
|
parts.push(`<<file:file-format(1.0):${item.type}|${item.fileName}|${item.origSize}|${item.origHashB64u}|${item.encHashB64u}|${item.encSize}|${item.keyB64u}|${item.nonceB64u}>>`);
|
||||||
|
});
|
||||||
|
return parts.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureUploads = async (preparedAttachments) => {
|
||||||
|
for (const item of preparedAttachments) {
|
||||||
|
const exists = await authService.headDmFile(item.encHashB64u, state.entrySettings.shineServerHttp);
|
||||||
|
if (!exists) {
|
||||||
|
await authService.uploadDmFileCiphertext({
|
||||||
|
encHashB64u: item.encHashB64u,
|
||||||
|
encSize: item.encSize,
|
||||||
|
ciphertextBytes: item.ciphertextBytes,
|
||||||
|
serverHttpBase: state.entrySettings.shineServerHttp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sendTextMessage = async (rawText) => {
|
const sendTextMessage = async (rawText) => {
|
||||||
const text = String(rawText || '').trim();
|
const text = String(rawText || '').trim();
|
||||||
if (!text) return;
|
if (!text && pendingFiles.length === 0) return;
|
||||||
const tempId = addOutgoingPendingMessage(chatId, text);
|
const tempLabel = text || `Файлы: ${pendingFiles.length}`;
|
||||||
|
const tempId = addOutgoingPendingMessage(chatId, tempLabel);
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId, {
|
||||||
onOpenActions: (msg) => openMessageActionsModal({
|
onOpenActions: (msg) => openMessageActionsModal({
|
||||||
messageText: msg?.text || '',
|
messageText: msg?.text || '',
|
||||||
@ -357,16 +460,47 @@ export function render({ navigate, route }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authService.sendDirectMessage({
|
const filesToSend = pendingFiles.slice(0, 12);
|
||||||
|
const preparedAttachments = [];
|
||||||
|
for (const file of filesToSend) {
|
||||||
|
preparedAttachments.push(await authService.prepareEncryptedDmAttachment(file));
|
||||||
|
}
|
||||||
|
await ensureUploads(preparedAttachments);
|
||||||
|
const messagePayloadText = buildMessagePayloadText(text, preparedAttachments);
|
||||||
|
const result = await authService.sendDirectMessageWithAttachments({
|
||||||
login: state.session.login,
|
login: state.session.login,
|
||||||
toLogin: chatId,
|
toLogin: chatId,
|
||||||
text,
|
text: messagePayloadText,
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
attachments: preparedAttachments,
|
||||||
});
|
});
|
||||||
|
pendingFiles = [];
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
renderPendingFiles();
|
||||||
markOutgoingSent(tempId, {
|
markOutgoingSent(tempId, {
|
||||||
messageKey: result?.outgoingKey || '',
|
messageKey: result?.outgoingKey || '',
|
||||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||||
});
|
});
|
||||||
|
if (result?.localOutgoingBlobB64) {
|
||||||
|
try {
|
||||||
|
const parsed = authService.parseSignedMessageBlob(result.localOutgoingBlobB64);
|
||||||
|
addSignedMessageToChat({
|
||||||
|
chatId,
|
||||||
|
messageKey: result?.outgoingKey || parsed?.messageKey || '',
|
||||||
|
baseKey: result?.baseKey || parsed?.baseKey || '',
|
||||||
|
from: 'out',
|
||||||
|
text: parsed?.text || '',
|
||||||
|
messageType: Number(parsed?.messageType || 2),
|
||||||
|
unread: false,
|
||||||
|
rawBlobB64: result.localOutgoingBlobB64,
|
||||||
|
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
|
||||||
|
attachments: Array.isArray(parsed?.bodyAttachments) ? parsed.bodyAttachments : [],
|
||||||
|
deleted: Boolean(parsed?.deleted),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore local parse failure; server backlog/realtime will reconcile later
|
||||||
|
}
|
||||||
|
}
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId, {
|
||||||
onOpenActions: (msg) => openMessageActionsModal({
|
onOpenActions: (msg) => openMessageActionsModal({
|
||||||
messageText: msg?.text || '',
|
messageText: msg?.text || '',
|
||||||
@ -417,6 +551,15 @@ export function render({ navigate, route }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const input = form.elements.message;
|
const input = form.elements.message;
|
||||||
|
form.querySelector('#chat-file-pick')?.addEventListener('click', () => fileInput?.click());
|
||||||
|
fileInput?.addEventListener('change', () => {
|
||||||
|
const selected = Array.from(fileInput.files || []);
|
||||||
|
if (selected.length > 12) {
|
||||||
|
showToast('Можно приложить не больше 12 файлов', { kind: 'error', timeoutMs: 1400 });
|
||||||
|
}
|
||||||
|
pendingFiles = selected.slice(0, 12);
|
||||||
|
renderPendingFiles();
|
||||||
|
});
|
||||||
autoResizeComposer(input);
|
autoResizeComposer(input);
|
||||||
input?.addEventListener('input', () => autoResizeComposer(input));
|
input?.addEventListener('input', () => autoResizeComposer(input));
|
||||||
input?.addEventListener('focus', () => {
|
input?.addEventListener('focus', () => {
|
||||||
@ -441,7 +584,7 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = String(input.value || '').trim();
|
const text = String(input.value || '').trim();
|
||||||
if (!text) return;
|
if (!text && pendingFiles.length === 0) return;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
autoResizeComposer(input);
|
autoResizeComposer(input);
|
||||||
await sendTextMessage(text);
|
await sendTextMessage(text);
|
||||||
@ -465,7 +608,7 @@ export function render({ navigate, route }) {
|
|||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text) return;
|
if (!text && pendingFiles.length === 0) return;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
autoResizeComposer(input);
|
autoResizeComposer(input);
|
||||||
await sendTextMessage(text);
|
await sendTextMessage(text);
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
import { WsJsonClient } from './ws-client.js';
|
import { WsJsonClient } from './ws-client.js';
|
||||||
import {
|
import {
|
||||||
base64ToBytes,
|
base64ToBytes,
|
||||||
|
base64UrlToBytes,
|
||||||
bytesToBase64,
|
bytesToBase64,
|
||||||
|
bytesToBase64Url,
|
||||||
|
decryptBytesAesGcm,
|
||||||
deriveEd25519FromMasterSecret,
|
deriveEd25519FromMasterSecret,
|
||||||
deriveMasterSecretFromPassword,
|
deriveMasterSecretFromPassword,
|
||||||
|
encryptBytesAesGcm,
|
||||||
exportEd25519PublicKeyB64,
|
exportEd25519PublicKeyB64,
|
||||||
exportPkcs8B64,
|
exportPkcs8B64,
|
||||||
generateEd25519Pair,
|
generateEd25519Pair,
|
||||||
importPkcs8Ed25519,
|
importPkcs8Ed25519,
|
||||||
publicKeyB64FromPkcs8Ed25519,
|
publicKeyB64FromPkcs8Ed25519,
|
||||||
|
randomBytes,
|
||||||
randomBase64,
|
randomBase64,
|
||||||
sha256Bytes,
|
sha256Bytes,
|
||||||
signBytes,
|
signBytes,
|
||||||
@ -199,11 +204,16 @@ function uint8Bytes(value) {
|
|||||||
return new Uint8Array([Number(value) & 0xff]);
|
return new Uint8Array([Number(value) & 0xff]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
||||||
const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
|
const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
|
||||||
const DM2_TYPE_INCOMING = 1;
|
const DM2_TYPE_INCOMING = 1;
|
||||||
const DM2_TYPE_OUTGOING_COPY = 2;
|
const DM2_TYPE_OUTGOING_COPY = 2;
|
||||||
const DM2_TYPE_READ_INCOMING = 3;
|
const DM2_TYPE_READ_INCOMING = 3;
|
||||||
const DM2_TYPE_READ_OUTGOING_COPY = 4;
|
const DM2_TYPE_READ_OUTGOING_COPY = 4;
|
||||||
|
const DM_FORMAT_VERSION_MAJOR = 1;
|
||||||
|
const DM_FORMAT_VERSION_MINOR = 0;
|
||||||
|
const DM_MAX_ATTACHMENTS = 12;
|
||||||
|
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
|
||||||
|
|
||||||
function ensureAsciiBytes(value, field, min = 1, max = 60) {
|
function ensureAsciiBytes(value, field, min = 1, max = 60) {
|
||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
@ -238,6 +248,51 @@ function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, ref
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDmAttachmentsSectionBytes(attachments = []) {
|
||||||
|
const list = Array.isArray(attachments) ? attachments : [];
|
||||||
|
if (list.length > DM_MAX_ATTACHMENTS) {
|
||||||
|
throw new Error(`Вложений должно быть не больше ${DM_MAX_ATTACHMENTS}`);
|
||||||
|
}
|
||||||
|
const parts = [uint8Bytes(list.length)];
|
||||||
|
list.forEach((item, index) => {
|
||||||
|
const hashB64u = String(item?.encHashB64u || '').trim();
|
||||||
|
const hashBytes = base64UrlToBytes(hashB64u);
|
||||||
|
if (hashBytes.length !== 32) throw new Error(`Некорректный encHash у вложения #${index + 1}`);
|
||||||
|
const encSize = Number(item?.encSize ?? item?.encFileSize ?? 0);
|
||||||
|
if (!Number.isFinite(encSize) || encSize < 0) throw new Error(`Некорректный encSize у вложения #${index + 1}`);
|
||||||
|
parts.push(hashBytes, uint64Bytes(encSize));
|
||||||
|
});
|
||||||
|
return concatBytes(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDmTextAttachments(text) {
|
||||||
|
const raw = String(text || '');
|
||||||
|
const regex = /<<file:file-format\(1\.0\):([^>|]+)\|([^>|]+)\|(\d+)\|([^>|]+)\|([^>|]+)\|(\d+)\|([^>|]+)\|([^>|]+)>>/g;
|
||||||
|
const attachments = [];
|
||||||
|
let cleaned = '';
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(raw)) !== null) {
|
||||||
|
cleaned += raw.slice(lastIndex, match.index);
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
attachments.push({
|
||||||
|
type: String(match[1] || 'file'),
|
||||||
|
fileName: String(match[2] || ''),
|
||||||
|
origSize: Number(match[3] || 0),
|
||||||
|
origHashB64u: String(match[4] || ''),
|
||||||
|
encHashB64u: String(match[5] || ''),
|
||||||
|
encSize: Number(match[6] || 0),
|
||||||
|
keyB64u: String(match[7] || ''),
|
||||||
|
nonceB64u: String(match[8] || ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cleaned += raw.slice(lastIndex);
|
||||||
|
return {
|
||||||
|
text: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
|
||||||
|
attachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseSignedMessageBlockBytes(bytes) {
|
function parseSignedMessageBlockBytes(bytes) {
|
||||||
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
||||||
let o = 0;
|
let o = 0;
|
||||||
@ -274,6 +329,70 @@ function parseSignedMessageBlockBytes(bytes) {
|
|||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startsWith = (prefix) => {
|
||||||
|
if (bytes.length < prefix.length) return false;
|
||||||
|
for (let i = 0; i < prefix.length; i += 1) {
|
||||||
|
if (bytes[i] !== prefix[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startsWith(DM_PREFIX_V1)) {
|
||||||
|
read(DM_PREFIX_V1.length);
|
||||||
|
const formatVersionMajor = readU8();
|
||||||
|
const formatVersionMinor = readU8();
|
||||||
|
const toLogin = readAscii();
|
||||||
|
const fromLogin = readAscii();
|
||||||
|
const timeMs = readU64();
|
||||||
|
const nonce = readU32();
|
||||||
|
const messageType = readU16();
|
||||||
|
const revisionTimeMs = readU64();
|
||||||
|
const attachmentsCount = readU8();
|
||||||
|
if (attachmentsCount > DM_MAX_ATTACHMENTS) throw new Error('TOO_MANY_ATTACHMENTS');
|
||||||
|
const attachments = [];
|
||||||
|
for (let i = 0; i < attachmentsCount; i += 1) {
|
||||||
|
const hashBytes = read(32);
|
||||||
|
const encSize = readU64();
|
||||||
|
attachments.push({
|
||||||
|
encHashBytes: hashBytes,
|
||||||
|
encHashB64u: bytesToBase64Url(hashBytes),
|
||||||
|
encSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const encryptedBodyLen = readU32();
|
||||||
|
const encryptedBodyBytes = read(encryptedBodyLen);
|
||||||
|
const signatureBytes = read(64);
|
||||||
|
if (o !== bytes.length) throw new Error('BAD_LEN');
|
||||||
|
const signedBody = bytes.slice(0, bytes.length - 64);
|
||||||
|
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
|
||||||
|
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
||||||
|
const bodyText = new TextDecoder().decode(encryptedBodyBytes);
|
||||||
|
const parsedBody = parseDmTextAttachments(bodyText);
|
||||||
|
return {
|
||||||
|
toLogin,
|
||||||
|
fromLogin,
|
||||||
|
timeMs,
|
||||||
|
nonce,
|
||||||
|
messageType,
|
||||||
|
revisionTimeMs,
|
||||||
|
formatVersionMajor,
|
||||||
|
formatVersionMinor,
|
||||||
|
attachments,
|
||||||
|
encryptedBodyBytes,
|
||||||
|
encryptedBodyText: bodyText,
|
||||||
|
text: parsedBody.text,
|
||||||
|
bodyAttachments: parsedBody.attachments,
|
||||||
|
payloadBytes: encryptedBodyBytes,
|
||||||
|
signatureBytes,
|
||||||
|
signedBody,
|
||||||
|
rawBytes: bytes,
|
||||||
|
baseKey,
|
||||||
|
messageKey,
|
||||||
|
legacyFormat: false,
|
||||||
|
deleted: attachmentsCount === 0 && encryptedBodyLen === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const prefix = read(DM2_PREFIX.length);
|
const prefix = read(DM2_PREFIX.length);
|
||||||
for (let i = 0; i < DM2_PREFIX.length; i += 1) {
|
for (let i = 0; i < DM2_PREFIX.length; i += 1) {
|
||||||
if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
|
if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
|
||||||
@ -298,12 +417,20 @@ function parseSignedMessageBlockBytes(bytes) {
|
|||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
messageType,
|
messageType,
|
||||||
|
revisionTimeMs: 0,
|
||||||
|
attachments: [],
|
||||||
|
encryptedBodyBytes: payloadBytes,
|
||||||
|
encryptedBodyText: new TextDecoder().decode(payloadBytes),
|
||||||
|
text: new TextDecoder().decode(payloadBytes),
|
||||||
|
bodyAttachments: [],
|
||||||
payloadBytes,
|
payloadBytes,
|
||||||
signatureBytes,
|
signatureBytes,
|
||||||
signedBody,
|
signedBody,
|
||||||
rawBytes: bytes,
|
rawBytes: bytes,
|
||||||
baseKey,
|
baseKey,
|
||||||
messageKey,
|
messageKey,
|
||||||
|
legacyFormat: true,
|
||||||
|
deleted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1846,6 +1973,51 @@ export class AuthService {
|
|||||||
return concatBytes(preimage, signature);
|
return concatBytes(preimage, signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async buildSignedDmV1Block({
|
||||||
|
login,
|
||||||
|
toLogin,
|
||||||
|
storagePwd,
|
||||||
|
timeMs,
|
||||||
|
nonce,
|
||||||
|
messageType,
|
||||||
|
revisionTimeMs = 0,
|
||||||
|
attachments = [],
|
||||||
|
encryptedBodyBytes = new Uint8Array(0),
|
||||||
|
}) {
|
||||||
|
const cleanFromLogin = String(login || '').trim();
|
||||||
|
const cleanToLogin = String(toLogin || '').trim();
|
||||||
|
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||||
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||||||
|
if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
||||||
|
throw new Error(`encryptedBody должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||||||
|
const devicePriv = secrets?.deviceKey;
|
||||||
|
if (!devicePriv) throw new Error('Не найден приватный deviceKey');
|
||||||
|
const privateKey = await importPkcs8Ed25519(devicePriv);
|
||||||
|
|
||||||
|
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
|
||||||
|
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
|
||||||
|
const attachmentsSection = buildDmAttachmentsSectionBytes(attachments);
|
||||||
|
const preimage = concatBytes(
|
||||||
|
DM_PREFIX_V1,
|
||||||
|
uint8Bytes(DM_FORMAT_VERSION_MAJOR),
|
||||||
|
uint8Bytes(DM_FORMAT_VERSION_MINOR),
|
||||||
|
uint8Bytes(toBytes.length), toBytes,
|
||||||
|
uint8Bytes(fromBytes.length), fromBytes,
|
||||||
|
uint64Bytes(timeMs),
|
||||||
|
uint32Bytes(nonce),
|
||||||
|
uint16Bytes(messageType),
|
||||||
|
uint64Bytes(revisionTimeMs),
|
||||||
|
attachmentsSection,
|
||||||
|
uint32Bytes(encryptedBodyBytes.length),
|
||||||
|
encryptedBodyBytes,
|
||||||
|
);
|
||||||
|
const signature = await signBytes(privateKey, preimage);
|
||||||
|
return concatBytes(preimage, signature);
|
||||||
|
}
|
||||||
|
|
||||||
parseSignedMessageBlob(blobB64) {
|
parseSignedMessageBlob(blobB64) {
|
||||||
const bytes = base64ToBytes(String(blobB64 || '').trim());
|
const bytes = base64ToBytes(String(blobB64 || '').trim());
|
||||||
return parseSignedMessageBlockBytes(bytes);
|
return parseSignedMessageBlockBytes(bytes);
|
||||||
@ -1901,33 +2073,48 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
|
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
|
||||||
|
return this.sendDirectMessageWithAttachments({ login, toLogin, text, storagePwd, attachments: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendDirectMessageWithAttachments({
|
||||||
|
login,
|
||||||
|
toLogin,
|
||||||
|
text,
|
||||||
|
storagePwd,
|
||||||
|
attachments = [],
|
||||||
|
timeMs = Date.now(),
|
||||||
|
nonce = Math.floor(Math.random() * 0x100000000),
|
||||||
|
revisionTimeMs = 0,
|
||||||
|
}) {
|
||||||
const cleanFromLogin = String(login || '').trim();
|
const cleanFromLogin = String(login || '').trim();
|
||||||
const cleanToLogin = String(toLogin || '').trim();
|
const cleanToLogin = String(toLogin || '').trim();
|
||||||
const cleanText = String(text || '');
|
const cleanText = String(text || '');
|
||||||
if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text');
|
const normalizedAttachments = Array.isArray(attachments) ? attachments : [];
|
||||||
|
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||||
|
if (!cleanText && normalizedAttachments.length === 0) throw new Error('Пустое сообщение');
|
||||||
|
const encryptedBodyBytes = utf8Bytes(cleanText);
|
||||||
|
|
||||||
const timeMs = Date.now();
|
const incomingBlock = await this.buildSignedDmV1Block({
|
||||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
|
||||||
const incomingPayload = utf8Bytes(cleanText);
|
|
||||||
const outgoingPayload = utf8Bytes(cleanText);
|
|
||||||
|
|
||||||
const incomingBlock = await this.buildSignedDm2Block({
|
|
||||||
login: cleanFromLogin,
|
login: cleanFromLogin,
|
||||||
toLogin: cleanToLogin,
|
toLogin: cleanToLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
messageType: DM2_TYPE_INCOMING,
|
messageType: DM2_TYPE_INCOMING,
|
||||||
payloadBytes: incomingPayload,
|
revisionTimeMs,
|
||||||
|
attachments: normalizedAttachments,
|
||||||
|
encryptedBodyBytes,
|
||||||
});
|
});
|
||||||
const outgoingBlock = await this.buildSignedDm2Block({
|
const outgoingBlock = await this.buildSignedDmV1Block({
|
||||||
login: cleanFromLogin,
|
login: cleanFromLogin,
|
||||||
toLogin: cleanToLogin,
|
toLogin: cleanToLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
messageType: DM2_TYPE_OUTGOING_COPY,
|
messageType: DM2_TYPE_OUTGOING_COPY,
|
||||||
payloadBytes: outgoingPayload,
|
revisionTimeMs,
|
||||||
|
attachments: normalizedAttachments,
|
||||||
|
encryptedBodyBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = await this.sendMessagePair({
|
const payload = await this.sendMessagePair({
|
||||||
@ -1977,6 +2164,90 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getHttpBaseUrl(serverHttpBase = '') {
|
||||||
|
const explicit = String(serverHttpBase || '').trim();
|
||||||
|
if (explicit) return explicit.replace(/\/$/, '');
|
||||||
|
try {
|
||||||
|
const parsed = new URL(this.serverUrl);
|
||||||
|
parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:';
|
||||||
|
parsed.pathname = '';
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
return parsed.toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDmFileUrl(encHashB64u, serverHttpBase = '') {
|
||||||
|
const base = this.getHttpBaseUrl(serverHttpBase);
|
||||||
|
return `${base}/f/${encodeURIComponent(String(encHashB64u || '').trim())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async headDmFile(encHashB64u, serverHttpBase = '') {
|
||||||
|
const url = this.buildDmFileUrl(encHashB64u, serverHttpBase);
|
||||||
|
const response = await fetch(url, { method: 'HEAD' });
|
||||||
|
return response.status === 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadDmFileCiphertext({ encHashB64u, encSize, ciphertextBytes, serverHttpBase = '' }) {
|
||||||
|
const base = this.getHttpBaseUrl(serverHttpBase);
|
||||||
|
const url = `${base}/upload?hash=${encodeURIComponent(String(encHashB64u || '').trim())}&size=${encodeURIComponent(String(encSize || 0))}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
body: ciphertextBytes,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`upload_failed_${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareEncryptedDmAttachment(file) {
|
||||||
|
if (!(file instanceof File)) throw new Error('Ожидался File');
|
||||||
|
const fileName = String(file.name || 'file.bin');
|
||||||
|
if (!fileName || /[|:>\n\r]/.test(fileName)) {
|
||||||
|
throw new Error('Имя файла содержит запрещённые символы для DM-протокола');
|
||||||
|
}
|
||||||
|
const plainBytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const origHashBytes = await sha256Bytes(plainBytes);
|
||||||
|
const aesKeyBytes = randomBytes(32);
|
||||||
|
const ivBytes = randomBytes(12);
|
||||||
|
const cipherBytes = await encryptBytesAesGcm(plainBytes, aesKeyBytes, ivBytes);
|
||||||
|
const encHashBytes = await sha256Bytes(cipherBytes);
|
||||||
|
const type = String(file.type || '').startsWith('image/')
|
||||||
|
? 'photo'
|
||||||
|
: (String(file.type || '').startsWith('video/')
|
||||||
|
? 'video'
|
||||||
|
: (String(file.type || '').startsWith('audio/') ? 'audio' : 'file'));
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
mime: String(file.type || 'application/octet-stream'),
|
||||||
|
fileName,
|
||||||
|
origSize: plainBytes.length,
|
||||||
|
origHashB64u: bytesToBase64Url(origHashBytes),
|
||||||
|
encHashB64u: bytesToBase64Url(encHashBytes),
|
||||||
|
encSize: cipherBytes.length,
|
||||||
|
keyB64u: bytesToBase64Url(aesKeyBytes),
|
||||||
|
nonceB64u: bytesToBase64Url(ivBytes),
|
||||||
|
ciphertextBytes: cipherBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAndDecryptDmAttachment(attachment, serverHttpBase = '') {
|
||||||
|
const encHashB64u = String(attachment?.encHashB64u || '').trim();
|
||||||
|
if (!encHashB64u) throw new Error('Не указан encHashB64u');
|
||||||
|
const response = await fetch(this.buildDmFileUrl(encHashB64u, serverHttpBase));
|
||||||
|
if (!response.ok) throw new Error(`download_failed_${response.status}`);
|
||||||
|
const cipherBytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
const keyBytes = base64UrlToBytes(String(attachment?.keyB64u || '').trim());
|
||||||
|
const nonceBytes = base64UrlToBytes(String(attachment?.nonceB64u || '').trim());
|
||||||
|
return decryptBytesAesGcm(cipherBytes, keyBytes, nonceBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
|
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
|
||||||
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
|
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
|
||||||
|
|||||||
@ -25,6 +25,10 @@ function base64UrlToBase64(value) {
|
|||||||
return normalized + '='.repeat(padLen);
|
return normalized + '='.repeat(padLen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function base64ToBase64Url(value) {
|
||||||
|
return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
|
||||||
export function bytesToBase58(bytes) {
|
export function bytesToBase58(bytes) {
|
||||||
@ -93,6 +97,10 @@ export function bytesToBase64(bytes) {
|
|||||||
return btoa(binary);
|
return btoa(binary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bytesToBase64Url(bytes) {
|
||||||
|
return base64ToBase64Url(bytesToBase64(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
export function base64ToBytes(base64) {
|
export function base64ToBytes(base64) {
|
||||||
const normalized = (base64 || '').trim();
|
const normalized = (base64 || '').trim();
|
||||||
const binary = atob(normalized);
|
const binary = atob(normalized);
|
||||||
@ -103,6 +111,10 @@ export function base64ToBytes(base64) {
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function base64UrlToBytes(value) {
|
||||||
|
return base64ToBytes(base64UrlToBase64(String(value || '').trim()));
|
||||||
|
}
|
||||||
|
|
||||||
export function utf8Bytes(value) {
|
export function utf8Bytes(value) {
|
||||||
return encoder.encode(value);
|
return encoder.encode(value);
|
||||||
}
|
}
|
||||||
@ -260,6 +272,28 @@ export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
|
|||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt']) {
|
||||||
|
return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes) {
|
||||||
|
const key = await importAesKeyRaw(keyBytes, ['encrypt']);
|
||||||
|
const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes);
|
||||||
|
return new Uint8Array(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) {
|
||||||
|
const key = await importAesKeyRaw(keyBytes, ['decrypt']);
|
||||||
|
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes);
|
||||||
|
return new Uint8Array(plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomBytes(byteLen = 32) {
|
||||||
|
const out = new Uint8Array(byteLen);
|
||||||
|
getCryptoApi().getRandomValues(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateEd25519Pair() {
|
export async function generateEd25519Pair() {
|
||||||
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,13 @@ export async function putStoredMessage(record) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteStoredMessage(messageKey) {
|
||||||
|
if (!messageKey) return;
|
||||||
|
await withStore('readwrite', (store) => {
|
||||||
|
store.delete(messageKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listStoredMessages() {
|
export async function listStoredMessages() {
|
||||||
return withStore('readonly', (store) => new Promise((resolve, reject) => {
|
return withStore('readonly', (store) => new Promise((resolve, reject) => {
|
||||||
const req = store.getAll();
|
const req = store.getAll();
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { AuthService } from './services/auth-service.js';
|
import { AuthService } from './services/auth-service.js';
|
||||||
import { listStoredMessages, putStoredMessage, clearStoredMessages } from './services/message-store.js';
|
import { listStoredMessages, putStoredMessage, deleteStoredMessage, clearStoredMessages } from './services/message-store.js';
|
||||||
import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js';
|
import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SHINE_SERVER_HTTP,
|
DEFAULT_SHINE_SERVER_HTTP,
|
||||||
@ -378,15 +378,22 @@ function persistMessageRecord(chatId, row) {
|
|||||||
baseKey: String(row.baseKey || ''),
|
baseKey: String(row.baseKey || ''),
|
||||||
messageType: Number(row.messageType || 0),
|
messageType: Number(row.messageType || 0),
|
||||||
rawBlobB64: String(row.rawBlobB64 || ''),
|
rawBlobB64: String(row.rawBlobB64 || ''),
|
||||||
|
revisionTimeMs: Number(row.revisionTimeMs || 0),
|
||||||
unread: Boolean(row.unread),
|
unread: Boolean(row.unread),
|
||||||
firstTick: Boolean(row.firstTick),
|
firstTick: Boolean(row.firstTick),
|
||||||
secondTick: Boolean(row.secondTick),
|
secondTick: Boolean(row.secondTick),
|
||||||
readReceiptSent: Boolean(row.readReceiptSent),
|
readReceiptSent: Boolean(row.readReceiptSent),
|
||||||
refBaseKey: String(row.refBaseKey || ''),
|
refBaseKey: String(row.refBaseKey || ''),
|
||||||
|
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
||||||
ts: resolvedTs > 0 ? resolvedTs : Date.now(),
|
ts: resolvedTs > 0 ? resolvedTs : Date.now(),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeStoredMessageRecord(messageKey) {
|
||||||
|
if (!messageKey) return;
|
||||||
|
void deleteStoredMessage(messageKey).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export async function hydrateMessagesFromStore() {
|
export async function hydrateMessagesFromStore() {
|
||||||
try {
|
try {
|
||||||
const rows = await listStoredMessages();
|
const rows = await listStoredMessages();
|
||||||
@ -407,11 +414,13 @@ export async function hydrateMessagesFromStore() {
|
|||||||
baseKey: String(row.baseKey || ''),
|
baseKey: String(row.baseKey || ''),
|
||||||
messageType: Number(row.messageType || 0),
|
messageType: Number(row.messageType || 0),
|
||||||
rawBlobB64: String(row.rawBlobB64 || ''),
|
rawBlobB64: String(row.rawBlobB64 || ''),
|
||||||
|
revisionTimeMs: Number(row.revisionTimeMs || 0),
|
||||||
unread: Boolean(row.unread),
|
unread: Boolean(row.unread),
|
||||||
firstTick: Boolean(row.firstTick),
|
firstTick: Boolean(row.firstTick),
|
||||||
secondTick: Boolean(row.secondTick),
|
secondTick: Boolean(row.secondTick),
|
||||||
readReceiptSent: Boolean(row.readReceiptSent),
|
readReceiptSent: Boolean(row.readReceiptSent),
|
||||||
refBaseKey: String(row.refBaseKey || ''),
|
refBaseKey: String(row.refBaseKey || ''),
|
||||||
|
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
||||||
createdAtMs: Number(row.ts || 0),
|
createdAtMs: Number(row.ts || 0),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -565,25 +574,45 @@ export function addSignedMessageToChat({
|
|||||||
unread = false,
|
unread = false,
|
||||||
rawBlobB64 = '',
|
rawBlobB64 = '',
|
||||||
refBaseKey = '',
|
refBaseKey = '',
|
||||||
|
revisionTimeMs = 0,
|
||||||
|
attachments = [],
|
||||||
|
deleted = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const id = String(messageKey || '').trim();
|
const id = String(messageKey || '').trim();
|
||||||
if (!chatId || !id) return false;
|
if (!chatId || !id) return false;
|
||||||
if (state.knownMessageKeys[id]) return false;
|
const list = getChatMessages(chatId);
|
||||||
state.knownMessageKeys[id] = true;
|
const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id);
|
||||||
|
const existing = existingIndex >= 0 ? list[existingIndex] : null;
|
||||||
|
|
||||||
const row = {
|
if (deleted) {
|
||||||
from: from === 'out' ? 'out' : 'in',
|
if (existingIndex >= 0) {
|
||||||
text: String(text || ''),
|
list.splice(existingIndex, 1);
|
||||||
messageKey: id,
|
removeStoredMessageRecord(id);
|
||||||
baseKey: String(baseKey || ''),
|
sortChatMessagesInPlace(chatId);
|
||||||
messageType: Number(messageType || 0),
|
return true;
|
||||||
rawBlobB64: String(rawBlobB64 || ''),
|
}
|
||||||
unread: Boolean(unread),
|
return false;
|
||||||
refBaseKey: String(refBaseKey || ''),
|
}
|
||||||
firstTick: from === 'out',
|
|
||||||
secondTick: false,
|
state.knownMessageKeys[id] = true;
|
||||||
};
|
const row = existing || {};
|
||||||
getChatMessages(chatId).push(row);
|
row.from = from === 'out' ? 'out' : 'in';
|
||||||
|
row.text = String(text || '');
|
||||||
|
row.messageKey = id;
|
||||||
|
row.baseKey = String(baseKey || '');
|
||||||
|
row.messageType = Number(messageType || 0);
|
||||||
|
row.rawBlobB64 = String(rawBlobB64 || '');
|
||||||
|
row.revisionTimeMs = Number(revisionTimeMs || 0);
|
||||||
|
row.attachments = Array.isArray(attachments) ? attachments : [];
|
||||||
|
row.unread = row.from === 'in' ? Boolean(unread) : false;
|
||||||
|
row.refBaseKey = String(refBaseKey || '');
|
||||||
|
row.firstTick = row.from === 'out';
|
||||||
|
row.secondTick = Boolean(existing?.secondTick);
|
||||||
|
row.readReceiptSent = Boolean(existing?.readReceiptSent);
|
||||||
|
|
||||||
|
if (existingIndex < 0) {
|
||||||
|
list.push(row);
|
||||||
|
}
|
||||||
sortChatMessagesInPlace(chatId);
|
sortChatMessagesInPlace(chatId);
|
||||||
persistMessageRecord(chatId, row);
|
persistMessageRecord(chatId, row);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user