From a95bd245cfc112e1b29191d9acda11ba03a7a97c74e98abd033a3de799210497 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 18 Jun 2026 12:24:14 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=95=20=D0=9F=D0=A0=D0=9E=D0=92=D0=95?= =?UTF-8?q?=D0=A0=D0=95=D0=9D=D0=9E:=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=20DM?= =?UTF-8?q?-=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D0=B8=D0=B7=D0=B8=D0=B8=20=D0=B8=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/09_Operations_Index.md | 2 +- .../API/12_Direct_Messages_Push_Calls_API.md | 106 ++--- .../2026-06-17_1735_dm_files_and_revisions.md | 17 +- Dev_Docs/Personal_Messages/README.md | 402 ++++++------------ .../Черновик_будущих_DM_вложений.md | 73 ++++ .../java/shine/db/DatabaseInitializer.java | 30 -- .../java/shine/db/SqliteDbController.java | 58 ++- .../shine/db/dao/SignedMessagesV2DAO.java | 160 +------ .../java/shine/db/entities/DmFileRef.java | 19 - .../JSON/messages/DmFileStorage.java | 122 ------ .../Net_ReceiveIncomingMessage_Handler.java | 3 +- .../messages/Net_SendMessagePair_Handler.java | 15 +- .../JSON/messages/SignedMessageBlock.java | 28 +- .../JSON/messages/SignedMessagesCore.java | 68 +-- .../java/server/files/DmFilesServlet.java | 66 --- .../java/server/files/DmUploadServlet.java | 69 --- .../src/main/java/server/ws/WsServer.java | 5 - .../src/main/resources/application.properties | 2 - VERSION.properties | 4 +- shine-UI/js/app.js | 1 - shine-UI/js/pages/chat-view.js | 142 +------ shine-UI/js/services/auth-service.js | 180 +------- shine-UI/js/state.js | 4 - 23 files changed, 309 insertions(+), 1267 deletions(-) create mode 100644 Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmFileRef.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmFileStorage.java delete mode 100644 SHiNE-server/src/main/java/server/files/DmFilesServlet.java delete mode 100644 SHiNE-server/src/main/java/server/files/DmUploadServlet.java diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 1241063..5e13779 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -61,6 +61,6 @@ ## Важные замечания - `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`. -- HTTP endpoints для DM-файлов (`HEAD/GET /f/` и `POST /upload`) не являются WebSocket `op`, поэтому в таблицу выше не входят; они описаны в `12_Direct_Messages_Push_Calls_API.md`. +- Отдельных HTTP endpoints для DM-файлов сейчас нет. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. diff --git a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md index d7f2bc2..0cbac5e 100644 --- a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md @@ -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` @@ -70,7 +72,7 @@ - `incomingBlobB64` — блок `type=1` или `type=3` - `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_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён - `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM` -- `400 / TOO_MANY_ATTACHMENTS` — больше 12 вложений -- `400 / ATTACHMENT_NOT_FOUND` — сообщение ссылается на blob, которого нет на сервере +- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0` - `404 / USER_NOT_FOUND` — один из логинов не найден - `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 в активные сессии адресата. @@ -152,71 +173,18 @@ Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока. -## 6. HTTP `HEAD /f/` - -Проверка, есть ли ciphertext-файл на сервере. - -### Ответы - -- `200` — файл существует -- `404` — файла нет - -## 7. HTTP `GET /f/` - -Отдаёт ciphertext-файл. - -### Особенности - -- `Content-Type: application/octet-stream` -- файл сейчас доступен публично -- имя файла на диске и в URL — `base64url(SHA-256(ciphertext))` - -## 8. HTTP `POST /upload?hash=&size=` - -Загружает 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` +## 7. `CallInviteBroadcast` Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. -## 10. `CallSignalToSession` +## 8. `CallSignalToSession` Требует авторизации. Шлёт сигнал звонка в конкретную сессию. -## 11. Замечания +## 9. Замечания -- Для нового DM-файла сценарий такой: `HEAD /f/` → при `404` `POST /upload` → затем `SendMessagePair`. -- Сервер хранит только последнюю версию контентного сообщения по `messageKey`. -- Удаление сообщения реализуется новой ревизией с пустым телом и нулём вложений. +- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2` +- контентные DM `type=1/2` используют `SHiNE_DM` +- сервер хранит только последнюю версию контентного сообщения по `messageKey` +- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0` +- HTTP endpoints для DM-файлов сейчас отсутствуют diff --git a/Dev_Docs/Pending_Features/2026-06-17_1735_dm_files_and_revisions.md b/Dev_Docs/Pending_Features/2026-06-17_1735_dm_files_and_revisions.md index 0341911..cbd90ac 100644 --- a/Dev_Docs/Pending_Features/2026-06-17_1735_dm_files_and_revisions.md +++ b/Dev_Docs/Pending_Features/2026-06-17_1735_dm_files_and_revisions.md @@ -1,19 +1,18 @@ -# DM-вложения, upload и ревизии сообщений +# Ревизии и удаление личных сообщений - краткое описание фичи: - Добавлен новый формат контентных DM `SHiNE_DM`, HTTP upload/download ciphertext-файлов, серверный `upsert` последней версии сообщения и UI-скачивание/расшифровка вложений. + Добавлен новый формат контентных DM `SHiNE_DM` без вложений, серверный `upsert` последней версии сообщения, редактирование через `revisionTimeMs` и удаление пустой ревизией. - что проверять: 1. Отправка обычного текста без вложений. - 2. Отправка сообщения с 1-2 вложениями. - 3. Повторная отправка сообщения с уже загруженным файлом без повторной записи blob. - 4. Скачивание вложения из UI и корректная расшифровка файла. - 5. Доставка backlog после переподключения сессии. - 6. Обновление существующего сообщения той же парой `timeMs+nonce` и большим `revisionTimeMs`. - 7. Удаление сообщения пустой ревизией (`attachments=0`, `encryptedBodyLen=0`) и исчезновение из UI. + 2. Повторная отправка того же логического сообщения с тем же `timeMs + nonce`, но большим `revisionTimeMs`. + 3. Обновление текста у уже существующего сообщения в UI без появления нового пузыря. + 4. Игнорирование более старой ревизии на сервере. + 5. Удаление сообщения пустой ревизией (`attachmentsCount = 0`, `encryptedBodyLen = 0`) и исчезновение из UI. + 6. Доставка backlog после переподключения сессии для последней версии сообщения. - ожидаемый результат: - Сообщения `type=1/2` приходят в формате `SHiNE_DM`, файлы доступны по `/f/`, UI показывает вложения кнопками скачивания, сервер хранит только последнюю ревизию по `messageKey`, а пустая ревизия убирает сообщение из интерфейса. + Контентные сообщения `type=1/2` приходят в формате `SHiNE_DM`, сервер хранит только последнюю ревизию по `messageKey`, более старая ревизия не перетирает новую, а пустая ревизия убирает сообщение из интерфейса. - статус: pending diff --git a/Dev_Docs/Personal_Messages/README.md b/Dev_Docs/Personal_Messages/README.md index 7aec00a..c7e6fac 100644 --- a/Dev_Docs/Personal_Messages/README.md +++ b/Dev_Docs/Personal_Messages/README.md @@ -1,52 +1,62 @@ -# Личные сообщения (DM): как это устроено +# Личные сообщения (DM) -## Коротко +## Текущее состояние -Личные сообщения в SHiNE теперь работают в двух слоях: +Сейчас в проекте реализованы: -- контентные сообщения `type=1/2` идут в новом бинарном формате `SHiNE_DM`; -- read-receipt `type=3/4` пока остаются на legacy-формате `SHiNE_dm2`, чтобы не ломать текущую механику подтверждения прочтения. +- новый формат контентных личных сообщений `SHiNE_DM`; +- ревизии сообщений через `revisionTimeMs`; +- редактирование сообщения через повторную отправку той же логической пары; +- удаление сообщения через пустую ревизию; +- `upsert` последней версии сообщения на сервере. -Одно логическое сообщение по-прежнему отправляется парой блоков: +Сейчас в проекте **не реализованы**: -- `type=1` — входящее сообщение для получателя; +- вложения в DM; +- upload/download файлов для DM; +- UI-кнопка прикрепления файла; +- серверное хранение файловых связей для DM. + +Черновик будущих вложений вынесен отдельно: + +- `Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md` + +## Общая схема + +Личное сообщение по-прежнему отправляется парой signed-блоков: + +- `type=1` — входящий блок для получателя; - `type=2` — исходящая копия для отправителя. -Ключ сообщения остаётся прежним: +Read-receipt пока остаются в legacy-формате: -- `baseKey = from|to|timeMs|nonce` +- `type=3` — входящее подтверждение прочтения; +- `type=4` — исходящая копия подтверждения. + +Ключи сообщения: + +- `baseKey = fromLogin|toLogin|timeMs|nonce` - `messageKey = baseKey|messageType` -Теперь `timeMs + nonce` задаются один раз на всё логическое сообщение и не меняются при редактировании. -Для новой версии того же письма используется то же `messageKey`, но большее `revisionTimeMs`. -Сервер хранит только последнюю версию записи по этому `messageKey` через `upsert`. +Логический идентификатор письма задаётся парой: ---- +- `timeMs` +- `nonce` -## 1) Общая схема потока +Эти поля не меняются при редактировании или удалении. Меняется только: -1. Клиент при необходимости сначала шифрует вложенные файлы локально. -2. Клиент считает `SHA-256(ciphertext)` и размер ciphertext. -3. Клиент проверяет наличие blob через `HEAD /f/`. -4. Если файла нет, клиент загружает ciphertext через `POST /upload?hash=&size=`. -5. После загрузки всех файлов клиент строит пару signed DM-блоков `type=1/2`. -6. Сервер валидирует подписи, формат, наличие blob-файлов и делает `upsert` пары. -7. В одной транзакции сервер: - - удаляет старые файловые связи этого сообщения; - - корректирует `ref_count`; - - записывает новую версию `signed_messages_v2`; - - создаёт новые файловые связи; - - сбрасывает pending-доставку по сессиям. -8. Сервер рассылает обновлённые блоки в активные сессии через `SignedMessageArrived`. -9. Клиент обновляет существующий пузырь по `messageKey`; если тело и вложения пустые — сообщение удаляется из UI. +- `revisionTimeMs` +- содержимое `encryptedBody` ---- +Сервер хранит только последнюю версию записи для каждого `messageKey`. -## 2) Формат signed DM-блока для контентных сообщений (`SHiNE_DM`) +## Формат контентного DM: `SHiNE_DM` -Префикс: `SHiNE_DM` (ASCII). +Префикс бинарного блока: -Далее поля, big-endian: +- `SHiNE_DM` + +Поля идут в big-endian порядке: 1. `formatVersionMajor` (`u8`) = `1` 2. `formatVersionMinor` (`u8`) = `0` @@ -56,27 +66,24 @@ 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) +9. `attachmentsCount` (`u8`) +10. `encryptedBodyLen` (`u32`) +11. `encryptedBody` (`bytes`) +12. `signature` (`64 bytes`, Ed25519) -### Важные правила +### Ограничения -- `messageType` не входит в ID логического письма, он только различает сторону пары. -- ID логического письма = `fromLogin + toLogin + timeMs + nonce`. -- У оригинала `revisionTimeMs = 0`. -- Для редактирования и удаления `timeMs`/`nonce` не меняются, меняется только `revisionTimeMs`. -- Чем больше `revisionTimeMs`, тем новее версия. +- `attachmentsCount` сейчас всегда должен быть `0` +- `encryptedBodyLen` сейчас ограничен сервером до `16384` байт +- `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` 2. `fromLoginLen` (`u8`) + `fromLogin` @@ -87,278 +94,107 @@ 7. `payloadBytes` 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`; +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: +Основная таблица: -- показывает обычный текст без маркеров; -- заменяет маркеры карточками скачивания; -- скачивает ciphertext по `/f/`; -- локально расшифровывает файл и отдаёт пользователю оригинал. +- `signed_messages_v2` ---- +Для контентных DM в ней используются: -## 6) RPC и события - -### `SendMessagePair` / `ReceiveOutcomingMessage` - -Запрос не меняется: сервер по-прежнему принимает пару `incomingBlobB64` + `outgoingBlobB64`. - -```json -{ - "op": "SendMessagePair", - "requestId": "req-1", - "payload": { - "incomingBlobB64": "", - "outgoingBlobB64": "" - } -} -``` - -Успешный ответ: - -```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/` - -Проверяет наличие ciphertext-файла. - -- `200` — файл есть; -- `404` — файла нет. - -### `GET /f/` - -Отдаёт ciphertext-файл как `application/octet-stream`. - -Сейчас доступ публичный, без проверки логина. - -### `POST /upload?hash=&size=` - -Принимает raw bytes ciphertext-файла. - -Сервер: - -- пересчитывает `SHA-256`; -- проверяет размер; -- сохраняет blob в папку `f/` под именем ``; -- если файл уже существует, повторно не пишет его на диск; -- регистрирует строку в `dm_files`. - ---- - -## 8) Хранение на сервере (SQLite) - -### `signed_messages_v2` - -Основная таблица текущих DM: - -- `message_key` (PK) +- `message_key` - `base_key` - `target_login` -- `from_login`, `to_login` -- `time_ms`, `nonce` +- `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`, поэтому в таблице всегда лежит только последняя версия конкретной стороны пары. +Отдельных таблиц файлов для DM сейчас нет. -### `signed_message_session_delivery` +## События и доставка -Хранит pending/ack по сессиям: +Запрос на отправку по WebSocket остаётся прежним: -- `(message_key, session_id)` — PK -- `delivered` -- `delivered_at_ms` -- `created_at_ms` +- `SendMessagePair` +- `ReceiveOutcomingMessage` как алиас -При новой ревизии того же сообщения сервер сбрасывает доставку этого `message_key` обратно в `delivered=0`. +Клиент отправляет: -### `dm_files` +- `incomingBlobB64` +- `outgoingBlobB64` -- `file_hash_sha256` (`BLOB`, PK) -- `file_size` -- `ref_count` +Событие в активные сессии: -### `dm_message_file_links` +- `SignedMessageArrived` -- `message_key` -- `login` -- `file_hash_sha256` +Если пришла новая ревизия того же сообщения, `messageKey` остаётся прежним, а внутри `blobB64` будет более новый `revisionTimeMs`. -По этой таблице сервер понимает, какие файловые ссылки нужно снять при редактировании/удалении сообщения. +Подтверждение доставки в сессию: -`ref_count` считается по числу логических message-side ссылок: +- `AckSessionDelivery` -- у одного письма с вложением обычно две ссылки: - - получатель (`type=1`) - - отправитель (`type=2`) +## Правила UI -Файлы с `ref_count = 0` на диске не удаляются автоматически. +UI сейчас работает так: ---- +- показывает только текст `encryptedBody`; +- умеет обновлять уже существующее сообщение по тому же `messageKey`; +- не показывает удалённые сообщения; +- не показывает и не принимает вложения. -## 9) Доставка, редактирование и удаление +## Что обязательно помнить -### Новое сообщение - -- `revisionTimeMs = 0` -- создаётся пара `1/2` -- сервер делает upsert, создаёт файловые связи и доставляет событие - -### Редактирование - -- используется тот же `timeMs + nonce` -- отправляется новая пара `1/2` -- `revisionTimeMs` больше -- может измениться и `encryptedBody`, и список вложений - -### Удаление - -- тот же `timeMs + nonce` -- новая ревизия с большим `revisionTimeMs` -- `attachmentsCount = 0` -- `encryptedBodyLen = 0` - -UI такое сообщение полностью убирает из чата. - ---- - -## 10) Логика UI-клиента - -### Хранилище сообщений - -- in-memory: `state.chats[chatId]` -- IndexedDB: `shine-ui-messages-v1`, store `messages`, key = `messageKey` - -Так как `messageKey` теперь стабилен для всех ревизий одного message-side, клиент делает не append, а update той же записи. - -### Поведение UI - -- входящая новая ревизия с тем же `messageKey` обновляет существующий пузырь; -- пустая ревизия (`attachments=0`, `encryptedBodyLen=0`) удаляет пузырь из IndexedDB и из in-memory; -- вложения показываются кнопками скачивания; -- ciphertext скачивается с HTTP, затем расшифровывается локально в браузере. - ---- - -## 11) Межсерверная синхронизация - -Межсерверный relay DM пока не реализован. - -Когда он появится, серверы должны будут: - -- синхронизировать только актуальную ревизию `message_key`; -- применять правило "больший `revisionTimeMs` побеждает"; -- одинаково пересчитывать файловые связи и `ref_count`. - ---- - -## 12) Инварианты - -1. Пара `1/2` должна применяться атомарно. -2. `baseKey/messageKey` формат не меняется. -3. Для одного `messageKey` в `signed_messages_v2` хранится только последняя версия. -4. Все изменения DM и файловых связей применяются одной транзакцией. -5. Для контентных сообщений обязательна предварительная загрузка blob-файлов. -6. Для одного сообщения разрешено не больше `12` вложений. -7. UI не показывает удалённые сообщения. - ---- - -## 13) Ключевые файлы реализации - -- UI: - - `shine-UI/js/services/auth-service.js` - - `shine-UI/js/services/crypto-utils.js` - - `shine-UI/js/state.js` - - `shine-UI/js/app.js` - - `shine-UI/js/pages/chat-view.js` -- Server: - - `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessageBlock.java` - - `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesCore.java` - - `SHiNE-server/shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java` - - `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesRealtime.java` - - `SHiNE-server/shine-server-net-protocol/.../messages/DmFileStorage.java` - - `SHiNE-server/shine-server-db/.../dao/SignedMessagesV2DAO.java` - - `SHiNE-server/src/main/java/server/files/DmFilesServlet.java` - - `SHiNE-server/src/main/java/server/files/DmUploadServlet.java` +- вложения в DM сейчас отключены на уровне протокола и UI; +- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны; +- если позже вложения вернутся, их формат и серверная логика могут быть другими. diff --git a/Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md b/Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md new file mode 100644 index 0000000..f339c67 --- /dev/null +++ b/Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md @@ -0,0 +1,73 @@ +# Черновик будущих вложений в DM + +## Важно + +Этот документ описывает только ранний черновик идеи. + +Сейчас в проекте **нет** поддержки вложений в личных сообщениях: + +- в реализованном формате `SHiNE_DM` поле `attachmentsCount` пока всегда должно быть `0`; +- UI не показывает кнопку прикрепления файлов; +- сервер не принимает upload файлов для DM; +- сервер не раздаёт специальные DM-файлы по отдельным endpoints; +- сервер не хранит отдельные файловые связи для личных сообщений. + +Этот документ нужен только для того, чтобы рядом с актуальной документацией было явно видно: + +- какие идеи обсуждались; +- что это **не реализовано**; +- что формат, хранение и способ загрузки потом могут сильно измениться. + +## Что обсуждалось + +Рассматривался такой общий подход: + +- у контентного DM есть внешний список вложений; +- во внешнем формате лежат только технические данные; +- человекочитаемые данные о файле живут внутри зашифрованного тела сообщения; +- один и тот же blob-файл теоретически мог бы переиспользоваться в нескольких сообщениях. + +Черновой вариант внешнего списка: + +- `attachmentsCount` +- далее для каждого вложения: + - `encFileHashSHA256` (`32 bytes`) + - `encFileSize` (`u64`) + +Черновой вариант внутреннего маркера в тексте: + +```text +<> +``` + +Где обсуждались поля: + +- `type` +- `fileName` +- `origSize` +- `origHashB64u` +- `encHashB64u` +- `encSize` +- `keyB64u` +- `nonceB64u` + +## Что может измениться + +В будущем могут измениться любые части идеи: + +- сам бинарный формат; +- способ привязки файлов к сообщению; +- момент загрузки файла относительно отправки сообщения; +- серверное хранение blob-файлов; +- права доступа к скачиванию; +- способ рендера вложения в UI. + +Именно поэтому этот файл не надо воспринимать как актуальную спецификацию. + +## Источник истины на сейчас + +Актуальное состояние личных сообщений описано только в: + +- `Dev_Docs/Personal_Messages/README.md` + +Если между этим черновиком и основным README есть расхождение, верным считается `README.md`. diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index efac569..8cdd221 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -640,36 +640,6 @@ public final class DatabaseInitializer { 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(""" CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming ON signed_messages_v2 (target_login, receipt_ref_base_key) diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 32ec640..91e8d26 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { 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; @@ -89,6 +89,7 @@ public final class SqliteDbController { case 4 -> migrateToV4(); case 5 -> migrateToV5(); case 6 -> migrateToV6(); + case 7 -> migrateToV7(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -216,7 +217,6 @@ public final class SqliteDbController { c.setAutoCommit(false); try { ensureSignedMessagesRevisionColumn(c, st); - ensureDmFileTables(st); setSchemaVersion(c, 6); c.commit(); } 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 { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -357,36 +376,11 @@ public final class SqliteDbController { } } - 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 void dropDmFileTables(Statement st) throws SQLException { + st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login"); + st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message"); + st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links"); + st.executeUpdate("DROP TABLE IF EXISTS dm_files"); } private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java index 595916f..01ffa31 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java @@ -1,7 +1,6 @@ package shine.db.dao; import shine.db.SqliteDbController; -import shine.db.entities.DmFileRef; import shine.db.entities.SignedMessageV2Entry; import java.sql.Connection; @@ -10,9 +9,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashSet; import java.util.List; -import java.util.Set; public final class SignedMessagesV2DAO { 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 { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); @@ -73,25 +67,11 @@ public final class SignedMessagesV2DAO { } } - /** - * Атомарный upsert пары контентных DM с полной заменой файловых связей. - * Возвращает true, только если ревизия применена. Более старая или идентичная ревизия игнорируется. - */ - public boolean upsertContentPairReplaceFiles( - SignedMessageV2Entry incoming, - List incomingFiles, - SignedMessageV2Entry outgoing, - List outgoingFiles - ) throws Exception { + public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); c.setAutoCommit(false); try { - if (!allFilesExist(c, incomingFiles) || !allFilesExist(c, outgoingFiles)) { - c.rollback(); - return false; - } - Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey()); Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey()); long currentRevision = Math.max( @@ -112,12 +92,8 @@ public final class SignedMessagesV2DAO { 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()); @@ -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 { try (Connection c = db.getConnection()) { String sql = """ @@ -299,106 +241,6 @@ public final class SignedMessagesV2DAO { } } - private void replaceFileLinks(Connection c, String messageKey, String login, List nextFiles) throws SQLException { - List 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 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 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 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 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)) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmFileRef.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmFileRef.java deleted file mode 100644 index 557d2de..0000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmFileRef.java +++ /dev/null @@ -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; } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmFileStorage.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmFileStorage.java deleted file mode 100644 index 192f714..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmFileStorage.java +++ /dev/null @@ -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) { - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index f95b7b7..a1f9faf 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -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.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; 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 подтверждения"); } - boolean inserted = SignedMessagesCore.saveIfAbsent(entry); + boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (inserted) { counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index d68d669..db0f9e8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -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.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.entities.DmFileRef; import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; -import java.util.List; - public class Net_SendMessagePair_Handler implements JsonMessageHandler { @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { @@ -35,13 +32,9 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { try { SignedMessagesCore.verifyUsersAndSignature(incoming); SignedMessagesCore.verifyUsersAndSignature(outgoing); - SignedMessagesCore.ensureAllFilesExist(incoming); - SignedMessagesCore.ensureAllFilesExist(outgoing); } catch (IllegalArgumentException ex) { String code = ex.getMessage(); - int status = "USER_NOT_FOUND".equals(code) - ? 404 - : ("ATTACHMENT_NOT_FOUND".equals(code) ? WireCodes.Status.BAD_REQUEST : WireCodes.Status.UNVERIFIED); + int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED; return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } @@ -58,10 +51,8 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { boolean pairInserted; if (incoming.isContentType()) { - List incomingFiles = SignedMessagesCore.attachmentRefs(incoming); - List outgoingFiles = SignedMessagesCore.attachmentRefs(outgoing); - pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPairReplaceFiles( - incomingEntry, incomingFiles, outgoingEntry, outgoingFiles + pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair( + incomingEntry, outgoingEntry ); } else { pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java index dad1e42..4528abf 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java @@ -1,14 +1,9 @@ package server.logic.ws_protocol.JSON.messages; -import shine.db.entities.DmFileRef; - import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.List; final class SignedMessageBlock { 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_READ_INCOMING = 3; static final int TYPE_READ_OUTGOING_COPY = 4; - static final int MAX_ATTACHMENTS = 12; final String toLogin; final String fromLogin; @@ -29,7 +23,6 @@ final class SignedMessageBlock { final int formatVersionMinor; final byte[] payloadBytes; final byte[] encryptedBodyBytes; - final List attachments; final byte[] signedBody; final byte[] signature64; final byte[] rawPacket; @@ -46,7 +39,6 @@ final class SignedMessageBlock { int formatVersionMinor, byte[] payloadBytes, byte[] encryptedBodyBytes, - List attachments, byte[] signedBody, byte[] signature64, byte[] rawPacket, @@ -62,7 +54,6 @@ final class SignedMessageBlock { this.formatVersionMinor = formatVersionMinor; this.payloadBytes = payloadBytes; this.encryptedBodyBytes = encryptedBodyBytes; - this.attachments = attachments; this.signedBody = signedBody; this.signature64 = signature64; this.rawPacket = rawPacket; @@ -125,7 +116,6 @@ final class SignedMessageBlock { 0, payload, payload, - List.of(), signedBody, signature64, raw, @@ -157,19 +147,8 @@ final class SignedMessageBlock { 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 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 (attachmentsCount != 0) { + throw new IllegalArgumentException("ATTACHMENTS_DISABLED"); } if (bb.remaining() < 4 + 64) { @@ -199,7 +178,6 @@ final class SignedMessageBlock { minor, encryptedBody, encryptedBody, - Collections.unmodifiableList(attachments), signedBody, signature64, raw, @@ -224,7 +202,7 @@ final class SignedMessageBlock { } boolean isDeletedContent() { - return isContentType() && !legacyFormat && attachments.isEmpty() && encryptedBodyBytes.length == 0; + return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0; } String targetLogin() { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index fbe3deb..4e2bf93 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -1,18 +1,12 @@ package server.logic.ws_protocol.JSON.messages; -import shine.db.dao.SignedMessagesV2DAO; import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.DmFileRef; import shine.db.entities.SignedMessageV2Entry; import shine.db.entities.SolanaUserEntry; import utils.crypto.Ed25519Util; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Base64; -import java.util.HashSet; -import java.util.List; -import java.util.Set; final class SignedMessagesCore { private static final int MAX_ENCRYPTED_BODY_BYTES = 16384; @@ -24,7 +18,23 @@ final class SignedMessagesCore { byte[] raw = Base64.getDecoder().decode(blobB64.trim()); return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES); } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); + String code = e.getMessage(); + if (code == null || code.isBlank()) { + 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"); + } } } @@ -71,33 +81,6 @@ final class SignedMessagesCore { || incoming.formatVersionMinor != outgoing.formatVersionMinor) { throw new IllegalArgumentException("BAD_FORMAT_VERSION"); } - if (incoming.attachments.size() != outgoing.attachments.size()) { - throw new IllegalArgumentException("BAD_ATTACHMENTS"); - } - - Set seenIncoming = new HashSet<>(); - Set 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"); @@ -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) { 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); @@ -147,18 +121,10 @@ final class SignedMessagesCore { return entry; } - static List 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 { - return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); - } } diff --git a/SHiNE-server/src/main/java/server/files/DmFilesServlet.java b/SHiNE-server/src/main/java/server/files/DmFilesServlet.java deleted file mode 100644 index c23c7cc..0000000 --- a/SHiNE-server/src/main/java/server/files/DmFilesServlet.java +++ /dev/null @@ -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"); - } -} diff --git a/SHiNE-server/src/main/java/server/files/DmUploadServlet.java b/SHiNE-server/src/main/java/server/files/DmUploadServlet.java deleted file mode 100644 index 06db6c7..0000000 --- a/SHiNE-server/src/main/java/server/files/DmUploadServlet.java +++ /dev/null @@ -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"); - } -} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 0fdc7eb..01f4884 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -1,14 +1,11 @@ package server.ws; import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.debug.DebugApiConfigurator; -import server.files.DmFilesServlet; -import server.files.DmUploadServlet; import utils.config.AppConfig; import java.time.Duration; @@ -64,8 +61,6 @@ public final class WsServer { // HTTP debug API DebugApiConfigurator.register(context); - context.addServlet(new ServletHolder(new DmFilesServlet()), "/f/*"); - context.addServlet(new ServletHolder(new DmUploadServlet()), "/upload"); // Инициализация контейнера WebSocket JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> { diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index a8e0776..7609457 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -1,7 +1,5 @@ server.1port=7070 db.path=data/shine.sqlite -dm.files.dir=f -dm.upload.maxBytes=104857600 # ------------------------------------------------------------ # Server public info diff --git a/VERSION.properties b/VERSION.properties index 103f24e..2ded924 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.208 -server.version=1.2.197 +client.version=1.2.209 +server.version=1.2.198 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index f73bc5b..b40f27c 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -917,7 +917,6 @@ async function init() { unread: isIncomingForCurrent, rawBlobB64: blobB64, revisionTimeMs: Number(parsed.revisionTimeMs || 0), - attachments: Array.isArray(parsed.bodyAttachments) ? parsed.bodyAttachments : [], deleted: Boolean(parsed.deleted), }); if (added) { diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index abcabb3..df2e011 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -166,35 +166,6 @@ function resolveDeliveryStatus(msg) { 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) { if (!list) return; const apply = () => { @@ -228,35 +199,10 @@ function renderLog(list, chatId, { onOpenActions } = {}) { const bubbleKind = String(msg?.kind || '').trim(); bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; - const plainText = messagePlainText(msg); - if (plainText) { - 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 textNode = document.createElement('div'); + textNode.className = 'bubble-text'; + textNode.textContent = msg.text || ''; + bubble.append(textNode); const metaNode = document.createElement('div'); metaNode.className = 'bubble-meta'; @@ -388,64 +334,17 @@ export function render({ navigate, route }) { const form = document.createElement('form'); form.className = 'chat-input dm-chat-input'; form.innerHTML = ` - -
-
`; - 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(`<>`); - }); - 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 text = String(rawText || '').trim(); - if (!text && pendingFiles.length === 0) return; - const tempLabel = text || `Файлы: ${pendingFiles.length}`; - const tempId = addOutgoingPendingMessage(chatId, tempLabel); + if (!text) return; + const tempId = addOutgoingPendingMessage(chatId, text); renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', @@ -460,23 +359,12 @@ export function render({ navigate, route }) { }); try { - 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({ + const result = await authService.sendDirectMessage({ login: state.session.login, toLogin: chatId, - text: messagePayloadText, + text, storagePwd: state.session.storagePwdInMemory, - attachments: preparedAttachments, }); - pendingFiles = []; - if (fileInput) fileInput.value = ''; - renderPendingFiles(); markOutgoingSent(tempId, { messageKey: result?.outgoingKey || '', baseKey: result?.baseKey || result?.localBaseKey || '', @@ -494,7 +382,6 @@ export function render({ navigate, route }) { unread: false, rawBlobB64: result.localOutgoingBlobB64, revisionTimeMs: Number(parsed?.revisionTimeMs || 0), - attachments: Array.isArray(parsed?.bodyAttachments) ? parsed.bodyAttachments : [], deleted: Boolean(parsed?.deleted), }); } catch { @@ -551,15 +438,6 @@ export function render({ navigate, route }) { }; 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); input?.addEventListener('input', () => autoResizeComposer(input)); input?.addEventListener('focus', () => { @@ -584,7 +462,7 @@ export function render({ navigate, route }) { } event.preventDefault(); const text = String(input.value || '').trim(); - if (!text && pendingFiles.length === 0) return; + if (!text) return; input.value = ''; autoResizeComposer(input); await sendTextMessage(text); @@ -608,7 +486,7 @@ export function render({ navigate, route }) { form.addEventListener('submit', async (event) => { event.preventDefault(); const text = input.value.trim(); - if (!text && pendingFiles.length === 0) return; + if (!text) return; input.value = ''; autoResizeComposer(input); await sendTextMessage(text); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 5461c85..b4e731a 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1,19 +1,14 @@ import { WsJsonClient } from './ws-client.js'; import { base64ToBytes, - base64UrlToBytes, bytesToBase64, - bytesToBase64Url, - decryptBytesAesGcm, deriveEd25519FromMasterSecret, deriveMasterSecretFromPassword, - encryptBytesAesGcm, exportEd25519PublicKeyB64, exportPkcs8B64, generateEd25519Pair, importPkcs8Ed25519, publicKeyB64FromPkcs8Ed25519, - randomBytes, randomBase64, sha256Bytes, signBytes, @@ -212,7 +207,6 @@ const DM2_TYPE_READ_INCOMING = 3; 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) { @@ -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 = /<|]+)\|([^>|]+)\|(\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) { if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); let o = 0; @@ -348,17 +297,7 @@ function parseSignedMessageBlockBytes(bytes) { 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, - }); - } + if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED'); const encryptedBodyLen = readU32(); const encryptedBodyBytes = read(encryptedBodyLen); const signatureBytes = read(64); @@ -367,7 +306,6 @@ function parseSignedMessageBlockBytes(bytes) { 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, @@ -377,11 +315,10 @@ function parseSignedMessageBlockBytes(bytes) { revisionTimeMs, formatVersionMajor, formatVersionMinor, - attachments, encryptedBodyBytes, encryptedBodyText: bodyText, - text: parsedBody.text, - bodyAttachments: parsedBody.attachments, + text: bodyText, + bodyAttachments: [], payloadBytes: encryptedBodyBytes, signatureBytes, signedBody, @@ -418,7 +355,6 @@ function parseSignedMessageBlockBytes(bytes) { nonce, messageType, revisionTimeMs: 0, - attachments: [], encryptedBodyBytes: payloadBytes, encryptedBodyText: new TextDecoder().decode(payloadBytes), text: new TextDecoder().decode(payloadBytes), @@ -1981,7 +1917,6 @@ export class AuthService { nonce, messageType, revisionTimeMs = 0, - attachments = [], encryptedBodyBytes = new Uint8Array(0), }) { const cleanFromLogin = String(login || '').trim(); @@ -1999,7 +1934,6 @@ export class AuthService { 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), @@ -2010,7 +1944,7 @@ export class AuthService { uint32Bytes(nonce), uint16Bytes(messageType), uint64Bytes(revisionTimeMs), - attachmentsSection, + uint8Bytes(0), uint32Bytes(encryptedBodyBytes.length), encryptedBodyBytes, ); @@ -2073,25 +2007,13 @@ export class AuthService { } 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 cleanToLogin = String(toLogin || '').trim(); const cleanText = String(text || ''); - const normalizedAttachments = Array.isArray(attachments) ? attachments : []; 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 incomingBlock = await this.buildSignedDmV1Block({ @@ -2101,8 +2023,6 @@ export class AuthService { timeMs, nonce, messageType: DM2_TYPE_INCOMING, - revisionTimeMs, - attachments: normalizedAttachments, encryptedBodyBytes, }); const outgoingBlock = await this.buildSignedDmV1Block({ @@ -2112,8 +2032,6 @@ export class AuthService { timeMs, nonce, messageType: DM2_TYPE_OUTGOING_COPY, - revisionTimeMs, - attachments: normalizedAttachments, encryptedBodyBytes, }); @@ -2164,90 +2082,6 @@ export class AuthService { 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 }) { const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type }); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 9d3796d..be3836c 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -384,7 +384,6 @@ function persistMessageRecord(chatId, row) { secondTick: Boolean(row.secondTick), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), - attachments: Array.isArray(row.attachments) ? row.attachments : [], ts: resolvedTs > 0 ? resolvedTs : Date.now(), }).catch(() => {}); } @@ -420,7 +419,6 @@ export async function hydrateMessagesFromStore() { secondTick: Boolean(row.secondTick), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), - attachments: Array.isArray(row.attachments) ? row.attachments : [], createdAtMs: Number(row.ts || 0), }); }); @@ -575,7 +573,6 @@ export function addSignedMessageToChat({ rawBlobB64 = '', refBaseKey = '', revisionTimeMs = 0, - attachments = [], deleted = false, } = {}) { const id = String(messageKey || '').trim(); @@ -603,7 +600,6 @@ export function addSignedMessageToChat({ 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';