НЕ ПРОВЕРЕНО: откат DM-вложений, оставлены ревизии и удаление

This commit is contained in:
AidarKC 2026-06-18 12:24:14 +04:00
parent 92fd315505
commit a95bd245cf
23 changed files with 309 additions and 1267 deletions

View File

@ -61,6 +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`. - Отдельных HTTP endpoints для DM-файлов сейчас нет.
- Классы `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`.

View File

@ -1,8 +1,10 @@
# API для разработчиков: DM, файлы, push и сигналы звонков # API для разработчиков: DM, push и сигналы звонков
Документ описывает публичные операции и endpoints, связанные с личными сообщениями, файлами для DM, WebPush и сигналами звонков. Документ описывает публичные операции, связанные с личными сообщениями, WebPush и сигналами звонков.
Подробная логика DM и бинарного формата: `Dev_Docs/Personal_Messages/README.md`. Подробная логика DM и бинарного формата:
- `Dev_Docs/Personal_Messages/README.md`
## 1. `UpsertPushToken` ## 1. `UpsertPushToken`
@ -70,7 +72,7 @@
- `incomingBlobB64` — блок `type=1` или `type=3` - `incomingBlobB64` — блок `type=1` или `type=3`
- `outgoingBlobB64` — блок `type=2` или `type=4` - `outgoingBlobB64` — блок `type=2` или `type=4`
Для контентных сообщений `type=1/2` внутри base64 лежит новый бинарный формат `SHiNE_DM`. Для контентных сообщений `type=1/2` внутри base64 лежит бинарный формат `SHiNE_DM`.
### Запрос ### Запрос
@ -108,12 +110,31 @@
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64` - `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён - `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM` - `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM`
- `400 / TOO_MANY_ATTACHMENTS` — больше 12 вложений - `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0`
- `400 / ATTACHMENT_NOT_FOUND` — сообщение ссылается на blob, которого нет на сервере
- `404 / USER_NOT_FOUND` — один из логинов не найден - `404 / USER_NOT_FOUND` — один из логинов не найден
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку - `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
## 4. `AckSessionDelivery` ## 4. `ReceiveIncomingMessage`
Принимает только один входящий signed DM-блок.
### Назначение
Используется там, где нужно принять только incoming-вариант сообщения.
### Запрос
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "dm-in-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK"
}
}
```
## 5. `AckSessionDelivery`
Требует авторизации. Подтверждает доставку в текущую сессию. Требует авторизации. Подтверждает доставку в текущую сессию.
@ -129,7 +150,7 @@
} }
``` ```
## 5. Событие `SignedMessageArrived` ## 6. Событие `SignedMessageArrived`
Сервер присылает его по WebSocket в активные сессии адресата. Сервер присылает его по WebSocket в активные сессии адресата.
@ -152,71 +173,18 @@
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока. Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
## 6. HTTP `HEAD /f/<hashB64url>` ## 7. `CallInviteBroadcast`
Проверка, есть ли 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
{
"ok": true,
"hash": "base64url_sha256",
"size": 245120,
"alreadyExists": false
}
```
### Ошибки
- `400 / bad hash`
- `400 / bad size`
- `400 / SIZE_MISMATCH`
- `400 / HASH_MISMATCH`
- `400 / UPLOAD_TOO_LARGE`
- `500 / upload_failed`
## 9. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
## 10. `CallSignalToSession` ## 8. `CallSignalToSession`
Требует авторизации. Шлёт сигнал звонка в конкретную сессию. Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
## 11. Замечания ## 9. Замечания
- Для нового DM-файла сценарий такой: `HEAD /f/<hash>` → при `404` `POST /upload` → затем `SendMessagePair`. - read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2`
- Сервер хранит только последнюю версию контентного сообщения по `messageKey`. - контентные DM `type=1/2` используют `SHiNE_DM`
- Удаление сообщения реализуется новой ревизией с пустым телом и нулём вложений. - сервер хранит только последнюю версию контентного сообщения по `messageKey`
- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0`
- HTTP endpoints для DM-файлов сейчас отсутствуют

View File

@ -1,19 +1,18 @@
# DM-вложения, upload и ревизии сообщений # Ревизии и удаление личных сообщений
- краткое описание фичи: - краткое описание фичи:
Добавлен новый формат контентных DM `SHiNE_DM`, HTTP upload/download ciphertext-файлов, серверный `upsert` последней версии сообщения и UI-скачивание/расшифровка вложений. Добавлен новый формат контентных DM `SHiNE_DM` без вложений, серверный `upsert` последней версии сообщения, редактирование через `revisionTimeMs` и удаление пустой ревизией.
- что проверять: - что проверять:
1. Отправка обычного текста без вложений. 1. Отправка обычного текста без вложений.
2. Отправка сообщения с 1-2 вложениями. 2. Повторная отправка того же логического сообщения с тем же `timeMs + nonce`, но большим `revisionTimeMs`.
3. Повторная отправка сообщения с уже загруженным файлом без повторной записи blob. 3. Обновление текста у уже существующего сообщения в UI без появления нового пузыря.
4. Скачивание вложения из UI и корректная расшифровка файла. 4. Игнорирование более старой ревизии на сервере.
5. Доставка backlog после переподключения сессии. 5. Удаление сообщения пустой ревизией (`attachmentsCount = 0`, `encryptedBodyLen = 0`) и исчезновение из UI.
6. Обновление существующего сообщения той же парой `timeMs+nonce` и большим `revisionTimeMs`. 6. Доставка backlog после переподключения сессии для последней версии сообщения.
7. Удаление сообщения пустой ревизией (`attachments=0`, `encryptedBodyLen=0`) и исчезновение из UI.
- ожидаемый результат: - ожидаемый результат:
Сообщения `type=1/2` приходят в формате `SHiNE_DM`, файлы доступны по `/f/<hash>`, UI показывает вложения кнопками скачивания, сервер хранит только последнюю ревизию по `messageKey`, а пустая ревизия убирает сообщение из интерфейса. Контентные сообщения `type=1/2` приходят в формате `SHiNE_DM`, сервер хранит только последнюю ревизию по `messageKey`, более старая ревизия не перетирает новую, а пустая ревизия убирает сообщение из интерфейса.
- статус: - статус:
pending pending

View File

@ -1,52 +1,62 @@
# Личные сообщения (DM): как это устроено # Личные сообщения (DM)
## Коротко ## Текущее состояние
Личные сообщения в SHiNE теперь работают в двух слоях: Сейчас в проекте реализованы:
- контентные сообщения `type=1/2` идут в новом бинарном формате `SHiNE_DM`; - новый формат контентных личных сообщений `SHiNE_DM`;
- read-receipt `type=3/4` пока остаются на legacy-формате `SHiNE_dm2`, чтобы не ломать текущую механику подтверждения прочтения. - ревизии сообщений через `revisionTimeMs`;
- редактирование сообщения через повторную отправку той же логической пары;
- удаление сообщения через пустую ревизию;
- `upsert` последней версии сообщения на сервере.
Одно логическое сообщение по-прежнему отправляется парой блоков: Сейчас в проекте **не реализованы**:
- `type=1` — входящее сообщение для получателя; - вложения в DM;
- upload/download файлов для DM;
- UI-кнопка прикрепления файла;
- серверное хранение файловых связей для DM.
Черновик будущих вложений вынесен отдельно:
- `Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md`
## Общая схема
Личное сообщение по-прежнему отправляется парой signed-блоков:
- `type=1` — входящий блок для получателя;
- `type=2` — исходящая копия для отправителя. - `type=2` — исходящая копия для отправителя.
Ключ сообщения остаётся прежним: Read-receipt пока остаются в legacy-формате:
- `baseKey = from|to|timeMs|nonce` - `type=3` — входящее подтверждение прочтения;
- `type=4` — исходящая копия подтверждения.
Ключи сообщения:
- `baseKey = fromLogin|toLogin|timeMs|nonce`
- `messageKey = baseKey|messageType` - `messageKey = baseKey|messageType`
Теперь `timeMs + nonce` задаются один раз на всё логическое сообщение и не меняются при редактировании. Логический идентификатор письма задаётся парой:
Для новой версии того же письма используется то же `messageKey`, но большее `revisionTimeMs`.
Сервер хранит только последнюю версию записи по этому `messageKey` через `upsert`.
--- - `timeMs`
- `nonce`
## 1) Общая схема потока Эти поля не меняются при редактировании или удалении. Меняется только:
1. Клиент при необходимости сначала шифрует вложенные файлы локально. - `revisionTimeMs`
2. Клиент считает `SHA-256(ciphertext)` и размер ciphertext. - содержимое `encryptedBody`
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.
--- Сервер хранит только последнюю версию записи для каждого `messageKey`.
## 2) Формат signed DM-блока для контентных сообщений (`SHiNE_DM`) ## Формат контентного DM: `SHiNE_DM`
Префикс: `SHiNE_DM` (ASCII). Префикс бинарного блока:
Далее поля, big-endian: - `SHiNE_DM`
Поля идут в big-endian порядке:
1. `formatVersionMajor` (`u8`) = `1` 1. `formatVersionMajor` (`u8`) = `1`
2. `formatVersionMinor` (`u8`) = `0` 2. `formatVersionMinor` (`u8`) = `0`
@ -56,27 +66,24 @@
6. `nonce` (`u32`) 6. `nonce` (`u32`)
7. `messageType` (`u16`) — только `1` или `2` 7. `messageType` (`u16`) — только `1` или `2`
8. `revisionTimeMs` (`u64`) 8. `revisionTimeMs` (`u64`)
9. `attachmentsCount` (`u8`) — `0..12` 9. `attachmentsCount` (`u8`)
10. `attachments[]`: 10. `encryptedBodyLen` (`u32`)
- `encFileHashSHA256` (`32 bytes`) 11. `encryptedBody` (`bytes`)
- `encFileSize` (`u64`) 12. `signature` (`64 bytes`, Ed25519)
11. `encryptedBodyLen` (`u32`) — сервер сейчас ограничивает до `16384`
12. `encryptedBody` (`bytes`)
13. `signature` (`64 bytes`, Ed25519)
### Важные правила ### Ограничения
- `messageType` не входит в ID логического письма, он только различает сторону пары. - `attachmentsCount` сейчас всегда должен быть `0`
- ID логического письма = `fromLogin + toLogin + timeMs + nonce`. - `encryptedBodyLen` сейчас ограничен сервером до `16384` байт
- У оригинала `revisionTimeMs = 0`. - `revisionTimeMs` не может быть отрицательным
- Для редактирования и удаления `timeMs`/`nonce` не меняются, меняется только `revisionTimeMs`.
- Чем больше `revisionTimeMs`, тем новее версия.
--- Если приходит `attachmentsCount != 0`, сервер отклоняет такой DM как:
## 3) Legacy read-receipt (`SHiNE_dm2`) - `ATTACHMENTS_DISABLED`
Пока только блоки `type=3/4` остаются в старом формате `SHiNE_dm2`: ## Legacy read-receipt: `SHiNE_dm2`
Подтверждения прочтения `type=3/4` пока используют старый контейнер `SHiNE_dm2`:
1. `toLoginLen` (`u8`) + `toLogin` 1. `toLoginLen` (`u8`) + `toLogin`
2. `fromLoginLen` (`u8`) + `fromLogin` 2. `fromLoginLen` (`u8`) + `fromLogin`
@ -87,278 +94,107 @@
7. `payloadBytes` 7. `payloadBytes`
8. `signature` 8. `signature`
Это временный совместимый слой. Контентные сообщения `1/2` в `SHiNE_dm2` больше не считаются актуальным форматом. ## Редактирование
--- Редактирование делается новой отправкой той же логической пары сообщения:
## 4) Внешний контейнер вложений - `timeMs` и `nonce` остаются теми же;
- `messageType` остаётся `1/2`;
- `revisionTimeMs` становится больше;
- `encryptedBody` содержит новую версию текста.
Внешняя часть сообщения содержит только технические ссылки на blob-файлы: Если на сервер приходит более старая ревизия, она игнорируется.
- `attachmentsCount` Если приходит та же ревизия и тот же бинарный блок, сервер тоже её не применяет повторно.
- список `encFileHashSHA256 + encFileSize`
Во внешней части **нет**: ## Удаление
- имени файла; Удаление личного сообщения делается как новая ревизия того же сообщения:
- MIME;
- пароля/ключа;
- nonce/iv;
- `origHash`.
Это позволяет серверу хранить и отдавать blob, не зная человеческих метаданных вложения. - `timeMs` и `nonce` остаются прежними;
- `revisionTimeMs` увеличивается;
- `attachmentsCount = 0`;
- `encryptedBodyLen = 0`;
- `encryptedBody` пустой.
--- В UI такое сообщение не показывается.
## 5) Внутреннее содержимое `encryptedBody` На сервере это не отдельный тип сообщения, а просто последняя пустая ревизия того же `messageKey`.
Сейчас `encryptedBody` содержит текстовый контейнер сообщения, который UI интерпретирует как текст + встроенные метки файлов. ## Поведение сервера
Формат маркера: Для контентных DM сервер:
```text 1. принимает пару signed-блоков `type=1/2`;
<<file:file-format(1.0):type|fileName|origSize|origHashB64u|encHashB64u|encSize|keyB64u|nonceB64u>> 2. валидирует формат, подпись и совпадение ключевых полей пары;
``` 3. проверяет, что для обеих сторон пары совпадают:
- `fromLogin`
- `toLogin`
- `timeMs`
- `nonce`
- `revisionTimeMs`
- `encryptedBody`
4. делает `upsert` последней версии в `signed_messages_v2`;
5. сбрасывает pending-доставку по сессиям для новой ревизии;
6. рассылает актуальную версию адресатам через `SignedMessageArrived`.
Где: История старых ревизий сейчас не хранится отдельно: в таблице остаётся только последняя версия по каждому `messageKey`.
- `type` = `photo` / `video` / `audio` / `file` ## Хранение в БД
- `fileName` — настоящее имя файла, без символов `|`, `:`, `>`, переводов строки
- `origSize` — размер исходного файла
- `origHashB64u``SHA-256` исходного файла в `base64url`
- `encHashB64u``SHA-256` ciphertext-файла в `base64url`
- `encSize` — размер ciphertext-файла
- `keyB64u` — симметричный ключ расшифровки файла
- `nonceB64u` — nonce/iv для расшифровки файла
UI: Основная таблица:
- показывает обычный текст без маркеров; - `signed_messages_v2`
- заменяет маркеры карточками скачивания;
- скачивает ciphertext по `/f/<encHashB64u>`;
- локально расшифровывает файл и отдаёт пользователю оригинал.
--- Для контентных DM в ней используются:
## 6) RPC и события - `message_key`
### `SendMessagePair` / `ReceiveOutcomingMessage`
Запрос не меняется: сервер по-прежнему принимает пару `incomingBlobB64` + `outgoingBlobB64`.
```json
{
"op": "SendMessagePair",
"requestId": "req-1",
"payload": {
"incomingBlobB64": "<base64 signed block type 1 or 3>",
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
}
}
```
Успешный ответ:
```json
{
"op": "SendMessagePair",
"requestId": "req-1",
"status": 200,
"ok": true,
"payload": {
"baseKey": "from|to|time|nonce",
"incomingKey": "from|to|time|nonce|1",
"outgoingKey": "from|to|time|nonce|2",
"deliveredWsSessions": 2,
"deliveredWebPushSessions": 1
}
}
```
### `SignedMessageArrived`
Событие в сессию всё ещё содержит:
- `messageKey`, `baseKey`
- `fromLogin`, `toLogin`, `targetLogin`
- `messageType`, `timeMs`, `nonce`
- `blobB64`
- `backlog`
Новая версия того же письма приходит с тем же `messageKey`, но с более новым `revisionTimeMs` внутри бинарного блока.
### `AckSessionDelivery`
Формат не меняется.
---
## 7) HTTP endpoints для файлов
### `HEAD /f/<hashB64url>`
Проверяет наличие ciphertext-файла.
- `200` — файл есть;
- `404` — файла нет.
### `GET /f/<hashB64url>`
Отдаёт ciphertext-файл как `application/octet-stream`.
Сейчас доступ публичный, без проверки логина.
### `POST /upload?hash=<hashB64url>&size=<bytes>`
Принимает raw bytes ciphertext-файла.
Сервер:
- пересчитывает `SHA-256`;
- проверяет размер;
- сохраняет blob в папку `f/` под именем `<hashB64url>`;
- если файл уже существует, повторно не пишет его на диск;
- регистрирует строку в `dm_files`.
---
## 8) Хранение на сервере (SQLite)
### `signed_messages_v2`
Основная таблица текущих DM:
- `message_key` (PK)
- `base_key` - `base_key`
- `target_login` - `target_login`
- `from_login`, `to_login` - `from_login`
- `time_ms`, `nonce` - `to_login`
- `time_ms`
- `nonce`
- `message_type` - `message_type`
- `revision_time_ms` - `revision_time_ms`
- `raw_block` - `raw_block`
- `created_at_ms` - `created_at_ms`
- `source_api`, `origin_session_id`
- `receipt_ref_base_key`, `receipt_ref_type`
Для контентных сообщений сервер делает `upsert` по `message_key`, поэтому в таблице всегда лежит только последняя версия конкретной стороны пары. Отдельных таблиц файлов для DM сейчас нет.
### `signed_message_session_delivery` ## События и доставка
Хранит pending/ack по сессиям: Запрос на отправку по WebSocket остаётся прежним:
- `(message_key, session_id)` — PK - `SendMessagePair`
- `delivered` - `ReceiveOutcomingMessage` как алиас
- `delivered_at_ms`
- `created_at_ms`
При новой ревизии того же сообщения сервер сбрасывает доставку этого `message_key` обратно в `delivered=0`. Клиент отправляет:
### `dm_files` - `incomingBlobB64`
- `outgoingBlobB64`
- `file_hash_sha256` (`BLOB`, PK) Событие в активные сессии:
- `file_size`
- `ref_count`
### `dm_message_file_links` - `SignedMessageArrived`
- `message_key` Если пришла новая ревизия того же сообщения, `messageKey` остаётся прежним, а внутри `blobB64` будет более новый `revisionTimeMs`.
- `login`
- `file_hash_sha256`
По этой таблице сервер понимает, какие файловые ссылки нужно снять при редактировании/удалении сообщения. Подтверждение доставки в сессию:
`ref_count` считается по числу логических message-side ссылок: - `AckSessionDelivery`
- у одного письма с вложением обычно две ссылки: ## Правила UI
- получатель (`type=1`)
- отправитель (`type=2`)
Файлы с `ref_count = 0` на диске не удаляются автоматически. UI сейчас работает так:
--- - показывает только текст `encryptedBody`;
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
- не показывает удалённые сообщения;
- не показывает и не принимает вложения.
## 9) Доставка, редактирование и удаление ## Что обязательно помнить
### Новое сообщение - вложения в DM сейчас отключены на уровне протокола и UI;
- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны;
- `revisionTimeMs = 0` - если позже вложения вернутся, их формат и серверная логика могут быть другими.
- создаётся пара `1/2`
- сервер делает upsert, создаёт файловые связи и доставляет событие
### Редактирование
- используется тот же `timeMs + nonce`
- отправляется новая пара `1/2`
- `revisionTimeMs` больше
- может измениться и `encryptedBody`, и список вложений
### Удаление
- тот же `timeMs + nonce`
- новая ревизия с большим `revisionTimeMs`
- `attachmentsCount = 0`
- `encryptedBodyLen = 0`
UI такое сообщение полностью убирает из чата.
---
## 10) Логика UI-клиента
### Хранилище сообщений
- in-memory: `state.chats[chatId]`
- IndexedDB: `shine-ui-messages-v1`, store `messages`, key = `messageKey`
Так как `messageKey` теперь стабилен для всех ревизий одного message-side, клиент делает не append, а update той же записи.
### Поведение UI
- входящая новая ревизия с тем же `messageKey` обновляет существующий пузырь;
- пустая ревизия (`attachments=0`, `encryptedBodyLen=0`) удаляет пузырь из IndexedDB и из in-memory;
- вложения показываются кнопками скачивания;
- ciphertext скачивается с HTTP, затем расшифровывается локально в браузере.
---
## 11) Межсерверная синхронизация
Межсерверный relay DM пока не реализован.
Когда он появится, серверы должны будут:
- синхронизировать только актуальную ревизию `message_key`;
- применять правило "больший `revisionTimeMs` побеждает";
- одинаково пересчитывать файловые связи и `ref_count`.
---
## 12) Инварианты
1. Пара `1/2` должна применяться атомарно.
2. `baseKey/messageKey` формат не меняется.
3. Для одного `messageKey` в `signed_messages_v2` хранится только последняя версия.
4. Все изменения DM и файловых связей применяются одной транзакцией.
5. Для контентных сообщений обязательна предварительная загрузка blob-файлов.
6. Для одного сообщения разрешено не больше `12` вложений.
7. UI не показывает удалённые сообщения.
---
## 13) Ключевые файлы реализации
- UI:
- `shine-UI/js/services/auth-service.js`
- `shine-UI/js/services/crypto-utils.js`
- `shine-UI/js/state.js`
- `shine-UI/js/app.js`
- `shine-UI/js/pages/chat-view.js`
- Server:
- `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessageBlock.java`
- `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesCore.java`
- `SHiNE-server/shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
- `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
- `SHiNE-server/shine-server-net-protocol/.../messages/DmFileStorage.java`
- `SHiNE-server/shine-server-db/.../dao/SignedMessagesV2DAO.java`
- `SHiNE-server/src/main/java/server/files/DmFilesServlet.java`
- `SHiNE-server/src/main/java/server/files/DmUploadServlet.java`

View File

@ -0,0 +1,73 @@
# Черновик будущих вложений в DM
## Важно
Этот документ описывает только ранний черновик идеи.
Сейчас в проекте **нет** поддержки вложений в личных сообщениях:
- в реализованном формате `SHiNE_DM` поле `attachmentsCount` пока всегда должно быть `0`;
- UI не показывает кнопку прикрепления файлов;
- сервер не принимает upload файлов для DM;
- сервер не раздаёт специальные DM-файлы по отдельным endpoints;
- сервер не хранит отдельные файловые связи для личных сообщений.
Этот документ нужен только для того, чтобы рядом с актуальной документацией было явно видно:
- какие идеи обсуждались;
- что это **не реализовано**;
- что формат, хранение и способ загрузки потом могут сильно измениться.
## Что обсуждалось
Рассматривался такой общий подход:
- у контентного DM есть внешний список вложений;
- во внешнем формате лежат только технические данные;
- человекочитаемые данные о файле живут внутри зашифрованного тела сообщения;
- один и тот же blob-файл теоретически мог бы переиспользоваться в нескольких сообщениях.
Черновой вариант внешнего списка:
- `attachmentsCount`
- далее для каждого вложения:
- `encFileHashSHA256` (`32 bytes`)
- `encFileSize` (`u64`)
Черновой вариант внутреннего маркера в тексте:
```text
<<file:file-format(1.0):type|fileName|origSize|origHashB64u|encHashB64u|encSize|keyB64u|nonceB64u>>
```
Где обсуждались поля:
- `type`
- `fileName`
- `origSize`
- `origHashB64u`
- `encHashB64u`
- `encSize`
- `keyB64u`
- `nonceB64u`
## Что может измениться
В будущем могут измениться любые части идеи:
- сам бинарный формат;
- способ привязки файлов к сообщению;
- момент загрузки файла относительно отправки сообщения;
- серверное хранение blob-файлов;
- права доступа к скачиванию;
- способ рендера вложения в UI.
Именно поэтому этот файл не надо воспринимать как актуальную спецификацию.
## Источник истины на сейчас
Актуальное состояние личных сообщений описано только в:
- `Dev_Docs/Personal_Messages/README.md`
Если между этим черновиком и основным README есть расхождение, верным считается `README.md`.

View File

@ -640,36 +640,6 @@ 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)

View File

@ -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 = 6; private static final int LATEST_SCHEMA_VERSION = 7;
private final String jdbcUrl; private final String jdbcUrl;
@ -89,6 +89,7 @@ public final class SqliteDbController {
case 4 -> migrateToV4(); case 4 -> migrateToV4();
case 5 -> migrateToV5(); case 5 -> migrateToV5();
case 6 -> migrateToV6(); case 6 -> migrateToV6();
case 7 -> migrateToV7();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
} }
} }
@ -216,7 +217,6 @@ public final class SqliteDbController {
c.setAutoCommit(false); c.setAutoCommit(false);
try { try {
ensureSignedMessagesRevisionColumn(c, st); ensureSignedMessagesRevisionColumn(c, st);
ensureDmFileTables(st);
setSchemaVersion(c, 6); setSchemaVersion(c, 6);
c.commit(); c.commit();
} catch (Exception e) { } catch (Exception e) {
@ -230,6 +230,25 @@ public final class SqliteDbController {
} }
} }
private void migrateToV7() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
dropDmFileTables(st);
setSchemaVersion(c, 7);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v7 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v7 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 (
@ -357,36 +376,11 @@ public final class SqliteDbController {
} }
} }
private static void ensureDmFileTables(Statement st) throws SQLException { private static void dropDmFileTables(Statement st) throws SQLException {
st.executeUpdate(""" st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login");
CREATE TABLE IF NOT EXISTS dm_files ( st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
file_hash_sha256 BLOB NOT NULL PRIMARY KEY, st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links");
file_size INTEGER NOT NULL, st.executeUpdate("DROP TABLE IF EXISTS dm_files");
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 {

View File

@ -1,7 +1,6 @@
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;
@ -10,9 +9,7 @@ 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.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;
@ -45,9 +42,6 @@ public final class SignedMessagesV2DAO {
} }
} }
/**
* Атомарная вставка пары блоков legacy/read-receipt: либо вставляются оба, либо не вставляется ни один.
*/
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()) {
boolean prevAutoCommit = c.getAutoCommit(); boolean prevAutoCommit = c.getAutoCommit();
@ -73,25 +67,11 @@ public final class SignedMessagesV2DAO {
} }
} }
/** public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
* Атомарный upsert пары контентных DM с полной заменой файловых связей.
* Возвращает true, только если ревизия применена. Более старая или идентичная ревизия игнорируется.
*/
public boolean upsertContentPairReplaceFiles(
SignedMessageV2Entry incoming,
List<DmFileRef> incomingFiles,
SignedMessageV2Entry outgoing,
List<DmFileRef> outgoingFiles
) throws Exception {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit(); boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false); c.setAutoCommit(false);
try { try {
if (!allFilesExist(c, incomingFiles) || !allFilesExist(c, outgoingFiles)) {
c.rollback();
return false;
}
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey()); Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey()); Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey());
long currentRevision = Math.max( long currentRevision = Math.max(
@ -112,12 +92,8 @@ public final class SignedMessagesV2DAO {
return false; return false;
} }
replaceFileLinks(c, incoming.getMessageKey(), incoming.getTargetLogin(), incomingFiles);
replaceFileLinks(c, outgoing.getMessageKey(), outgoing.getTargetLogin(), outgoingFiles);
upsertMessage(c, incoming); upsertMessage(c, incoming);
upsertMessage(c, outgoing); upsertMessage(c, outgoing);
resetDeliveryRows(c, incoming.getMessageKey()); resetDeliveryRows(c, incoming.getMessageKey());
resetDeliveryRows(c, outgoing.getMessageKey()); resetDeliveryRows(c, outgoing.getMessageKey());
@ -132,40 +108,6 @@ public final class SignedMessagesV2DAO {
} }
} }
public boolean fileExists(byte[] fileHash, long fileSize) throws Exception {
try (Connection c = db.getConnection()) {
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 {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
String sql = """ String sql = """
@ -299,106 +241,6 @@ public final class SignedMessagesV2DAO {
} }
} }
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 { private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException {
String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {

View File

@ -1,19 +0,0 @@
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; }
}

View File

@ -1,122 +0,0 @@
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) {
}
}

View File

@ -8,6 +8,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_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.dao.SignedMessagesV2DAO;
import shine.db.entities.SignedMessageV2Entry; import shine.db.entities.SignedMessageV2Entry;
public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
@ -43,7 +44,7 @@ public class Net_ReceiveIncomingMessage_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 inserted = SignedMessagesCore.saveIfAbsent(entry); boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (inserted) { if (inserted) {
counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null); counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);

View File

@ -8,12 +8,9 @@ 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 {
@ -35,13 +32,9 @@ 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) int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED;
? 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, "Сообщение не прошло проверку");
} }
@ -58,10 +51,8 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
boolean pairInserted; boolean pairInserted;
if (incoming.isContentType()) { if (incoming.isContentType()) {
List<DmFileRef> incomingFiles = SignedMessagesCore.attachmentRefs(incoming); pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair(
List<DmFileRef> outgoingFiles = SignedMessagesCore.attachmentRefs(outgoing); incomingEntry, outgoingEntry
pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPairReplaceFiles(
incomingEntry, incomingFiles, outgoingEntry, outgoingFiles
); );
} else { } else {
pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);

View File

@ -1,14 +1,9 @@
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[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII); static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
@ -17,7 +12,6 @@ final class SignedMessageBlock {
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;
@ -29,7 +23,6 @@ final class SignedMessageBlock {
final int formatVersionMinor; final int formatVersionMinor;
final byte[] payloadBytes; final byte[] payloadBytes;
final byte[] encryptedBodyBytes; 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;
@ -46,7 +39,6 @@ final class SignedMessageBlock {
int formatVersionMinor, int formatVersionMinor,
byte[] payloadBytes, byte[] payloadBytes,
byte[] encryptedBodyBytes, byte[] encryptedBodyBytes,
List<DmFileRef> attachments,
byte[] signedBody, byte[] signedBody,
byte[] signature64, byte[] signature64,
byte[] rawPacket, byte[] rawPacket,
@ -62,7 +54,6 @@ final class SignedMessageBlock {
this.formatVersionMinor = formatVersionMinor; this.formatVersionMinor = formatVersionMinor;
this.payloadBytes = payloadBytes; this.payloadBytes = payloadBytes;
this.encryptedBodyBytes = encryptedBodyBytes; this.encryptedBodyBytes = encryptedBodyBytes;
this.attachments = attachments;
this.signedBody = signedBody; this.signedBody = signedBody;
this.signature64 = signature64; this.signature64 = signature64;
this.rawPacket = rawPacket; this.rawPacket = rawPacket;
@ -125,7 +116,6 @@ final class SignedMessageBlock {
0, 0,
payload, payload,
payload, payload,
List.of(),
signedBody, signedBody,
signature64, signature64,
raw, raw,
@ -157,19 +147,8 @@ final class SignedMessageBlock {
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
int attachmentsCount = Byte.toUnsignedInt(bb.get()); int attachmentsCount = Byte.toUnsignedInt(bb.get());
if (attachmentsCount > MAX_ATTACHMENTS) { if (attachmentsCount != 0) {
throw new IllegalArgumentException("TOO_MANY_ATTACHMENTS"); throw new IllegalArgumentException("ATTACHMENTS_DISABLED");
}
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) { if (bb.remaining() < 4 + 64) {
@ -199,7 +178,6 @@ final class SignedMessageBlock {
minor, minor,
encryptedBody, encryptedBody,
encryptedBody, encryptedBody,
Collections.unmodifiableList(attachments),
signedBody, signedBody,
signature64, signature64,
raw, raw,
@ -224,7 +202,7 @@ final class SignedMessageBlock {
} }
boolean isDeletedContent() { boolean isDeletedContent() {
return isContentType() && !legacyFormat && attachments.isEmpty() && encryptedBodyBytes.length == 0; return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0;
} }
String targetLogin() { String targetLogin() {

View File

@ -1,18 +1,12 @@
package server.logic.ws_protocol.JSON.messages; package server.logic.ws_protocol.JSON.messages;
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.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_ENCRYPTED_BODY_BYTES = 16384; private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
@ -24,8 +18,24 @@ final class SignedMessagesCore {
byte[] raw = Base64.getDecoder().decode(blobB64.trim()); byte[] raw = Base64.getDecoder().decode(blobB64.trim());
return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES); return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
String code = e.getMessage();
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
} }
switch (code) {
case "ATTACHMENTS_DISABLED",
"BAD_PREFIX",
"BAD_LEN",
"BAD_TO_LOGIN",
"BAD_FROM_LOGIN",
"BAD_TIME",
"BAD_MESSAGE_TYPE",
"BAD_MESSAGE_LEN",
"BAD_FORMAT_VERSION",
"BAD_REVISION_TIME" -> throw e;
default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
}
}
} }
static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception { static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception {
@ -71,33 +81,6 @@ final class SignedMessagesCore {
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) { || incoming.formatVersionMinor != outgoing.formatVersionMinor) {
throw new IllegalArgumentException("BAD_FORMAT_VERSION"); 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) { if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) {
throw new IllegalArgumentException("BAD_MESSAGE_LEN"); throw new IllegalArgumentException("BAD_MESSAGE_LEN");
@ -109,15 +92,6 @@ final class SignedMessagesCore {
} }
} }
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");
}
}
}
static SignedMessageV2Entry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) { static SignedMessageV2Entry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) {
String baseKey = SignedMessageKeys.baseKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce); String baseKey = SignedMessageKeys.baseKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce);
String messageKey = SignedMessageKeys.messageKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce, block.messageType); String messageKey = SignedMessageKeys.messageKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce, block.messageType);
@ -147,18 +121,10 @@ final class SignedMessagesCore {
return entry; return entry;
} }
static List<DmFileRef> attachmentRefs(SignedMessageBlock block) {
return new ArrayList<>(block.attachments);
}
static String previewTextForPush(SignedMessageBlock block) { static String previewTextForPush(SignedMessageBlock block) {
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) { if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
return ""; return "";
} }
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8); return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
} }
static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
}
} }

View File

@ -1,66 +0,0 @@
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");
}
}

View File

@ -1,69 +0,0 @@
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");
}
}

View File

@ -1,14 +1,11 @@
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;
@ -64,8 +61,6 @@ 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) -> {

View File

@ -1,7 +1,5 @@
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

View File

@ -1,2 +1,2 @@
client.version=1.2.208 client.version=1.2.209
server.version=1.2.197 server.version=1.2.198

View File

@ -917,7 +917,6 @@ async function init() {
unread: isIncomingForCurrent, unread: isIncomingForCurrent,
rawBlobB64: blobB64, rawBlobB64: blobB64,
revisionTimeMs: Number(parsed.revisionTimeMs || 0), revisionTimeMs: Number(parsed.revisionTimeMs || 0),
attachments: Array.isArray(parsed.bodyAttachments) ? parsed.bodyAttachments : [],
deleted: Boolean(parsed.deleted), deleted: Boolean(parsed.deleted),
}); });
if (added) { if (added) {

View File

@ -166,35 +166,6 @@ 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 = () => {
@ -228,35 +199,10 @@ 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 plainText = messagePlainText(msg);
if (plainText) {
const textNode = document.createElement('div'); const textNode = document.createElement('div');
textNode.className = 'bubble-text'; textNode.className = 'bubble-text';
textNode.textContent = plainText; textNode.textContent = msg.text || '';
bubble.append(textNode); 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';
@ -388,64 +334,17 @@ 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 = `
<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> <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 && pendingFiles.length === 0) return; if (!text) return;
const tempLabel = text || `Файлы: ${pendingFiles.length}`; const tempId = addOutgoingPendingMessage(chatId, text);
const tempId = addOutgoingPendingMessage(chatId, tempLabel);
renderLog(log, chatId, { renderLog(log, chatId, {
onOpenActions: (msg) => openMessageActionsModal({ onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '', messageText: msg?.text || '',
@ -460,23 +359,12 @@ export function render({ navigate, route }) {
}); });
try { try {
const filesToSend = pendingFiles.slice(0, 12); const result = await authService.sendDirectMessage({
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: messagePayloadText, text,
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 || '',
@ -494,7 +382,6 @@ export function render({ navigate, route }) {
unread: false, unread: false,
rawBlobB64: result.localOutgoingBlobB64, rawBlobB64: result.localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0), revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
attachments: Array.isArray(parsed?.bodyAttachments) ? parsed.bodyAttachments : [],
deleted: Boolean(parsed?.deleted), deleted: Boolean(parsed?.deleted),
}); });
} catch { } catch {
@ -551,15 +438,6 @@ 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', () => {
@ -584,7 +462,7 @@ export function render({ navigate, route }) {
} }
event.preventDefault(); event.preventDefault();
const text = String(input.value || '').trim(); const text = String(input.value || '').trim();
if (!text && pendingFiles.length === 0) return; if (!text) return;
input.value = ''; input.value = '';
autoResizeComposer(input); autoResizeComposer(input);
await sendTextMessage(text); await sendTextMessage(text);
@ -608,7 +486,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 && pendingFiles.length === 0) return; if (!text) return;
input.value = ''; input.value = '';
autoResizeComposer(input); autoResizeComposer(input);
await sendTextMessage(text); await sendTextMessage(text);

View File

@ -1,19 +1,14 @@
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,
@ -212,7 +207,6 @@ 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_MAJOR = 1;
const DM_FORMAT_VERSION_MINOR = 0; const DM_FORMAT_VERSION_MINOR = 0;
const DM_MAX_ATTACHMENTS = 12;
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384; const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
function ensureAsciiBytes(value, field, min = 1, max = 60) { function ensureAsciiBytes(value, field, min = 1, max = 60) {
@ -248,51 +242,6 @@ 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;
@ -348,17 +297,7 @@ function parseSignedMessageBlockBytes(bytes) {
const messageType = readU16(); const messageType = readU16();
const revisionTimeMs = readU64(); const revisionTimeMs = readU64();
const attachmentsCount = readU8(); const attachmentsCount = readU8();
if (attachmentsCount > DM_MAX_ATTACHMENTS) throw new Error('TOO_MANY_ATTACHMENTS'); if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED');
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 encryptedBodyLen = readU32();
const encryptedBodyBytes = read(encryptedBodyLen); const encryptedBodyBytes = read(encryptedBodyLen);
const signatureBytes = read(64); const signatureBytes = read(64);
@ -367,7 +306,6 @@ function parseSignedMessageBlockBytes(bytes) {
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }); const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
const bodyText = new TextDecoder().decode(encryptedBodyBytes); const bodyText = new TextDecoder().decode(encryptedBodyBytes);
const parsedBody = parseDmTextAttachments(bodyText);
return { return {
toLogin, toLogin,
fromLogin, fromLogin,
@ -377,11 +315,10 @@ function parseSignedMessageBlockBytes(bytes) {
revisionTimeMs, revisionTimeMs,
formatVersionMajor, formatVersionMajor,
formatVersionMinor, formatVersionMinor,
attachments,
encryptedBodyBytes, encryptedBodyBytes,
encryptedBodyText: bodyText, encryptedBodyText: bodyText,
text: parsedBody.text, text: bodyText,
bodyAttachments: parsedBody.attachments, bodyAttachments: [],
payloadBytes: encryptedBodyBytes, payloadBytes: encryptedBodyBytes,
signatureBytes, signatureBytes,
signedBody, signedBody,
@ -418,7 +355,6 @@ function parseSignedMessageBlockBytes(bytes) {
nonce, nonce,
messageType, messageType,
revisionTimeMs: 0, revisionTimeMs: 0,
attachments: [],
encryptedBodyBytes: payloadBytes, encryptedBodyBytes: payloadBytes,
encryptedBodyText: new TextDecoder().decode(payloadBytes), encryptedBodyText: new TextDecoder().decode(payloadBytes),
text: new TextDecoder().decode(payloadBytes), text: new TextDecoder().decode(payloadBytes),
@ -1981,7 +1917,6 @@ export class AuthService {
nonce, nonce,
messageType, messageType,
revisionTimeMs = 0, revisionTimeMs = 0,
attachments = [],
encryptedBodyBytes = new Uint8Array(0), encryptedBodyBytes = new Uint8Array(0),
}) { }) {
const cleanFromLogin = String(login || '').trim(); const cleanFromLogin = String(login || '').trim();
@ -1999,7 +1934,6 @@ export class AuthService {
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin'); const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin'); const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
const attachmentsSection = buildDmAttachmentsSectionBytes(attachments);
const preimage = concatBytes( const preimage = concatBytes(
DM_PREFIX_V1, DM_PREFIX_V1,
uint8Bytes(DM_FORMAT_VERSION_MAJOR), uint8Bytes(DM_FORMAT_VERSION_MAJOR),
@ -2010,7 +1944,7 @@ export class AuthService {
uint32Bytes(nonce), uint32Bytes(nonce),
uint16Bytes(messageType), uint16Bytes(messageType),
uint64Bytes(revisionTimeMs), uint64Bytes(revisionTimeMs),
attachmentsSection, uint8Bytes(0),
uint32Bytes(encryptedBodyBytes.length), uint32Bytes(encryptedBodyBytes.length),
encryptedBodyBytes, encryptedBodyBytes,
); );
@ -2073,25 +2007,13 @@ 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 || '');
const normalizedAttachments = Array.isArray(attachments) ? attachments : [];
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
if (!cleanText && normalizedAttachments.length === 0) throw new Error('Пустое сообщение'); if (!cleanText) throw new Error('Пустое сообщение');
const timeMs = Date.now();
const nonce = Math.floor(Math.random() * 0x100000000);
const encryptedBodyBytes = utf8Bytes(cleanText); const encryptedBodyBytes = utf8Bytes(cleanText);
const incomingBlock = await this.buildSignedDmV1Block({ const incomingBlock = await this.buildSignedDmV1Block({
@ -2101,8 +2023,6 @@ export class AuthService {
timeMs, timeMs,
nonce, nonce,
messageType: DM2_TYPE_INCOMING, messageType: DM2_TYPE_INCOMING,
revisionTimeMs,
attachments: normalizedAttachments,
encryptedBodyBytes, encryptedBodyBytes,
}); });
const outgoingBlock = await this.buildSignedDmV1Block({ const outgoingBlock = await this.buildSignedDmV1Block({
@ -2112,8 +2032,6 @@ export class AuthService {
timeMs, timeMs,
nonce, nonce,
messageType: DM2_TYPE_OUTGOING_COPY, messageType: DM2_TYPE_OUTGOING_COPY,
revisionTimeMs,
attachments: normalizedAttachments,
encryptedBodyBytes, encryptedBodyBytes,
}); });
@ -2164,90 +2082,6 @@ 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 });

View File

@ -384,7 +384,6 @@ function persistMessageRecord(chatId, row) {
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(() => {});
} }
@ -420,7 +419,6 @@ export async function hydrateMessagesFromStore() {
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),
}); });
}); });
@ -575,7 +573,6 @@ export function addSignedMessageToChat({
rawBlobB64 = '', rawBlobB64 = '',
refBaseKey = '', refBaseKey = '',
revisionTimeMs = 0, revisionTimeMs = 0,
attachments = [],
deleted = false, deleted = false,
} = {}) { } = {}) {
const id = String(messageKey || '').trim(); const id = String(messageKey || '').trim();
@ -603,7 +600,6 @@ export function addSignedMessageToChat({
row.messageType = Number(messageType || 0); row.messageType = Number(messageType || 0);
row.rawBlobB64 = String(rawBlobB64 || ''); row.rawBlobB64 = String(rawBlobB64 || '');
row.revisionTimeMs = Number(revisionTimeMs || 0); row.revisionTimeMs = Number(revisionTimeMs || 0);
row.attachments = Array.isArray(attachments) ? attachments : [];
row.unread = row.from === 'in' ? Boolean(unread) : false; row.unread = row.from === 'in' ? Boolean(unread) : false;
row.refBaseKey = String(refBaseKey || ''); row.refBaseKey = String(refBaseKey || '');
row.firstTick = row.from === 'out'; row.firstTick = row.from === 'out';