Merge origin/main (+56: DM v1 E2EE, промокоды, recovery key) + локальные фичи
Слито и разрешено вручную: - chat-view.js: база — новый DM-чат агента (ответы/реплаи, ревизии, клавиатурный UX, голосовой ввод); поверх встроен эмодзи-пикер (Telegram-набор): кнопка ☺, вставка в позицию курсора, анимированные эмодзи в пузырях (renderMessageText поверх displayText), остановка анимаций в cleanup. - register-view.js: база — версия агента (промокоды, глаз пароля, TEMP-заглушка коротких логинов); возвращена компактная ссылка «Вопросы о регистрации» (FAQ-экран у агента осиротел — снова достижим). - components.css: шапка «Связей» — вариант агента (sticky + grid minmax, заголовок сокращается, кнопки не обрезаются); стили эмодзи-пикера сохранены. - Заметки Pending_Features (шапка связей, поиск каналов, локальный вход) переложены в новую структуру docs/Pending_Features. - VERSION: client 1.2.314, server 1.2.292 (продолжение нумерации origin). Проверено локально: старт → «Открыть локальный тестовый режим» → Личные → чат: эмодзи-пикер открывается, эмодзи вставляется, PNG-ассеты грузятся; регистрация: промокод/12 слов/FAQ-ссылка работают; консоль чистая. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -56,9 +56,12 @@
|
|||||||
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
|
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
|
||||||
|
|
||||||
## Документация личных сообщений (DM)
|
## Документация личных сообщений (DM)
|
||||||
- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/README.md`.
|
- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`.
|
||||||
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять `Dev_Docs/Personal_Messages/README.md`.
|
- Точный байтовый формат DM находится в `Dev_Docs/Personal_Messages/Формат_DM_v1.md`.
|
||||||
- Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`.
|
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять оба документа:
|
||||||
|
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`
|
||||||
|
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`
|
||||||
|
- Логика личных сообщений в коде должна всегда соответствовать этим документам.
|
||||||
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
|
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
|
||||||
|
|
||||||
## Документация API сервера
|
## Документация API сервера
|
||||||
@@ -68,10 +71,11 @@
|
|||||||
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
|
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
|
||||||
- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
|
- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
|
||||||
|
|
||||||
## Известная проблема (временная пометка)
|
## Документация Figma
|
||||||
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
|
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `Dev_Docs/Figma/`.
|
||||||
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.
|
- Точка входа: `Dev_Docs/Figma/README.md`.
|
||||||
- До фикса считать эту часть функционала незавершённой и обязательно перепроверять вручную после каждого деплоя.
|
- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`.
|
||||||
|
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
||||||
@@ -83,14 +87,14 @@
|
|||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
|
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
|
||||||
- Production-хост SHiNE: `player@shineup.me` (`185.229.109.118`).
|
- Production-хост SHiNE: `player@shineup.me` (`178.208.64.62`).
|
||||||
- Основной test-хост SHiNE: `player@193.8.215.70` (`test2.shineup.me`).
|
- Основной test-хост SHiNE: `player@193.8.215.70` (`t.shineup.me`).
|
||||||
- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
|
- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
|
||||||
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
|
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
|
||||||
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
||||||
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
|
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
|
||||||
- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
|
- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
|
||||||
- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `test2.shineup.me`.
|
- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `t.shineup.me`.
|
||||||
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
|
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
|
||||||
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
|
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
|
||||||
- Production server deploy: `./gradlew deployServerProduction`.
|
- Production server deploy: `./gradlew deployServerProduction`.
|
||||||
@@ -134,17 +138,19 @@
|
|||||||
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
|
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
|
||||||
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
|
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
|
||||||
|
|
||||||
## Будущие фичи
|
## Будущие фичи / TODO
|
||||||
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
|
- Папка для задач, сознательно отложенных на будущее: `TODO/`.
|
||||||
- Точка входа по планам: `Dev_Docs/Future_Features/README.md`.
|
- Точка входа по планам: `TODO/README.md`.
|
||||||
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/`.
|
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/` и тематическим подпапкам.
|
||||||
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `Dev_Docs/Future_Features/README.md`, затем при необходимости конкретные файлы из горизонтов.
|
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `TODO/README.md`, затем при необходимости конкретные файлы из подпапок.
|
||||||
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
|
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
|
||||||
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
|
- Старую папку `Dev_Docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей.
|
||||||
|
- Если часть кода временно отключена или закомментирована, либо удалена как временная заглушка, в TODO-файле подробно описывать:
|
||||||
- какие файлы и участки отключены;
|
- какие файлы и участки отключены;
|
||||||
- что осталось в коде как заготовка;
|
- что осталось в коде как заготовка;
|
||||||
- какие документы нужно обновить при возврате;
|
- какие документы нужно обновить при возврате;
|
||||||
- с какого сценария продолжать разработку.
|
- с какого сценария продолжать разработку;
|
||||||
|
- из какого коммита брать последнюю полную реализацию.
|
||||||
|
|
||||||
## Коммуникация по новым задачам (обязательно)
|
## Коммуникация по новым задачам (обязательно)
|
||||||
- При получении нового задания сначала кратко пересказать задачу своими словами.
|
- При получении нового задания сначала кратко пересказать задачу своими словами.
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
# DAO_запуск
|
|
||||||
|
|
||||||
Рабочий документ по тому, что ещё нужно сделать для первого запуска DAO-сценария SHiNE.
|
|
||||||
|
|
||||||
Логика документа:
|
|
||||||
|
|
||||||
- `этап1` — то, без чего нельзя считать сценарий первого запуска собранным даже в тестовом виде;
|
|
||||||
- `этап2` — то, что полезно и, вероятно, потребуется дальше, но это можно делать после старта `этап1` или параллельно без блокировки первого результата.
|
|
||||||
|
|
||||||
Базовая среда первого прохода:
|
|
||||||
|
|
||||||
- сеть `Solana devnet`;
|
|
||||||
- модель синхронизации: `server-to-server`;
|
|
||||||
- `Solana + Arweave` используются как якорь и архив;
|
|
||||||
- DAO понимается как стандартный governance/smart-contract контур, который управляет отдельными программами SHiNE, приносящими деньги.
|
|
||||||
|
|
||||||
## Краткий вывод
|
|
||||||
|
|
||||||
Для первого запуска DAO в тестовом виде текущего списка в целом хватает, но только если понимать запуск как:
|
|
||||||
|
|
||||||
- можно развернуть и проверить базовый DAO-контур;
|
|
||||||
- можно зарегистрировать пользователей и ключевые сущности;
|
|
||||||
- можно провести тестовую покупку билета через smart contract;
|
|
||||||
- можно завести тестовый денежный поток в программы, управляемые DAO;
|
|
||||||
- можно проверить опорную межсерверную синхронизацию и фиксацию состояния в архивный слой.
|
|
||||||
|
|
||||||
Если же под "запуском" понимать уже полностью устойчивую production-схему с ротацией ключей, восстановлением любого сервера из архива, железными устройствами подписи и полным циклом администрирования, то текущий список нужно будет ещё расширять.
|
|
||||||
|
|
||||||
## Этап1
|
|
||||||
|
|
||||||
Цель этапа: собрать минимально жизнеспособный DAO-сценарий в `devnet`, который можно пройти руками от регистрации до базовой экономики и проверки архитектуры.
|
|
||||||
|
|
||||||
### 1. Переписать и стабилизировать регистрацию пользователей без Anchor
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- довести `shine_users` в чистом Rust/Solana SDK до рабочего и проверенного состояния;
|
|
||||||
- убедиться, что `shine_login_guard` и связанный сценарий регистрации совместимы с новым ABI;
|
|
||||||
- проверить создание и чтение `user_pda`;
|
|
||||||
- проверить update пользовательской записи и связанные экономические параметры;
|
|
||||||
- синхронизировать сервер, UI и lazy-import с новым форматом и seed'ами.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- без стабильной пользовательской регистрации дальше нельзя строить ни DAO-сценарий, ни привязку устройств, ни платёжные сценарии.
|
|
||||||
|
|
||||||
### 2. Проверить полный сценарий регистрации и базовой Solana-интеграции
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- руками прогнать регистрацию нового пользователя;
|
|
||||||
- руками прогнать создание и update server PDA там, где это требуется текущему сценарию;
|
|
||||||
- убедиться, что сервер читает новые PDA без anchor-зависимостей и без старых discriminator'ов;
|
|
||||||
- зафиксировать, какие именно части сценария уже подтверждены руками, а какие ещё нет.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- сейчас в проекте уже есть признаки перехода на pure Rust, но без ручной проверки это нельзя считать завершённым.
|
|
||||||
|
|
||||||
### 3. Создать стандартный DAO smart contract / governance-контур
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- определить и реализовать стандартный DAO-контур, который будет управлять программами SHiNE;
|
|
||||||
- зафиксировать, какие права сразу передаются DAO, а какие временно остаются на отдельных ключах;
|
|
||||||
- подготовить тестовую DAO-структуру в `devnet`.
|
|
||||||
|
|
||||||
Минимум для первого запуска:
|
|
||||||
|
|
||||||
- DAO существует как управляемая сущность;
|
|
||||||
- DAO может владеть или контролировать ключевые права управления денежными программами;
|
|
||||||
- есть понятный путь, как DAO влияет на доходные программы SHiNE.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- без этого "DAO-запуск" будет только запуском отдельных Solana-программ, но не запуском управляемой DAO-системы.
|
|
||||||
|
|
||||||
### 4. Доработать смарт-контракт выплат с третьей очередью
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- добавить в `shine_payments` третью очередь, о которой уже принято решение;
|
|
||||||
- проверить совместимость с текущей моделью тикетов, выплат и DAO-управления;
|
|
||||||
- убедиться, что логика очередей соответствует ожидаемой экономике проекта.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- по текущей постановке это нужно именно для сценария регистрации DAO и дальнейшей экономики.
|
|
||||||
|
|
||||||
### 5. Сделать UI для покупки билетов и просмотра очереди
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- добавить UI-сценарий покупки билетов через smart contract;
|
|
||||||
- показать пользователю, сколько перед ним человек в очереди;
|
|
||||||
- убедиться, что UI отражает актуальное состояние контрактной логики, а не локальные предположения.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- покупка билетов у тебя обозначена как часть DAO-сценария, а не как побочная функция;
|
|
||||||
- без UI можно тестировать контракт вручную, но нельзя считать сценарий запуска достаточно собранным для нормальной проверки.
|
|
||||||
|
|
||||||
### 6. Реализовать базовую синхронизацию серверов
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- сделать обмен состоянием между серверами по модели `server-to-server`;
|
|
||||||
- определить минимальный набор данных, который обязан синхронизироваться;
|
|
||||||
- предусмотреть фиксацию синхронизированного состояния в `Arweave`, а `Solana` использовать как якорь и ссылочный слой;
|
|
||||||
- описать, какой сервер считается источником истины в спорных случаях или как решается конфликт.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- без межсерверной синхронизации трудно обосновать архитектуру сети как воспроизводимую и переносимую;
|
|
||||||
- это напрямую связано с идеей, что любой сможет поднять свой сервер.
|
|
||||||
|
|
||||||
### 7. Подготовить базовый сценарий архивирования и восстановления
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- описать и частично реализовать схему: серверы синхронизируются между собой, архив состояния уходит в `Arweave`, ссылка/якорь фиксируется через `Solana`;
|
|
||||||
- определить минимальный сценарий восстановления блоков или состояния из архивного слоя;
|
|
||||||
- подтвердить, что новый сервер может получить достаточно данных для старта.
|
|
||||||
|
|
||||||
Почему это в `этап1`:
|
|
||||||
|
|
||||||
- это один из ключевых признаков независимой и воспроизводимой DAO-инфраструктуры.
|
|
||||||
|
|
||||||
## Этап2
|
|
||||||
|
|
||||||
Цель этапа: усилить безопасность, автономность и удобство системы после того, как минимальный DAO-сценарий уже запустился и проверен в `devnet`.
|
|
||||||
|
|
||||||
### 1. Смена ключей цифровой подписи
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- продумать и реализовать смену `root key`, `client key`, `blockchain key`;
|
|
||||||
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
|
|
||||||
- продумать, как не потерять доступ и как обновлять доверие к новым ключам.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- для production это очень важно;
|
|
||||||
- для первого тестового запуска можно временно использовать фиксированный набор ключей.
|
|
||||||
|
|
||||||
### 2. Полная повторная перепроверка всех сценариев
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- повторно прогнать регистрацию, DAO, выплаты, билеты, синхронизацию и архивирование после стабилизации `этап1`;
|
|
||||||
- оформить итоговый чек-лист ручной проверки;
|
|
||||||
- отдельно проверить пограничные сценарии и восстановление после ошибок.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- это обязательный шаг перед переходом от "собрали" к "доверяем".
|
|
||||||
|
|
||||||
### 3. Устройство на ESP32 как homeserver с ключами
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- дописать прошивку, чтобы устройство могло выступать homeserver с ключами;
|
|
||||||
- дать ему возможность регистрироваться и подключаться к серверу;
|
|
||||||
- определить, какие операции устройство подписывает и где хранит ключевой материал.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- это очень сильное развитие архитектуры, но оно не должно блокировать первый DAO-запуск.
|
|
||||||
|
|
||||||
### 4. Логин и подпись через коробочки / устройства
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- реализовать сценарий входа через устройство или хотя бы сценарий подписи сообщений и ключей через устройство;
|
|
||||||
- определить, как это встраивается в регистрацию DAO и подтверждение действий;
|
|
||||||
- проверить, можно ли через это безопасно регистрировать DAO или подписывать критичные команды.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- это следующий уровень безопасности и UX, но не минимальный блокер первого старта.
|
|
||||||
|
|
||||||
### 5. Создание тестового DAO с использованием устройств подписи
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- после готовности устройств собрать тестовый DAO-сценарий уже с аппаратным участием;
|
|
||||||
- проверить, где устройство достаточно, а где всё ещё нужен обычный кошелёк или управляющий ключ.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- это проверка усиленной модели, а не базового старта.
|
|
||||||
|
|
||||||
### 6. Расписание синхронизации серверов
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- определить периодичность и правила фоновой синхронизации;
|
|
||||||
- продумать ручной и автоматический режим;
|
|
||||||
- решить, как часто публиковать архивные снимки и якоря.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- сначала важнее добиться самой работающей синхронизации, а потом уже делать её регулярной и автономной.
|
|
||||||
|
|
||||||
### 7. Полное восстановление блоков из Solana/Arweave
|
|
||||||
|
|
||||||
Что сделать:
|
|
||||||
|
|
||||||
- довести процедуру восстановления до сценария "любой может поднять свой сервер";
|
|
||||||
- определить минимальный bootstrap-набор;
|
|
||||||
- проверить восстановление на чистом окружении.
|
|
||||||
|
|
||||||
Почему это в `этап2`:
|
|
||||||
|
|
||||||
- для концепции сети это критично, но как полноценная задача обычно идёт после появления базового архива и первичной синхронизации.
|
|
||||||
|
|
||||||
## Что блокирует первый запуск сильнее всего
|
|
||||||
|
|
||||||
Если расставить приоритет внутри `этап1`, то самый жёсткий порядок сейчас выглядит так:
|
|
||||||
|
|
||||||
1. pure Rust регистрация пользователей и ручная проверка сценария;
|
|
||||||
2. DAO/gov-контур и его права управления;
|
|
||||||
3. доработка выплат с третьей очередью;
|
|
||||||
4. покупка билетов через smart contract и UI-проверка очереди;
|
|
||||||
5. межсерверная синхронизация;
|
|
||||||
6. архивирование в `Arweave` с якорем в `Solana`;
|
|
||||||
7. минимальное восстановление состояния новым сервером.
|
|
||||||
|
|
||||||
## Что уже частично похоже на готовое
|
|
||||||
|
|
||||||
По текущим документам и следам в проекте уже видно, что:
|
|
||||||
|
|
||||||
- переход `shine_users` и `shine_login_guard` на pure Rust уже начат и в значительной степени сделан;
|
|
||||||
- архитектура DAO, `shine_users` и `shine_payments` уже описана;
|
|
||||||
- часть Solana-структуры и PDA-форматов уже формализована;
|
|
||||||
- тема ESP32 уже отдельно присутствует в проекте как направление.
|
|
||||||
|
|
||||||
Это хорошо, потому что документ получается не "с нуля", а как сборка того, что уже назрело в коде и планах.
|
|
||||||
|
|
||||||
## Вопросы, которые всё ещё стоит уточнить
|
|
||||||
|
|
||||||
1. Какой именно стандарт DAO планируется использовать в первом проходе: готовый governance-стек Solana или собственная минимальная обвязка вокруг управляющих кошельков?
|
|
||||||
2. Третья очередь в `shine_payments` уже точно определена по смыслу, или пока есть только решение "она нужна", но без финальной экономики?
|
|
||||||
3. Что именно считается единицей синхронизации между серверами: блоки SHiNE, агрегированные снапшоты, PDA-состояния, или смесь этих вариантов?
|
|
||||||
4. Нужен ли для `этап1` уже полноценный автоматический recovery нового сервера, или достаточно доказать это в полу-ручном сценарии?
|
|
||||||
5. Покупка билетов должна в первом проходе работать только через web/UI, или также нужен отдельный сценарий из серверного UI или скриптов?
|
|
||||||
|
|
||||||
## Рекомендуемый следующий практический шаг
|
|
||||||
|
|
||||||
Если идти без распыления, то следующим рабочим фокусом стоит считать:
|
|
||||||
|
|
||||||
1. закрыть ручную проверку pure Rust регистрации;
|
|
||||||
2. после этого формализовать минимальный DAO-контур;
|
|
||||||
3. затем переходить к третьей очереди выплат и к UI покупки билетов;
|
|
||||||
4. после этого делать синхронизацию, архив и восстановление.
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# DEBUG: тестирование сетевого соединения между двумя клиентами
|
|
||||||
|
|
||||||
Документ описывает временный debug-контур для проверки WebRTC соединения между двумя активными WS-сессиями.
|
|
||||||
|
|
||||||
## 1) Подготовка
|
|
||||||
|
|
||||||
|
|
||||||
0. Убедись, что в `application.properties` включен параметр:
|
|
||||||
`debug.tempApi.enabled=true`
|
|
||||||
1. Создай файл `.debug-token` в корне проекта на основе `debug-token.example`.
|
|
||||||
2. В `.debug-token` должна быть одна строка: секретный токен.
|
|
||||||
3. Перезапусти сервер.
|
|
||||||
|
|
||||||
## 2) API debug
|
|
||||||
|
|
||||||
Базовый заголовок для всех запросов:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.1 Получить список живых клиентов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s \
|
|
||||||
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>" \
|
|
||||||
http://localhost:7070/debug/ws/clients | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
Ответ содержит `sessionId`, `login`, `ip`, `userAgent`, и клиентскую информацию.
|
|
||||||
|
|
||||||
### 2.2 Запустить debug-соединение между двумя сессиями
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"initiatorSessionId": "SESSION_ID_A",
|
|
||||||
"responderSessionId": "SESSION_ID_B",
|
|
||||||
"clearDebugLog": false
|
|
||||||
}' \
|
|
||||||
http://localhost:7070/debug/ws/connect | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
В ответе придёт `runId`. Его используй для фильтра логов.
|
|
||||||
|
|
||||||
### 2.3 Читать последние N debug-логов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s \
|
|
||||||
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>" \
|
|
||||||
"http://localhost:7070/debug/ws/logs?limit=200&runId=<RUN_ID>" | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3) Операционный сценарий “Codex + пользователь”
|
|
||||||
|
|
||||||
1. Codex поднимает сервер и сообщает пользователю ссылку на UI.
|
|
||||||
2. Codex пишет пользователю: **«Запусти двух клиентов и скажи “продолжай”»**.
|
|
||||||
3. Пользователь запускает два клиента (лучше под разными логинами).
|
|
||||||
4. Пользователь пишет: **«продолжай»**.
|
|
||||||
5. Codex:
|
|
||||||
- вызывает `/debug/ws/clients`,
|
|
||||||
- выбирает 2 сессии,
|
|
||||||
- вызывает `/debug/ws/connect`,
|
|
||||||
- получает `runId`,
|
|
||||||
- читает `/debug/ws/logs?runId=...` и сообщает прогресс.
|
|
||||||
6. Если соединение не удалось:
|
|
||||||
- Codex сообщает ошибки по логам,
|
|
||||||
- при необходимости просит перезапустить 2 клиента,
|
|
||||||
- повторяет запуск debug-run.
|
|
||||||
|
|
||||||
## 4) Какие сообщения считать успехом
|
|
||||||
|
|
||||||
- `peer_connection_connected`
|
|
||||||
- `debug_connection_success`
|
|
||||||
- `signal_sent_200/210/220` без ошибок
|
|
||||||
|
|
||||||
## 5) Что говорить пользователю в ходе прогона (через «колонку»/чат)
|
|
||||||
|
|
||||||
Рекомендуемые фразы:
|
|
||||||
|
|
||||||
- «Сервер запущен. Запусти двух клиентов и напиши “продолжай”.»
|
|
||||||
- «Вижу 2 активные сессии, запускаю тест соединения.»
|
|
||||||
- «Тест запущен, runId=... Сейчас проверяю логи.»
|
|
||||||
- «Соединение установлено / не установлено. Ниже причины и следующий шаг.»
|
|
||||||
|
|
||||||
## 6) Ограничения
|
|
||||||
|
|
||||||
- Механизм временный, не для production-эксплуатации.
|
|
||||||
- Доступ к debug API имеет любой, кто знает токен.
|
|
||||||
- Рекомендуется тестить между разными логинами.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
0. ПЕРЕДЕЛАТЬ ВСЁ НА НОВЫЙ ФОРМАТ!!
|
|
||||||
|
|
||||||
ВЫНЕСТИ ЭТИ ТРИ ВЕЩИ В ОБЩИЙ ПАРСЕР
|
|
||||||
* [2] type - тип соощения
|
|
||||||
* [2] Sиbtype - субтип сообщения
|
|
||||||
* [2] version - версия формата соощения
|
|
||||||
|
|
||||||
А ОСТАЛЬНОЕ В РЕАЛИЗАЦИЮ
|
|
||||||
|
|
||||||
|
|
||||||
ПЕРЕДЕЛАЕМ БД
|
|
||||||
|
|
||||||
1. СДЕЛАЕМ ЛИНИЮ ТОЛЬКО ДЛЯ ТЕХ ТИПОВ КОМУ НАДО (ЛАЙКАМ И ОТВЕТАМ НЕ НАДО)
|
|
||||||
(НОМЕР СООБЩЕНИЯ В ЛИНИИ ХРАНИТЬ В БЛОКАХ ВРОДЕ И НЕ НАДО ТЕМ БОЛЕЕ ЕГО ПОТОМ ПЕРЕПРОВЕРЯТЬ ВСЁ РАВНО)
|
|
||||||
А МОЖЕТ И НАДО ТК КАК ПО ОДНОМУ БЛОКУ ( ИЛИ ЧАСТИ БЛОКОВ ПОНЯТЬ КАКАЯ ЭТО ЧАСТЬ ПЕРЕПИСКИ - ВЕДЬ ГЛОБАЛ НОМЕР ВООБЩЕ НЕ ПОКАЗАТЕЛЬ)
|
|
||||||
|
|
||||||
В БД ПОМЕЧАТЬ ЧТО БЛОК ИЗ ЭТОЙ ЛИНИИ (ДЛЯ БЫСТРОГО ПОИСКА)
|
|
||||||
|
|
||||||
А УНИКАЛЬНЫЙ НОМЕР ЛИНИИ ЭТО ПО СУТИ НОМЕР СООБЩЕНИЯ СОЗДАВШЕГО ЛИНИЮ КАНАЛ (НУ И ФОРМАТ СООБЩЕНИЯ НАЧАЛА ЛИНИИ - КАНАЛА)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3. СООТВЕТСТВЕННО удалить НАПИСАТЬ/ПЕРОВЕРИТЬ НОРМАЛЬНЫЙ SubscriptionsDAO - ТК СТАРЫЙ РАБОТАЛ НО НА ДРУГОМ ФОРМАТЕ И ТИПО КРИВО
|
|
||||||
|
|
||||||
и дальше:
|
|
||||||
ЗДЕЛАТЬ ТРИ ЗАПРОСА:
|
|
||||||
СПИСОК КАНАЛОВ НА КОГО ПОДПИСАН И ПО СКОЛЬКО СООБЩЕНИЙ И ПОСЛДНИЙ ТЕКСТ
|
|
||||||
ДОДЕЛАТЬ И СВЯЗ ПОДПИСАН УЖЕ НЕ ТОЛЬКО НА ЧЕЛА НО И НА КАНАЛ. (И ПОЛУЧАЕТСЯ ЕСТЬ ОБЩИЙ КАНАЛЛ ПОСТОВ (НО НЕКОТОРЫЕ ПОСТЫ В НИКУДА-
|
|
||||||
А НЕКОТОРЫЕ ПОСТЫ ОБЪЯВЛЕНИЕ КАНАЛА
|
|
||||||
|
|
||||||
СПИСОК СООБЩЕНИЙ В КАНАЛЕ
|
|
||||||
|
|
||||||
ОПСИСАНИЕ ОДНОГО СОООБЩЕНИЯ (С ИСТОРИЕЙ ДО НАЧАЛА ВЕТКИ И СО ВСЕМИ ОТВЕТАМИ НА НЕГО)
|
|
||||||
|
|
||||||
(НУ И В БУДУЩЕМ четвёртый ИСТОРИЮ сообщения ПО ЕДИТУ)
|
|
||||||
|
|
||||||
|
|
||||||
И ПОМЯТКА
|
|
||||||
ВСЕГДА СЧИТАЕМ ПО ПОСЛЕДНЕМУ БЛОКЧЕЙНУ ДОСТУПНОМУ ПОЛЬЗОВАТЕЛЮ
|
|
||||||
ХОТЯ ССЫЛКА ПО НОМЕРУ БЛОКЧЕЙНА КУДА ДОБАВИЛИ
|
|
||||||
|
|
||||||
ЛАЙКИ И ОТВЕТЫ ПИШЕМ НА НОМЕР СООБЩЕНИЯ ЕДИТА
|
|
||||||
(СЧИТАЕМ ТРИГЕРОМ И НА ОРИГИНАЛЬНЫЙ СУМАРНОЕ И ОТДЕЛЬНО НА НЕГО, И НА КАЖДЫЙ ЕДИТ ОТДЕЛЬНО)
|
|
||||||
|
|
||||||
ОТВЕТЫ ПОКАЗЫВАЕМ ВСЕ ВРАЗ
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Сделать возможность убрать свой лайк. (пока не надо а сложность что надо больше проверок) - хотя можно и без проверки, просто за двойной лайк или за снятие двойное лайка. Будет двойное проникновение :)) тому кто изменил код клиента и убрал проверку на клиенте - и блокчейн заблокируется и всё.
|
|
||||||
поэтому просто на каждую реакцию добавиться убрать эту ракцию .
|
|
||||||
- это просто
|
|
||||||
|
|
||||||
сделатьпотом что бы в солану_юзерс хранилось имя текущего блокчейна пользователя. Что бы потом можно было грузить именно актуальный ТО ЕСТЬ потом можно будет менять блокченый!
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
сделать сессион пасворд тоже ключём подписи устройства!!
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
Перечень библиотек и их краткое описание
|
|
||||||
|
|
||||||
shine-server-log
|
|
||||||
Статический “сиренный” метод для максимально заметного критического лога администратору
|
|
||||||
|
|
||||||
shine-server-config
|
|
||||||
Минимальный конфиг-лоадер, который один раз читает application.properties и даёт доступ к параметрам.
|
|
||||||
|
|
||||||
shine-server-geo
|
|
||||||
Утилиты, которые вытаскивают IP/язык/UA из Jetty WebSocket и (опционально) резолвят гео по IP с кэшем в БД.
|
|
||||||
|
|
||||||
shine-server-crypto
|
|
||||||
Базовые крипто-утилиты для SHA-256 и Ed25519 (BouncyCastle) + проверка подписи/хэша для .bch сущностей и маленький self-test.
|
|
||||||
|
|
||||||
shine-server-bd
|
|
||||||
Библиотека реалезующая всю работу с БД:
|
|
||||||
|
|
||||||
shine-server-blockchain
|
|
||||||
Библиотека, которая задаёт единый бинарный формат блоков (RAW+signature+hash), парсит/валидирует “тело” блока по type/version, и проверяет целостность/подпись цепочки через SHA-256 + Ed25519 с привязкой к login и предыдущим хэшам.
|
|
||||||
|
|
||||||
shine-server-protocol
|
|
||||||
Библиотека JSON-протокол поверх WebSocket для взаимодействия с клиентами.
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
SHiNE — структура БД (актуальная версия)
|
|
||||||
|
|
||||||
Перечень таблиц и назначение
|
|
||||||
|
|
||||||
solana_users
|
|
||||||
Справочник пользователей: логин + ключ устройства + (опционально) Solana-ключ.
|
|
||||||
Базовая таблица, используется как FK почти везде.
|
|
||||||
|
|
||||||
active_sessions
|
|
||||||
Активные сессии авторизации/работы клиента: секреты, тайминги, WebPush-данные, IP и информация о клиенте.
|
|
||||||
|
|
||||||
users_params
|
|
||||||
Хранилище актуальных параметров пользователя.
|
|
||||||
Для каждой пары (login, param) хранится только самая новая версия по time_ms.
|
|
||||||
|
|
||||||
ip_geo_cache
|
|
||||||
Кеш геолокации по IP для снижения нагрузки на внешние сервисы.
|
|
||||||
|
|
||||||
blockchain_state
|
|
||||||
Агрегированное состояние блокчейна по blockchain_name:
|
|
||||||
лимиты, текущий размер, последний глобальный блок и состояние линий 0..7.
|
|
||||||
|
|
||||||
blocks
|
|
||||||
Журнал всех блоков и сообщений.
|
|
||||||
Содержит историю событий: тексты, реакции, ответы, связи.
|
|
||||||
PRIMARY KEY намеренно отсутствует.
|
|
||||||
|
|
||||||
connections_state ⭐
|
|
||||||
Актуальное состояние связей между пользователями
|
|
||||||
(друг / контакт / подписка).
|
|
||||||
Обновляется автоматически на основе событий из blocks.
|
|
||||||
|
|
||||||
message_stats ⭐
|
|
||||||
Агрегированные счётчики лайков и ответов на конкретные сообщения.
|
|
||||||
Поддерживается триггерами из blocks.
|
|
||||||
|
|
||||||
Таблицы подробно
|
|
||||||
|
|
||||||
solana_users
|
|
||||||
login — TEXT PK — уникальный логин пользователя
|
|
||||||
client_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64))
|
|
||||||
solana_key — TEXT NULL — публичный ключ Solana-аккаунта
|
|
||||||
|
|
||||||
active_sessions
|
|
||||||
session_id — TEXT PK — идентификатор сессии
|
|
||||||
login — TEXT NOT NULL, FK → solana_users(login)
|
|
||||||
session_pwd — TEXT NOT NULL — секрет сессии
|
|
||||||
storage_pwd — TEXT NOT NULL — секрет storage
|
|
||||||
session_created_at_ms — INTEGER NOT NULL
|
|
||||||
last_authirificated_at_ms — INTEGER NOT NULL
|
|
||||||
push_endpoint — TEXT NULL
|
|
||||||
push_p256dh_key — TEXT NULL
|
|
||||||
push_auth_key — TEXT NULL
|
|
||||||
client_ip — TEXT NULL
|
|
||||||
client_info_from_client — TEXT NULL
|
|
||||||
client_info_from_request — TEXT NULL
|
|
||||||
user_language — TEXT NULL
|
|
||||||
|
|
||||||
users_params
|
|
||||||
login — TEXT NOT NULL, FK → solana_users(login)
|
|
||||||
param — TEXT NOT NULL
|
|
||||||
time_ms — INTEGER NOT NULL
|
|
||||||
value — TEXT NOT NULL
|
|
||||||
client_key — TEXT NULL
|
|
||||||
signature — TEXT NULL
|
|
||||||
|
|
||||||
Ограничение:
|
|
||||||
UNIQUE(login, param)
|
|
||||||
|
|
||||||
Логика:
|
|
||||||
обновление принимается только если excluded.time_ms > users_params.time_ms
|
|
||||||
|
|
||||||
ip_geo_cache
|
|
||||||
ip — TEXT PK
|
|
||||||
geo — TEXT NULL
|
|
||||||
updated_at_ms — INTEGER NOT NULL
|
|
||||||
|
|
||||||
blockchain_state
|
|
||||||
blockchain_name — TEXT PK
|
|
||||||
login — TEXT NOT NULL, FK → solana_users(login)
|
|
||||||
blockchain_key — TEXT NOT NULL
|
|
||||||
size_limit — INTEGER NOT NULL
|
|
||||||
file_size_bytes — INTEGER NOT NULL
|
|
||||||
last_global_number — INTEGER NOT NULL (-1 = genesis)
|
|
||||||
last_global_hash — TEXT NOT NULL
|
|
||||||
updated_at_ms — INTEGER NOT NULL
|
|
||||||
|
|
||||||
Линии 0..7:
|
|
||||||
для каждой линии:
|
|
||||||
lineX_last_number
|
|
||||||
lineX_last_hash
|
|
||||||
|
|
||||||
blocks
|
|
||||||
login — TEXT NOT NULL
|
|
||||||
bch_name — TEXT NOT NULL
|
|
||||||
block_global_number — INTEGER NOT NULL
|
|
||||||
block_global_pre_hash — TEXT NOT NULL
|
|
||||||
block_line_index — INTEGER NOT NULL
|
|
||||||
block_line_number — INTEGER NOT NULL
|
|
||||||
block_line_pre_hash — TEXT NOT NULL
|
|
||||||
msg_type — INTEGER NOT NULL
|
|
||||||
msg_sub_type — INTEGER NOT NULL
|
|
||||||
block_bytes — BLOB NULL
|
|
||||||
|
|
||||||
Ссылка на другой блок (nullable):
|
|
||||||
to_login
|
|
||||||
to_bch_name
|
|
||||||
to_block_global_number
|
|
||||||
to_block_hash
|
|
||||||
|
|
||||||
connections_state ⭐
|
|
||||||
Текущее агрегированное состояние связей.
|
|
||||||
|
|
||||||
login — TEXT NOT NULL
|
|
||||||
rel_type — INTEGER NOT NULL
|
|
||||||
10 = FRIEND
|
|
||||||
20 = CONTACT
|
|
||||||
30 = FOLLOW
|
|
||||||
to_login — TEXT NOT NULL
|
|
||||||
to_bch_name — TEXT NOT NULL
|
|
||||||
to_block_global_number — INTEGER NULL
|
|
||||||
to_block_hash — TEXT NULL
|
|
||||||
|
|
||||||
Ограничение:
|
|
||||||
UNIQUE(login, rel_type, to_login)
|
|
||||||
|
|
||||||
message_stats ⭐
|
|
||||||
Счётчики активности по целевому сообщению.
|
|
||||||
|
|
||||||
to_login — TEXT NOT NULL
|
|
||||||
to_bch_name — TEXT NOT NULL
|
|
||||||
to_block_global_number — INTEGER NOT NULL
|
|
||||||
to_block_hash — TEXT NOT NULL
|
|
||||||
likes_count — INTEGER NOT NULL DEFAULT 0
|
|
||||||
replies_count — INTEGER NOT NULL DEFAULT 0
|
|
||||||
|
|
||||||
UNIQUE:
|
|
||||||
(to_login, to_bch_name, to_block_global_number, to_block_hash)
|
|
||||||
|
|
||||||
Триггеры БД (полная логика)
|
|
||||||
|
|
||||||
3.1 Связи пользователей
|
|
||||||
trg_blocks_connection_state_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
|
|
||||||
Условие:
|
|
||||||
msg_type = 3 (connection)
|
|
||||||
|
|
||||||
Добавление / обновление связи
|
|
||||||
msg_sub_type IN (10,20,30)
|
|
||||||
выполняется UPSERT в connections_state
|
|
||||||
|
|
||||||
Удаление связи
|
|
||||||
msg_sub_type IN (11,21,31)
|
|
||||||
удаляется соответствующая связь:
|
|
||||||
11 → 10
|
|
||||||
21 → 20
|
|
||||||
31 → 30
|
|
||||||
|
|
||||||
Итог:
|
|
||||||
blocks — журнал событий
|
|
||||||
connections_state — всегда актуальное состояние
|
|
||||||
|
|
||||||
3.2 Подсчёт лайков ⭐
|
|
||||||
trg_blocks_message_stats_like_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
|
|
||||||
Условие:
|
|
||||||
msg_type = 2 (reaction)
|
|
||||||
msg_sub_type = 1 (like)
|
|
||||||
|
|
||||||
Действие:
|
|
||||||
определяется цель по to_bch_name, to_block_global_number, to_block_hash
|
|
||||||
to_login вычисляется как
|
|
||||||
substr(to_bch_name, 1, length(to_bch_name) - 3)
|
|
||||||
выполняется UPSERT в message_stats
|
|
||||||
likes_count += 1
|
|
||||||
|
|
||||||
3.3 Подсчёт ответов ⭐
|
|
||||||
trg_blocks_message_stats_reply_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
|
|
||||||
Условие:
|
|
||||||
msg_type = 1 (text)
|
|
||||||
msg_sub_type = 2 (reply)
|
|
||||||
|
|
||||||
Действие:
|
|
||||||
цель определяется аналогично лайкам
|
|
||||||
выполняется UPSERT в message_stats
|
|
||||||
replies_count += 1
|
|
||||||
|
|
||||||
Индексы (смысл)
|
|
||||||
|
|
||||||
idx_solana_users_login — поиск пользователя
|
|
||||||
idx_active_sessions_login — сессии пользователя
|
|
||||||
idx_users_params_login — параметры пользователя
|
|
||||||
idx_ip_geo_cache_updated_at — чистка кеша
|
|
||||||
idx_blockchain_state_login — блокчейны пользователя
|
|
||||||
idx_blockchain_state_updated_at — обслуживание
|
|
||||||
idx_blocks_chain_global — чтение цепочки
|
|
||||||
idx_blocks_to_target — реакции / ответы
|
|
||||||
idx_message_stats_target — быстрый доступ к счётчикам
|
|
||||||
|
|
||||||
Итоговая модель мышления
|
|
||||||
|
|
||||||
blocks — неизменяемый журнал событий
|
|
||||||
connections_state — проекция связей
|
|
||||||
message_stats — проекция активности
|
|
||||||
всё вычисляется детерминированно через триггеры
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# Протокол звонков (MVP)
|
|
||||||
|
|
||||||
Версия: browser-to-browser, runtime-only signaling.
|
|
||||||
|
|
||||||
## Цели
|
|
||||||
- Технические сообщения звонка не сохраняются в БД direct_messages.
|
|
||||||
- Первый INVITE рассылается всем активным сессиям получателя и дублируется web push.
|
|
||||||
- Последующие сигналы идут только в конкретную sessionId и не дублируются в push.
|
|
||||||
|
|
||||||
## Операции API
|
|
||||||
|
|
||||||
### 1) CallInviteBroadcast
|
|
||||||
Отправляет общий вызов пользователю.
|
|
||||||
|
|
||||||
Запрос payload:
|
|
||||||
- `toLogin: string`
|
|
||||||
- `callId: string`
|
|
||||||
- `type: 100` (INVITE)
|
|
||||||
|
|
||||||
Поведение сервера:
|
|
||||||
- Рассылает `IncomingCallInvite` во все активные WS-сессии `toLogin`.
|
|
||||||
- В payload события передаёт:
|
|
||||||
- `fromLogin`
|
|
||||||
- `fromSessionId` (session инициатора)
|
|
||||||
- `toLogin`
|
|
||||||
- `callId`
|
|
||||||
- `type=100`
|
|
||||||
- `timeMs`
|
|
||||||
- Отправляет web push уведомление о входящем вызове.
|
|
||||||
|
|
||||||
Ответ payload:
|
|
||||||
- `callId`
|
|
||||||
- `deliveredWsSessions`
|
|
||||||
- `deliveredFcmSessions`
|
|
||||||
|
|
||||||
### 2) CallSignalToSession
|
|
||||||
Отправляет технический сигнал в конкретную сессию.
|
|
||||||
|
|
||||||
Запрос payload:
|
|
||||||
- `toLogin: string`
|
|
||||||
- `targetSessionId: string`
|
|
||||||
- `callId: string`
|
|
||||||
- `type: int`
|
|
||||||
- `data: string` (для SDP/ICE/служебных строк)
|
|
||||||
|
|
||||||
Поведение сервера:
|
|
||||||
- Ищет только `targetSessionId`.
|
|
||||||
- Проверяет, что сессия принадлежит `toLogin`.
|
|
||||||
- Отправляет `IncomingCallSignal` только в эту сессию.
|
|
||||||
- В БД ничего не сохраняет.
|
|
||||||
- Push не отправляет.
|
|
||||||
|
|
||||||
Ответ payload:
|
|
||||||
- `delivered: boolean`
|
|
||||||
|
|
||||||
## Коды type
|
|
||||||
- `100` INVITE
|
|
||||||
- `110` RINGING
|
|
||||||
- `120` ACCEPT
|
|
||||||
- `130` DECLINE_BUSY
|
|
||||||
- `140` TIMEOUT
|
|
||||||
- `150` HANGUP
|
|
||||||
- `200` OFFER
|
|
||||||
- `210` ANSWER
|
|
||||||
- `220` ICE
|
|
||||||
|
|
||||||
## Правила UI/логики
|
|
||||||
- Если уже есть активный звонок и пришел новый INVITE -> автоответ `DECLINE_BUSY` без UI.
|
|
||||||
- После ACCEPT `callId` остаётся во всех OFFER/ANSWER/ICE сообщениях до конца звонка.
|
|
||||||
- При параллельных звонках A<->B допускается детерминированное правило, кто создаёт OFFER.
|
|
||||||
|
|
||||||
## Тайминги MVP
|
|
||||||
- Ожидание подтверждения/реакции после INVITE: до 5с (у инициатора).
|
|
||||||
- Ожидание принятия у входящего звонка: 20с.
|
|
||||||
- Общий лимит ожидания до соединения: 22с.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
Дальше делать:
|
|
||||||
Описание форматов.
|
|
||||||
Запросы клиент-сервер.
|
|
||||||
Промт на клиента.
|
|
||||||
|
|
||||||
---
|
|
||||||
Потом в сервак дописать синхронизацию серверов.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# SHiNE Deployment Servers Inventory
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
This folder contains all deployment-related notes and server records for SHiNE.
|
|
||||||
|
|
||||||
## Legacy Production Server
|
|
||||||
- Name: `VPS-02` (legacy)
|
|
||||||
- Access: `root@194.87.0.247`
|
|
||||||
- Current role: old production server
|
|
||||||
- Confirmed services:
|
|
||||||
- `coturn` is installed and active (`systemd: active/running`)
|
|
||||||
- `caddy` is installed (reported by project context; verify version on host if needed)
|
|
||||||
- TURN configuration observed on host:
|
|
||||||
- `listening-port=3478`
|
|
||||||
- `external-ip=194.87.0.247`
|
|
||||||
- `relay-ip=194.87.0.247`
|
|
||||||
- auth mode: `use-auth-secret` + `static-auth-secret`
|
|
||||||
- SHiNE deployment note:
|
|
||||||
- This host is used as current/legacy runtime for SHiNE.
|
|
||||||
- Gradle-based deployment is used in this project (see repository deploy tasks and scripts).
|
|
||||||
|
|
||||||
## Target Production Server (Migration)
|
|
||||||
- Name: `VPS-05` (new)
|
|
||||||
- Access: `root@45.136.124.227`
|
|
||||||
- Planned role: new primary production server for gradual migration
|
|
||||||
- Baseline setup done:
|
|
||||||
- `ripgrep` installed
|
|
||||||
- user `player` created
|
|
||||||
- user `player` added to `sudo` group
|
|
||||||
- deployment directory created: `/home/player/SHiNE`
|
|
||||||
- Rule:
|
|
||||||
- All SHiNE-related runtime files and deployments on VPS-05 should be placed under `/home/player/SHiNE`.
|
|
||||||
|
|
||||||
## Additional TURN Node
|
|
||||||
- Name: `promo-node-93`
|
|
||||||
- Access: `ubuntu@93.170.12.154` (and `player` user for SHiNE operations)
|
|
||||||
- Role: additional TURN node for SHiNE calls
|
|
||||||
- TURN setup:
|
|
||||||
- `coturn` installed and active
|
|
||||||
- `listening-port=3478`
|
|
||||||
- `tls-listening-port=5349`
|
|
||||||
- `use-auth-secret` + shared `static-auth-secret`
|
|
||||||
- relay UDP port range: `49152-50152`
|
|
||||||
- Runtime files:
|
|
||||||
- `/etc/turnserver.conf`
|
|
||||||
- `/home/player/SHiNE/coturn/turnserver.conf`
|
|
||||||
- Cleanup done:
|
|
||||||
- Disabled old reverse SSH tunnel (`reverse-ssh.service`) that exposed `0.0.0.0:1200 -> localhost:22` to `194.87.0.247`.
|
|
||||||
|
|
||||||
## Next Migration Steps (recommended)
|
|
||||||
1. Install and configure runtime dependencies (JDK, Caddy, DB, TURN if required).
|
|
||||||
2. Mirror SHiNE deployment process from VPS-02 using existing Gradle deployment flow.
|
|
||||||
3. Move traffic gradually and validate logs/metrics before final cutover.
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Production-серверы SHiNE
|
||||||
|
|
||||||
|
## Короткий ответ
|
||||||
|
|
||||||
|
По текущим данным репозитория у SHiNE описаны **два production-контура**:
|
||||||
|
|
||||||
|
- `player@shineup.me`
|
||||||
|
- домен `shineup.me`
|
||||||
|
- IP `178.208.64.62`
|
||||||
|
|
||||||
|
и
|
||||||
|
|
||||||
|
- `player@193.8.215.70`
|
||||||
|
- домен `t.shineup.me`
|
||||||
|
- IP `193.8.215.70`
|
||||||
|
|
||||||
|
Отдельно резервный хост `test.shineup.me` сейчас помечен как временно неработающий.
|
||||||
|
|
||||||
|
## 1. Основной production-хост
|
||||||
|
|
||||||
|
- SSH: `player@shineup.me`
|
||||||
|
- домен: `shineup.me`
|
||||||
|
- IP: `178.208.64.62`
|
||||||
|
- пользователь: `player`
|
||||||
|
- базовый путь: `/home/player`
|
||||||
|
|
||||||
|
Основные каталоги:
|
||||||
|
|
||||||
|
- проект SHiNE: `/home/player/SHiNE`
|
||||||
|
- серверный jar: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||||
|
- UI: `/home/player/SHiNE/shine-ui`
|
||||||
|
- данные: `/home/player/SHiNE/shine-server/data/`
|
||||||
|
- логи: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
|
||||||
|
Сервисы:
|
||||||
|
|
||||||
|
- `shine-server.service`
|
||||||
|
- `caddy.service`
|
||||||
|
|
||||||
|
Caddy:
|
||||||
|
|
||||||
|
- активный конфиг: `/etc/caddy/Caddyfile`
|
||||||
|
- UI root: `/home/player/SHiNE/shine-ui`
|
||||||
|
- `/ws` проксируется на `127.0.0.1:7070`
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
|
||||||
|
- `./gradlew deployServerProduction`
|
||||||
|
- `./gradlew deployUIProduction`
|
||||||
|
|
||||||
|
Правило:
|
||||||
|
|
||||||
|
- любые изменения на `shineup.me` делать только после отдельного подтверждения пользователя.
|
||||||
|
|
||||||
|
## 2. Второй production-сервер
|
||||||
|
|
||||||
|
- SSH: `player@193.8.215.70`
|
||||||
|
- домен: `t.shineup.me`
|
||||||
|
- IP: `193.8.215.70`
|
||||||
|
- пользователь: `player`
|
||||||
|
- базовый путь: `/home/player`
|
||||||
|
|
||||||
|
Роль:
|
||||||
|
|
||||||
|
- второй production-контур SHiNE;
|
||||||
|
- использовать как production-сервер, несмотря на исторические имена deploy-задач
|
||||||
|
`deployServerTest2` / `deployUITest2`.
|
||||||
|
|
||||||
|
Основные каталоги:
|
||||||
|
|
||||||
|
- проект SHiNE: `/home/player/SHiNE`
|
||||||
|
- серверный jar: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||||
|
- UI: `/home/player/SHiNE/shine-ui`
|
||||||
|
- данные: `/home/player/SHiNE/shine-server/data/`
|
||||||
|
- логи: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
|
||||||
|
## 3. Связанные публичные production-публикации на том же хосте
|
||||||
|
|
||||||
|
На этом же production-хосте есть отдельная публикация для `shine_payments`:
|
||||||
|
|
||||||
|
- каталог: `/home/player/sites/test-solana-tickets.shineup.me`
|
||||||
|
- домены:
|
||||||
|
- `https://test-solana-tickets.shineup.me`
|
||||||
|
- `https://test-solana-tickets.shiningpeople.ru`
|
||||||
|
|
||||||
|
Это не второй production-хост SHiNE, а отдельный сайт на том же сервере.
|
||||||
|
|
||||||
|
## 4. Какие серверы не считать production
|
||||||
|
|
||||||
|
Не production:
|
||||||
|
|
||||||
|
- `test.shineup.me` (`93.170.12.154`) — резервный хост, временно не работает
|
||||||
|
- `t1.shineup.me`
|
||||||
|
- `t2.shineup.me`
|
||||||
|
- `t3.shineup.me`
|
||||||
|
- `t4.shineup.me`
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Deploy
|
||||||
|
|
||||||
|
Подробности о том, где что задеплоено в SHiNE, нужно искать в папке `Deploy/`.
|
||||||
|
|
||||||
|
Эта папка служит краткой картой окружений:
|
||||||
|
|
||||||
|
- [TEST_SERVERS.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Deploy/TEST_SERVERS.md) — тестовые серверы и стенды;
|
||||||
|
- [PRODUCTION_SERVERS.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Deploy/PRODUCTION_SERVERS.md) — production-контур и связанные публичные публикации.
|
||||||
|
|
||||||
|
Ниже краткая сводка.
|
||||||
|
|
||||||
|
## Основные публичные контуры
|
||||||
|
|
||||||
|
- Production SHiNE:
|
||||||
|
- `player@shineup.me`
|
||||||
|
- домен `shineup.me`
|
||||||
|
- IP `178.208.64.62`
|
||||||
|
- Второй production SHiNE:
|
||||||
|
- `player@193.8.215.70`
|
||||||
|
- домен `t.shineup.me`
|
||||||
|
- IP `193.8.215.70`
|
||||||
|
- Временно неработающий резервный test:
|
||||||
|
- `player@93.170.12.154`
|
||||||
|
- домен `test.shineup.me`
|
||||||
|
- IP `93.170.12.154`
|
||||||
|
|
||||||
|
## Отдельный quad-devnet стенд
|
||||||
|
|
||||||
|
На отдельном VPS `178.208.90.249` подняты 4 независимых test/devnet-инстанса:
|
||||||
|
|
||||||
|
- `t1.shineup.me`
|
||||||
|
- `t2.shineup.me`
|
||||||
|
- `t3.shineup.me`
|
||||||
|
- `t4.shineup.me`
|
||||||
|
|
||||||
|
## Важно
|
||||||
|
|
||||||
|
- Production-контура SHiNE сейчас два: `shineup.me` и `t.shineup.me`.
|
||||||
|
- `test.shineup.me` сейчас помечен как временно неработающий резервный test-хост.
|
||||||
|
- `t1..t4.shineup.me` — это отдельные тестовые/devnet-контуры, не production.
|
||||||
|
- Любые изменения на `shineup.me` делать только после отдельного подтверждения пользователя.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Тестовые серверы SHiNE
|
||||||
|
|
||||||
|
Этот файл описывает тестовые контуры, которые сейчас фигурируют в проекте.
|
||||||
|
|
||||||
|
## 1. Исторический `test2`, теперь второй production-сервер
|
||||||
|
|
||||||
|
- SSH: `player@193.8.215.70`
|
||||||
|
- Домен: `t.shineup.me`
|
||||||
|
- IP: `193.8.215.70`
|
||||||
|
- Назначение: второй production-контур SHiNE
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
|
||||||
|
- каталог SHiNE: `/home/player/SHiNE`
|
||||||
|
- сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||||
|
- UI: `/home/player/SHiNE/shine-ui`
|
||||||
|
- данные: `/home/player/SHiNE/shine-server/data/`
|
||||||
|
- логи: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
|
||||||
|
Сервисы:
|
||||||
|
|
||||||
|
- `shine-server.service`
|
||||||
|
- `caddy.service`
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
|
||||||
|
- `./gradlew deployServer`
|
||||||
|
- `./gradlew deployServerTest2`
|
||||||
|
- `./gradlew deployUI`
|
||||||
|
- `./gradlew deployUITest2`
|
||||||
|
|
||||||
|
Примечания:
|
||||||
|
|
||||||
|
- этот хост больше не считать test-контуром;
|
||||||
|
- исторические имена deploy-задач `deployServerTest2` / `deployUITest2` сохранены, но сам хост считать production;
|
||||||
|
- при описании окружений перечислять его как второй production-сервер.
|
||||||
|
|
||||||
|
## 2. Резервный test-сервер, временно неработающий
|
||||||
|
|
||||||
|
- SSH: `player@93.170.12.154`
|
||||||
|
- Домен: `test.shineup.me`
|
||||||
|
- IP: `93.170.12.154`
|
||||||
|
- Назначение: резервный test
|
||||||
|
|
||||||
|
Структура по проектным докам:
|
||||||
|
|
||||||
|
- каталог SHiNE: `/home/player/SHiNE`
|
||||||
|
- сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||||
|
- UI: `/home/player/SHiNE/shine-ui`
|
||||||
|
- данные: `/home/player/SHiNE/shine-server/data/`
|
||||||
|
- логи: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
|
||||||
|
- `./gradlew deployServerTest`
|
||||||
|
- `./gradlew deployUITest`
|
||||||
|
|
||||||
|
Примечания:
|
||||||
|
|
||||||
|
- этот хост резервный;
|
||||||
|
- сейчас помечен как временно неработающий;
|
||||||
|
- использовать его без отдельной причины не нужно.
|
||||||
|
|
||||||
|
## 3. Отдельный quad-devnet стенд `t1..t4`
|
||||||
|
|
||||||
|
- VPS: `178.208.90.249`
|
||||||
|
- пользователь: `player`
|
||||||
|
- назначение: 4 независимых SHiNE-инстанса на Solana `devnet`
|
||||||
|
|
||||||
|
Домены и логины:
|
||||||
|
|
||||||
|
- `server_t1` -> `https://t1.shineup.me`
|
||||||
|
- `server_t2` -> `https://t2.shineup.me`
|
||||||
|
- `server_t3` -> `https://t3.shineup.me`
|
||||||
|
- `server_t4` -> `https://t4.shineup.me`
|
||||||
|
|
||||||
|
Каталоги:
|
||||||
|
|
||||||
|
- `/home/player/t1/server`
|
||||||
|
- `/home/player/t1/UI`
|
||||||
|
- `/home/player/t2/server`
|
||||||
|
- `/home/player/t2/UI`
|
||||||
|
- `/home/player/t3/server`
|
||||||
|
- `/home/player/t3/UI`
|
||||||
|
- `/home/player/t4/server`
|
||||||
|
- `/home/player/t4/UI`
|
||||||
|
|
||||||
|
Подробная памятка на самом VPS:
|
||||||
|
|
||||||
|
- `/home/player/Agents.md`
|
||||||
|
|
||||||
|
Порты и systemd:
|
||||||
|
|
||||||
|
- `t1` -> `7101` -> `shine-t1.service`
|
||||||
|
- `t2` -> `7102` -> `shine-t2.service`
|
||||||
|
- `t3` -> `7103` -> `shine-t3.service`
|
||||||
|
- `t4` -> `7104` -> `shine-t4.service`
|
||||||
|
|
||||||
|
Что важно по конфигу каждого инстанса:
|
||||||
|
|
||||||
|
- отдельный `/home/player/tX/server/application.properties`
|
||||||
|
- `server.port=710X`
|
||||||
|
- `server.SHiNE.login=server_tX`
|
||||||
|
- `db.path=data/shine.sqlite`
|
||||||
|
- `solana.cluster=devnet`
|
||||||
|
- `solana.rpcUrl=https://api.devnet.solana.com`
|
||||||
|
- `server.ui.indexPath=/home/player/tX/UI/index.html`
|
||||||
|
- `server.info.url=https://tX.shineup.me`
|
||||||
|
|
||||||
|
UI каждого инстанса:
|
||||||
|
|
||||||
|
- живёт в отдельной копии `shine-UI`;
|
||||||
|
- использует свой `js/deploy-config.js`;
|
||||||
|
- по умолчанию смотрит именно на свой `tX.shineup.me`.
|
||||||
|
|
||||||
|
Caddy на стенде:
|
||||||
|
|
||||||
|
- конфиг: `/etc/caddy/Caddyfile`
|
||||||
|
- статика: `/home/player/tX/UI`
|
||||||
|
- `/ws` проксируется на `127.0.0.1:710X`
|
||||||
|
|
||||||
|
Operational-нюанс:
|
||||||
|
|
||||||
|
- при одновременных рестартах возможны `HTTP 429` от `api.devnet.solana.com`;
|
||||||
|
- поэтому сервисы `shine-t1..shine-t4` лучше перезапускать по одному, с паузой.
|
||||||
|
|
||||||
|
## 4. Что проверять первым делом
|
||||||
|
|
||||||
|
Для любого test-контура полезны такие быстрые проверки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://t.shineup.me
|
||||||
|
curl -I https://test.shineup.me
|
||||||
|
curl -I https://t1.shineup.me
|
||||||
|
curl -I https://t2.shineup.me
|
||||||
|
curl -I https://t3.shineup.me
|
||||||
|
curl -I https://t4.shineup.me
|
||||||
|
```
|
||||||
|
|
||||||
|
Для quad-devnet VPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4
|
||||||
|
```
|
||||||
|
|
||||||
|
Для основного/резервного test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl --no-pager --full status shine-server caddy
|
||||||
|
```
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
# API для разработчиков: Технические запросы
|
|
||||||
|
|
||||||
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
|
|
||||||
|
|
||||||
Сейчас здесь шесть методов:
|
|
||||||
|
|
||||||
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
|
|
||||||
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
|
|
||||||
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
|
|
||||||
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
|
|
||||||
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
|
|
||||||
- `CallDeliveryReport` — диагностический отчёт клиента о доставке/установке звонка.
|
|
||||||
|
|
||||||
Логика раздела такая:
|
|
||||||
|
|
||||||
- `Ping` нужен для регулярной проверки, что соединение всё ещё живо;
|
|
||||||
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
|
|
||||||
|
|
||||||
Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов.
|
|
||||||
|
|
||||||
## 1. `Ping`
|
|
||||||
|
|
||||||
### Назначение
|
|
||||||
|
|
||||||
Служебный keep-alive запрос.
|
|
||||||
|
|
||||||
Клиент может отправлять его периодически, чтобы:
|
|
||||||
|
|
||||||
- поддерживать активное WebSocket-соединение;
|
|
||||||
- понимать, что сервер отвечает;
|
|
||||||
- при необходимости получать текущее серверное время.
|
|
||||||
|
|
||||||
### Запрос
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "Ping",
|
|
||||||
"requestId": "ping-001",
|
|
||||||
"payload": {
|
|
||||||
"ts": 1774700000123
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Поле `ts` в запросе необязательно для логики сервера. Сервер его не валидирует и не использует для принятия решения.
|
|
||||||
|
|
||||||
### Успешный ответ
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "Ping",
|
|
||||||
"requestId": "ping-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"ts": 1774700000456
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Специфические коды ошибок `Ping`
|
|
||||||
|
|
||||||
- У `Ping` нет специальных прикладных ошибок.
|
|
||||||
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `GetServerInfo`
|
|
||||||
|
|
||||||
### Назначение
|
|
||||||
|
|
||||||
Запрос публичной информации о сервере.
|
|
||||||
|
|
||||||
Он нужен клиенту для выбора сервера в децентрализованной сети. По этому запросу клиент может:
|
|
||||||
|
|
||||||
- проверить, что сервер вообще доступен;
|
|
||||||
- показать URL и версию сервера;
|
|
||||||
- показать физический регион или адрес размещения;
|
|
||||||
- показать описание сервера;
|
|
||||||
- показать поле `origin` как комментарий о природе этого узла;
|
|
||||||
- показать дополнительную текстовую информацию.
|
|
||||||
|
|
||||||
Этот запрос доступен без авторизации.
|
|
||||||
|
|
||||||
### Источник данных
|
|
||||||
|
|
||||||
- `version` берётся из Gradle build и подставляется в `application.properties`;
|
|
||||||
- остальные поля читаются из настроек сервера;
|
|
||||||
- если значение в конфиге не задано, сервер возвращает пустую строку.
|
|
||||||
|
|
||||||
### Запрос
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "GetServerInfo",
|
|
||||||
"requestId": "srv-001",
|
|
||||||
"payload": {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Успешный ответ
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "GetServerInfo",
|
|
||||||
"requestId": "srv-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"url": "wss://node.example.org/ws",
|
|
||||||
"version": "1.0",
|
|
||||||
"physicalRegion": "Грузия, Тбилиси",
|
|
||||||
"description": "Public community SHiNE node",
|
|
||||||
"origin": "Community-operated node",
|
|
||||||
"extraInfo": "IPv4 + IPv6; test federation enabled"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Поля ответа
|
|
||||||
|
|
||||||
- `url` — публичный URL сервера.
|
|
||||||
- `version` — версия сервера из Gradle build.
|
|
||||||
- `physicalRegion` — физический регион или адрес размещения сервера.
|
|
||||||
- `description` — человекочитаемое описание сервера.
|
|
||||||
- `origin` — комментарий о том, какой это сервер.
|
|
||||||
- `extraInfo` — любая дополнительная информация о сервере.
|
|
||||||
|
|
||||||
### Специфические коды ошибок `GetServerInfo`
|
|
||||||
|
|
||||||
- У `GetServerInfo` нет специальных прикладных ошибок при штатной работе.
|
|
||||||
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. `GetCallIceConfig`
|
|
||||||
|
|
||||||
Доступно только после успешной авторизации.
|
|
||||||
|
|
||||||
### Запрос
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "GetCallIceConfig",
|
|
||||||
"requestId": "ice-001",
|
|
||||||
"payload": {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Успешный ответ
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "GetCallIceConfig",
|
|
||||||
"requestId": "ice-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"stunUrls": ["stun:stun.example.org:3478"],
|
|
||||||
"turnUrls": ["turn:turn.example.org:3478?transport=udp"],
|
|
||||||
"turnUsername": "user",
|
|
||||||
"turnPassword": "password",
|
|
||||||
"turnServers": [
|
|
||||||
{
|
|
||||||
"id": "primary",
|
|
||||||
"urls": ["turn:turn.example.org:3478?transport=udp"],
|
|
||||||
"username": "user",
|
|
||||||
"password": "password"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"turnEnabled": true,
|
|
||||||
"generatedAtMs": 1774700000123,
|
|
||||||
"expiresAtMs": 1774700300123,
|
|
||||||
"ttlSec": 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Специфические коды ошибок `GetCallIceConfig`
|
|
||||||
|
|
||||||
- `422 / NOT_AUTHENTICATED` — требуется авторизация.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. `ClientErrorLog`
|
|
||||||
|
|
||||||
### Запрос
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "ClientErrorLog",
|
|
||||||
"requestId": "err-001",
|
|
||||||
"payload": {
|
|
||||||
"kind": "global_error",
|
|
||||||
"message": "TypeError: failed",
|
|
||||||
"stack": "...",
|
|
||||||
"sourceUrl": "https://shineup.me/app.js",
|
|
||||||
"lineNumber": 10,
|
|
||||||
"columnNumber": 20,
|
|
||||||
"route": "#/channel-view/own-0",
|
|
||||||
"href": "https://shineup.me/#/channel-view/own-0",
|
|
||||||
"userAgent": "...",
|
|
||||||
"clientTs": 1774700000123,
|
|
||||||
"requestOp": "GetChannelMessages",
|
|
||||||
"requestIdRef": "GetChannelMessages-123",
|
|
||||||
"contextJson": "{\"screen\":\"channels\"}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Успешный ответ
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "ClientErrorLog",
|
|
||||||
"requestId": "err-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"serverTs": 1774700000456,
|
|
||||||
"accepted": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Специфические коды ошибок `ClientErrorLog`
|
|
||||||
|
|
||||||
- `400 / BAD_FIELDS` — обязательные поля ошибки не заполнены.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. `ClientDebugLog`
|
|
||||||
|
|
||||||
### Запрос
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "ClientDebugLog",
|
|
||||||
"requestId": "dbg-001",
|
|
||||||
"payload": {
|
|
||||||
"runId": "ui-run-1",
|
|
||||||
"level": "info",
|
|
||||||
"message": "opened channels tab",
|
|
||||||
"details": "{\"route\":\"#/channels\"}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Успешный ответ
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "ClientDebugLog",
|
|
||||||
"requestId": "dbg-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"accepted": true,
|
|
||||||
"serverTs": 1774700000456
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Специфические коды ошибок `ClientDebugLog`
|
|
||||||
|
|
||||||
- `400 / BAD_FIELDS` — поле `message` не заполнено.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. `CallDeliveryReport`
|
|
||||||
|
|
||||||
### Запрос
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "CallDeliveryReport",
|
|
||||||
"requestId": "call-report-001",
|
|
||||||
"payload": {
|
|
||||||
"type": "outgoing_failed",
|
|
||||||
"value": "{\"reason\":\"ice_failed\",\"callId\":\"call-1\"}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Успешный ответ
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "CallDeliveryReport",
|
|
||||||
"requestId": "call-report-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"serverTs": 1774700000456,
|
|
||||||
"accepted": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Специфические коды ошибок `CallDeliveryReport`
|
|
||||||
|
|
||||||
- `400 / BAD_FIELDS` — поле `type` не заполнено.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Короткое резюме
|
|
||||||
|
|
||||||
- `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо.
|
|
||||||
- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле.
|
|
||||||
- `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации.
|
|
||||||
- `ClientErrorLog`, `ClientDebugLog`, `CallDeliveryReport` используются для диагностики клиента и звонков.
|
|
||||||
|
|
||||||
|
|
||||||
## 8. Прямое техническое сообщение в конкретную сессию
|
|
||||||
|
|
||||||
На текущий момент в публичном JSON API этого документа **нет отдельного RPC** для отправки произвольного технического сообщения в конкретную сессию пользователя (по `sessionId`).
|
|
||||||
|
|
||||||
Что уже есть в системе:
|
|
||||||
|
|
||||||
- сервер хранит `sessionId` активной сессии;
|
|
||||||
- есть `ListSessions`, чтобы клиент получил список sessionId своего пользователя;
|
|
||||||
- у сервера есть внутренний реестр активных WS-подключений по `sessionId`.
|
|
||||||
|
|
||||||
Чего не хватает для полноценной фичи «direct tech message by sessionId»:
|
|
||||||
|
|
||||||
1. отдельная API-операция (например, `SendSessionTechMessage`);
|
|
||||||
2. правило авторизации (кто имеет право писать в чужую/свою сессию);
|
|
||||||
3. унифицированный формат payload и события доставки;
|
|
||||||
4. коды ошибок (`SESSION_OFFLINE`, `SESSION_NOT_FOUND`, `FORBIDDEN` и т.п.).
|
|
||||||
|
|
||||||
Итог: как инфраструктурная база это почти готово, но нужен отдельный RPC-слой и политика доступа.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# История изменений документации блокчейна
|
|
||||||
|
|
||||||
## 2026-05-24 11:40:00 +0300
|
|
||||||
- Базовый коммит-ориентир: `abdce05`.
|
|
||||||
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
|
|
||||||
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
|
|
||||||
- В `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
|
|
||||||
|
|
||||||
## 2026-05-21 19:05:00 +0300
|
|
||||||
- Базовый коммит-ориентир: `5344c42`.
|
|
||||||
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
|
|
||||||
- обновлён перечень типов в `11_TEXT_Blocks.md`;
|
|
||||||
- обновлена быстрая карта типов в `00_Blockchain_Formats_and_Block_Types.md`.
|
|
||||||
- Уточнено API-описание поддержанных подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
|
|
||||||
- В документе `Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`.
|
|
||||||
|
|
||||||
## 2026-05-20 11:34:17 +0300
|
|
||||||
- Базовый коммит-ориентир: `a53444b`.
|
|
||||||
- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы:
|
|
||||||
- `60/61` — `known_person / unknown_person` (знаю этого человека);
|
|
||||||
- `70/71` — `shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий);
|
|
||||||
- `74/75` — `shine_seen / shine_unseen` (мало знаком, но видел сияющим).
|
|
||||||
- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
|
|
||||||
|
|
||||||
## 2026-05-19 20:30:21 +0300
|
|
||||||
- Базовый коммит-ориентир: `7986184`.
|
|
||||||
- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения.
|
|
||||||
- Явно закреплено, что отдельного `DELETE`-подтипа нет, удаление выполняется edit-блоком.
|
|
||||||
|
|
||||||
## 2026-05-19 00:22:46 +0300
|
|
||||||
- Базовый коммит-ориентир: `c27da63a3e65`.
|
|
||||||
- Актуализирован `README.md` как точка входа для MVP-документации по протоколу.
|
|
||||||
- В документации явно зафиксировано, что `channelType=100` и `channelType=200` присутствуют в формате, но пока не используются в UI.
|
|
||||||
- Актуализирован перечень REACTION-подтипов: добавлен `REACTION_UNLIKE (subType=2)`.
|
|
||||||
- Актуализирован перечень CONNECTION-подтипов: добавлены `SPOUSE/PARENT/CHILD/SIBLING` и обратные операции.
|
|
||||||
- В документ `02_Blockchain_Kinds_and_Lines.md` добавлены фактические серверные правила валидации line-полей.
|
|
||||||
- Обновлён корневой `AGENTS.md`: формат блокчейна менять только после явного подтверждения пользователя и с предварительным предупреждением.
|
|
||||||
|
|
||||||
## 2026-05-13 00:02:32 +0300
|
|
||||||
- Базовый коммит-ориентир: `f63f40f1eb2f`.
|
|
||||||
- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`.
|
|
||||||
- Зафиксированы типы каналов: `0=stories`, `1=public`, `100=personal`, `200=group`.
|
|
||||||
- Серверная уникальность имени канала изменена на `owner + type + name(slug)`.
|
|
||||||
- Root-канал `0` переименован в `stories` на уровне API-чтения.
|
|
||||||
- Для персонального канала (`type=100`) включена сборка парного потока при чтении (`A->B` + `B->A`, если существует).
|
|
||||||
- Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении.
|
|
||||||
- Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками).
|
|
||||||
- В `AGENTS.md` добавлено обязательное правило актуализации документации в `Dev_Docs/Blockchain/`.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# Синхронизация блоков и DM между серверами SHiNE
|
|
||||||
|
|
||||||
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
|
|
||||||
|
|
||||||
## 1. Зачем нужна синхронизация
|
|
||||||
|
|
||||||
Пользователи SHiNE могут быть «приписаны» к разным серверам.
|
|
||||||
Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y):
|
|
||||||
|
|
||||||
1. Сервер X принимает сообщение;
|
|
||||||
2. Сервер X должен переслать DM-блок серверу Y;
|
|
||||||
3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B.
|
|
||||||
|
|
||||||
Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться,
|
|
||||||
чтобы любой партнёрский сервер мог отдать полную историю пользователя.
|
|
||||||
|
|
||||||
## 2. Список серверов синхронизации (`sync_servers`)
|
|
||||||
|
|
||||||
Каждый сервер регистрирует в своей Solana PDA список `sync_servers` —
|
|
||||||
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
|
||||||
|
|
||||||
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
|
||||||
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
|
||||||
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
|
|
||||||
|
|
||||||
## 3. Что синхронизируется
|
|
||||||
|
|
||||||
### 3.1 Личные сообщения (DM)
|
|
||||||
|
|
||||||
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
|
|
||||||
- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя.
|
|
||||||
- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии.
|
|
||||||
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
|
|
||||||
|
|
||||||
### 3.2 Блоки пользовательского блокчейна
|
|
||||||
|
|
||||||
- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него.
|
|
||||||
- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`.
|
|
||||||
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
|
||||||
- Дедупликация по глобальному номеру блока и хэшу.
|
|
||||||
|
|
||||||
## 4. Протокол синхронизации (целевой, не реализован)
|
|
||||||
|
|
||||||
### 4.1 Межсерверное соединение
|
|
||||||
|
|
||||||
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
|
|
||||||
- Адрес партнёра определяется по `server_address` из его Solana PDA.
|
|
||||||
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
|
|
||||||
- При разрыве — переподключение с экспоненциальным backoff.
|
|
||||||
|
|
||||||
### 4.2 Доставка новых данных (push)
|
|
||||||
|
|
||||||
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
|
|
||||||
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
|
|
||||||
|
|
||||||
### 4.3 Начальная синхронизация (backfill)
|
|
||||||
|
|
||||||
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
|
|
||||||
последний глобальный номер блока, последний известный DM-ключ.
|
|
||||||
- Сервер с более полной историей досылает недостающее партнёру.
|
|
||||||
|
|
||||||
### 4.4 Разрешение конфликтов
|
|
||||||
|
|
||||||
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
|
|
||||||
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`).
|
|
||||||
- DM: конфликтов нет, `message_key` уникален.
|
|
||||||
|
|
||||||
## 5. Маршрутизация DM между серверами
|
|
||||||
|
|
||||||
При отправке DM от пользователя A к пользователю B:
|
|
||||||
|
|
||||||
1. Клиент A отправляет пару блоков на свой сервер X.
|
|
||||||
2. Сервер X определяет, на каком сервере зарегистрирован пользователь B.
|
|
||||||
- Сначала проверяет локально (если B зарегистрирован на X).
|
|
||||||
- Иначе читает PDA пользователя B из Solana и смотрит `access_servers`.
|
|
||||||
- Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM.
|
|
||||||
3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки.
|
|
||||||
|
|
||||||
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
|
|
||||||
|
|
||||||
## 6. Безопасность
|
|
||||||
|
|
||||||
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
|
|
||||||
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
|
|
||||||
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
|
|
||||||
|
|
||||||
## 7. Статус реализации
|
|
||||||
|
|
||||||
| Компонент | Статус |
|
|
||||||
|-----------|--------|
|
|
||||||
| Регистрация серверной PDA в Solana | ✅ Реализовано |
|
|
||||||
| Чтение `sync_servers` из PDA | Нужна реализация |
|
|
||||||
| Межсерверный WebSocket-канал | Нужна реализация |
|
|
||||||
| Push новых DM партнёрам | Нужна реализация |
|
|
||||||
| Push блоков блокчейна партнёрам | Нужна реализация |
|
|
||||||
| Backfill при первом подключении | Нужна реализация |
|
|
||||||
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
|
|
||||||
|
|
||||||
Текущая версия сервера работает без межсерверной синхронизации.
|
|
||||||
Синхронизация — задача следующего этапа разработки.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Дальнее будущее
|
|
||||||
|
|
||||||
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
|
|
||||||
|
|
||||||
## Идеи
|
|
||||||
|
|
||||||
- `2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через сервер SHiNE или WebRTC DataChannel, а также файловый обмен чанками с хранением на SD-карте.
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# Telegram-агент для разрешённых игроков
|
|
||||||
|
|
||||||
- Горизонт:
|
|
||||||
`near`
|
|
||||||
- Ориентир:
|
|
||||||
сегодня/завтра
|
|
||||||
- Статус:
|
|
||||||
`proposal`
|
|
||||||
|
|
||||||
## Кратко
|
|
||||||
|
|
||||||
Нужно расширить `SHiNE-agent-bot-coder`, чтобы агент мог принимать личные сообщения от заранее разрешённых пользователей, вести по каждому отдельную рабочую папку и историю, помогать им с обсуждениями/документами без изменения кода, а краткий результат публиковать в общий канал.
|
|
||||||
|
|
||||||
## Пользовательский сценарий
|
|
||||||
|
|
||||||
1. Разрешённый пользователь пишет агенту в личные сообщения текстом или голосом.
|
|
||||||
2. Голосовое сообщение распознаётся так же, как сейчас распознаются voice/audio-задачи.
|
|
||||||
3. Сервис определяет пользователя по разрешённому списку логинов.
|
|
||||||
4. Для пользователя используется отдельная папка в `Players/`.
|
|
||||||
5. Codex запускается с системным контекстом: от имени какого человека он работает, где лежит его папка, какие у него локальные инструкции.
|
|
||||||
6. Агент может читать код и документацию проекта, но писать должен только в папку этого пользователя, если нет отдельного согласования на изменение общего проекта.
|
|
||||||
7. После ответа пользователю агент отправляет в общий канал короткую сводку двумя сообщениями или двумя блоками: вопрос пользователя и полученный ответ.
|
|
||||||
8. Команда `/new` или `New` сбрасывает только сессию этого пользователя.
|
|
||||||
|
|
||||||
## Предлагаемая структура
|
|
||||||
|
|
||||||
- `Players/`
|
|
||||||
- `Ivan/`
|
|
||||||
- `AGENTS.md`
|
|
||||||
- `history/`
|
|
||||||
- `files/`
|
|
||||||
- `Sergey/`
|
|
||||||
- `AGENTS.md`
|
|
||||||
- `history/`
|
|
||||||
- `files/`
|
|
||||||
- `Milana/`
|
|
||||||
- `AGENTS.md`
|
|
||||||
- `history/`
|
|
||||||
- `files/`
|
|
||||||
|
|
||||||
Имена папок можно уточнить после получения точных Telegram-логинов.
|
|
||||||
|
|
||||||
## Что нужно сделать
|
|
||||||
|
|
||||||
1. Добавить конфигурацию разрешённых Telegram-пользователей.
|
|
||||||
2. Описать соответствие `telegram username -> имя игрока -> папка`.
|
|
||||||
3. Создавать или использовать отдельную историю диалога для каждого игрока.
|
|
||||||
4. Поддержать личные сообщения от разрешённых пользователей.
|
|
||||||
5. Запретить постановку задач от неизвестных пользователей.
|
|
||||||
6. Для групп/каналов оставить текущую логику: команды Айдара имеют приоритет.
|
|
||||||
7. При запуске Codex для игрока добавлять отдельный системный контекст:
|
|
||||||
- имя пользователя;
|
|
||||||
- путь к его папке;
|
|
||||||
- правило записи только в эту папку;
|
|
||||||
- путь к персональному `AGENTS.md`.
|
|
||||||
8. После ответа игроку отправлять краткую сводку в общий канал.
|
|
||||||
9. Поддержать `/new`/`New` как сброс только персональной сессии игрока.
|
|
||||||
10. Добавить защиту от случайного изменения общего кода в режиме игрока.
|
|
||||||
|
|
||||||
## Вопросы перед реализацией
|
|
||||||
|
|
||||||
1. Точные Telegram-логины Ивана, Сергея и Миланы.
|
|
||||||
2. Какой общий канал использовать для сводок: текущий `@shine_writing` или отдельный чат.
|
|
||||||
3. Нужно ли отправлять в общий канал полный текст вопроса/ответа или краткую выжимку.
|
|
||||||
4. Нужно ли пересылать вложения игроков в общий канал или только текстовые сводки.
|
|
||||||
5. Разрешить ли игрокам читать все документы проекта, включая технические заметки деплоя.
|
|
||||||
6. Что делать, если пользователь просит изменить код: отказать, создать предложение в своей папке или просить подтверждение Айдара.
|
|
||||||
7. Нужны ли русские имена папок (`Иван`, `Сергей`, `Милана`) или ASCII-имена (`Ivan`, `Sergey`, `Milana`).
|
|
||||||
8. Нужно ли хранить истории игроков в общей папке сервиса или внутри `Players/<name>/history/`.
|
|
||||||
|
|
||||||
## Риски и ограничения
|
|
||||||
|
|
||||||
- Нужно аккуратно разделить режим Айдара и режим игрока, чтобы игроки не могли случайно запустить изменение общего кода.
|
|
||||||
- Нужно не смешать истории разных пользователей.
|
|
||||||
- Нужно ограничить публикацию в общий канал, чтобы не утекали личные или слишком длинные ответы.
|
|
||||||
- Нужна проверка Telegram-идентификации: username может меняться, поэтому желательно хранить и `user_id`.
|
|
||||||
|
|
||||||
## Документы, которые обновить при реализации
|
|
||||||
|
|
||||||
- `SHiNE-agent-bot-coder/AGENTS.md`
|
|
||||||
- `SHiNE-agent-bot-coder/AGENT.md`
|
|
||||||
- `SHiNE-agent-bot-coder/README.md`
|
|
||||||
- `Dev_Docs/deploy/agent-bot-coder-local-systemd.md`, если появятся новые переменные окружения или настройки сервиса.
|
|
||||||
|
|
||||||
## Минимальная проверка
|
|
||||||
|
|
||||||
1. Айдар по-прежнему может ставить задачи из `@shine_writing`.
|
|
||||||
2. Неизвестный пользователь не ставит задачу в очередь.
|
|
||||||
3. Разрешённый игрок пишет личное текстовое сообщение и получает ответ.
|
|
||||||
4. Разрешённый игрок отправляет voice, оно распознаётся и обрабатывается.
|
|
||||||
5. История одного игрока не попадает в историю другого.
|
|
||||||
6. `/new` сбрасывает только историю текущего игрока.
|
|
||||||
7. Сводка вопрос/ответ появляется в общем канале.
|
|
||||||
8. В режиме игрока агент не пишет за пределы `Players/<name>/` без отдельного подтверждения.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Быстрое скрытие экрана звонка и остановка гудков при отклонении
|
|
||||||
|
|
||||||
- краткое описание фичи:
|
|
||||||
- Исправлена нижняя подпись вкладки личных сообщений на `личные`.
|
|
||||||
- Исправлена логика звонка, когда одна из входящих сессий отклоняет вызов до принятия: исходящая сессия теперь должна прекращать гудки даже если ранее `RINGING` пришёл от другой входящей сессии.
|
|
||||||
- На устройстве, где пользователь нажимает отмену/сброс звонка, экран вызова теперь скрывается сразу локально, без ожидания сетевого ответа.
|
|
||||||
|
|
||||||
- что именно проверять:
|
|
||||||
- В нижней панели с 5 кнопками подпись первой кнопки должна отображаться как `личные`.
|
|
||||||
- Сценарий: один пользователь звонит, у второго входящий вызов приходит на несколько устройств; на любом одном входящем устройстве нажать `Сбросить`.
|
|
||||||
- Проверить, что у звонящего сразу прекращаются гудки и экран вызова корректно завершается.
|
|
||||||
- Проверить, что на устройстве, где нажали `Сбросить` или `Положить трубку`, overlay звонка исчезает сразу, без заметной задержки.
|
|
||||||
- Проверить, что после принятия звонка на одном устройстве поздние отмены с других устройств не ломают уже выбранную пару соединения.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- Подпись в нижней панели корректная.
|
|
||||||
- При отклонении входящего звонка любым устройством звонящего не оставляет в состоянии бесконечных гудков.
|
|
||||||
- Локальный экран звонка скрывается мгновенно после нажатия кнопки отмены.
|
|
||||||
- Уже зафиксированный сценарий соединения после `ACCEPT` не сбивается другими сессиями.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- `pending`
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Отключение устаревшего TURN-узла `45.136.124.227`
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- из конфигурации звонков убран устаревший TURN-узел `45.136.124.227:3478`;
|
|
||||||
- основным и единственным выдаваемым TURN-узлом оставлен `93.170.12.154:3478`.
|
|
||||||
- что проверять:
|
|
||||||
- сделать несколько тестовых звонков между разными устройствами/сетями;
|
|
||||||
- убедиться, что звонок доходит до стадии соединения и появляется звук;
|
|
||||||
- убедиться, что в логах `CallDeliveryReport` больше не фигурирует `45.136.124.227`.
|
|
||||||
- ожидаемый результат:
|
|
||||||
- клиентам больше не выдаётся устаревший TURN-адрес;
|
|
||||||
- звонки не заваливаются из-за попыток использовать отключённый TURN-узел.
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Фикс самообрыва звонка из-за `stop_call` push своей же сессии
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- исправлена ситуация, когда активный звонок мог оборваться сразу после соединения;
|
|
||||||
- причина была в том, что `stop_call` push, предназначенный для других сессий того же пользователя, обрабатывался и в исходной сессии.
|
|
||||||
- что проверять:
|
|
||||||
- открыть несколько вкладок/устройств одного пользователя;
|
|
||||||
- принять звонок на одной сессии;
|
|
||||||
- убедиться, что активная сессия не обрывает звонок сразу после соединения;
|
|
||||||
- убедиться, что лишние сессии при этом закрывают свой локальный экран звонка.
|
|
||||||
- ожидаемый результат:
|
|
||||||
- звонок не завершается сразу после `call_connected`;
|
|
||||||
- `accepted_on_other_device` и связанные `stop_call` события больше не убивают исходную активную сессию.
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Фикс привязки call push к целевой sessionId
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- push-события `incoming_call` и `stop_call` теперь помечаются целевой `sessionId`;
|
|
||||||
- UI и service worker обрабатывают call push только для своей целевой сессии;
|
|
||||||
- `stop_call` для лишних сессий закрывает локальный экран тихо, без обратных сигналов и без лишних тех-сообщений.
|
|
||||||
- что проверять:
|
|
||||||
- держать несколько сессий одного пользователя в одном браузере/на одном origin;
|
|
||||||
- позвонить этому пользователю и убедиться, что входящий экран закрывается корректно только на целевых сессиях;
|
|
||||||
- после `ACCEPT` одной сессии остальные должны тихо убрать экран вызова и не ломать выбранную пару;
|
|
||||||
- после отмены входящей сессией исходящая сессия должна централизованно завершить сценарий.
|
|
||||||
- ожидаемый результат:
|
|
||||||
- push одного session endpoint больше не влияет на чужие сессии этого же origin;
|
|
||||||
- исчезают ложные `stop_call_push:accepted_on_other_device` и `terminal_call_signal_150` на неправильных сессиях.
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# ESP32 homeserver: заявки на подключение устройств
|
|
||||||
|
|
||||||
- краткое описание фичи:
|
|
||||||
- на ESP32 homeserver в `SETTINGS` добавлен первый пункт `Device requests`, который появляется только после авторизации homeserver в SHiNE;
|
|
||||||
- экран показывает список активных pairing-заявок, позволяет открыть каждую заявку и подтвердить или отклонить её;
|
|
||||||
- формат кода подключения изменён на `10` цифр и показывается как `5` пар.
|
|
||||||
|
|
||||||
- что проверять:
|
|
||||||
- на обычном клиенте и в wallet-plugin код отображается как `XX XX XX XX XX`;
|
|
||||||
- на доверенном веб-клиенте экран `Подключить по коду` показывает все активные заявки без поля ручного ввода;
|
|
||||||
- на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым;
|
|
||||||
- `REFRESH` реально загружает активные заявки;
|
|
||||||
- на экране видно две плитки, список листается вертикально;
|
|
||||||
- client-session заявка после `YES` подключается с передачей только `client key`;
|
|
||||||
- wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session;
|
|
||||||
- `NO` отклоняет заявку и она исчезает из списка активных.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- все три клиента используют единый формат кода;
|
|
||||||
- активные заявки видны без ручного ввода кода;
|
|
||||||
- ESP32 может одобрять и отклонять живые pairing-заявки пользователя.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Тестовые deploy-контуры `test2.shineup.me` и `test.shineup.me`
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- default deploy-задачи `deployServer` и `deployUI` переведены на основной тестовый сервер `test2.shineup.me`;
|
|
||||||
- production-задачи вынесены в `deployServerProduction` и `deployUIProduction`;
|
|
||||||
- `test.shineup.me` оставлен как резервный тестовый сервер без обычного deploy по умолчанию.
|
|
||||||
|
|
||||||
- что проверять:
|
|
||||||
- `./gradlew deployServer` и `./gradlew deployUI` действительно направлены на `test2.shineup.me`;
|
|
||||||
- `./gradlew deployServerProduction` и `./gradlew deployUIProduction` больше не используются как default;
|
|
||||||
- `https://test2.shineup.me` открывает UI;
|
|
||||||
- `wss://test2.shineup.me/ws` отвечает;
|
|
||||||
- на `test2.shineup.me` после deploy есть копия продовой `shine.sqlite` и `.bch`.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- default deploy идёт только на `test2.shineup.me`;
|
|
||||||
- production `shineup.me` меняется только после отдельного подтверждения;
|
|
||||||
- `test.shineup.me` остаётся резервным тестовым сервером;
|
|
||||||
- тестовый deploy не гоняет удалённые тесты и не создаёт пустую БД.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Исправление chatId личных сообщений через lowercase
|
|
||||||
|
|
||||||
- краткое описание фичи:
|
|
||||||
- В клиентском UI SHiNE для личных сообщений технический `chatId` теперь канонизируется через `trim().toLowerCase()` при приёме DM, открытии чата и восстановлении сообщений из IndexedDB.
|
|
||||||
- Цель: исключить рассинхрон, когда unread-индикатор есть, а входящие сообщения конкретного собеседника не видны из-за разного регистра логина.
|
|
||||||
|
|
||||||
- что именно проверять:
|
|
||||||
- Отправить личные сообщения между двумя пользователями, у одного из которых логин отображается с заглавными буквами.
|
|
||||||
- Убедиться, что входящие сообщения показываются внутри открытого чата, а не только в общем unread-индикаторе.
|
|
||||||
- Перезагрузить страницу и проверить, что история чата после гидрации из IndexedDB остаётся в одном диалоге.
|
|
||||||
- Проверить, что переход в чат из списка диалогов и из графа связей открывает тот же диалог без дублирования.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- Все сообщения одного собеседника попадают в один и тот же DM-чат независимо от регистра логина.
|
|
||||||
- Общий unread, список диалогов и содержимое открытого чата совпадают между собой.
|
|
||||||
- После перезагрузки UI не появляется отдельный дубль диалога с тем же логином в другом регистре.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# ESP32 выбор кошелька и wallet RPC для browser extension
|
|
||||||
|
|
||||||
- краткое описание:
|
|
||||||
- в ESP32 заменён домашний блок `баланс + QR` на единый вход в экран кошелька;
|
|
||||||
- добавлен выбор активного кошелька `ClientKey / RootKey / Custom`;
|
|
||||||
- для browser extension добавлен первый RPC `get_wallet_public_key` через существующий `wallet-session` и `CallSignalToSession`;
|
|
||||||
- в popup расширения добавлен запрос текущего кошелька и копирование `publicKeyBase58`.
|
|
||||||
|
|
||||||
- что проверять:
|
|
||||||
- на ESP32 после ввода секрета на главном экране видна кнопка `Кошелёк: ...`;
|
|
||||||
- экран `WALLET` открывается и показывает текущий тип кошелька;
|
|
||||||
- экран `WALLET_SELECT` переключает `ClientKey`, `RootKey` и `Custom`;
|
|
||||||
- для `Custom` открывается ввод имени и после сохранения derivation работает;
|
|
||||||
- `Показать баланс кошелька` читает баланс именно активного кошелька;
|
|
||||||
- `Показать QR-код кошелька` показывает QR и адрес именно активного кошелька;
|
|
||||||
- browser extension после подключения wallet-session может запросить текущий кошелёк у ESP32;
|
|
||||||
- extension показывает тип кошелька, полный `publicKeyBase58`, результат проверки через PDA и копирует ключ в буфер;
|
|
||||||
- для `client.key` и `root.key` проверка через PDA даёт ожидаемое совпадение.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- активный кошелёк на ESP32 реально влияет на баланс, QR и ответ `get_wallet_public_key`;
|
|
||||||
- browser extension получает ответ без ручного ввода `walletSelector`;
|
|
||||||
- homeserver выбирается из опубликованных в PDA sessions и запрос приходит в нужное устройство;
|
|
||||||
- копирование ключа из extension работает.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# ESP32 English UI and trusted login fix
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
переведён экранный UI ESP32 homeserver на английский язык; добавлена локальная инструкция для AGENTS по ограничению кириллицы; исправлено падение `StartTrustedDeviceLogin`, когда у пользователя ещё нет записи `esp_pairing_settings`.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском.
|
|
||||||
2. Проверить сценарий выбора `ClientKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения.
|
|
||||||
3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login.
|
|
||||||
4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`.
|
|
||||||
5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- ESP32 UI читается на устройстве без кириллических строк.
|
|
||||||
- Подключение через trusted-device login стартует без server-side `INTERNAL_HANDLER_ERROR`, даже если настройки pairing ещё ни разу не сохранялись.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Browser wallet side panel
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
browser extension `SHiNE Wallet` переведён с toolbar popup на штатный Chromium side panel.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. Перезагрузить unpacked extension в Chromium-браузере.
|
|
||||||
2. Нажать на иконку `SHiNE Wallet` в toolbar.
|
|
||||||
3. Убедиться, что открывается side panel, а не всплывающий popup.
|
|
||||||
4. Проверить, что панель можно держать открытой при навигации по сайтам.
|
|
||||||
5. Проверить сценарии `Подключить`, `Отключить`, `Обновить устройства`, `Запросить кошелёк`.
|
|
||||||
6. Проверить, что сторона панели управляется браузером, а UI корректно выглядит и слева, и справа.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- расширение открывается в штатной боковой панели Chromium;
|
|
||||||
- UI работает так же, как раньше в popup;
|
|
||||||
- панель остаётся доступной как постоянная колонка браузера.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Wallet provider and ESP sign_transaction
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
browser extension `SHiNE Wallet` теперь внедряет `window.solana` для сайтов и умеет выполнять `connect` и `signTransaction`; подпись транзакции уходит на ESP32 через wallet RPC `sign_transaction`, а подтверждение делается на устройстве.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. Перезагрузить unpacked extension в Chromium.
|
|
||||||
2. Открыть сайт/тестовую страницу, которая вызывает `window.solana.connect()`.
|
|
||||||
3. Подтвердить подключение кошелька и убедиться, что сайт получает публичный ключ.
|
|
||||||
4. Открыть сайт/тестовую страницу, которая вызывает `window.solana.signTransaction(...)`.
|
|
||||||
5. Убедиться, что на ESP32 открывается экран `SIGN REQUEST` с комментарием.
|
|
||||||
6. Проверить оба варианта:
|
|
||||||
- `APPROVE` возвращает сайту подписанную транзакцию;
|
|
||||||
- `REJECT` возвращает отказ.
|
|
||||||
7. Проверить сценарии для `ClientKey`, `RootKey`, `Custom`.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- сайт может подключить кошелёк через provider расширения;
|
|
||||||
- транзакция подписывается только после подтверждения на ESP32;
|
|
||||||
- отказ на ESP32 корректно доходит до сайта.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Wallet Standard support
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
расширение `SHiNE Wallet` теперь не только внедряет legacy `window.solana`, но и регистрирует себя как `Wallet Standard` wallet для Solana dapp.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. Перезагрузить unpacked extension.
|
|
||||||
2. Открыть dapp, который использует Wallet Standard, например `app.realms.today` на `devnet`.
|
|
||||||
3. Открыть список кошельков и убедиться, что `SHiNE Wallet` появился отдельным вариантом.
|
|
||||||
4. Проверить `connect`.
|
|
||||||
5. Проверить подпись транзакции через сценарий dapp, который использует стандартный wallet interface.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- dapp видит `SHiNE Wallet` как standard wallet, а не только как legacy Phantom-style provider.
|
|
||||||
- connect и подпись работают через тот же ESP32 approval-flow.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
## Кратко
|
|
||||||
|
|
||||||
Исправлена ESP32-ветка обновления `user_pda` для добавления `homeserver`-сессии после миграции формата PDA на `RecoveryKeyBlock`.
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
|
|
||||||
- В `shine_homeserver_main.ino` синхронизирован `create/update` payload с новым форматом `shine_users`.
|
|
||||||
- В сериализацию и парсинг PDA добавлен `RecoveryKeyBlock`.
|
|
||||||
- Для ветки `Add Homeserver` добавлены промежуточные checkpoint-записи в NVS, чтобы после crash или reset было видно, на каком шаге оборвалась операция.
|
|
||||||
- В `ESP32/AGENTS.md` добавлена памятка по чтению `last_error`.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
- Зарегистрировать или использовать уже существующий аккаунт на ESP32.
|
|
||||||
- Дойти до состояния `homeserver not in PDA`.
|
|
||||||
- Нажать `Add Homeserver`.
|
|
||||||
- Если операция не успешна, считать `last_error` по USB serial и убедиться, что видна свежая запись именно по шагам `Homeserver PDA update ...`, а не старый diag.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- `Add Homeserver` добавляет `homeserver1` в `sessions` блока `SessionsBlock`.
|
|
||||||
- Если операция падает, в NVS сохраняется свежая диагностическая запись с текущим этапом, а не устаревший лог регистрации.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
`pending`
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# TODO: доработка персональных сообщений для агентов
|
|
||||||
|
|
||||||
Статус: отложено.
|
|
||||||
|
|
||||||
## Что хотели сделать
|
|
||||||
|
|
||||||
Добавить упрощённую маршрутизацию персональных сообщений через служебную инструкцию в начале текстового payload (внутри подписанного DM-блока), чтобы:
|
|
||||||
|
|
||||||
- отличать сообщения человеку от сообщений агенту;
|
|
||||||
- отличать сообщения от человека и от агента;
|
|
||||||
- скрывать в обычном UI сообщения, адресованные агенту (`target=agent`);
|
|
||||||
- поддержать сценарий «сообщения самому себе между своими клиентами/устройствами», где один клиент/агент пишет другому в рамках одного логина.
|
|
||||||
|
|
||||||
## Базовая идея формата (черновик)
|
|
||||||
|
|
||||||
Пример префикса:
|
|
||||||
|
|
||||||
```text
|
|
||||||
@shine:pm:v1 {"target":"agent","agentId":"assistant","author":"human"}
|
|
||||||
Текст сообщения...
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример ответа агента:
|
|
||||||
|
|
||||||
```text
|
|
||||||
@shine:pm:v1 {"target":"user","author":"agent","agentId":"assistant","agentLabel":"My Bot"}
|
|
||||||
Ответ агента...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Почему отложено
|
|
||||||
|
|
||||||
- нужно отдельно согласовать финальный формат инструкции;
|
|
||||||
- нужно определить строгие правила UI-фильтрации и fallback;
|
|
||||||
- нужно определить, нужен ли позднее отдельный серверный роутинг для agent-сессий.
|
|
||||||
|
|
||||||
## Что сделать при возвращении к задаче
|
|
||||||
|
|
||||||
1. Зафиксировать окончательный формат префикса и JSON-полей.
|
|
||||||
2. Описать правила парсинга/валидации (включая битые/неполные префиксы).
|
|
||||||
3. Добавить UI-логику показа/скрытия agent-сообщений.
|
|
||||||
4. Добавить маркировку «ответ агента» в диалоге.
|
|
||||||
5. Продумать режим self-chat (между своими клиентами/агентом) в рамках одного логина.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Черновик будущих вложений в 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`.
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Деплой и инициализация Solana-регистрации (две обязательные программы)
|
|
||||||
|
|
||||||
## Коротко
|
|
||||||
|
|
||||||
Для рабочей регистрации пользователя нужны **обе** программы:
|
|
||||||
|
|
||||||
1. `shine_users` — хранение и обновление `user_pda`, economy-конфиг, логика регистрации.
|
|
||||||
2. `shine_login_guard` — проверка/классификация логина (CPI из `shine_users`).
|
|
||||||
|
|
||||||
Если задеплоена только одна из них — регистрация неработоспособна.
|
|
||||||
|
|
||||||
## Актуальные адреса (devnet)
|
|
||||||
|
|
||||||
- `shine_users` (регистрация пользователей):
|
|
||||||
`3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
|
|
||||||
- `shine_login_guard`:
|
|
||||||
`3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
|
|
||||||
- `shine_payments`:
|
|
||||||
`c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
|
|
||||||
|
|
||||||
## Подтверждение деплоя
|
|
||||||
|
|
||||||
- Сеть: `https://api.devnet.solana.com`
|
|
||||||
- `shine_users`:
|
|
||||||
- `Program ID`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
|
|
||||||
- TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM`
|
|
||||||
- `shine_login_guard`:
|
|
||||||
- `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
|
|
||||||
- TX deploy: `5iptngPYrLLjPE3Xby24zyNW3edVUnBNLBx785vjojMoq5JNLFNQvLNAm3jNYHbpf2B36qtbpTNzcvUNyRDqm1Mf`
|
|
||||||
|
|
||||||
## Порядок деплоя (devnet)
|
|
||||||
|
|
||||||
1. Убедиться, что CLI смотрит в devnet и у кошелька есть SOL.
|
|
||||||
2. Собрать и задеплоить `shine_login_guard`.
|
|
||||||
3. Собрать и задеплоить `shine_users`.
|
|
||||||
4. Проверить, что адреса совпадают между:
|
|
||||||
- `Anchor.toml`
|
|
||||||
- `declare_id!` в `programs/*/src/lib.rs`
|
|
||||||
- UI/серверными константами.
|
|
||||||
5. Выполнить `init_users_economy_config` (один раз на программу `shine_users`).
|
|
||||||
|
|
||||||
Пример команд:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd shine-solana/shine
|
|
||||||
solana config get
|
|
||||||
solana balance
|
|
||||||
|
|
||||||
anchor build -p shine_login_guard
|
|
||||||
anchor deploy -p shine_login_guard
|
|
||||||
|
|
||||||
anchor build -p shine_users
|
|
||||||
anchor deploy -p shine_users
|
|
||||||
```
|
|
||||||
|
|
||||||
## Куда вписаны адреса в проекте
|
|
||||||
|
|
||||||
### UI
|
|
||||||
|
|
||||||
- Общие Solana-константы:
|
|
||||||
- `shine-UI/js/solana-programs.js`
|
|
||||||
- Страница инициализации:
|
|
||||||
- `shine-UI/js/pages/solana-users-init-view.js`
|
|
||||||
- Переход на страницу:
|
|
||||||
- `shine-UI/js/pages/developer-settings-view.js`
|
|
||||||
|
|
||||||
### Browser plugin wallet
|
|
||||||
|
|
||||||
- Резолвер PDA и проверка адресов:
|
|
||||||
- `SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js`
|
|
||||||
- Проверка текущего wallet по PDA:
|
|
||||||
- `SHiNE-browser-plugin-wallet/background.js`
|
|
||||||
- Отображение состояния в popup:
|
|
||||||
- `SHiNE-browser-plugin-wallet/popup.js`
|
|
||||||
|
|
||||||
### Сервер
|
|
||||||
|
|
||||||
- Серверные константы Solana:
|
|
||||||
- `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java`
|
|
||||||
|
|
||||||
### Solana / Anchor
|
|
||||||
|
|
||||||
- `shine-solana/shine/Anchor.toml`
|
|
||||||
- `shine-solana/shine/programs/shine_users/src/lib.rs` (`declare_id!`)
|
|
||||||
- `shine-solana/shine/programs/shine_login_guard/src/lib.rs` (`declare_id!`)
|
|
||||||
- `shine-solana/shine/programs/shine_payments/src/lib.rs` (`declare_id!`)
|
|
||||||
|
|
||||||
### Где ещё нужно синхронизировать адреса после нового deploy
|
|
||||||
|
|
||||||
- UI-константы и все потребители в `shine-UI/js/*`
|
|
||||||
- browser-plugin-wallet
|
|
||||||
- серверный `SolanaProgramsConfig.java`
|
|
||||||
- Anchor-конфиг и `declare_id!` в Solana-модуле
|
|
||||||
- документы:
|
|
||||||
- `Dev_Docs/Solana/user_pda/README.md`
|
|
||||||
- `shine-solana/shine/doc/programs/shine_users.md`
|
|
||||||
- `shine-solana/shine/doc/devnet_keys_and_deploy.md`
|
|
||||||
|
|
||||||
## Как запустить инициализацию economy PDA
|
|
||||||
|
|
||||||
1. Открыть UI.
|
|
||||||
2. Перейти: `Профиль -> Настройки -> Настройки разработчика -> Solana: init регистрации`.
|
|
||||||
3. Подключить кошелёк (Phantom, devnet).
|
|
||||||
4. Нажать `Запустить init_users_economy_config`.
|
|
||||||
5. Дождаться статуса `Успешно`.
|
|
||||||
|
|
||||||
Страница сама вычисляет PDA `users_economy_config` по seed:
|
|
||||||
|
|
||||||
- seed: `shine_users_economy_config`
|
|
||||||
- program: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
|
|
||||||
|
|
||||||
## Кто оплачивает create/update user_pda
|
|
||||||
|
|
||||||
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
|
|
||||||
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
|
|
||||||
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
|
|
||||||
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
|
|
||||||
|
|
||||||
## Важно
|
|
||||||
|
|
||||||
- `init_users_economy_config` выполняется один раз на программу.
|
|
||||||
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
|
|
||||||
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `clientKey`, а содержимое записи подписывает `rootKey`.
|
|
||||||
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
|
|
||||||
Несовпадение адреса приведёт к ошибке регистрации.
|
|
||||||
@@ -70,10 +70,10 @@ int ge25519_frombytes(ge25519_p3 *h, const unsigned char *s);
|
|||||||
#define TEXT_EDIT_PANEL_H 330
|
#define TEXT_EDIT_PANEL_H 330
|
||||||
#define TEST_VERSION "SHiNE homeserver (v.0.18)"
|
#define TEST_VERSION "SHiNE homeserver (v.0.18)"
|
||||||
|
|
||||||
static const char *kShineUsersProgramId = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
|
static const char *kShineUsersProgramId = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6";
|
||||||
static const char *kShineUsersUserPdaSeedPrefix = "user_login=";
|
static const char *kShineUsersUserPdaSeedPrefix = "user_login=";
|
||||||
static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
|
static const char *kShineLoginGuardProgramId = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh";
|
||||||
static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
|
static const char *kShinePaymentsProgramId = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v";
|
||||||
static const char *kSystemProgramId = "11111111111111111111111111111111";
|
static const char *kSystemProgramId = "11111111111111111111111111111111";
|
||||||
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
|
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
|
||||||
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
|
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
|
||||||
@@ -285,6 +285,17 @@ struct ActiveWalletSignRequest {
|
|||||||
Screen returnScreen = SCREEN_HOME;
|
Screen returnScreen = SCREEN_HOME;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct PendingRemoteAddBlockRequest {
|
||||||
|
String fromLogin;
|
||||||
|
String fromSessionId;
|
||||||
|
String signalRequestId;
|
||||||
|
String signalType;
|
||||||
|
String data;
|
||||||
|
String sessionSignatureB64;
|
||||||
|
String clientSignatureB64;
|
||||||
|
uint64_t timeMs = 0;
|
||||||
|
};
|
||||||
|
|
||||||
static lv_disp_draw_buf_t gDrawBuf;
|
static lv_disp_draw_buf_t gDrawBuf;
|
||||||
static lv_color_t *gBuf1 = nullptr;
|
static lv_color_t *gBuf1 = nullptr;
|
||||||
static lv_color_t *gBuf2 = nullptr;
|
static lv_color_t *gBuf2 = nullptr;
|
||||||
@@ -324,7 +335,7 @@ static String gScanResults[MAX_SCAN_RESULTS];
|
|||||||
static int gScanResultCount = 0;
|
static int gScanResultCount = 0;
|
||||||
static WifiViewMode gWifiViewMode = WIFI_VIEW_OVERVIEW;
|
static WifiViewMode gWifiViewMode = WIFI_VIEW_OVERVIEW;
|
||||||
|
|
||||||
static String gSolanaRpcUrl = "https://api.devnet.solana.com";
|
static String gSolanaRpcUrl = "https://api.mainnet-beta.solana.com";
|
||||||
static String gShineServerLogin = kDefaultShineServerLogin;
|
static String gShineServerLogin = kDefaultShineServerLogin;
|
||||||
static String gShineServerUrl;
|
static String gShineServerUrl;
|
||||||
static String gResolvedShineServerLogin;
|
static String gResolvedShineServerLogin;
|
||||||
@@ -415,6 +426,7 @@ static String gCustomWalletName;
|
|||||||
static String gCustomWalletPubB58;
|
static String gCustomWalletPubB58;
|
||||||
static String gCustomWalletPrivB58;
|
static String gCustomWalletPrivB58;
|
||||||
static std::vector<PendingWalletRpcRequest> gPendingWalletRpcRequests;
|
static std::vector<PendingWalletRpcRequest> gPendingWalletRpcRequests;
|
||||||
|
static std::vector<PendingRemoteAddBlockRequest> gPendingRemoteAddBlockRequests;
|
||||||
static const int kWalletRpcSignalTypeRequest = 9100;
|
static const int kWalletRpcSignalTypeRequest = 9100;
|
||||||
static const int kWalletRpcSignalTypeResponse = 9101;
|
static const int kWalletRpcSignalTypeResponse = 9101;
|
||||||
static ActiveWalletSignRequest gActiveWalletSignRequest;
|
static ActiveWalletSignRequest gActiveWalletSignRequest;
|
||||||
@@ -477,7 +489,9 @@ static void refreshSelectedWalletBalanceState();
|
|||||||
static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut);
|
static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut);
|
||||||
static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut);
|
static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut);
|
||||||
static bool queueIncomingWalletRpcRequest(const String &frame);
|
static bool queueIncomingWalletRpcRequest(const String &frame);
|
||||||
|
static bool queueIncomingRemoteAddBlockRequest(const String &frame);
|
||||||
static void processPendingWalletRpcRequests();
|
static void processPendingWalletRpcRequests();
|
||||||
|
static void processPendingRemoteAddBlockRequests();
|
||||||
static void pumpShineIncomingFrames(uint32_t maxFrames = 3);
|
static void pumpShineIncomingFrames(uint32_t maxFrames = 3);
|
||||||
static String loginDisplayValue();
|
static String loginDisplayValue();
|
||||||
static String homeserverDisplayValue();
|
static String homeserverDisplayValue();
|
||||||
@@ -496,11 +510,13 @@ static void saveShineSessionPrefs();
|
|||||||
static String normalizeLoginValue(const String &value);
|
static String normalizeLoginValue(const String &value);
|
||||||
static bool base58ToFixed32(const String &value, uint8_t out[32]);
|
static bool base58ToFixed32(const String &value, uint8_t out[32]);
|
||||||
static bool base64DecodeStd(const String &value, std::vector<uint8_t> &out);
|
static bool base64DecodeStd(const String &value, std::vector<uint8_t> &out);
|
||||||
|
static bool hex64ToBytes(const String &value, uint8_t out[32]);
|
||||||
static String bytesToBase64String(const uint8_t *data, size_t len);
|
static String bytesToBase64String(const uint8_t *data, size_t len);
|
||||||
static String jsonEscape(const String &value);
|
static String jsonEscape(const String &value);
|
||||||
static bool jsonStringField(const String &json, const String &field, String &valueOut);
|
static bool jsonStringField(const String &json, const String &field, String &valueOut);
|
||||||
static bool jsonBoolField(const String &json, const String &field, bool &valueOut);
|
static bool jsonBoolField(const String &json, const String &field, bool &valueOut);
|
||||||
static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut);
|
static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut);
|
||||||
|
static bool jsonSignedInt64Field(const String &json, const String &field, int64_t &valueOut);
|
||||||
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
|
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
|
||||||
static String formatPairingShortCode(const String &value);
|
static String formatPairingShortCode(const String &value);
|
||||||
static bool pairingMenuVisible();
|
static bool pairingMenuVisible();
|
||||||
@@ -617,6 +633,22 @@ static bool sendWalletRpcResponse(const String &toLogin,
|
|||||||
const String &targetSessionId,
|
const String &targetSessionId,
|
||||||
const String &callId,
|
const String &callId,
|
||||||
const String &responseData);
|
const String &responseData);
|
||||||
|
static bool sendSignalResponse(const String &toLogin,
|
||||||
|
const String &targetSessionId,
|
||||||
|
const String &signalType,
|
||||||
|
const String &signalRequestId,
|
||||||
|
const String &responseData);
|
||||||
|
static bool buildSendSignalSignatures(const String &toLogin,
|
||||||
|
const String &targetMode,
|
||||||
|
const String &targetSessionId,
|
||||||
|
const String &signalType,
|
||||||
|
const String &signalRequestId,
|
||||||
|
const String &data,
|
||||||
|
uint64_t timeMs,
|
||||||
|
bool includeClientSignature,
|
||||||
|
String &sessionSignatureB64Out,
|
||||||
|
String &clientSignatureB64Out,
|
||||||
|
String &errorOut);
|
||||||
static void queueWalletSignRequest(const PendingWalletRpcRequest &item,
|
static void queueWalletSignRequest(const PendingWalletRpcRequest &item,
|
||||||
const String &requestId,
|
const String &requestId,
|
||||||
const String &publicKeyBase58,
|
const String &publicKeyBase58,
|
||||||
@@ -881,6 +913,14 @@ static String bytesToBase64String(const uint8_t *data, size_t len) {
|
|||||||
return base64Std(data, len);
|
return base64Std(data, len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String trimBase64Padding(const String &value) {
|
||||||
|
String out = value;
|
||||||
|
while (out.endsWith("=")) {
|
||||||
|
out.remove(out.length() - 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
static String bytesToHexString(const uint8_t *data, size_t len) {
|
static String bytesToHexString(const uint8_t *data, size_t len) {
|
||||||
static const char *kHex = "0123456789abcdef";
|
static const char *kHex = "0123456789abcdef";
|
||||||
String out;
|
String out;
|
||||||
@@ -900,6 +940,30 @@ static String normalizeLoginValue(const String &value) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int hexNibbleValue(char c) {
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
|
||||||
|
if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hex64ToBytes(const String &value, uint8_t out[32]) {
|
||||||
|
String clean = value;
|
||||||
|
clean.trim();
|
||||||
|
if (clean.length() != 64) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < 32; ++i) {
|
||||||
|
int hi = hexNibbleValue(clean.charAt((int)(i * 2)));
|
||||||
|
int lo = hexNibbleValue(clean.charAt((int)(i * 2 + 1)));
|
||||||
|
if (hi < 0 || lo < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out[i] = (uint8_t)((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool isValidShineServerLoginValue(const String &value) {
|
static bool isValidShineServerLoginValue(const String &value) {
|
||||||
if (value.isEmpty() || value.length() > 20) {
|
if (value.isEmpty() || value.length() > 20) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1587,6 +1651,34 @@ static bool jsonInt64Field(const String &json, const String &field, uint64_t &va
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool jsonSignedInt64Field(const String &json, const String &field, int64_t &valueOut) {
|
||||||
|
String needle = "\"" + field + "\"";
|
||||||
|
int keyPos = json.indexOf(needle);
|
||||||
|
if (keyPos < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int colon = json.indexOf(':', keyPos + needle.length());
|
||||||
|
if (colon < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int pos = colon + 1;
|
||||||
|
while (pos < (int)json.length() && (json[pos] == ' ' || json[pos] == '\n' || json[pos] == '\r' || json[pos] == '\t')) {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
int start = pos;
|
||||||
|
if (pos < (int)json.length() && json[pos] == '-') {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
while (pos < (int)json.length() && isDigit((unsigned char)json[pos])) {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
if (pos == start || (pos == start + 1 && json[start] == '-')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
valueOut = strtoll(json.substring(start, pos).c_str(), nullptr, 10);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static String formatSolValue(uint64_t lamports) {
|
static String formatSolValue(uint64_t lamports) {
|
||||||
uint64_t whole = lamports / 1000000000ULL;
|
uint64_t whole = lamports / 1000000000ULL;
|
||||||
uint64_t frac = (lamports % 1000000000ULL) / 1000000ULL;
|
uint64_t frac = (lamports % 1000000000ULL) / 1000000ULL;
|
||||||
@@ -2695,6 +2787,195 @@ static bool sendWalletRpcResponse(const String &toLogin,
|
|||||||
return shineWsRequest(gShineWs, "CallSignalToSession", req, response, SHINE_RPC_TIMEOUT_MS);
|
return shineWsRequest(gShineWs, "CallSignalToSession", req, response, SHINE_RPC_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool buildSendSignalSignatures(const String &toLogin,
|
||||||
|
const String &targetMode,
|
||||||
|
const String &targetSessionId,
|
||||||
|
const String &signalType,
|
||||||
|
const String &signalRequestId,
|
||||||
|
const String &data,
|
||||||
|
uint64_t timeMs,
|
||||||
|
bool includeClientSignature,
|
||||||
|
String &sessionSignatureB64Out,
|
||||||
|
String &clientSignatureB64Out,
|
||||||
|
String &errorOut) {
|
||||||
|
sessionSignatureB64Out = "";
|
||||||
|
clientSignatureB64Out = "";
|
||||||
|
errorOut = "";
|
||||||
|
|
||||||
|
if (gLoginValue.isEmpty() || gShineSessionId.isEmpty()) {
|
||||||
|
errorOut = "session_context_missing";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t dataHash32[32] = {};
|
||||||
|
sha256calc(reinterpret_cast<const uint8_t *>(data.c_str()), data.length(), dataHash32);
|
||||||
|
String dataSha256B64 = trimBase64Padding(bytesToBase64String(dataHash32, sizeof(dataHash32)));
|
||||||
|
|
||||||
|
uint8_t subSeed[32] = {};
|
||||||
|
uint8_t subPub[32] = {};
|
||||||
|
uint8_t subSec[64] = {};
|
||||||
|
if (!deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) {
|
||||||
|
errorOut = "homeserver_session_key_unavailable";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionPreimage = String("SEND_SIGNAL_SESSION:")
|
||||||
|
+ gLoginValue + ":"
|
||||||
|
+ gShineSessionId + ":"
|
||||||
|
+ toLogin + ":"
|
||||||
|
+ targetMode + ":"
|
||||||
|
+ targetSessionId + ":"
|
||||||
|
+ signalType + ":"
|
||||||
|
+ signalRequestId + ":"
|
||||||
|
+ String((unsigned long long)timeMs) + ":"
|
||||||
|
+ dataSha256B64;
|
||||||
|
uint8_t sessionSignature[64] = {};
|
||||||
|
std::vector<uint8_t> sessionMessage(reinterpret_cast<const uint8_t *>(sessionPreimage.c_str()),
|
||||||
|
reinterpret_cast<const uint8_t *>(sessionPreimage.c_str()) + sessionPreimage.length());
|
||||||
|
if (!signMessageEd25519(sessionMessage, subSec, sessionSignature)) {
|
||||||
|
errorOut = "session_signal_sign_failed";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sessionSignatureB64Out = bytesToBase64String(sessionSignature, sizeof(sessionSignature));
|
||||||
|
|
||||||
|
if (!includeClientSignature) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t clientSeed[32] = {};
|
||||||
|
uint8_t clientPub[32] = {};
|
||||||
|
uint8_t clientSec[64] = {};
|
||||||
|
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, clientSec)) {
|
||||||
|
errorOut = "client_key_unavailable";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String clientPreimage = String("SEND_SIGNAL_CLIENT:")
|
||||||
|
+ gLoginValue + ":"
|
||||||
|
+ gShineSessionId + ":"
|
||||||
|
+ toLogin + ":"
|
||||||
|
+ targetMode + ":"
|
||||||
|
+ targetSessionId + ":"
|
||||||
|
+ signalType + ":"
|
||||||
|
+ signalRequestId + ":"
|
||||||
|
+ String((unsigned long long)timeMs) + ":"
|
||||||
|
+ dataSha256B64;
|
||||||
|
uint8_t clientSignature[64] = {};
|
||||||
|
std::vector<uint8_t> clientMessage(reinterpret_cast<const uint8_t *>(clientPreimage.c_str()),
|
||||||
|
reinterpret_cast<const uint8_t *>(clientPreimage.c_str()) + clientPreimage.length());
|
||||||
|
if (!signMessageEd25519(clientMessage, clientSec, clientSignature)) {
|
||||||
|
errorOut = "client_signal_sign_failed";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clientSignatureB64Out = bytesToBase64String(clientSignature, sizeof(clientSignature));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool sendSignalResponse(const String &toLogin,
|
||||||
|
const String &targetSessionId,
|
||||||
|
const String &signalType,
|
||||||
|
const String &signalRequestId,
|
||||||
|
const String &responseData) {
|
||||||
|
String response;
|
||||||
|
uint64_t timeMs = shineNowMs();
|
||||||
|
String sessionSignatureB64;
|
||||||
|
String clientSignatureB64;
|
||||||
|
String error;
|
||||||
|
if (!buildSendSignalSignatures(toLogin,
|
||||||
|
"single_session",
|
||||||
|
targetSessionId,
|
||||||
|
signalType,
|
||||||
|
signalRequestId,
|
||||||
|
responseData,
|
||||||
|
timeMs,
|
||||||
|
false,
|
||||||
|
sessionSignatureB64,
|
||||||
|
clientSignatureB64,
|
||||||
|
error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String req = String("{\"toLogin\":\"") + jsonEscape(toLogin)
|
||||||
|
+ "\",\"targetMode\":\"single_session\""
|
||||||
|
+ ",\"targetSessionId\":\"" + jsonEscape(targetSessionId)
|
||||||
|
+ "\",\"signalType\":\"" + jsonEscape(signalType)
|
||||||
|
+ "\",\"signalRequestId\":\"" + jsonEscape(signalRequestId)
|
||||||
|
+ "\",\"data\":\"" + jsonEscape(responseData)
|
||||||
|
+ "\",\"timeMs\":" + String((unsigned long long)timeMs)
|
||||||
|
+ ",\"sessionSignatureB64\":\"" + jsonEscape(sessionSignatureB64)
|
||||||
|
+ "\",\"clientSignatureB64\":\"" + jsonEscape(clientSignatureB64) + "\"}";
|
||||||
|
return shineWsRequest(gShineWs, "SendSignal", req, response, SHINE_RPC_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void appendUint16BE(std::vector<uint8_t> &out, uint16_t value) {
|
||||||
|
out.push_back((uint8_t)((value >> 8) & 0xFF));
|
||||||
|
out.push_back((uint8_t)(value & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void appendInt32BE(std::vector<uint8_t> &out, int32_t value) {
|
||||||
|
uint32_t v = (uint32_t)value;
|
||||||
|
out.push_back((uint8_t)((v >> 24) & 0xFF));
|
||||||
|
out.push_back((uint8_t)((v >> 16) & 0xFF));
|
||||||
|
out.push_back((uint8_t)((v >> 8) & 0xFF));
|
||||||
|
out.push_back((uint8_t)(v & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void appendInt64BE(std::vector<uint8_t> &out, int64_t value) {
|
||||||
|
uint64_t v = (uint64_t)value;
|
||||||
|
out.push_back((uint8_t)((v >> 56) & 0xFF));
|
||||||
|
out.push_back((uint8_t)((v >> 48) & 0xFF));
|
||||||
|
out.push_back((uint8_t)((v >> 40) & 0xFF));
|
||||||
|
out.push_back((uint8_t)((v >> 32) & 0xFF));
|
||||||
|
out.push_back((uint8_t)((v >> 24) & 0xFF));
|
||||||
|
out.push_back((uint8_t)((v >> 16) & 0xFF));
|
||||||
|
out.push_back((uint8_t)((v >> 8) & 0xFF));
|
||||||
|
out.push_back((uint8_t)(v & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint16_t readUint16BE(const uint8_t *data) {
|
||||||
|
return (uint16_t)(((uint16_t)data[0] << 8) | (uint16_t)data[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool fetchRemoteAddBlockCursor(const String &login,
|
||||||
|
String &blockchainNameOut,
|
||||||
|
int32_t &lastBlockNumberOut,
|
||||||
|
String &lastBlockHashOut,
|
||||||
|
String &errorMessageOut,
|
||||||
|
String &errorCodeOut) {
|
||||||
|
const char *kRemoteZeroHash64 = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
blockchainNameOut = "";
|
||||||
|
lastBlockNumberOut = -1;
|
||||||
|
lastBlockHashOut = String(kRemoteZeroHash64);
|
||||||
|
errorMessageOut = "";
|
||||||
|
errorCodeOut = "";
|
||||||
|
|
||||||
|
String getUserReq = String("{\"login\":\"") + jsonEscape(login) + "\"}";
|
||||||
|
String getUserResp;
|
||||||
|
bool ok = shineWsRequest(gShineWs, "GetUser", getUserReq, getUserResp, SHINE_RPC_TIMEOUT_MS);
|
||||||
|
uint64_t statusCode = 0;
|
||||||
|
jsonInt64Field(getUserResp, "status", statusCode);
|
||||||
|
if (!ok || statusCode != 200) {
|
||||||
|
jsonStringField(getUserResp, "message", errorMessageOut);
|
||||||
|
jsonStringField(getUserResp, "code", errorCodeOut);
|
||||||
|
if (errorCodeOut.isEmpty()) errorCodeOut = ok ? "getuser_rejected" : "getuser_request_failed";
|
||||||
|
if (errorMessageOut.isEmpty()) errorMessageOut = ok ? "GetUser rejected by server" : "GetUser request failed";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t lastBlockNumberI64 = -1;
|
||||||
|
jsonStringField(getUserResp, "blockchainName", blockchainNameOut);
|
||||||
|
jsonStringField(getUserResp, "serverLastGlobalHash", lastBlockHashOut);
|
||||||
|
if (!jsonSignedInt64Field(getUserResp, "serverLastGlobalNumber", lastBlockNumberI64)) {
|
||||||
|
lastBlockNumberI64 = -1;
|
||||||
|
}
|
||||||
|
lastBlockNumberOut = (int32_t)lastBlockNumberI64;
|
||||||
|
if (lastBlockHashOut.isEmpty()) {
|
||||||
|
lastBlockHashOut = String(kRemoteZeroHash64);
|
||||||
|
}
|
||||||
|
lastBlockHashOut.toLowerCase();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static void queueWalletSignRequest(const PendingWalletRpcRequest &item,
|
static void queueWalletSignRequest(const PendingWalletRpcRequest &item,
|
||||||
const String &requestId,
|
const String &requestId,
|
||||||
const String &publicKeyBase58,
|
const String &publicKeyBase58,
|
||||||
@@ -4075,6 +4356,7 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
queueIncomingWalletRpcRequest(frame);
|
queueIncomingWalletRpcRequest(frame);
|
||||||
|
queueIncomingRemoteAddBlockRequest(frame);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -4121,6 +4403,50 @@ static bool queueIncomingWalletRpcRequest(const String &frame) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool queueIncomingRemoteAddBlockRequest(const String &frame) {
|
||||||
|
if (frame.indexOf("\"op\":\"IncomingSignal\"") < 0 || frame.indexOf("\"event\":true") < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int payloadPos = frame.indexOf("\"payload\":");
|
||||||
|
if (payloadPos < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int objectStart = frame.indexOf('{', payloadPos);
|
||||||
|
if (objectStart < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int objectEnd = -1;
|
||||||
|
String payloadJson;
|
||||||
|
if (!extractJsonObjectAt(frame, objectStart, objectEnd, payloadJson)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String signalType;
|
||||||
|
if (!jsonStringField(payloadJson, "signalType", signalType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (signalType != "remote_addblock_request") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingRemoteAddBlockRequest item;
|
||||||
|
jsonStringField(payloadJson, "fromLogin", item.fromLogin);
|
||||||
|
jsonStringField(payloadJson, "fromSessionId", item.fromSessionId);
|
||||||
|
jsonStringField(payloadJson, "signalRequestId", item.signalRequestId);
|
||||||
|
jsonStringField(payloadJson, "data", item.data);
|
||||||
|
jsonStringField(payloadJson, "sessionSignatureB64", item.sessionSignatureB64);
|
||||||
|
jsonStringField(payloadJson, "clientSignatureB64", item.clientSignatureB64);
|
||||||
|
uint64_t timeMs = 0;
|
||||||
|
jsonInt64Field(payloadJson, "timeMs", timeMs);
|
||||||
|
item.timeMs = timeMs;
|
||||||
|
item.signalType = signalType;
|
||||||
|
if (item.fromLogin.isEmpty() || item.fromSessionId.isEmpty() || item.signalRequestId.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
gPendingRemoteAddBlockRequests.push_back(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static void processPendingWalletRpcRequests() {
|
static void processPendingWalletRpcRequests() {
|
||||||
if (gPendingWalletRpcRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
if (gPendingWalletRpcRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
||||||
return;
|
return;
|
||||||
@@ -4180,6 +4506,158 @@ static void processPendingWalletRpcRequests() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void processPendingRemoteAddBlockRequests() {
|
||||||
|
if (gPendingRemoteAddBlockRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!gPendingRemoteAddBlockRequests.empty()) {
|
||||||
|
PendingRemoteAddBlockRequest item = gPendingRemoteAddBlockRequests.front();
|
||||||
|
gPendingRemoteAddBlockRequests.erase(gPendingRemoteAddBlockRequests.begin());
|
||||||
|
|
||||||
|
String responseData;
|
||||||
|
String blockchainName;
|
||||||
|
String blockBodyB64;
|
||||||
|
|
||||||
|
if (item.fromLogin != gLoginValue) {
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"forbidden_login\",\"errorMessage\":\"Signal login mismatch\",\"requestId\":\"")
|
||||||
|
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.sessionSignatureB64.isEmpty() || item.clientSignatureB64.isEmpty()) {
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"missing_required_signatures\",\"errorMessage\":\"Client and session signatures are required\",\"requestId\":\"")
|
||||||
|
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStringField(item.data, "blockchainName", blockchainName);
|
||||||
|
jsonStringField(item.data, "blockBodyB64", blockBodyB64);
|
||||||
|
if (blockchainName.isEmpty() || blockBodyB64.isEmpty()) {
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"bad_request\",\"errorMessage\":\"Missing required AddBlock fields\",\"requestId\":\"")
|
||||||
|
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> remoteBody;
|
||||||
|
if (!base64DecodeStd(blockBodyB64, remoteBody) || remoteBody.size() < 6) {
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"bad_block_body_base64\",\"errorMessage\":\"Invalid remote block body base64\",\"requestId\":\"")
|
||||||
|
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t msgType = readUint16BE(remoteBody.data());
|
||||||
|
uint16_t msgSubType = readUint16BE(remoteBody.data() + 2);
|
||||||
|
uint16_t msgVersion = readUint16BE(remoteBody.data() + 4);
|
||||||
|
std::vector<uint8_t> bodyBytes(remoteBody.begin() + 6, remoteBody.end());
|
||||||
|
|
||||||
|
String resolvedBlockchainName;
|
||||||
|
int32_t lastBlockNumber = -1;
|
||||||
|
String prevBlockHash;
|
||||||
|
String cursorError;
|
||||||
|
String cursorErrorCode;
|
||||||
|
if (!fetchRemoteAddBlockCursor(gLoginValue, resolvedBlockchainName, lastBlockNumber, prevBlockHash, cursorError, cursorErrorCode)) {
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"") + jsonEscape(cursorErrorCode)
|
||||||
|
+ "\",\"errorMessage\":\"" + jsonEscape(cursorError)
|
||||||
|
+ "\",\"requestId\":\"" + jsonEscape(item.signalRequestId) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (resolvedBlockchainName.isEmpty()) {
|
||||||
|
resolvedBlockchainName = blockchainName;
|
||||||
|
}
|
||||||
|
int32_t nextBlockNumber = lastBlockNumber + 1;
|
||||||
|
String cleanPrevHash = prevBlockHash.isEmpty()
|
||||||
|
? String("0000000000000000000000000000000000000000000000000000000000000000")
|
||||||
|
: prevBlockHash;
|
||||||
|
uint8_t prevHash32[32] = {};
|
||||||
|
if (!hex64ToBytes(cleanPrevHash, prevHash32)) {
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"bad_prev_hash\",\"errorMessage\":\"Invalid previous block hash from GetUser\",\"requestId\":\"")
|
||||||
|
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> preimage;
|
||||||
|
preimage.reserve(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.size());
|
||||||
|
appendUint16BE(preimage, 0);
|
||||||
|
preimage.insert(preimage.end(), prevHash32, prevHash32 + 32);
|
||||||
|
int32_t blockSize = (int32_t)(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.size());
|
||||||
|
appendInt32BE(preimage, blockSize);
|
||||||
|
appendInt32BE(preimage, nextBlockNumber);
|
||||||
|
appendInt64BE(preimage, (int64_t)(shineNowMs() / 1000ULL));
|
||||||
|
appendUint16BE(preimage, msgType);
|
||||||
|
appendUint16BE(preimage, msgSubType);
|
||||||
|
appendUint16BE(preimage, msgVersion);
|
||||||
|
preimage.insert(preimage.end(), bodyBytes.begin(), bodyBytes.end());
|
||||||
|
|
||||||
|
uint8_t blockchainSeed[32] = {};
|
||||||
|
uint8_t blockchainPub[32] = {};
|
||||||
|
uint8_t blockchainSec[64] = {};
|
||||||
|
if (!deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec)) {
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"blockchain_key_unavailable\",\"errorMessage\":\"Blockchain key is unavailable on homeserver\",\"requestId\":\"")
|
||||||
|
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t hash32[32] = {};
|
||||||
|
sha256calc(preimage.data(), preimage.size(), hash32);
|
||||||
|
uint8_t signature[64] = {};
|
||||||
|
if (!signMessageEd25519(std::vector<uint8_t>(hash32, hash32 + 32), blockchainSec, signature)) {
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"sign_failed\",\"errorMessage\":\"Failed to sign AddBlock hash\",\"requestId\":\"")
|
||||||
|
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> fullBlock = preimage;
|
||||||
|
fullBlock.push_back(0x01);
|
||||||
|
fullBlock.push_back(0x00);
|
||||||
|
fullBlock.insert(fullBlock.end(), signature, signature + 64);
|
||||||
|
String addBlockReq = String("{\"blockchainName\":\"") + jsonEscape(resolvedBlockchainName)
|
||||||
|
+ "\",\"blockNumber\":" + String((long long)nextBlockNumber)
|
||||||
|
+ ",\"prevBlockHash\":\"" + jsonEscape(cleanPrevHash)
|
||||||
|
+ "\",\"blockBytesB64\":\"" + jsonEscape(bytesToBase64String(fullBlock.data(), fullBlock.size())) + "\"}";
|
||||||
|
String addBlockResp;
|
||||||
|
bool addBlockOk = shineWsRequest(gShineWs, "AddBlock", addBlockReq, addBlockResp, SHINE_RPC_TIMEOUT_MS);
|
||||||
|
uint64_t statusCode = 0;
|
||||||
|
jsonInt64Field(addBlockResp, "status", statusCode);
|
||||||
|
String serverLastHash;
|
||||||
|
String errorCode;
|
||||||
|
String errorMessage;
|
||||||
|
uint64_t serverLastNumber = 0;
|
||||||
|
jsonStringField(addBlockResp, "serverLastGlobalHash", serverLastHash);
|
||||||
|
jsonStringField(addBlockResp, "code", errorCode);
|
||||||
|
jsonStringField(addBlockResp, "message", errorMessage);
|
||||||
|
jsonInt64Field(addBlockResp, "serverLastGlobalNumber", serverLastNumber);
|
||||||
|
|
||||||
|
if (!addBlockOk || statusCode != 200) {
|
||||||
|
if (errorCode.isEmpty()) {
|
||||||
|
errorCode = addBlockOk ? "addblock_rejected" : "addblock_request_failed";
|
||||||
|
}
|
||||||
|
if (errorMessage.isEmpty()) {
|
||||||
|
errorMessage = addBlockOk ? "AddBlock rejected by server" : "AddBlock request failed";
|
||||||
|
}
|
||||||
|
responseData = String("{\"ok\":false,\"error\":\"") + jsonEscape(errorCode)
|
||||||
|
+ "\",\"errorMessage\":\"" + jsonEscape(errorMessage)
|
||||||
|
+ "\",\"requestId\":\"" + jsonEscape(item.signalRequestId)
|
||||||
|
+ "\",\"serverLastGlobalNumber\":" + String((unsigned long long)serverLastNumber)
|
||||||
|
+ ",\"serverLastGlobalHash\":\"" + jsonEscape(serverLastHash) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData = String("{\"ok\":true,\"requestId\":\"") + jsonEscape(item.signalRequestId)
|
||||||
|
+ "\",\"serverLastGlobalNumber\":" + String((unsigned long long)serverLastNumber)
|
||||||
|
+ ",\"serverLastGlobalHash\":\"" + jsonEscape(serverLastHash) + "\"}";
|
||||||
|
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void pumpShineIncomingFrames(uint32_t maxFrames) {
|
static void pumpShineIncomingFrames(uint32_t maxFrames) {
|
||||||
if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
||||||
return;
|
return;
|
||||||
@@ -4193,6 +4671,7 @@ static void pumpShineIncomingFrames(uint32_t maxFrames) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
queueIncomingWalletRpcRequest(frame);
|
queueIncomingWalletRpcRequest(frame);
|
||||||
|
queueIncomingRemoteAddBlockRequest(frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4748,6 +5227,7 @@ static void manageShineConnection() {
|
|||||||
|
|
||||||
pumpShineIncomingFrames();
|
pumpShineIncomingFrames();
|
||||||
processPendingWalletRpcRequests();
|
processPendingWalletRpcRequests();
|
||||||
|
processPendingRemoteAddBlockRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void upsertKnownWifi(const String &ssid, const String &password) {
|
static void upsertKnownWifi(const String &ssid, const String &password) {
|
||||||
@@ -4791,7 +5271,7 @@ static void loadPrefs() {
|
|||||||
if (!gWifiSavedSsid.isEmpty() && findKnownWifiIndex(gWifiSavedSsid) < 0) {
|
if (!gWifiSavedSsid.isEmpty() && findKnownWifiIndex(gWifiSavedSsid) < 0) {
|
||||||
upsertKnownWifi(gWifiSavedSsid, gWifiSavedPassword);
|
upsertKnownWifi(gWifiSavedSsid, gWifiSavedPassword);
|
||||||
}
|
}
|
||||||
gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com");
|
gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.mainnet-beta.solana.com");
|
||||||
String storedShineServerLogin = normalizeLoginValue(gPrefs.getString("shine_server_login", ""));
|
String storedShineServerLogin = normalizeLoginValue(gPrefs.getString("shine_server_login", ""));
|
||||||
if (!isValidShineServerLoginValue(storedShineServerLogin)) {
|
if (!isValidShineServerLoginValue(storedShineServerLogin)) {
|
||||||
String legacyShineServer = normalizeLoginValue(gPrefs.getString("shine_server", ""));
|
String legacyShineServer = normalizeLoginValue(gPrefs.getString("shine_server", ""));
|
||||||
@@ -5603,6 +6083,13 @@ static void editorKeyCb(lv_event_t *event) {
|
|||||||
rebuildScreen();
|
rebuildScreen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (strcmp(token, "<SPACE>") == 0) {
|
||||||
|
keepCursorAtEnd();
|
||||||
|
lv_textarea_add_text(gInputTextArea, " ");
|
||||||
|
syncEditValueFromTextarea();
|
||||||
|
keepCursorAtEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String out = token;
|
String out = token;
|
||||||
if (gKeyboardMode == KEYBOARD_MODE_ALPHA && gKeyboardShift) {
|
if (gKeyboardMode == KEYBOARD_MODE_ALPHA && gKeyboardShift) {
|
||||||
@@ -6773,10 +7260,10 @@ static void drawKeyRow(const char *const *tokens,
|
|||||||
lv_coord_t h,
|
lv_coord_t h,
|
||||||
lv_coord_t gap,
|
lv_coord_t gap,
|
||||||
uint32_t bgColor,
|
uint32_t bgColor,
|
||||||
bool uppercase = false,
|
bool uppercase = false,
|
||||||
const lv_font_t *font = &lv_font_montserrat_18) {
|
const lv_font_t *font = &lv_font_montserrat_18) {
|
||||||
for (int i = 0; i < count; ++i) {
|
for (int i = 0; i < count; ++i) {
|
||||||
String label = tokens[i];
|
String label = strcmp(tokens[i], "<SPACE>") == 0 ? String("SPACE") : String(tokens[i]);
|
||||||
if (uppercase) {
|
if (uppercase) {
|
||||||
label.toUpperCase();
|
label.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -6796,8 +7283,8 @@ static void drawTextEditorKeyboard(lv_coord_t originY) {
|
|||||||
static const char *symLeftRow1[] = {"://", "/", ":", "_", "@"};
|
static const char *symLeftRow1[] = {"://", "/", ":", "_", "@"};
|
||||||
static const char *symLeftRow2[] = {".com", ".net", ".org", ".me", ".io"};
|
static const char *symLeftRow2[] = {".com", ".net", ".org", ".me", ".io"};
|
||||||
static const char *symRightRow0[] = {"6", "7", "8", "9", "0"};
|
static const char *symRightRow0[] = {"6", "7", "8", "9", "0"};
|
||||||
static const char *symRightRow1[] = {"?", "&", "=", "+", "-"};
|
static const char *symRightRow1[] = {"?", "!", "=", "+", "-"};
|
||||||
static const char *symRightRow2[] = {"%", "#", "~", ":", "/"};
|
static const char *symRightRow2[] = {"<SPACE>", "#", "~", ":", "/"};
|
||||||
|
|
||||||
const lv_coord_t keyH = 62;
|
const lv_coord_t keyH = 62;
|
||||||
const lv_coord_t keyW5 = 84;
|
const lv_coord_t keyW5 = 84;
|
||||||
|
|||||||
@@ -239,9 +239,9 @@ static DerivedKeyState gDerivedKeys = {};
|
|||||||
static const char *kSystemProgramId = "11111111111111111111111111111111";
|
static const char *kSystemProgramId = "11111111111111111111111111111111";
|
||||||
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
|
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
|
||||||
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
|
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
|
||||||
static const char *kShineUsersProgramId = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
|
static const char *kShineUsersProgramId = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6";
|
||||||
static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
|
static const char *kShineLoginGuardProgramId = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh";
|
||||||
static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
|
static const char *kShinePaymentsProgramId = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v";
|
||||||
static const char *kUsersSeedPrefix = "user_login=";
|
static const char *kUsersSeedPrefix = "user_login=";
|
||||||
static const char *kUsersEconomyConfigSeed = "shine_users_economy_config";
|
static const char *kUsersEconomyConfigSeed = "shine_users_economy_config";
|
||||||
static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault";
|
static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault";
|
||||||
@@ -1314,7 +1314,7 @@ static String defaultApiUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String defaultRpcUrl() {
|
static String defaultRpcUrl() {
|
||||||
return "https://api.devnet.solana.com";
|
return "https://api.mainnet-beta.solana.com";
|
||||||
}
|
}
|
||||||
|
|
||||||
static String defaultWsUrl() {
|
static String defaultWsUrl() {
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
- кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован.
|
- кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован.
|
||||||
|
|
||||||
Значения по умолчанию:
|
Значения по умолчанию:
|
||||||
- Solana RPC: `https://api.devnet.solana.com`
|
- Solana RPC: `https://api.mainnet-beta.solana.com`
|
||||||
- SHiNE server login: `shineupme`
|
- SHiNE server login: `shineupme`
|
||||||
|
|
||||||
Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`.
|
Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`.
|
||||||
@@ -259,6 +259,7 @@
|
|||||||
- переключение страниц выполняется свайпом влево/вправо внутри `TEXT_EDIT_SCREEN`;
|
- переключение страниц выполняется свайпом влево/вправо внутри `TEXT_EDIT_SCREEN`;
|
||||||
- страница 1 содержит в основном буквы и базовые URL-символы;
|
- страница 1 содержит в основном буквы и базовые URL-символы;
|
||||||
- страница 2 содержит цифры и дополнительные URL/символьные кнопки;
|
- страница 2 содержит цифры и дополнительные URL/символьные кнопки;
|
||||||
|
- на символьных страницах должны быть доступны как минимум `!` и отдельная кнопка `SPACE`;
|
||||||
- переключение `ABC/123` и `SHIFT` не должно очищать уже введённый текст;
|
- переключение `ABC/123` и `SHIFT` не должно очищать уже введённый текст;
|
||||||
- переключение страниц клавиатуры свайпом тоже не должно очищать уже введённый текст, включая цифры, символы и уже набранные заглавные буквы;
|
- переключение страниц клавиатуры свайпом тоже не должно очищать уже введённый текст, включая цифры, символы и уже набранные заглавные буквы;
|
||||||
- есть специальные действия `DEL` и `CLR`.
|
- есть специальные действия `DEL` и `CLR`.
|
||||||
@@ -308,6 +309,7 @@
|
|||||||
- половина букв находится на левой странице, половина на правой;
|
- половина букв находится на левой странице, половина на правой;
|
||||||
- на правой странице кнопки тоже стоят в ровных колонках, без сдвига рядов вправо;
|
- на правой странице кнопки тоже стоят в ровных колонках, без сдвига рядов вправо;
|
||||||
- отдельный режим `symbols` тоже разделён на 2 страницы;
|
- отдельный режим `symbols` тоже разделён на 2 страницы;
|
||||||
|
- в режиме `symbols` на правой странице есть отдельная кнопка `SPACE` и отдельная кнопка `!`;
|
||||||
- 3 основных ряда клавиш и нижний служебный ряд увеличены по высоте;
|
- 3 основных ряда клавиш и нижний служебный ряд увеличены по высоте;
|
||||||
- клавиши дополнительно увеличены по высоте по сравнению с предыдущим промежуточным вариантом;
|
- клавиши дополнительно увеличены по высоте по сравнению с предыдущим промежуточным вариантом;
|
||||||
- четвёртый ряд содержит:
|
- четвёртый ряд содержит:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -48,13 +48,13 @@
|
|||||||
- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе.
|
- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе.
|
||||||
|
|
||||||
## Планы и отложенные фичи
|
## Планы и отложенные фичи
|
||||||
- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
|
- Планы проекта по отложенным фичам хранятся в `TODO/`.
|
||||||
- Внутри есть три горизонта:
|
- Внутри есть три горизонта:
|
||||||
- `near/` - ближайшие планы, обычно сегодня/завтра;
|
- `near/` - ближайшие планы, обычно сегодня/завтра;
|
||||||
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
|
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
|
||||||
- `far/` - дальнее будущее без понятного срока.
|
- `far/` - дальнее будущее без понятного срока.
|
||||||
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
|
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
|
||||||
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
|
- Файлы из `TODO/` не начинать реализовывать без явной команды пользователя.
|
||||||
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
|
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
|
||||||
|
|
||||||
## Центр задач и предложений
|
## Центр задач и предложений
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`.
|
- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`.
|
||||||
|
|
||||||
## Планы и задачи
|
## Планы и задачи
|
||||||
- Отложенные задачи проекта лежат в `../Dev_Docs/Future_Features/`.
|
- Отложенные задачи проекта лежат в `../TODO/`.
|
||||||
- Точка входа по планам: `../Dev_Docs/Future_Features/README.md`.
|
- Точка входа по планам: `../TODO/README.md`.
|
||||||
- Горизонты планов:
|
- Горизонты планов:
|
||||||
- `near/` - ближайшие планы;
|
- `near/` - ближайшие планы;
|
||||||
- `medium/` - среднесрочные планы;
|
- `medium/` - среднесрочные планы;
|
||||||
- `far/` - дальнее будущее.
|
- `far/` - дальнее будущее.
|
||||||
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
|
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
|
||||||
- Не начинать реализацию задач из `Future_Features` без явной команды пользователя.
|
- Не начинать реализацию задач из `TODO` без явной команды пользователя.
|
||||||
|
|
||||||
## Проверка после изменений
|
## Проверка после изменений
|
||||||
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
|
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { base64ToBytes } from './crypto-utils.js';
|
import { base64ToBytes } from './crypto-utils.js';
|
||||||
import { PublicKey } from './vendor/solana-publickey-bundle.js';
|
import { PublicKey } from './vendor/solana-publickey-bundle.js';
|
||||||
|
|
||||||
const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com';
|
const SOLANA_ENDPOINT_DEFAULT = 'https://solana-rpc.publicnode.com';
|
||||||
const SHINE_USERS_PROGRAM_ID = '3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ';
|
const SHINE_USERS_PROGRAM_ID = 'SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6';
|
||||||
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
|
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
|
||||||
const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme';
|
const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme';
|
||||||
const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me';
|
const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me';
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ shine-UI/server-ui.html
|
|||||||
Для обновления — только root + device (blockchain-ключ не нужен).
|
Для обновления — только root + device (blockchain-ключ не нужен).
|
||||||
|
|
||||||
Актуальные адреса программ Solana (devnet):
|
Актуальные адреса программ Solana (devnet):
|
||||||
- `shine_users`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
|
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
|
||||||
- `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
|
- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
|
||||||
|
|
||||||
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
|
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ shine-UI/server-ui.html
|
|||||||
./gradlew deployUI
|
./gradlew deployUI
|
||||||
```
|
```
|
||||||
|
|
||||||
Default deploy по умолчанию идёт на `test2.shineup.me` (`player@193.8.215.70`).
|
Default deploy по умолчанию идёт на `t.shineup.me` (`player@193.8.215.70`).
|
||||||
|
|
||||||
Production deploy:
|
Production deploy:
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ Production deploy:
|
|||||||
./gradlew deployUITest
|
./gradlew deployUITest
|
||||||
```
|
```
|
||||||
|
|
||||||
`test.shineup.me` пока не использовать для обычного deploy.
|
`test.shineup.me` считается резервным тестовым сервером и в обычный deploy не включается.
|
||||||
|
|
||||||
Логи на проде:
|
Логи на проде:
|
||||||
- `/home/player/SHiNE/shine-server/logs/app.log`
|
- `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import java.util.Objects;
|
|||||||
* FileStoreUtil — утилита работы с файлами в папке data/.
|
* FileStoreUtil — утилита работы с файлами в папке data/.
|
||||||
*
|
*
|
||||||
* Теперь поддерживает:
|
* Теперь поддерживает:
|
||||||
* - основной файл блокчейна: <blockchainName>.bch
|
* - основной файл блокчейна: <blockchainName>.bch
|
||||||
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
||||||
|
* - sidecar-файл проверки записи: <blockchainName>.write_check
|
||||||
|
* - marker-файл записи: <blockchainName>.write_pending
|
||||||
*
|
*
|
||||||
* Важное:
|
* Важное:
|
||||||
* - validateSimpleFileName() запрещает path traversal.
|
* - validateSimpleFileName() запрещает path traversal.
|
||||||
@@ -29,6 +31,15 @@ public final class FileStoreUtil {
|
|||||||
/** Расширение временного файла (старое+новое). */
|
/** Расширение временного файла (старое+новое). */
|
||||||
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
|
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
|
||||||
|
|
||||||
|
/** Маркер того, что chain сейчас в процессе полного resync. */
|
||||||
|
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
|
||||||
|
|
||||||
|
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
|
||||||
|
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
|
||||||
|
|
||||||
|
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
|
||||||
|
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
|
||||||
|
|
||||||
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
||||||
|
|
||||||
private final Path dataDirPath;
|
private final Path dataDirPath;
|
||||||
@@ -130,6 +141,87 @@ public final class FileStoreUtil {
|
|||||||
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** <blockchainName>.write_check */
|
||||||
|
public String buildBlockchainWriteCheckFileName(String blockchainName) {
|
||||||
|
validateSimpleFileName(blockchainName);
|
||||||
|
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
|
||||||
|
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
|
||||||
|
StringBuilder sb = new StringBuilder(128);
|
||||||
|
sb.append("blockNumber=").append(blockNumber).append('\n');
|
||||||
|
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
|
||||||
|
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <blockchainName>.write_pending */
|
||||||
|
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
|
||||||
|
validateSimpleFileName(blockchainName);
|
||||||
|
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
|
||||||
|
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeBlockchainWritePendingMarker(String blockchainName) {
|
||||||
|
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <blockchainName>.resync_pending */
|
||||||
|
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
|
||||||
|
validateSimpleFileName(blockchainName);
|
||||||
|
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
|
||||||
|
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
|
||||||
|
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteIfExists(Path path) {
|
||||||
|
if (path == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainFileIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
|
||||||
|
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsBlockchainResyncMarker(String blockchainName) {
|
||||||
|
return exists(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Атомарно заменить основной файл блокчейна временным:
|
* Атомарно заменить основной файл блокчейна временным:
|
||||||
* <name>.tmp_bch -> <name>.bch
|
* <name>.tmp_bch -> <name>.bch
|
||||||
@@ -202,4 +294,4 @@ public final class FileStoreUtil {
|
|||||||
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
|
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package utils.config;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
|
||||||
public final class AppConfig {
|
public final class AppConfig {
|
||||||
@@ -26,18 +29,30 @@ public final class AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void load() {
|
private void load() {
|
||||||
try (InputStream in = getClass().getClassLoader()
|
try (InputStream in = getClass().getClassLoader().getResourceAsStream("application.properties")) {
|
||||||
.getResourceAsStream("application.properties")) {
|
|
||||||
|
|
||||||
if (in == null) {
|
if (in == null) {
|
||||||
throw new RuntimeException("Config file application.properties not found");
|
throw new RuntimeException("Config file application.properties not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
properties.load(in);
|
properties.load(in);
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Failed to load application.properties", e);
|
throw new RuntimeException("Failed to load application.properties", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Path externalConfig = Paths.get("application.properties");
|
||||||
|
if (!Files.isRegularFile(externalConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Properties override = new Properties();
|
||||||
|
try (InputStream in = Files.newInputStream(externalConfig)) {
|
||||||
|
override.load(in);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to load external application.properties from " + externalConfig.toAbsolutePath(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String name : override.stringPropertyNames()) {
|
||||||
|
properties.setProperty(name, override.getProperty(name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вернёт значение строки или null, если параметр не найден */
|
/** Вернёт значение строки или null, если параметр не найден */
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
package utils.config;
|
package utils.config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Публичные адреса Solana-программ SHiNE (жёстко зафиксированы для devnet).
|
* Публичные адреса Solana-программ SHiNE.
|
||||||
* Секреты на сервере не хранятся.
|
* Program ID одинаковые для mainnet/devnet, а cluster/RPC должны
|
||||||
|
* переопределяться через application.properties конкретного инстанса.
|
||||||
*/
|
*/
|
||||||
public final class SolanaProgramsConfig {
|
public final class SolanaProgramsConfig {
|
||||||
|
|
||||||
private SolanaProgramsConfig() {}
|
private SolanaProgramsConfig() {}
|
||||||
|
|
||||||
public static final String SOLANA_CLUSTER = "devnet";
|
private static final AppConfig CONFIG = AppConfig.getInstance();
|
||||||
public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com";
|
|
||||||
|
|
||||||
// Программа регистрации пользователей (shine_users), задеплоена в devnet.
|
public static final String SOLANA_CLUSTER = getParam("solana.cluster", "mainnet-beta");
|
||||||
public static final String SHINE_USERS_PROGRAM_ID = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
|
public static final String SOLANA_RPC_URL = getParam("solana.rpcUrl", "https://api.mainnet-beta.solana.com");
|
||||||
|
|
||||||
|
// Программа регистрации пользователей (shine_users), задеплоена в mainnet.
|
||||||
|
public static final String SHINE_USERS_PROGRAM_ID = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6";
|
||||||
|
|
||||||
// Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково.
|
// Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково.
|
||||||
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
|
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh";
|
||||||
public static final String SHINE_PAYMENTS_PROGRAM_ID = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
|
public static final String SHINE_PAYMENTS_PROGRAM_ID = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v";
|
||||||
|
|
||||||
|
private static String getParam(String key, String defaultValue) {
|
||||||
|
String value = CONFIG.getParam(key);
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ public final class DatabaseInitializer {
|
|||||||
ON signed_direct_messages_history (to_login, created_at_ms);
|
ON signed_direct_messages_history (to_login, created_at_ms);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
// 13) signed_messages_v2 (универсальное хранилище блоков типов 1/2/3/4)
|
// 13) signed_messages_v2 (универсальное хранилище блоков типов 1..8)
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS signed_messages_v2 (
|
CREATE TABLE IF NOT EXISTS signed_messages_v2 (
|
||||||
message_key TEXT NOT NULL PRIMARY KEY,
|
message_key TEXT NOT NULL PRIMARY KEY,
|
||||||
@@ -634,6 +634,7 @@ public final class DatabaseInitializer {
|
|||||||
nonce INTEGER NOT NULL,
|
nonce INTEGER NOT NULL,
|
||||||
message_type INTEGER NOT NULL,
|
message_type INTEGER NOT NULL,
|
||||||
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reencrypted_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
raw_block BLOB NOT NULL,
|
raw_block BLOB NOT NULL,
|
||||||
created_at_ms INTEGER NOT NULL,
|
created_at_ms INTEGER NOT NULL,
|
||||||
source_api TEXT NOT NULL,
|
source_api TEXT NOT NULL,
|
||||||
|
|||||||
@@ -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 = 8;
|
private static final int LATEST_SCHEMA_VERSION = 11;
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@@ -91,6 +91,9 @@ public final class SqliteDbController {
|
|||||||
case 6 -> migrateToV6();
|
case 6 -> migrateToV6();
|
||||||
case 7 -> migrateToV7();
|
case 7 -> migrateToV7();
|
||||||
case 8 -> migrateToV8();
|
case 8 -> migrateToV8();
|
||||||
|
case 9 -> migrateToV9();
|
||||||
|
case 10 -> migrateToV10();
|
||||||
|
case 11 -> migrateToV11();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +272,63 @@ public final class SqliteDbController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void migrateToV9() {
|
||||||
|
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement st = c.createStatement()) {
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
ensureSyncServersTable(st);
|
||||||
|
setSchemaVersion(c, 9);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw new RuntimeException("DB migration to v9 failed", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("DB migration to v9 failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateToV10() {
|
||||||
|
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement st = c.createStatement()) {
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
ensureSignedMessagesReencryptedColumn(c, st);
|
||||||
|
setSchemaVersion(c, 10);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw new RuntimeException("DB migration to v10 failed", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("DB migration to v10 failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateToV11() {
|
||||||
|
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement st = c.createStatement()) {
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
clearLegacySignedMessagesForDmV11(c, st);
|
||||||
|
setSchemaVersion(c, 11);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw new RuntimeException("DB migration to v11 failed", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("DB migration to v11 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 (
|
||||||
@@ -396,6 +456,28 @@ public final class SqliteDbController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureSignedMessagesReencryptedColumn(Connection c, Statement st) throws SQLException {
|
||||||
|
if (!tableExists(c, "signed_messages_v2")) return;
|
||||||
|
if (!columnExists(c, "signed_messages_v2", "reencrypted_at_ms")) {
|
||||||
|
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN reencrypted_at_ms INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Временная одноразовая миграция на переходе к SHiNE_DM v1:
|
||||||
|
* старые строки signed_messages_v2 больше не гарантированно совместимы
|
||||||
|
* с новым бинарным форматом и правилами tombstone/reencrypt.
|
||||||
|
* Позже эту миграцию нужно убрать, чтобы случайно не очищать рабочие DM.
|
||||||
|
*/
|
||||||
|
private static void clearLegacySignedMessagesForDmV11(Connection c, Statement st) throws SQLException {
|
||||||
|
if (tableExists(c, "signed_message_session_delivery")) {
|
||||||
|
st.executeUpdate("DELETE FROM signed_message_session_delivery");
|
||||||
|
}
|
||||||
|
if (tableExists(c, "signed_messages_v2")) {
|
||||||
|
st.executeUpdate("DELETE FROM signed_messages_v2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void dropDmFileTables(Statement st) throws SQLException {
|
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_login");
|
||||||
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
|
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
|
||||||
@@ -468,6 +550,20 @@ public final class SqliteDbController {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureSyncServersTable(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_servers (
|
||||||
|
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||||
|
server_address TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_servers_updated
|
||||||
|
ON sync_servers (updated_at_ms);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DatabaseInitializer;
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlockchainResyncCleanupDAO — подготовительный "жёсткий reset" одной blockchain-цепочки
|
||||||
|
* перед её полной повторной загрузкой от сервера-партнёра.
|
||||||
|
*
|
||||||
|
* Что делает этот DAO:
|
||||||
|
* 1) в ОДНОЙ SQL-транзакции сначала аккуратно уменьшает чужие агрегаты,
|
||||||
|
* которые были увеличены блоками удаляемой цепочки:
|
||||||
|
* - likes_count
|
||||||
|
* - replies_count
|
||||||
|
* 2) затем удаляет все локальные записи самой цепочки и её производные состояния.
|
||||||
|
*
|
||||||
|
* Почему это вынесено в отдельный DAO-метод, а не в триггеры DELETE:
|
||||||
|
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
||||||
|
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||||
|
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||||
|
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
||||||
|
* SQLite не может атомарно закоммитить и SQL, и файловую систему сразу;
|
||||||
|
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||||
|
* отдельным recovery/resync-слоем после успешного commit.
|
||||||
|
*
|
||||||
|
* Важный смысл текущей реализации:
|
||||||
|
* - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы;
|
||||||
|
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
|
||||||
|
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
|
||||||
|
*/
|
||||||
|
public final class BlockchainResyncCleanupDAO {
|
||||||
|
|
||||||
|
private static final int BLOCKCHAIN_LOGIN_SUFFIX_LEN = 4; // "-001"
|
||||||
|
|
||||||
|
private static volatile BlockchainResyncCleanupDAO instance;
|
||||||
|
|
||||||
|
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||||
|
|
||||||
|
private BlockchainResyncCleanupDAO() {}
|
||||||
|
|
||||||
|
public static BlockchainResyncCleanupDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (BlockchainResyncCleanupDAO.class) {
|
||||||
|
if (instance == null) instance = new BlockchainResyncCleanupDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полностью очищает одну blockchain-цепочку и локальные derived-state, собранные из неё.
|
||||||
|
*
|
||||||
|
* Порядок внутри транзакции намеренно такой:
|
||||||
|
* 1. Сначала уменьшаем чужие likes_count для тех целей, где финальное состояние
|
||||||
|
* реакции этой цепочки было LIKE.
|
||||||
|
* 2. Сначала уменьшаем чужие replies_count для reply-блоков этой цепочки.
|
||||||
|
* 3. После этого удаляем локальные derived-state самой цепочки.
|
||||||
|
* 4. В конце удаляем blocks и blockchain_state.
|
||||||
|
*
|
||||||
|
* Это правильно потому, что агрегаты (`message_stats`) должны видеть исходные blocks
|
||||||
|
* и reactions_state на момент пересчёта. Если удалить blocks раньше, мы потеряем
|
||||||
|
* источник правды для корректного уменьшения счётчиков.
|
||||||
|
*
|
||||||
|
* Метод идемпотентен по смыслу:
|
||||||
|
* - если часть данных уже удалена раньше, повторный вызов просто удалит "0 строк";
|
||||||
|
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
||||||
|
*
|
||||||
|
* Отдельно важно:
|
||||||
|
* - здесь НЕТ удаления .bch/.tmp_bch;
|
||||||
|
* - здесь НЕТ повторной загрузки цепочки;
|
||||||
|
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
||||||
|
*/
|
||||||
|
public CleanupResult cleanupBlockchainForFullResync(String blockchainName) throws SQLException {
|
||||||
|
if (blockchainName == null || blockchainName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("blockchainName is blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
String login = resolveLoginForCleanup(c, blockchainName);
|
||||||
|
|
||||||
|
int likesAdjusted = decreaseForeignLikesCount(c, blockchainName);
|
||||||
|
int repliesAdjusted = decreaseForeignRepliesCount(c, blockchainName);
|
||||||
|
|
||||||
|
int deletedMessageStats = deleteMessageStatsForOwnTargets(c, blockchainName);
|
||||||
|
int deletedReactionsState = deleteReactionsStateForActorChain(c, blockchainName);
|
||||||
|
int deletedConnectionsState = deleteConnectionsStateForLogin(c, login);
|
||||||
|
int deletedUsersParams = deleteUsersParamsForLogin(c, login);
|
||||||
|
int deletedChannelNames = deleteChannelNamesForOwnerChain(c, blockchainName);
|
||||||
|
int deletedChat200State = deleteChat200StateForOwnerChain(c, blockchainName);
|
||||||
|
int deletedChat200Members = deleteChat200MembersForOwnerChain(c, blockchainName);
|
||||||
|
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
||||||
|
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
|
||||||
|
return new CleanupResult(
|
||||||
|
login,
|
||||||
|
likesAdjusted,
|
||||||
|
repliesAdjusted,
|
||||||
|
deletedMessageStats,
|
||||||
|
deletedReactionsState,
|
||||||
|
deletedConnectionsState,
|
||||||
|
deletedUsersParams,
|
||||||
|
deletedChannelNames,
|
||||||
|
deletedChat200State,
|
||||||
|
deletedChat200Members,
|
||||||
|
deletedBlocks,
|
||||||
|
deletedBlockchainState
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||||
|
throw new SQLException("Не удалось очистить blockchain для полного resync: " + blockchainName, e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT login
|
||||||
|
FROM blockchain_state
|
||||||
|
WHERE blockchain_name = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, blockchainName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
String login = rs.getString("login");
|
||||||
|
if (login != null && !login.isBlank()) {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String loginFromName = loginFromBlockchainName(blockchainName);
|
||||||
|
if (loginFromName == null || loginFromName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Cannot derive login from blockchainName: " + blockchainName);
|
||||||
|
}
|
||||||
|
return loginFromName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO остаётся в модуле БД и не тянет зависимость на blockchain-utils модуль.
|
||||||
|
* Поэтому здесь локально повторяем минимальное правило имени chain:
|
||||||
|
* login + "-NNN".
|
||||||
|
*/
|
||||||
|
private String loginFromBlockchainName(String blockchainName) {
|
||||||
|
if (blockchainName == null) return null;
|
||||||
|
|
||||||
|
String s = blockchainName.trim();
|
||||||
|
if (s.length() <= BLOCKCHAIN_LOGIN_SUFFIX_LEN) return null;
|
||||||
|
|
||||||
|
int dashPos = s.length() - BLOCKCHAIN_LOGIN_SUFFIX_LEN;
|
||||||
|
if (s.charAt(dashPos) != '-') return null;
|
||||||
|
|
||||||
|
for (int i = dashPos + 1; i < s.length(); i++) {
|
||||||
|
char ch = s.charAt(i);
|
||||||
|
if (ch < '0' || ch > '9') return null;
|
||||||
|
}
|
||||||
|
return s.substring(0, dashPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уменьшаем likes_count только для ЧУЖИХ целей.
|
||||||
|
*
|
||||||
|
* Логика:
|
||||||
|
* - если у удаляемой цепочки финальное состояние реакции на цель = LIKE,
|
||||||
|
* значит при полном удалении цепочки этот активный лайк исчезает;
|
||||||
|
* - значит у message_stats этой чужой цели нужно сделать -1;
|
||||||
|
* - для целей внутри этой же chain этого делать не нужно, потому что сами цели
|
||||||
|
* тоже будут удалены вместе с цепочкой.
|
||||||
|
*/
|
||||||
|
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE message_stats
|
||||||
|
SET likes_count = MAX(
|
||||||
|
0,
|
||||||
|
likes_count - (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM reactions_state rs
|
||||||
|
WHERE rs.from_bch_name = ?
|
||||||
|
AND rs.reaction_type = ?
|
||||||
|
AND rs.last_sub_type = ?
|
||||||
|
AND rs.to_login = message_stats.to_login
|
||||||
|
AND rs.to_bch_name = message_stats.to_bch_name
|
||||||
|
AND rs.to_block_number = message_stats.to_block_number
|
||||||
|
AND rs.to_block_hash = message_stats.to_block_hash
|
||||||
|
AND rs.to_bch_name <> ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM reactions_state rs
|
||||||
|
WHERE rs.from_bch_name = ?
|
||||||
|
AND rs.reaction_type = ?
|
||||||
|
AND rs.last_sub_type = ?
|
||||||
|
AND rs.to_login = message_stats.to_login
|
||||||
|
AND rs.to_bch_name = message_stats.to_bch_name
|
||||||
|
AND rs.to_block_number = message_stats.to_block_number
|
||||||
|
AND rs.to_block_hash = message_stats.to_block_hash
|
||||||
|
AND rs.to_bch_name <> ?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уменьшаем replies_count только для ЧУЖИХ целей.
|
||||||
|
*
|
||||||
|
* Если reply этой цепочки ссылался на сообщение из другой цепочки,
|
||||||
|
* значит после удаления blocks этой цепочки чужой replies_count должен уменьшиться.
|
||||||
|
* Reply на собственные сообщения здесь игнорируем: целевая цепочка тоже будет удалена.
|
||||||
|
*/
|
||||||
|
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE message_stats
|
||||||
|
SET replies_count = MAX(
|
||||||
|
0,
|
||||||
|
replies_count - COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM blocks b
|
||||||
|
WHERE b.bch_name = ?
|
||||||
|
AND b.msg_type = 1
|
||||||
|
AND b.msg_sub_type = ?
|
||||||
|
AND b.to_login = message_stats.to_login
|
||||||
|
AND b.to_bch_name = message_stats.to_bch_name
|
||||||
|
AND b.to_block_number = message_stats.to_block_number
|
||||||
|
AND b.to_block_hash = message_stats.to_block_hash
|
||||||
|
AND b.to_bch_name <> ?
|
||||||
|
), 0)
|
||||||
|
)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM blocks b
|
||||||
|
WHERE b.bch_name = ?
|
||||||
|
AND b.msg_type = 1
|
||||||
|
AND b.msg_sub_type = ?
|
||||||
|
AND b.to_login = message_stats.to_login
|
||||||
|
AND b.to_bch_name = message_stats.to_bch_name
|
||||||
|
AND b.to_block_number = message_stats.to_block_number
|
||||||
|
AND b.to_block_hash = message_stats.to_block_hash
|
||||||
|
AND b.to_bch_name <> ?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
|
||||||
|
ps.setString(i++, blockchainName);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статистика сообщений самой удаляемой цепочки после reset не нужна,
|
||||||
|
* потому что её цели исчезают вместе с chain source data.
|
||||||
|
*/
|
||||||
|
private int deleteMessageStatsForOwnTargets(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM message_stats
|
||||||
|
WHERE to_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reactions_state хранит финальное состояние реакций АКТОРА.
|
||||||
|
* После удаления всей цепочки актор этой цепочки исчезает, поэтому
|
||||||
|
* достаточно удалить все строки по from_bch_name.
|
||||||
|
*/
|
||||||
|
private int deleteReactionsStateForActorChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM reactions_state
|
||||||
|
WHERE from_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* connections_state — текущее состояние связей, выставленных этим login.
|
||||||
|
* Чистим по владельцу состояния.
|
||||||
|
*/
|
||||||
|
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM connections_state
|
||||||
|
WHERE login = ? COLLATE NOCASE
|
||||||
|
""", login);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users_params — актуальные параметры, собранные из блоков пользователя.
|
||||||
|
*/
|
||||||
|
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM users_params
|
||||||
|
WHERE login = ? COLLATE NOCASE
|
||||||
|
""", login);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каналы принадлежат owner_bch_name.
|
||||||
|
*/
|
||||||
|
private int deleteChannelNamesForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM channel_names_state
|
||||||
|
WHERE owner_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int deleteChat200StateForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM chat200_state
|
||||||
|
WHERE owner_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int deleteChat200MembersForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM chat200_members_state
|
||||||
|
WHERE owner_bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* blocks удаляем в конце, потому что до этого шага они нужны как источник правды
|
||||||
|
* для уменьшения replies_count.
|
||||||
|
*/
|
||||||
|
private int deleteBlocksForChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM blocks
|
||||||
|
WHERE bch_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* blockchain_state удаляем после blocks, чтобы не нарушать FK-связь blocks -> blockchain_state.
|
||||||
|
*/
|
||||||
|
private int deleteBlockchainStateForChain(Connection c, String blockchainName) throws SQLException {
|
||||||
|
return executeDelete(c, """
|
||||||
|
DELETE FROM blockchain_state
|
||||||
|
WHERE blockchain_name = ?
|
||||||
|
""", blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int executeDelete(Connection c, String sql, String value) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, value);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Технический результат cleanup-операции.
|
||||||
|
* Нужен для будущего логирования и ручной диагностики resync-flow.
|
||||||
|
*/
|
||||||
|
public record CleanupResult(
|
||||||
|
String login,
|
||||||
|
int likesAdjustedRows,
|
||||||
|
int repliesAdjustedRows,
|
||||||
|
int deletedMessageStatsRows,
|
||||||
|
int deletedReactionsStateRows,
|
||||||
|
int deletedConnectionsStateRows,
|
||||||
|
int deletedUsersParamsRows,
|
||||||
|
int deletedChannelNamesRows,
|
||||||
|
int deletedChat200StateRows,
|
||||||
|
int deletedChat200MembersRows,
|
||||||
|
int deletedBlocksRows,
|
||||||
|
int deletedBlockchainStateRows
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import shine.db.SqliteDbController;
|
|||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public final class BlockchainStateDAO {
|
public final class BlockchainStateDAO {
|
||||||
|
|
||||||
@@ -53,6 +55,39 @@ public final class BlockchainStateDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Получить все blockchain_state записи. */
|
||||||
|
public List<BlockchainStateEntry> listAll() throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listAll(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Получить все blockchain_state записи с внешним соединением. Соединение НЕ закрывает. */
|
||||||
|
public List<BlockchainStateEntry> listAll(Connection c) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
blockchain_name,
|
||||||
|
login,
|
||||||
|
blockchain_key,
|
||||||
|
size_limit,
|
||||||
|
file_size_bytes,
|
||||||
|
last_block_number,
|
||||||
|
last_block_hash,
|
||||||
|
updated_at_ms
|
||||||
|
FROM blockchain_state
|
||||||
|
ORDER BY blockchain_name COLLATE NOCASE
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<BlockchainStateEntry> result = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql);
|
||||||
|
ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** UPSERT без внешнего соединения. Сам открывает/закрывает. */
|
/** UPSERT без внешнего соединения. Сам открывает/закрывает. */
|
||||||
public void upsert(BlockchainStateEntry e) throws SQLException {
|
public void upsert(BlockchainStateEntry e) throws SQLException {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
@@ -103,6 +138,54 @@ public final class BlockchainStateDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строгая вставка state только если записи ещё нет.
|
||||||
|
*
|
||||||
|
* Нужна для recovery / resync:
|
||||||
|
* - identity пользователя уже может существовать в solana_users;
|
||||||
|
* - в таком случае нам надо восстановить только blockchain_state;
|
||||||
|
* - если запись уже есть, метод просто ничего не меняет.
|
||||||
|
*/
|
||||||
|
public boolean insertIfMissing(Connection c, BlockchainStateEntry e) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO blockchain_state (
|
||||||
|
blockchain_name,
|
||||||
|
login,
|
||||||
|
blockchain_key,
|
||||||
|
size_limit,
|
||||||
|
file_size_bytes,
|
||||||
|
last_block_number,
|
||||||
|
last_block_hash,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(blockchain_name) DO NOTHING
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i = 1;
|
||||||
|
|
||||||
|
ps.setString(i++, e.getBlockchainName());
|
||||||
|
ps.setString(i++, nn(e.getLogin()));
|
||||||
|
ps.setString(i++, nn(e.getBlockchainKey()));
|
||||||
|
|
||||||
|
ps.setLong(i++, e.getSizeLimit());
|
||||||
|
ps.setLong(i++, e.getFileSizeBytes());
|
||||||
|
|
||||||
|
ps.setInt(i++, e.getLastBlockNumber());
|
||||||
|
setBytesNullable(ps, i++, e.getLastBlockHash());
|
||||||
|
|
||||||
|
ps.setLong(i++, e.getUpdatedAtMs());
|
||||||
|
|
||||||
|
return ps.executeUpdate() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean insertIfMissing(BlockchainStateEntry e) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return insertIfMissing(c, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Атомарно увеличить file_size_bytes на deltaBytes, но только если НЕ превысим size_limit.
|
* Атомарно увеличить file_size_bytes на deltaBytes, но только если НЕ превысим size_limit.
|
||||||
*/
|
*/
|
||||||
@@ -150,4 +233,4 @@ public final class BlockchainStateDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String nn(String s) { return s == null ? "" : s; }
|
private static String nn(String s) { return s == null ? "" : s; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import shine.db.SqliteDbController;
|
|||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DAO для таблицы blocks (новый формат).
|
* DAO для таблицы blocks (новый формат).
|
||||||
@@ -191,6 +193,53 @@ public final class BlocksDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
login,
|
||||||
|
bch_name,
|
||||||
|
block_number,
|
||||||
|
msg_type,
|
||||||
|
msg_sub_type,
|
||||||
|
block_bytes,
|
||||||
|
to_login,
|
||||||
|
to_bch_name,
|
||||||
|
to_block_number,
|
||||||
|
to_block_hash,
|
||||||
|
block_hash,
|
||||||
|
block_signature,
|
||||||
|
edited_by_block_number,
|
||||||
|
line_code,
|
||||||
|
prev_line_number,
|
||||||
|
prev_line_hash,
|
||||||
|
this_line_number
|
||||||
|
FROM blocks
|
||||||
|
WHERE bch_name = ?
|
||||||
|
AND block_number >= ?
|
||||||
|
AND block_number <= ?
|
||||||
|
ORDER BY block_number ASC
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<BlockEntry> result = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, bchName);
|
||||||
|
ps.setInt(2, fromBlockNumberInclusive);
|
||||||
|
ps.setInt(3, toBlockNumberInclusive);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- INTERNAL --------------------
|
// -------------------- INTERNAL --------------------
|
||||||
|
|
||||||
private BlockEntry mapRow(ResultSet rs) throws SQLException {
|
private BlockEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
@@ -242,4 +291,4 @@ public final class BlocksDAO {
|
|||||||
|
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ import java.sql.PreparedStatement;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public final class SignedMessagesV2DAO {
|
public final class SignedMessagesV2DAO {
|
||||||
|
public enum ApplyStatus {
|
||||||
|
APPLIED,
|
||||||
|
DUPLICATE_OR_OLDER,
|
||||||
|
BLOCKED_BY_MESSAGE_TOMBSTONE,
|
||||||
|
BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
|
|
||||||
|
public boolean applied() {
|
||||||
|
return this == APPLIED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static volatile SignedMessagesV2DAO instance;
|
private static volatile SignedMessagesV2DAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||||
|
|
||||||
@@ -26,18 +36,22 @@ public final class SignedMessagesV2DAO {
|
|||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
|
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
||||||
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
|
}
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
receipt_ref_base_key, receipt_ref_type
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
return ps.executeUpdate() > 0;
|
return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,29 +81,25 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) 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 {
|
||||||
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
|
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||||
Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey());
|
|
||||||
long currentRevision = Math.max(
|
|
||||||
currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE,
|
|
||||||
currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE
|
|
||||||
);
|
|
||||||
long nextRevision = incoming.getRevisionTimeMs();
|
|
||||||
|
|
||||||
if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) {
|
|
||||||
c.rollback();
|
c.rollback();
|
||||||
return false;
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
}
|
}
|
||||||
if (currentRevision != Long.MIN_VALUE
|
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||||
&& nextRevision == currentRevision
|
|
||||||
&& hasSameRawBlock(c, incoming)
|
|
||||||
&& hasSameRawBlock(c, outgoing)) {
|
|
||||||
c.rollback();
|
c.rollback();
|
||||||
return false;
|
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey());
|
||||||
|
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||||
|
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||||
|
c.rollback();
|
||||||
|
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertMessage(c, incoming);
|
upsertMessage(c, incoming);
|
||||||
@@ -98,7 +108,96 @@ public final class SignedMessagesV2DAO {
|
|||||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
return true;
|
return ApplyStatus.APPLIED;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||||
|
c.rollback();
|
||||||
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
|
}
|
||||||
|
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||||
|
c.rollback();
|
||||||
|
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey());
|
||||||
|
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||||
|
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||||
|
c.rollback();
|
||||||
|
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertMessage(c, incoming);
|
||||||
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
|
c.commit();
|
||||||
|
return ApplyStatus.APPLIED;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) {
|
||||||
|
c.rollback();
|
||||||
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
|
}
|
||||||
|
if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) {
|
||||||
|
c.rollback();
|
||||||
|
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||||
|
upsertMessage(c, tombstone);
|
||||||
|
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
return ApplyStatus.APPLIED;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||||
|
if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) {
|
||||||
|
c.rollback();
|
||||||
|
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||||
|
upsertMessage(c, tombstone);
|
||||||
|
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
return ApplyStatus.APPLIED;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
throw ex;
|
throw ex;
|
||||||
@@ -113,8 +212,9 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
|
receipt_ref_base_key, receipt_ref_type
|
||||||
FROM signed_messages_v2
|
FROM signed_messages_v2
|
||||||
WHERE message_key = ?
|
WHERE message_key = ?
|
||||||
""";
|
""";
|
||||||
@@ -128,6 +228,12 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getLatestConversationDelete(c, fromLogin, toLogin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String sql = """
|
String sql = """
|
||||||
@@ -180,26 +286,35 @@ public final class SignedMessagesV2DAO {
|
|||||||
)
|
)
|
||||||
SELECT m.message_key, ?, 0, NULL, ?
|
SELECT m.message_key, ?, 0, NULL, ?
|
||||||
FROM signed_messages_v2 m
|
FROM signed_messages_v2 m
|
||||||
WHERE m.target_login = ? COLLATE NOCASE
|
WHERE (
|
||||||
|
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
|
||||||
|
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
|
||||||
|
OR (m.message_type IN (5, 6, 7, 8)
|
||||||
|
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
||||||
|
)
|
||||||
""";
|
""";
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||||
ps.setString(1, sessionId);
|
ps.setString(1, sessionId);
|
||||||
ps.setLong(2, now);
|
ps.setLong(2, now);
|
||||||
ps.setString(3, login);
|
ps.setString(3, login);
|
||||||
|
ps.setString(4, login);
|
||||||
|
ps.setString(5, login);
|
||||||
|
ps.setString(6, login);
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms,
|
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||||
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
|
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||||
|
m.receipt_ref_base_key, m.receipt_ref_type
|
||||||
FROM signed_messages_v2 m
|
FROM signed_messages_v2 m
|
||||||
JOIN signed_message_session_delivery d
|
JOIN signed_message_session_delivery d
|
||||||
ON d.message_key = m.message_key
|
ON d.message_key = m.message_key
|
||||||
WHERE d.session_id = ? AND d.delivered = 0
|
WHERE d.session_id = ? AND d.delivered = 0
|
||||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC
|
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
||||||
""";
|
""";
|
||||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -216,9 +331,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO signed_messages_v2 (
|
INSERT INTO signed_messages_v2 (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
receipt_ref_base_key, receipt_ref_type
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(message_key) DO UPDATE SET
|
ON CONFLICT(message_key) DO UPDATE SET
|
||||||
base_key = excluded.base_key,
|
base_key = excluded.base_key,
|
||||||
target_login = excluded.target_login,
|
target_login = excluded.target_login,
|
||||||
@@ -228,6 +344,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
nonce = excluded.nonce,
|
nonce = excluded.nonce,
|
||||||
message_type = excluded.message_type,
|
message_type = excluded.message_type,
|
||||||
revision_time_ms = excluded.revision_time_ms,
|
revision_time_ms = excluded.revision_time_ms,
|
||||||
|
reencrypted_at_ms = excluded.reencrypted_at_ms,
|
||||||
raw_block = excluded.raw_block,
|
raw_block = excluded.raw_block,
|
||||||
created_at_ms = excluded.created_at_ms,
|
created_at_ms = excluded.created_at_ms,
|
||||||
source_api = excluded.source_api,
|
source_api = excluded.source_api,
|
||||||
@@ -241,28 +358,167 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException {
|
private RevisionMarker getRevisionMarkerByMessageKey(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, reencrypted_at_ms
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE message_key = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, messageKey);
|
ps.setString(1, messageKey);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return null;
|
if (!rs.next()) return null;
|
||||||
return rs.getLong(1);
|
return new RevisionMarker(rs.getLong(1), rs.getLong(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException {
|
private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException {
|
||||||
String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
|
String sql = """
|
||||||
|
SELECT revision_time_ms, reencrypted_at_ms
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE base_key = ?
|
||||||
|
AND message_type IN (1, 2)
|
||||||
|
ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, entry.getMessageKey());
|
ps.setString(1, baseKey);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return false;
|
if (!rs.next()) return null;
|
||||||
return Arrays.equals(rs.getBytes(1), entry.getRawBlock());
|
return new RevisionMarker(rs.getLong(1), rs.getLong(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT 1
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE base_key = ?
|
||||||
|
AND message_type IN (5, 6)
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, baseKey);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlockedByConversationDelete(Connection c, String fromLogin, String toLogin, long timeMs) throws SQLException {
|
||||||
|
Long boundary = getLatestConversationDeleteBoundary(c, fromLogin, toLogin);
|
||||||
|
return boundary != null && timeMs < boundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT MAX(time_ms)
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE message_type IN (7, 8)
|
||||||
|
AND (
|
||||||
|
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, fromLogin);
|
||||||
|
ps.setString(2, toLogin);
|
||||||
|
ps.setString(3, toLogin);
|
||||||
|
ps.setString(4, fromLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
long value = rs.getLong(1);
|
||||||
|
return rs.wasNull() ? null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
|
receipt_ref_base_key, receipt_ref_type
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE message_type IN (7, 8)
|
||||||
|
AND (
|
||||||
|
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
)
|
||||||
|
ORDER BY time_ms DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, fromLogin);
|
||||||
|
ps.setString(2, toLogin);
|
||||||
|
ps.setString(3, toLogin);
|
||||||
|
ps.setString(4, fromLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return mapRow(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException {
|
||||||
|
deleteDeliveryRowsByMessageSelection(c, """
|
||||||
|
SELECT message_key
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||||
|
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||||
|
""", baseKey, baseKey);
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
DELETE FROM signed_messages_v2
|
||||||
|
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||||
|
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, baseKey);
|
||||||
|
ps.setString(2, baseKey);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||||
|
deleteDeliveryRowsByMessageSelection(c, """
|
||||||
|
SELECT message_key
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE time_ms < ?
|
||||||
|
AND (
|
||||||
|
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
)
|
||||||
|
""", boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
DELETE FROM signed_messages_v2
|
||||||
|
WHERE time_ms < ?
|
||||||
|
AND (
|
||||||
|
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
)
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, boundaryTimeMs);
|
||||||
|
ps.setString(2, fromLogin);
|
||||||
|
ps.setString(3, toLogin);
|
||||||
|
ps.setString(4, toLogin);
|
||||||
|
ps.setString(5, fromLogin);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteDeliveryRowsByMessageSelection(Connection c, String messageKeySelectSql, Object... bindValues) throws SQLException {
|
||||||
|
String sql = "DELETE FROM signed_message_session_delivery WHERE message_key IN (" + messageKeySelectSql + ")";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
bindObjects(ps, bindValues);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
|
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
UPDATE signed_message_session_delivery
|
UPDATE signed_message_session_delivery
|
||||||
@@ -278,9 +534,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO signed_messages_v2 (
|
INSERT INTO signed_messages_v2 (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
receipt_ref_base_key, receipt_ref_type
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
@@ -298,13 +555,30 @@ public final class SignedMessagesV2DAO {
|
|||||||
ps.setLong(7, e.getNonce());
|
ps.setLong(7, e.getNonce());
|
||||||
ps.setInt(8, e.getMessageType());
|
ps.setInt(8, e.getMessageType());
|
||||||
ps.setLong(9, e.getRevisionTimeMs());
|
ps.setLong(9, e.getRevisionTimeMs());
|
||||||
ps.setBytes(10, e.getRawBlock());
|
ps.setLong(10, e.getReencryptedAtMs());
|
||||||
ps.setLong(11, e.getCreatedAtMs());
|
ps.setBytes(11, e.getRawBlock());
|
||||||
ps.setString(12, e.getSourceApi());
|
ps.setLong(12, e.getCreatedAtMs());
|
||||||
ps.setString(13, e.getOriginSessionId());
|
ps.setString(13, e.getSourceApi());
|
||||||
ps.setString(14, e.getReceiptRefBaseKey());
|
ps.setString(14, e.getOriginSessionId());
|
||||||
if (e.getReceiptRefType() == null) ps.setObject(15, null);
|
ps.setString(15, e.getReceiptRefBaseKey());
|
||||||
else ps.setInt(15, e.getReceiptRefType());
|
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
||||||
|
else ps.setInt(16, e.getReceiptRefType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException {
|
||||||
|
for (int i = 0; i < bindValues.length; i++) {
|
||||||
|
Object value = bindValues[i];
|
||||||
|
int param = i + 1;
|
||||||
|
if (value instanceof String s) {
|
||||||
|
ps.setString(param, s);
|
||||||
|
} else if (value instanceof Long l) {
|
||||||
|
ps.setLong(param, l);
|
||||||
|
} else if (value instanceof Integer n) {
|
||||||
|
ps.setInt(param, n);
|
||||||
|
} else {
|
||||||
|
ps.setObject(param, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isConstraintViolation(SQLException ex) {
|
private boolean isConstraintViolation(SQLException ex) {
|
||||||
@@ -312,6 +586,12 @@ public final class SignedMessagesV2DAO {
|
|||||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
||||||
|
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
|
||||||
|
if (revisionCompare != 0) return revisionCompare;
|
||||||
|
return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
||||||
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
||||||
e.setMessageKey(rs.getString("message_key"));
|
e.setMessageKey(rs.getString("message_key"));
|
||||||
@@ -323,6 +603,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
e.setNonce(rs.getLong("nonce"));
|
e.setNonce(rs.getLong("nonce"));
|
||||||
e.setMessageType(rs.getInt("message_type"));
|
e.setMessageType(rs.getInt("message_type"));
|
||||||
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
||||||
|
e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms"));
|
||||||
e.setRawBlock(rs.getBytes("raw_block"));
|
e.setRawBlock(rs.getBytes("raw_block"));
|
||||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
e.setSourceApi(rs.getString("source_api"));
|
e.setSourceApi(rs.getString("source_api"));
|
||||||
@@ -332,4 +613,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) {
|
||||||
|
private static RevisionMarker of(SignedMessageV2Entry entry) {
|
||||||
|
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
import shine.db.entities.SyncServerEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO локальной таблицы серверов-партнёров для будущей межсерверной синхронизации.
|
||||||
|
*/
|
||||||
|
public final class SyncServersDAO {
|
||||||
|
|
||||||
|
private static volatile SyncServersDAO instance;
|
||||||
|
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||||
|
|
||||||
|
private SyncServersDAO() {}
|
||||||
|
|
||||||
|
public static SyncServersDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (SyncServersDAO.class) {
|
||||||
|
if (instance == null) instance = new SyncServersDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SyncServerEntry> listAll() throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listAll(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SyncServerEntry> listAll(Connection c) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT login, server_address, updated_at_ms
|
||||||
|
FROM sync_servers
|
||||||
|
ORDER BY login
|
||||||
|
""";
|
||||||
|
List<SyncServerEntry> result = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql);
|
||||||
|
ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полностью заменяет список партнёров актуальным снимком из Solana PDA.
|
||||||
|
*/
|
||||||
|
public void replaceAll(List<SyncServerEntry> entries) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
replaceAll(c, entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void replaceAll(Connection c, List<SyncServerEntry> entries) throws SQLException {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try (Statement st = c.createStatement()) {
|
||||||
|
st.executeUpdate("DELETE FROM sync_servers");
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO sync_servers (
|
||||||
|
login, server_address, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
for (SyncServerEntry entry : entries) {
|
||||||
|
ps.setString(1, entry.getLogin());
|
||||||
|
ps.setString(2, safe(entry.getServerAddress()));
|
||||||
|
ps.setLong(3, entry.getUpdatedAtMs());
|
||||||
|
ps.addBatch();
|
||||||
|
}
|
||||||
|
ps.executeBatch();
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||||
|
throw new SQLException("Не удалось обновить таблицу sync_servers", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SyncServerEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
SyncServerEntry entry = new SyncServerEntry();
|
||||||
|
entry.setLogin(rs.getString("login"));
|
||||||
|
entry.setServerAddress(rs.getString("server_address"));
|
||||||
|
entry.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ public class SignedMessageV2Entry {
|
|||||||
private long nonce;
|
private long nonce;
|
||||||
private int messageType;
|
private int messageType;
|
||||||
private long revisionTimeMs;
|
private long revisionTimeMs;
|
||||||
|
private long reencryptedAtMs;
|
||||||
private byte[] rawBlock;
|
private byte[] rawBlock;
|
||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
private String sourceApi;
|
private String sourceApi;
|
||||||
@@ -35,6 +36,8 @@ public class SignedMessageV2Entry {
|
|||||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||||
public long getRevisionTimeMs() { return revisionTimeMs; }
|
public long getRevisionTimeMs() { return revisionTimeMs; }
|
||||||
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; }
|
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; }
|
||||||
|
public long getReencryptedAtMs() { return reencryptedAtMs; }
|
||||||
|
public void setReencryptedAtMs(long reencryptedAtMs) { this.reencryptedAtMs = reencryptedAtMs; }
|
||||||
public byte[] getRawBlock() { return rawBlock; }
|
public byte[] getRawBlock() { return rawBlock; }
|
||||||
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
||||||
public long getCreatedAtMs() { return createdAtMs; }
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись о сервере-партнёре, с которым текущий сервер должен синхронизироваться.
|
||||||
|
*/
|
||||||
|
public class SyncServerEntry {
|
||||||
|
|
||||||
|
private String login;
|
||||||
|
private String serverAddress;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public SyncServerEntry() {}
|
||||||
|
|
||||||
|
public SyncServerEntry(String login, String serverAddress, long updatedAtMs) {
|
||||||
|
this.login = login;
|
||||||
|
this.serverAddress = serverAddress;
|
||||||
|
this.updatedAtMs = updatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLogin() {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLogin(String login) {
|
||||||
|
this.login = login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServerAddress() {
|
||||||
|
return serverAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServerAddress(String serverAddress) {
|
||||||
|
this.serverAddress = serverAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUpdatedAtMs() {
|
||||||
|
return updatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) {
|
||||||
|
this.updatedAtMs = updatedAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,9 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_
|
|||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
||||||
@@ -87,6 +89,9 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
|
|||||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
|
||||||
@@ -95,6 +100,9 @@ import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
|||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||||
@@ -104,8 +112,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Reque
|
|||||||
// --- NEW: Ping ---
|
// --- NEW: Ping ---
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_GetCallIceConfig_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_GetCallIceConfig_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.Net_GetSyncUserProfile_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
|
||||||
@@ -113,6 +123,8 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog
|
|||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Request;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -161,6 +173,7 @@ public final class JsonHandlerRegistry {
|
|||||||
|
|
||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||||
|
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||||
@@ -182,17 +195,21 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||||
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
|
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
|
||||||
Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()),
|
|
||||||
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
||||||
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
||||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||||
|
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||||
|
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
||||||
|
Map.entry("SendSignal", new Net_SendSignal_Handler()),
|
||||||
|
|
||||||
// --- system ---
|
// --- system ---
|
||||||
Map.entry("Ping", new Net_Ping_Handler()),
|
Map.entry("Ping", new Net_Ping_Handler()),
|
||||||
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
|
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
|
||||||
|
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
|
||||||
|
Map.entry("GetSyncUserProfile", new Net_GetSyncUserProfile_Handler()),
|
||||||
Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()),
|
Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()),
|
||||||
Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()),
|
Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()),
|
||||||
Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()),
|
Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()),
|
||||||
@@ -236,6 +253,7 @@ public final class JsonHandlerRegistry {
|
|||||||
|
|
||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||||
|
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||||
@@ -257,17 +275,21 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||||
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
|
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
|
||||||
Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class),
|
|
||||||
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
|
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
|
||||||
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
||||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||||
|
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||||
|
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||||
|
Map.entry("SendSignal", Net_SendSignal_Request.class),
|
||||||
|
|
||||||
// --- system ---
|
// --- system ---
|
||||||
Map.entry("Ping", Net_Ping_Request.class),
|
Map.entry("Ping", Net_Ping_Request.class),
|
||||||
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
|
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
|
||||||
|
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
|
||||||
|
Map.entry("GetSyncUserProfile", Net_GetSyncUserProfile_Request.class),
|
||||||
Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class),
|
Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class),
|
||||||
Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class),
|
Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class),
|
||||||
Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class),
|
Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
|
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L;
|
private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L;
|
||||||
|
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
|
||||||
|
|
||||||
public static final long ALLOWED_SKEW_MS = 30_000L;
|
public static final long ALLOWED_SKEW_MS = 30_000L;
|
||||||
|
|
||||||
@@ -423,7 +424,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
|||||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||||
|
|
||||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||||
SignedMessagesRealtime.dispatchPendingForSession(ctx);
|
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
|
||||||
|
|
||||||
// --- формируем ответ ---
|
// --- формируем ответ ---
|
||||||
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
|
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class);
|
private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class);
|
||||||
|
|
||||||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||||||
|
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
@@ -301,7 +302,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
|||||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||||
|
|
||||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||||
SignedMessagesRealtime.dispatchPendingForSession(ctx);
|
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
|
||||||
|
|
||||||
// ответ
|
// ответ
|
||||||
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
|
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import java.net.http.HttpResponse;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy-import пользователя из Solana PDA в локальную БД сервера.
|
* Lazy-import пользователя из Solana PDA в локальную БД сервера.
|
||||||
@@ -82,7 +84,56 @@ public final class SolanaUserPdaImportService {
|
|||||||
return SessionTypeCheckResult.noRecord();
|
return SessionTypeCheckResult.noRecord();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чтение server PDA по логину сервера. Используется сервером при старте,
|
||||||
|
* чтобы получить актуальный server_address и список sync_servers.
|
||||||
|
*/
|
||||||
|
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
|
||||||
|
String login = normalizeLogin(loginRaw);
|
||||||
|
if (login == null) return null;
|
||||||
|
|
||||||
|
byte[] raw = fetchRawUserPda(login);
|
||||||
|
if (raw == null) return null;
|
||||||
|
|
||||||
|
ParsedServerProfile parsed = parseServerProfile(raw);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
if (!parsed.login.equalsIgnoreCase(login)) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ParsedServerRoute> fetchAccessServerRoutesByLogin(String loginRaw) throws Exception {
|
||||||
|
String login = normalizeLogin(loginRaw);
|
||||||
|
if (login == null) return List.of();
|
||||||
|
|
||||||
|
ParsedSolanaUser parsed = fetchFromSolana(login);
|
||||||
|
if (parsed == null) return List.of();
|
||||||
|
|
||||||
|
Map<String, ParsedServerRoute> routes = new LinkedHashMap<>();
|
||||||
|
for (String accessServerLogin : parsed.accessServers()) {
|
||||||
|
String normalized = normalizeLogin(accessServerLogin);
|
||||||
|
if (normalized == null) continue;
|
||||||
|
|
||||||
|
ParsedServerProfile serverProfile = fetchServerProfileByLogin(normalized);
|
||||||
|
if (serverProfile == null || !serverProfile.isServer()) continue;
|
||||||
|
|
||||||
|
String serverAddress = safe(serverProfile.serverAddress());
|
||||||
|
if (serverAddress.isBlank()) continue;
|
||||||
|
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
|
||||||
|
}
|
||||||
|
return new ArrayList<>(routes.values());
|
||||||
|
}
|
||||||
|
|
||||||
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
|
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
|
||||||
|
byte[] raw = fetchRawUserPda(login);
|
||||||
|
if (raw == null) return null;
|
||||||
|
ParsedSolanaUser parsed = parseUserPda(raw);
|
||||||
|
if (parsed != null && parsed.login.equalsIgnoreCase(login)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] fetchRawUserPda(String login) throws Exception {
|
||||||
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
|
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
|
||||||
String lenB58 = toBase58(new byte[]{(byte) login.length()});
|
String lenB58 = toBase58(new byte[]{(byte) login.length()});
|
||||||
|
|
||||||
@@ -128,11 +179,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
if (!dataNode.isArray() || dataNode.size() < 1) continue;
|
if (!dataNode.isArray() || dataNode.size() < 1) continue;
|
||||||
String b64 = dataNode.get(0).asText("");
|
String b64 = dataNode.get(0).asText("");
|
||||||
if (b64.isBlank()) continue;
|
if (b64.isBlank()) continue;
|
||||||
byte[] raw = Base64.getDecoder().decode(b64);
|
return Base64.getDecoder().decode(b64);
|
||||||
ParsedSolanaUser parsed = parseUserPda(raw);
|
|
||||||
if (parsed != null && parsed.login.equalsIgnoreCase(login)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -161,6 +208,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
byte[] clientKey32 = null;
|
byte[] clientKey32 = null;
|
||||||
long paidLimitBytes = 0L;
|
long paidLimitBytes = 0L;
|
||||||
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
||||||
|
List<String> accessServers = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < blocksCount; i++) {
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
int blockType = u8(raw, c++);
|
int blockType = u8(raw, c++);
|
||||||
@@ -216,7 +264,9 @@ public final class SolanaUserPdaImportService {
|
|||||||
int accessCount = u8(raw, c++);
|
int accessCount = u8(raw, c++);
|
||||||
for (int j = 0; j < accessCount; j++) {
|
for (int j = 0; j < accessCount; j++) {
|
||||||
int n = u8(raw, c++);
|
int n = u8(raw, c++);
|
||||||
|
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
c += n;
|
c += n;
|
||||||
|
accessServers.add(normalizeLogin(accessServerLogin));
|
||||||
}
|
}
|
||||||
} else if (blockType == 50) {
|
} else if (blockType == 50) {
|
||||||
int sessionsMode = u8(raw, c++);
|
int sessionsMode = u8(raw, c++);
|
||||||
@@ -254,10 +304,110 @@ public final class SolanaUserPdaImportService {
|
|||||||
Base64.getEncoder().encodeToString(blockchainKey32),
|
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||||
Base64.getEncoder().encodeToString(clientKey32),
|
Base64.getEncoder().encodeToString(clientKey32),
|
||||||
paidLimitBytes,
|
paidLimitBytes,
|
||||||
sessions
|
sessions,
|
||||||
|
accessServers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ParsedServerProfile parseServerProfile(byte[] raw) {
|
||||||
|
if (raw == null || raw.length < 128) return null;
|
||||||
|
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||||
|
|
||||||
|
int recordLen = u16le(raw, 7);
|
||||||
|
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||||
|
|
||||||
|
int c = 9;
|
||||||
|
c += 8; // created_at_ms
|
||||||
|
c += 8; // updated_at_ms
|
||||||
|
c += 4; // record_number
|
||||||
|
c += 32; // prev_record_hash
|
||||||
|
|
||||||
|
int loginLen = u8(raw, c++);
|
||||||
|
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||||
|
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||||
|
c += loginLen;
|
||||||
|
|
||||||
|
int blocksCount = u8(raw, c++);
|
||||||
|
boolean isServer = false;
|
||||||
|
String serverAddress = "";
|
||||||
|
List<String> syncServers = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
|
int blockType = u8(raw, c++);
|
||||||
|
int blockVer = u8(raw, c++);
|
||||||
|
if (blockVer != 0) return null;
|
||||||
|
|
||||||
|
if (blockType == 0 || blockType == 1 || blockType == 2) {
|
||||||
|
c += 32;
|
||||||
|
} else if (blockType == 3) {
|
||||||
|
int count = u8(raw, c++);
|
||||||
|
for (int j = 0; j < count; j++) {
|
||||||
|
c += 1; // blockchain_type
|
||||||
|
int bchLen = u8(raw, c++);
|
||||||
|
c += bchLen;
|
||||||
|
c += 32; // blockchain pubkey
|
||||||
|
c += 8; // paid_limit_bytes
|
||||||
|
c += 8; // used_bytes
|
||||||
|
c += 4; // last_block_number
|
||||||
|
c += 32; // last_block_hash
|
||||||
|
c += 64; // last_block_signature
|
||||||
|
int arweavePresent = u8(raw, c++);
|
||||||
|
if (arweavePresent == 1) {
|
||||||
|
int arLen = u8(raw, c++);
|
||||||
|
c += arLen;
|
||||||
|
} else if (arweavePresent != 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (blockType == 30) {
|
||||||
|
int isServerValue = u8(raw, c++);
|
||||||
|
if (isServerValue == 1) {
|
||||||
|
isServer = true;
|
||||||
|
c += 1; // address_format_type
|
||||||
|
c += 1; // address_format_version
|
||||||
|
int addrLen = u8(raw, c++);
|
||||||
|
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
|
||||||
|
c += addrLen;
|
||||||
|
int syncCount = u8(raw, c++);
|
||||||
|
for (int j = 0; j < syncCount; j++) {
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
|
c += n;
|
||||||
|
syncServers.add(normalizeLogin(syncLogin));
|
||||||
|
}
|
||||||
|
} else if (isServerValue != 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (blockType == 40) {
|
||||||
|
int accessCount = u8(raw, c++);
|
||||||
|
for (int j = 0; j < accessCount; j++) {
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
c += n;
|
||||||
|
}
|
||||||
|
} else if (blockType == 50) {
|
||||||
|
int sessionsMode = u8(raw, c++);
|
||||||
|
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
||||||
|
int sessionsCount = u8(raw, c++);
|
||||||
|
if (sessionsCount > 64) return null;
|
||||||
|
for (int j = 0; j < sessionsCount; j++) {
|
||||||
|
c += 1; // session_type
|
||||||
|
c += 1; // session_version
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
c += n;
|
||||||
|
c += 32;
|
||||||
|
}
|
||||||
|
} else if (blockType == 70) {
|
||||||
|
c += 1;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c > recordLen) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
|
||||||
|
}
|
||||||
|
|
||||||
private static String normalizeLogin(String login) {
|
private static String normalizeLogin(String login) {
|
||||||
if (login == null) return null;
|
if (login == null) return null;
|
||||||
String s = login.trim();
|
String s = login.trim();
|
||||||
@@ -316,13 +466,18 @@ public final class SolanaUserPdaImportService {
|
|||||||
return diff == 0;
|
return diff == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private record ParsedSolanaUser(
|
private record ParsedSolanaUser(
|
||||||
String login,
|
String login,
|
||||||
String blockchainName,
|
String blockchainName,
|
||||||
String blockchainKeyB64,
|
String blockchainKeyB64,
|
||||||
String clientKeyB64,
|
String clientKeyB64,
|
||||||
long paidLimitBytes,
|
long paidLimitBytes,
|
||||||
List<ParsedSessionRecord> sessions
|
List<ParsedSessionRecord> sessions,
|
||||||
|
List<String> accessServers
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private record ParsedSessionRecord(
|
private record ParsedSessionRecord(
|
||||||
@@ -350,4 +505,16 @@ public final class SolanaUserPdaImportService {
|
|||||||
return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName);
|
return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ParsedServerProfile(
|
||||||
|
String login,
|
||||||
|
boolean isServer,
|
||||||
|
String serverAddress,
|
||||||
|
List<String> syncServers
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record ParsedServerRoute(
|
||||||
|
String login,
|
||||||
|
String serverAddress
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.sync.BlockchainResyncGuard;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import server.sync.AddBlockSyncService;
|
||||||
import shine.db.channels.ChannelNameRules;
|
import shine.db.channels.ChannelNameRules;
|
||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
@@ -55,6 +57,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||||
private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance();
|
private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance();
|
||||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||||
|
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
|
||||||
|
|
||||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||||
|
|
||||||
@@ -68,6 +71,21 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq;
|
Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq;
|
||||||
|
|
||||||
String blockchainName = req.getBlockchainName();
|
String blockchainName = req.getBlockchainName();
|
||||||
|
if (blockchainName == null || blockchainName.isBlank()) {
|
||||||
|
return error(req, WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, "");
|
||||||
|
}
|
||||||
|
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||||
|
BlockchainStateEntry currentState = null;
|
||||||
|
try {
|
||||||
|
currentState = stateDAO.getByBlockchainName(blockchainName);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
|
||||||
|
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
|
||||||
|
return error(req, 423, "chain_resync_in_progress",
|
||||||
|
lastNum,
|
||||||
|
lastHash);
|
||||||
|
}
|
||||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||||
lock.lock();
|
lock.lock();
|
||||||
try {
|
try {
|
||||||
@@ -124,7 +142,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String humanMessage(String code) {
|
private static String humanMessage(String code) {
|
||||||
if (code == null) return "Ошибка добавления блока"; return switch (code) {
|
if (code == null) return "Ошибка добавления блока";
|
||||||
|
return switch (code) {
|
||||||
case "empty_blockchain_name" -> "Пустое имя блокчейна";
|
case "empty_blockchain_name" -> "Пустое имя блокчейна";
|
||||||
case "bad_blockchain_name" -> "Некорректное имя блокчейна";
|
case "bad_blockchain_name" -> "Некорректное имя блокчейна";
|
||||||
case "db_error" -> "Ошибка базы данных";
|
case "db_error" -> "Ошибка базы данных";
|
||||||
@@ -148,6 +167,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||||
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
|
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -484,6 +504,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
||||||
login, blockchainName, block.blockNumber, newHashHex);
|
login, blockchainName, block.blockNumber, newHashHex);
|
||||||
|
|
||||||
|
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
||||||
|
|
||||||
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,32 @@ import shine.db.entities.ChannelNameStateEntry;
|
|||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
import utils.files.FileStoreUtil;
|
import utils.files.FileStoreUtil;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlockchainWriter — запись блока в DB + обновление state + запись в файл.
|
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||||
*
|
*
|
||||||
* ВАЖНО:
|
* Текущая схема:
|
||||||
* - Это минимальный рабочий вариант под новый формат.
|
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
|
||||||
* - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом.
|
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
|
||||||
|
* 3) создаём пустой marker <blockchainName>.write_pending;
|
||||||
|
* 4) выполняем одну SQL-транзакцию;
|
||||||
|
* 5) после commit атомарно заменяем основной .bch из tmp;
|
||||||
|
* 6) убираем временные файлы.
|
||||||
|
*
|
||||||
|
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
|
||||||
|
* - если marker отсутствует, а tmp/check остались, это мусор;
|
||||||
|
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
|
||||||
*/
|
*/
|
||||||
public final class BlockchainWriter {
|
public final class BlockchainWriter {
|
||||||
|
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
private final BlocksDAO blocksDAO;
|
private final BlocksDAO blocksDAO;
|
||||||
private final BlockchainStateDAO stateDAO;
|
private final BlockchainStateDAO stateDAO;
|
||||||
private final ChannelNameStateDAO channelNameStateDAO;
|
private final ChannelNameStateDAO channelNameStateDAO;
|
||||||
@@ -47,7 +61,13 @@ public final class BlockchainWriter {
|
|||||||
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
||||||
|
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
|
byte[] blockBytes = block.toBytes();
|
||||||
|
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
|
||||||
|
String blockHashHex = HEX.formatHex(block.getHash32());
|
||||||
|
|
||||||
|
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||||
|
|
||||||
|
boolean committed = false;
|
||||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +77,7 @@ public final class BlockchainWriter {
|
|||||||
// 2) update state
|
// 2) update state
|
||||||
st.setLastBlockNumber(block.blockNumber);
|
st.setLastBlockNumber(block.blockNumber);
|
||||||
st.setLastBlockHash(block.getHash32());
|
st.setLastBlockHash(block.getHash32());
|
||||||
st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length);
|
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
|
||||||
st.setUpdatedAtMs(nowMs);
|
st.setUpdatedAtMs(nowMs);
|
||||||
|
|
||||||
stateDAO.upsert(c, st);
|
stateDAO.upsert(c, st);
|
||||||
@@ -72,18 +92,80 @@ public final class BlockchainWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
|
committed = true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
try {
|
||||||
if (e instanceof SQLException se) throw se;
|
c.rollback();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!committed) {
|
||||||
|
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e instanceof SQLException se) {
|
||||||
|
throw se;
|
||||||
|
}
|
||||||
throw new SQLException("appendBlockAndState failed", e);
|
throw new SQLException("appendBlockAndState failed", e);
|
||||||
} finally {
|
} finally {
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
try {
|
||||||
|
c.setAutoCommit(true);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) append to file (минимально: просто дописать)
|
// 3) После commit — атомарно подменяем основной файл.
|
||||||
// Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут.
|
try {
|
||||||
String fileName = fs.buildBlockchainFileName(blockchainName);
|
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||||
fs.addDataToFile(fileName, block.toBytes());
|
} catch (RuntimeException e) {
|
||||||
|
// marker/check/tmp оставляем для startup-recovery
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) После успешной подмены — чистим временные артефакты.
|
||||||
|
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||||
|
byte[] base;
|
||||||
|
try {
|
||||||
|
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
||||||
|
base = fs.readBlockchain(blockchainName);
|
||||||
|
} else {
|
||||||
|
base = new byte[0];
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] out = new byte[base.length + blockBytes.length];
|
||||||
|
System.arraycopy(base, 0, out, 0, base.length);
|
||||||
|
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
|
||||||
|
fs.writeBlockchainTmp(blockchainName, candidateBytes);
|
||||||
|
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
|
||||||
|
fs.writeBlockchainWritePendingMarker(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
|
||||||
|
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteQuietly(DeleteAction action) {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface DeleteAction {
|
||||||
|
void run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetBlockchainBlock — публичное чтение одного конкретного блока по имени цепочки и номеру.
|
||||||
|
*/
|
||||||
|
public final class Net_GetBlockchainBlock_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetBlockchainBlock_Handler.class);
|
||||||
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetBlockchainBlock_Request req = (Net_GetBlockchainBlock_Request) baseRequest;
|
||||||
|
|
||||||
|
String blockchainName = req.getBlockchainName() == null ? "" : req.getBlockchainName().trim();
|
||||||
|
if (blockchainName.isEmpty() || req.getBlockNumber() < 0) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.BAD_REQUEST,
|
||||||
|
"BAD_FIELDS",
|
||||||
|
"Некорректные поля: blockchainName, blockNumber"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
BlockEntry block = blocksDAO.getByNumber(blockchainName, req.getBlockNumber());
|
||||||
|
if (block == null || block.getBlockBytes() == null) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.NOT_FOUND,
|
||||||
|
"BLOCK_NOT_FOUND",
|
||||||
|
"Блок не найден"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetBlockchainBlock_Response resp = new Net_GetBlockchainBlock_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setBlockchainName(block.getBchName());
|
||||||
|
resp.setBlockNumber(block.getBlockNumber());
|
||||||
|
resp.setBlockHash(toHex(block.getBlockHash()));
|
||||||
|
resp.setBlockBytesB64(Base64Ws.encode(block.getBlockBytes()));
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ Internal error GetBlockchainBlock blockchainName={} blockNumber={}",
|
||||||
|
blockchainName, req.getBlockNumber(), e);
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.INTERNAL_ERROR,
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetBlockchainBlock", e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex(byte[] bytes) {
|
||||||
|
if (bytes == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) {
|
||||||
|
sb.append(Character.forDigit((b >>> 4) & 0xF, 16));
|
||||||
|
sb.append(Character.forDigit(b & 0xF, 16));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public final class Net_GetBlockchainBlock_Request extends Net_Request {
|
||||||
|
|
||||||
|
private String blockchainName;
|
||||||
|
private int blockNumber;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public final class Net_GetBlockchainBlock_Response extends Net_Response {
|
||||||
|
|
||||||
|
private String blockchainName;
|
||||||
|
private int blockNumber;
|
||||||
|
private String blockHash;
|
||||||
|
private String blockBytesB64;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||||
|
|
||||||
|
public String getBlockBytesB64() { return blockBytesB64; }
|
||||||
|
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.system;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.SolanaUserEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetSyncUserProfile — server-to-server профиль пользователя для межсерверной синхронизации.
|
||||||
|
* Нужен, чтобы принимающий сервер мог создать локальные solana_users + blockchain_state
|
||||||
|
* без прямого запроса в Solana RPC.
|
||||||
|
*/
|
||||||
|
public final class Net_GetSyncUserProfile_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetSyncUserProfile_Handler.class);
|
||||||
|
private final SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetSyncUserProfile_Request req = (Net_GetSyncUserProfile_Request) baseRequest;
|
||||||
|
|
||||||
|
String login = req.getLogin() == null ? "" : req.getLogin().trim();
|
||||||
|
if (login.isEmpty()) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.BAD_REQUEST,
|
||||||
|
"BAD_FIELDS",
|
||||||
|
"Некорректные поля: login"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SolanaUserEntry user = usersDAO.getByLogin(login);
|
||||||
|
|
||||||
|
Net_GetSyncUserProfile_Response resp = new Net_GetSyncUserProfile_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
resp.setExists(false);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockchainStateEntry state = stateDAO.getByBlockchainName(user.getBlockchainName());
|
||||||
|
if (state == null) {
|
||||||
|
log.warn("GetSyncUserProfile: blockchain_state not found for login={} blockchainName={}",
|
||||||
|
user.getLogin(), user.getBlockchainName());
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.NOT_FOUND,
|
||||||
|
"BLOCKCHAIN_STATE_NOT_FOUND",
|
||||||
|
"Состояние блокчейна пользователя не найдено"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.setExists(true);
|
||||||
|
resp.setLogin(user.getLogin());
|
||||||
|
resp.setBlockchainName(user.getBlockchainName());
|
||||||
|
resp.setSolanaKey(user.getSolanaKey());
|
||||||
|
resp.setBlockchainKey(user.getBlockchainKey());
|
||||||
|
resp.setClientKey(user.getClientKey());
|
||||||
|
resp.setBlockchainSizeLimitBytes(state.getSizeLimit());
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ Internal error GetSyncUserProfile login={}", login, e);
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.INTERNAL_ERROR,
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetSyncUserProfile", e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.system;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ListBlockchainHeads — получить краткую сводку по всем blockchain_state.
|
||||||
|
* Используется для межсерверной сверки heads перед догоняющей синхронизацией.
|
||||||
|
*/
|
||||||
|
public class Net_ListBlockchainHeads_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_ListBlockchainHeads_Handler.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_ListBlockchainHeads_Request req = (Net_ListBlockchainHeads_Request) baseRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<BlockchainStateEntry> states = BlockchainStateDAO.getInstance().listAll();
|
||||||
|
|
||||||
|
Net_ListBlockchainHeads_Response resp = new Net_ListBlockchainHeads_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
|
||||||
|
List<Net_ListBlockchainHeads_Response.Item> items = new ArrayList<>(states.size());
|
||||||
|
for (BlockchainStateEntry state : states) {
|
||||||
|
Net_ListBlockchainHeads_Response.Item item = new Net_ListBlockchainHeads_Response.Item();
|
||||||
|
item.setBlockchainName(state.getBlockchainName());
|
||||||
|
item.setLastBlockNumber(state.getLastBlockNumber());
|
||||||
|
item.setLastBlockHash(toHex32(state.getLastBlockHash()));
|
||||||
|
item.setFileSizeBytes(state.getFileSizeBytes());
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
resp.setBlockchains(items);
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ Internal error ListBlockchainHeads", e);
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.INTERNAL_ERROR,
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при ListBlockchainHeads", e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex32(byte[] bytes32) {
|
||||||
|
byte[] b = bytes32;
|
||||||
|
if (b == null || b.length != 32) {
|
||||||
|
b = new byte[32];
|
||||||
|
}
|
||||||
|
final char[] hex = "0123456789abcdef".toCharArray();
|
||||||
|
char[] out = new char[64];
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
int v = b[i] & 0xFF;
|
||||||
|
out[i * 2] = hex[v >>> 4];
|
||||||
|
out[i * 2 + 1] = hex[v & 0x0F];
|
||||||
|
}
|
||||||
|
return new String(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрос межсерверного профиля пользователя для синхронизации.
|
||||||
|
*/
|
||||||
|
public class Net_GetSyncUserProfile_Request extends Net_Request {
|
||||||
|
|
||||||
|
private String login;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ответ межсерверного профиля пользователя для синхронизации.
|
||||||
|
*/
|
||||||
|
public class Net_GetSyncUserProfile_Response extends Net_Response {
|
||||||
|
|
||||||
|
private Boolean exists;
|
||||||
|
private String login;
|
||||||
|
private String blockchainName;
|
||||||
|
private String solanaKey;
|
||||||
|
private String blockchainKey;
|
||||||
|
private String clientKey;
|
||||||
|
private Long blockchainSizeLimitBytes;
|
||||||
|
|
||||||
|
public Boolean getExists() { return exists; }
|
||||||
|
public void setExists(Boolean exists) { this.exists = exists; }
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public String getSolanaKey() { return solanaKey; }
|
||||||
|
public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; }
|
||||||
|
|
||||||
|
public String getBlockchainKey() { return blockchainKey; }
|
||||||
|
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
|
||||||
|
|
||||||
|
public String getClientKey() { return clientKey; }
|
||||||
|
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
||||||
|
|
||||||
|
public Long getBlockchainSizeLimitBytes() { return blockchainSizeLimitBytes; }
|
||||||
|
public void setBlockchainSizeLimitBytes(Long blockchainSizeLimitBytes) { this.blockchainSizeLimitBytes = blockchainSizeLimitBytes; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пустой запрос для получения списка heads всех blockchain_state.
|
||||||
|
*/
|
||||||
|
public class Net_ListBlockchainHeads_Request extends Net_Request {
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_ListBlockchainHeads_Response extends Net_Response {
|
||||||
|
|
||||||
|
public static final class Item {
|
||||||
|
private String blockchainName;
|
||||||
|
private int lastBlockNumber;
|
||||||
|
private String lastBlockHash;
|
||||||
|
private long fileSizeBytes;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public int getLastBlockNumber() { return lastBlockNumber; }
|
||||||
|
public void setLastBlockNumber(int lastBlockNumber) { this.lastBlockNumber = lastBlockNumber; }
|
||||||
|
|
||||||
|
public String getLastBlockHash() { return lastBlockHash; }
|
||||||
|
public void setLastBlockHash(String lastBlockHash) { this.lastBlockHash = lastBlockHash; }
|
||||||
|
|
||||||
|
public long getFileSizeBytes() { return fileSizeBytes; }
|
||||||
|
public void setFileSizeBytes(long fileSizeBytes) { this.fileSizeBytes = fileSizeBytes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Item> blockchains = new ArrayList<>();
|
||||||
|
|
||||||
|
public List<Item> getBlockchains() { return blockchains; }
|
||||||
|
public void setBlockchains(List<Item> blockchains) { this.blockchains = blockchains; }
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
|
||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response;
|
||||||
@@ -42,6 +43,9 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
SolanaUserEntry u = usersDAO.getByLogin(req.getLogin());
|
SolanaUserEntry u = usersDAO.getByLogin(req.getLogin());
|
||||||
|
if (u == null) {
|
||||||
|
u = SolanaUserPdaImportService.findOrImportByLogin(req.getLogin());
|
||||||
|
}
|
||||||
|
|
||||||
Net_GetUser_Response resp = new Net_GetUser_Response();
|
Net_GetUser_Response resp = new Net_GetUser_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
|
||||||
|
final class EncryptedBodyPayload {
|
||||||
|
static final int CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1;
|
||||||
|
|
||||||
|
final int cryptoMethod;
|
||||||
|
final int cryptoVersion;
|
||||||
|
final byte[] ephemeralPubKey;
|
||||||
|
final byte[] iv;
|
||||||
|
final byte[] cipherText;
|
||||||
|
|
||||||
|
private EncryptedBodyPayload(
|
||||||
|
int cryptoMethod,
|
||||||
|
int cryptoVersion,
|
||||||
|
byte[] ephemeralPubKey,
|
||||||
|
byte[] iv,
|
||||||
|
byte[] cipherText
|
||||||
|
) {
|
||||||
|
this.cryptoMethod = cryptoMethod;
|
||||||
|
this.cryptoVersion = cryptoVersion;
|
||||||
|
this.ephemeralPubKey = ephemeralPubKey;
|
||||||
|
this.iv = iv;
|
||||||
|
this.cipherText = cipherText;
|
||||||
|
}
|
||||||
|
|
||||||
|
static EncryptedBodyPayload parse(byte[] body, int maxBodyBytes) {
|
||||||
|
if (body == null || body.length < 1 + 1 + 1 + 1 + 4) {
|
||||||
|
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
||||||
|
}
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(body).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
|
int cryptoMethod = Byte.toUnsignedInt(bb.get());
|
||||||
|
int cryptoVersion = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (cryptoMethod != CRYPTO_METHOD_X25519_HKDF_AES_GCM || cryptoVersion != 0) {
|
||||||
|
throw new IllegalArgumentException("BAD_CRYPTO_METHOD");
|
||||||
|
}
|
||||||
|
|
||||||
|
int ephemeralPubKeyLen = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (ephemeralPubKeyLen <= 0 || bb.remaining() < ephemeralPubKeyLen) {
|
||||||
|
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
||||||
|
}
|
||||||
|
byte[] ephemeralPubKey = new byte[ephemeralPubKeyLen];
|
||||||
|
bb.get(ephemeralPubKey);
|
||||||
|
|
||||||
|
int ivLen = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (ivLen <= 0 || bb.remaining() < ivLen + 4) {
|
||||||
|
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
||||||
|
}
|
||||||
|
byte[] iv = new byte[ivLen];
|
||||||
|
bb.get(iv);
|
||||||
|
|
||||||
|
long cipherTextLen = Integer.toUnsignedLong(bb.getInt());
|
||||||
|
if (cipherTextLen <= 0 || cipherTextLen > maxBodyBytes || bb.remaining() != cipherTextLen) {
|
||||||
|
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
||||||
|
}
|
||||||
|
byte[] cipherText = new byte[(int) cipherTextLen];
|
||||||
|
bb.get(cipherText);
|
||||||
|
|
||||||
|
return new EncryptedBodyPayload(
|
||||||
|
cryptoMethod,
|
||||||
|
cryptoVersion,
|
||||||
|
ephemeralPubKey,
|
||||||
|
iv,
|
||||||
|
cipherText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package server.logic.ws_protocol.JSON.messages;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
@@ -24,6 +26,7 @@ import java.util.List;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_CallSignalToSession_Handler.class);
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final int TYPE_ACCEPT = 120;
|
private static final int TYPE_ACCEPT = 120;
|
||||||
private static final int TYPE_DECLINE_BUSY = 130;
|
private static final int TYPE_DECLINE_BUSY = 130;
|
||||||
@@ -55,28 +58,15 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId);
|
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId);
|
||||||
if (targetCtx == null || !to.equalsIgnoreCase(targetCtx.getLogin())) {
|
if (targetCtx == null || !to.equalsIgnoreCase(targetCtx.getLogin())) {
|
||||||
// Fallback: если точной сессии уже нет (переподключение), но у пользователя ровно 1 активная сессия,
|
logSessionNotFound(ctx, to, targetSessionId, callId, type, "target_ctx_missing_or_login_mismatch");
|
||||||
// отправляем в неё, чтобы не ронять звонок из-за устаревшего targetSessionId.
|
return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена");
|
||||||
Set<ConnectionContext> activeForLogin = ActiveConnectionsRegistry.getInstance().getByLogin(to);
|
}
|
||||||
if (activeForLogin.size() == 1) {
|
boolean delivered = sendSignalEvent(targetCtx, ctx.getLogin(), ctx.getSessionId(), to, callId, type, data);
|
||||||
targetCtx = activeForLogin.iterator().next();
|
String deliveredSessionId = String.valueOf(targetCtx.getSessionId() == null ? "" : targetCtx.getSessionId()).trim();
|
||||||
} else {
|
if (!delivered) {
|
||||||
return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена");
|
logSessionNotFound(ctx, to, targetSessionId, callId, type, "target_ws_not_delivered");
|
||||||
}
|
return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена");
|
||||||
}
|
}
|
||||||
|
|
||||||
String eventId = NetIdGenerator.eventId("evt");
|
|
||||||
ObjectNode payload = MAPPER.createObjectNode();
|
|
||||||
payload.put("eventId", eventId);
|
|
||||||
payload.put("fromLogin", ctx.getLogin());
|
|
||||||
payload.put("fromSessionId", ctx.getSessionId());
|
|
||||||
payload.put("toLogin", to);
|
|
||||||
payload.put("callId", callId);
|
|
||||||
payload.put("type", type);
|
|
||||||
payload.put("data", data);
|
|
||||||
payload.put("timeMs", System.currentTimeMillis());
|
|
||||||
|
|
||||||
boolean delivered = WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload);
|
|
||||||
|
|
||||||
if (type == TYPE_ACCEPT) {
|
if (type == TYPE_ACCEPT) {
|
||||||
notifyStopOnOtherSessions(
|
notifyStopOnOtherSessions(
|
||||||
@@ -101,7 +91,7 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
|||||||
);
|
);
|
||||||
notifyStopOnOtherSessions(
|
notifyStopOnOtherSessions(
|
||||||
to,
|
to,
|
||||||
targetCtx.getSessionId(),
|
deliveredSessionId,
|
||||||
ctx.getLogin(),
|
ctx.getLogin(),
|
||||||
ctx.getSessionId(),
|
ctx.getSessionId(),
|
||||||
callId,
|
callId,
|
||||||
@@ -177,10 +167,61 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean sendSignalEvent(
|
||||||
|
ConnectionContext targetCtx,
|
||||||
|
String fromLogin,
|
||||||
|
String fromSessionId,
|
||||||
|
String toLogin,
|
||||||
|
String callId,
|
||||||
|
int type,
|
||||||
|
String data
|
||||||
|
) throws Exception {
|
||||||
|
if (targetCtx == null || targetCtx.getWsSession() == null || !targetCtx.getWsSession().isOpen()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String eventId = NetIdGenerator.eventId("evt");
|
||||||
|
ObjectNode payload = MAPPER.createObjectNode();
|
||||||
|
payload.put("eventId", eventId);
|
||||||
|
payload.put("fromLogin", fromLogin);
|
||||||
|
payload.put("fromSessionId", fromSessionId);
|
||||||
|
payload.put("toLogin", toLogin);
|
||||||
|
payload.put("callId", callId);
|
||||||
|
payload.put("type", type);
|
||||||
|
payload.put("data", data);
|
||||||
|
payload.put("timeMs", System.currentTimeMillis());
|
||||||
|
return WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isBlank(String s) {
|
private boolean isBlank(String s) {
|
||||||
return s == null || s.isBlank();
|
return s == null || s.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void logSessionNotFound(
|
||||||
|
ConnectionContext fromCtx,
|
||||||
|
String toLogin,
|
||||||
|
String targetSessionId,
|
||||||
|
String callId,
|
||||||
|
Integer type,
|
||||||
|
String reason
|
||||||
|
) {
|
||||||
|
int activeSessionsCount = 0;
|
||||||
|
try {
|
||||||
|
activeSessionsCount = ActiveConnectionsRegistry.getInstance().getByLogin(toLogin).size();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
log.warn(
|
||||||
|
"CALL_SIGNAL_SESSION_NOT_FOUND fromLogin={} fromSessionId={} toLogin={} targetSessionId={} callId={} type={} activeSessionsCount={} reason={}",
|
||||||
|
fromCtx != null ? String.valueOf(fromCtx.getLogin()) : "",
|
||||||
|
fromCtx != null ? String.valueOf(fromCtx.getSessionId()) : "",
|
||||||
|
String.valueOf(toLogin),
|
||||||
|
String.valueOf(targetSessionId),
|
||||||
|
String.valueOf(callId),
|
||||||
|
type == null ? "" : String.valueOf(type),
|
||||||
|
activeSessionsCount,
|
||||||
|
String.valueOf(reason)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static String jsonEscape(String s) {
|
private static String jsonEscape(String s) {
|
||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
StringBuilder out = new StringBuilder();
|
StringBuilder out = new StringBuilder();
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import server.sync.DmFederationService;
|
||||||
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
|
public class Net_DeleteConversation_Handler implements JsonMessageHandler {
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_DeleteConversation_Request req = (Net_DeleteConversation_Request) baseRequest;
|
||||||
|
if (isBlank(req.getBlobB64())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен");
|
||||||
|
}
|
||||||
|
|
||||||
|
final SignedMessageBlock block;
|
||||||
|
try {
|
||||||
|
block = SignedMessagesCore.parseFromB64(req.getBlobB64());
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат блока удаления переписки");
|
||||||
|
}
|
||||||
|
if (!block.isConversationDeleteType()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "DeleteConversation принимает только типы 7/8");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SignedMessagesCore.verifyUsersAndSignature(block);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
String code = ex.getMessage();
|
||||||
|
int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED;
|
||||||
|
return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
|
||||||
|
}
|
||||||
|
|
||||||
|
SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteConversation", null);
|
||||||
|
SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteConversation(entry);
|
||||||
|
|
||||||
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
|
if (status.applied()) {
|
||||||
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
|
||||||
|
}
|
||||||
|
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
|
||||||
|
DmFederationService.fanOutDeleteConversation(block.fromLogin, block.toLogin, block.messageType, req.getBlobB64().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_DeleteConversation_Response resp = new Net_DeleteConversation_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setMessageKey(entry.getMessageKey());
|
||||||
|
resp.setBaseKey(entry.getBaseKey());
|
||||||
|
resp.setDeliveredWsSessions(counters.wsDelivered);
|
||||||
|
resp.setDeliveredWebPushSessions(counters.pushDelivered);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlank(String s) {
|
||||||
|
return s == null || s.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import server.sync.DmFederationService;
|
||||||
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
|
public class Net_DeleteMessage_Handler implements JsonMessageHandler {
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_DeleteMessage_Request req = (Net_DeleteMessage_Request) baseRequest;
|
||||||
|
if (isBlank(req.getBlobB64())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен");
|
||||||
|
}
|
||||||
|
|
||||||
|
final SignedMessageBlock block;
|
||||||
|
try {
|
||||||
|
block = SignedMessagesCore.parseFromB64(req.getBlobB64());
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат удаляющего блока");
|
||||||
|
}
|
||||||
|
if (!block.isMessageDeleteType()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "DeleteMessage принимает только типы 5/6");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SignedMessagesCore.verifyUsersAndSignature(block);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
String code = ex.getMessage();
|
||||||
|
int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED;
|
||||||
|
return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
|
||||||
|
}
|
||||||
|
|
||||||
|
SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteMessage", null);
|
||||||
|
SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteMessage(entry);
|
||||||
|
|
||||||
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
|
if (status.applied()) {
|
||||||
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
|
||||||
|
}
|
||||||
|
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
|
||||||
|
DmFederationService.fanOutDeleteMessage(block.fromLogin, block.toLogin, block.messageType, req.getBlobB64().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_DeleteMessage_Response resp = new Net_DeleteMessage_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setMessageKey(entry.getMessageKey());
|
||||||
|
resp.setBaseKey(entry.getBaseKey());
|
||||||
|
resp.setDeliveredWsSessions(counters.wsDelivered);
|
||||||
|
resp.setDeliveredWebPushSessions(counters.pushDelivered);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlank(String s) {
|
||||||
|
return s == null || s.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import server.logic.ws_protocol.WireCodes;
|
|||||||
import shine.db.dao.SignedMessagesV2DAO;
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
public class Net_ReceiveIncomingMessage_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 {
|
||||||
@@ -25,7 +27,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
|||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат входящего блока");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат входящего блока");
|
||||||
}
|
}
|
||||||
if (!incoming.isIncomingType()) {
|
if (incoming.messageType != SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||||
|
&& incoming.messageType != SignedMessageBlock.TYPE_READ_INCOMING) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "API принимает только входящие типы 1/3");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "API принимает только входящие типы 1/3");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +47,15 @@ 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 = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
SignedMessagesV2DAO.ApplyStatus status = incoming.isContentType()
|
||||||
|
? SignedMessagesV2DAO.getInstance().upsertIncomingCopy(entry)
|
||||||
|
: SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
||||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (inserted) {
|
if (status.applied()) {
|
||||||
counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||||
|
}
|
||||||
|
if (status == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||||
|
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
|
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
|
||||||
@@ -64,4 +72,15 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
|||||||
private boolean isBlank(String s) {
|
private boolean isBlank(String s) {
|
||||||
return s == null || s.isBlank();
|
return s == null || s.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception {
|
||||||
|
SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin);
|
||||||
|
if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return;
|
||||||
|
server.sync.DmFederationService.fanOutDeleteConversation(
|
||||||
|
tombstone.getFromLogin(),
|
||||||
|
tombstone.getToLogin(),
|
||||||
|
tombstone.getMessageType(),
|
||||||
|
Base64.getEncoder().encodeToString(tombstone.getRawBlock())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque
|
|||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import server.sync.DmFederationService;
|
||||||
import shine.db.dao.SignedMessagesV2DAO;
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
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 {
|
||||||
@@ -41,7 +44,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
SignedMessageV2Entry incomingEntry;
|
SignedMessageV2Entry incomingEntry;
|
||||||
SignedMessageV2Entry outgoingEntry;
|
SignedMessageV2Entry outgoingEntry;
|
||||||
try {
|
try {
|
||||||
String sourceApi = "ReceiveOutcomingMessage";
|
String sourceApi = "SendMessagePair";
|
||||||
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
|
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
|
||||||
incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId);
|
incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId);
|
||||||
outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId);
|
outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId);
|
||||||
@@ -49,18 +52,20 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean pairInserted;
|
SignedMessagesV2DAO.ApplyStatus pairStatus;
|
||||||
if (incoming.isContentType()) {
|
if (incoming.isContentType()) {
|
||||||
pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair(
|
pairStatus = SignedMessagesV2DAO.getInstance().upsertContentPair(
|
||||||
incomingEntry, outgoingEntry
|
incomingEntry, outgoingEntry
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
|
pairStatus = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry)
|
||||||
|
? SignedMessagesV2DAO.ApplyStatus.APPLIED
|
||||||
|
: SignedMessagesV2DAO.ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairInserted) {
|
if (pairStatus.applied()) {
|
||||||
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming);
|
inCounters = SignedMessagesRealtime.deliverToRelevantSessions(incomingEntry, incoming);
|
||||||
}
|
}
|
||||||
|
|
||||||
String excludeSessionId = null;
|
String excludeSessionId = null;
|
||||||
@@ -68,8 +73,21 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
excludeSessionId = ctx.getSessionId();
|
excludeSessionId = ctx.getSessionId();
|
||||||
}
|
}
|
||||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairInserted) {
|
if (pairStatus.applied()) {
|
||||||
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId);
|
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairStatus == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||||
|
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairStatus.applied() && ctx != null && ctx.isAuthenticatedUser()) {
|
||||||
|
DmFederationService.fanOutPair(
|
||||||
|
incoming.fromLogin,
|
||||||
|
incoming.toLogin,
|
||||||
|
req.getIncomingBlobB64().trim(),
|
||||||
|
req.getOutgoingBlobB64().trim()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
||||||
@@ -87,4 +105,15 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
private boolean isBlank(String s) {
|
private boolean isBlank(String s) {
|
||||||
return s == null || s.isBlank();
|
return s == null || s.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception {
|
||||||
|
SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin);
|
||||||
|
if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return;
|
||||||
|
DmFederationService.fanOutDeleteConversation(
|
||||||
|
tombstone.getFromLogin(),
|
||||||
|
tombstone.getToLogin(),
|
||||||
|
tombstone.getMessageType(),
|
||||||
|
Base64.getEncoder().encodeToString(tombstone.getRawBlock())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
|
import shine.db.entities.SolanaUserEntry;
|
||||||
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_SendSignal_Handler.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final String TARGET_MODE_SINGLE = "single_session";
|
||||||
|
private static final String TARGET_MODE_ALL = "all_sessions";
|
||||||
|
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_SendSignal_Request req = (Net_SendSignal_Request) baseRequest;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getActiveSession() == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||||
|
}
|
||||||
|
|
||||||
|
String fromLogin = safe(ctx.getLogin());
|
||||||
|
String fromSessionId = safe(ctx.getSessionId());
|
||||||
|
ActiveSessionEntry activeSession = ctx.getActiveSession();
|
||||||
|
|
||||||
|
String toRequest = safe(req.getToLogin());
|
||||||
|
String targetMode = safe(req.getTargetMode()).toLowerCase();
|
||||||
|
String targetSessionId = safe(req.getTargetSessionId());
|
||||||
|
String signalType = safe(req.getSignalType());
|
||||||
|
String signalRequestId = safe(req.getSignalRequestId());
|
||||||
|
String data = req.getData() == null ? "" : req.getData();
|
||||||
|
Long timeMs = req.getTimeMs();
|
||||||
|
String sessionSignatureB64 = safe(req.getSessionSignatureB64());
|
||||||
|
String clientSignatureB64 = safe(req.getClientSignatureB64());
|
||||||
|
|
||||||
|
if (toRequest.isBlank() || signalType.isBlank() || signalRequestId.isBlank() || timeMs == null || sessionSignatureB64.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/targetMode/signalType/signalRequestId/timeMs/sessionSignatureB64 обязательны");
|
||||||
|
}
|
||||||
|
if (!TARGET_MODE_SINGLE.equals(targetMode) && !TARGET_MODE_ALL.equals(targetMode)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_TARGET_MODE", "Поддерживаются targetMode=single_session или all_sessions");
|
||||||
|
}
|
||||||
|
if (TARGET_MODE_SINGLE.equals(targetMode) && targetSessionId.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Для single_session нужен targetSessionId");
|
||||||
|
}
|
||||||
|
if (TARGET_MODE_ALL.equals(targetMode) && !targetSessionId.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Для all_sessions нельзя передавать targetSessionId");
|
||||||
|
}
|
||||||
|
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "TIME_SKEW", "Время клиента отличается от сервера более чем на 30 секунд");
|
||||||
|
}
|
||||||
|
|
||||||
|
SolanaUserEntry senderUser = ctx.getSolanaUser();
|
||||||
|
if (senderUser == null || senderUser.getClientKey() == null || senderUser.getClientKey().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "NO_CLIENT_KEY", "Для пользователя не найден client key");
|
||||||
|
}
|
||||||
|
|
||||||
|
SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest);
|
||||||
|
if (targetUser == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||||
|
}
|
||||||
|
String toLogin = safe(targetUser.getLogin());
|
||||||
|
|
||||||
|
String digestB64;
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
digestB64 = Base64Ws.encode(digest.digest(data.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("SendSignal: failed to hash signal data (fromLogin={}, signalType={})", fromLogin, signalType, e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_DATA", "Не удалось обработать data");
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionPreimage = buildSessionPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
||||||
|
if (!verifySignature(activeSession.getSessionKey(), sessionPreimage, sessionSignatureB64, "sessionKey")) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientSignatureB64.isBlank()) {
|
||||||
|
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
||||||
|
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_CLIENT_SIGNATURE", "Некорректная подпись client key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ConnectionContext> targets = resolveTargets(targetMode, toLogin, targetSessionId);
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
|
||||||
|
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, code, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> deliveredSessionIds = new ArrayList<>();
|
||||||
|
for (ConnectionContext targetCtx : targets) {
|
||||||
|
String eventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt");
|
||||||
|
ObjectNode payload = MAPPER.createObjectNode();
|
||||||
|
payload.put("eventId", eventId);
|
||||||
|
payload.put("fromLogin", fromLogin);
|
||||||
|
payload.put("fromSessionId", fromSessionId);
|
||||||
|
payload.put("toLogin", toLogin);
|
||||||
|
payload.put("targetMode", targetMode);
|
||||||
|
payload.put("targetSessionId", safe(targetCtx.getSessionId()));
|
||||||
|
payload.put("signalType", signalType);
|
||||||
|
payload.put("signalRequestId", signalRequestId);
|
||||||
|
payload.put("data", data);
|
||||||
|
payload.put("timeMs", timeMs);
|
||||||
|
payload.put("sessionSignatureB64", sessionSignatureB64);
|
||||||
|
payload.put("clientSignatureB64", clientSignatureB64);
|
||||||
|
payload.put("dataSha256B64", digestB64);
|
||||||
|
if (WsEventSender.sendEvent(targetCtx, "IncomingSignal", eventId, payload)) {
|
||||||
|
deliveredSessionIds.add(safe(targetCtx.getSessionId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveredSessionIds.isEmpty()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию");
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_SendSignal_Response resp = new Net_SendSignal_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setDeliveredCount(deliveredSessionIds.size());
|
||||||
|
resp.setDeliveredSessionIds(deliveredSessionIds);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildSessionPreimage(String fromLogin,
|
||||||
|
String fromSessionId,
|
||||||
|
String toLogin,
|
||||||
|
String targetMode,
|
||||||
|
String targetSessionId,
|
||||||
|
String signalType,
|
||||||
|
String signalRequestId,
|
||||||
|
long timeMs,
|
||||||
|
String dataSha256B64) {
|
||||||
|
return "SEND_SIGNAL_SESSION:"
|
||||||
|
+ fromLogin + ":"
|
||||||
|
+ fromSessionId + ":"
|
||||||
|
+ toLogin + ":"
|
||||||
|
+ targetMode + ":"
|
||||||
|
+ targetSessionId + ":"
|
||||||
|
+ signalType + ":"
|
||||||
|
+ signalRequestId + ":"
|
||||||
|
+ timeMs + ":"
|
||||||
|
+ dataSha256B64;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildClientPreimage(String fromLogin,
|
||||||
|
String fromSessionId,
|
||||||
|
String toLogin,
|
||||||
|
String targetMode,
|
||||||
|
String targetSessionId,
|
||||||
|
String signalType,
|
||||||
|
String signalRequestId,
|
||||||
|
long timeMs,
|
||||||
|
String dataSha256B64) {
|
||||||
|
return "SEND_SIGNAL_CLIENT:"
|
||||||
|
+ fromLogin + ":"
|
||||||
|
+ fromSessionId + ":"
|
||||||
|
+ toLogin + ":"
|
||||||
|
+ targetMode + ":"
|
||||||
|
+ targetSessionId + ":"
|
||||||
|
+ signalType + ":"
|
||||||
|
+ signalRequestId + ":"
|
||||||
|
+ timeMs + ":"
|
||||||
|
+ dataSha256B64;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
|
||||||
|
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
|
||||||
|
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
|
||||||
|
return Ed25519Util.verify(preimage.getBytes(StandardCharsets.UTF_8), signature64, publicKey32);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ConnectionContext> resolveTargets(String targetMode, String toLogin, String targetSessionId) {
|
||||||
|
List<ConnectionContext> targets = new ArrayList<>();
|
||||||
|
if (TARGET_MODE_SINGLE.equals(targetMode)) {
|
||||||
|
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId);
|
||||||
|
if (targetCtx != null && toLogin.equalsIgnoreCase(safe(targetCtx.getLogin()))) {
|
||||||
|
targets.add(targetCtx);
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<ConnectionContext> onlineTargets = ActiveConnectionsRegistry.getInstance().getByLogin(toLogin);
|
||||||
|
onlineTargets.stream()
|
||||||
|
.filter(item -> item != null && item.getWsSession() != null && item.getWsSession().isOpen())
|
||||||
|
.sorted(Comparator.comparing(ConnectionContext::getSessionId, Comparator.nullsLast(String::compareTo)))
|
||||||
|
.forEach(targets::add);
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,18 +9,16 @@ final class ReadReceiptPayload {
|
|||||||
final String refFromLogin;
|
final String refFromLogin;
|
||||||
final long refTimeMs;
|
final long refTimeMs;
|
||||||
final long refNonce;
|
final long refNonce;
|
||||||
final int refType;
|
|
||||||
|
|
||||||
private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce, int refType) {
|
private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce) {
|
||||||
this.refToLogin = refToLogin;
|
this.refToLogin = refToLogin;
|
||||||
this.refFromLogin = refFromLogin;
|
this.refFromLogin = refFromLogin;
|
||||||
this.refTimeMs = refTimeMs;
|
this.refTimeMs = refTimeMs;
|
||||||
this.refNonce = refNonce;
|
this.refNonce = refNonce;
|
||||||
this.refType = refType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static ReadReceiptPayload parse(byte[] payload) {
|
static ReadReceiptPayload parse(byte[] payload) {
|
||||||
if (payload == null || payload.length < 1 + 1 + 8 + 4 + 2) {
|
if (payload == null || payload.length < 1 + 1 + 8 + 4) {
|
||||||
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
||||||
}
|
}
|
||||||
ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN);
|
||||||
@@ -29,14 +27,10 @@ final class ReadReceiptPayload {
|
|||||||
long refTimeMs = bb.getLong();
|
long refTimeMs = bb.getLong();
|
||||||
if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME");
|
if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME");
|
||||||
long refNonce = Integer.toUnsignedLong(bb.getInt());
|
long refNonce = Integer.toUnsignedLong(bb.getInt());
|
||||||
int refType = Short.toUnsignedInt(bb.getShort());
|
|
||||||
if (refType < SignedMessageBlock.TYPE_INCOMING_TEXT || refType > SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
|
||||||
throw new IllegalArgumentException("BAD_RECEIPT_REF_TYPE");
|
|
||||||
}
|
|
||||||
if (bb.hasRemaining()) {
|
if (bb.hasRemaining()) {
|
||||||
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
||||||
}
|
}
|
||||||
return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce, refType);
|
return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
String refBaseKey() {
|
String refBaseKey() {
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
final class SignedMessageBlock {
|
final class SignedMessageBlock {
|
||||||
static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
static final byte[] PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
||||||
static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
|
||||||
static final int TYPE_INCOMING_TEXT = 1;
|
static final int TYPE_INCOMING_TEXT = 1;
|
||||||
static final int TYPE_OUTGOING_COPY = 2;
|
static final int TYPE_OUTGOING_COPY = 2;
|
||||||
static final int TYPE_READ_INCOMING = 3;
|
static final int TYPE_READ_INCOMING = 3;
|
||||||
static final int TYPE_READ_OUTGOING_COPY = 4;
|
static final int TYPE_READ_OUTGOING_COPY = 4;
|
||||||
|
static final int TYPE_MESSAGE_DELETED_BY_SENDER = 5;
|
||||||
|
static final int TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6;
|
||||||
|
static final int TYPE_CONVERSATION_DELETED_BY_SENDER = 7;
|
||||||
|
static final int TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8;
|
||||||
|
|
||||||
final String toLogin;
|
final String toLogin;
|
||||||
final String fromLogin;
|
final String fromLogin;
|
||||||
@@ -19,14 +23,15 @@ final class SignedMessageBlock {
|
|||||||
final long nonce;
|
final long nonce;
|
||||||
final int messageType;
|
final int messageType;
|
||||||
final long revisionTimeMs;
|
final long revisionTimeMs;
|
||||||
|
final long reencryptedAtMs;
|
||||||
final int formatVersionMajor;
|
final int formatVersionMajor;
|
||||||
final int formatVersionMinor;
|
final int formatVersionMinor;
|
||||||
final byte[] payloadBytes;
|
final byte[] bodyBytes;
|
||||||
final byte[] encryptedBodyBytes;
|
final EncryptedBodyPayload encryptedBody;
|
||||||
|
final ReadReceiptPayload readReceiptBody;
|
||||||
final byte[] signedBody;
|
final byte[] signedBody;
|
||||||
final byte[] signature64;
|
final byte[] signature64;
|
||||||
final byte[] rawPacket;
|
final byte[] rawPacket;
|
||||||
final boolean legacyFormat;
|
|
||||||
|
|
||||||
private SignedMessageBlock(
|
private SignedMessageBlock(
|
||||||
String toLogin,
|
String toLogin,
|
||||||
@@ -35,14 +40,15 @@ final class SignedMessageBlock {
|
|||||||
long nonce,
|
long nonce,
|
||||||
int messageType,
|
int messageType,
|
||||||
long revisionTimeMs,
|
long revisionTimeMs,
|
||||||
|
long reencryptedAtMs,
|
||||||
int formatVersionMajor,
|
int formatVersionMajor,
|
||||||
int formatVersionMinor,
|
int formatVersionMinor,
|
||||||
byte[] payloadBytes,
|
byte[] bodyBytes,
|
||||||
byte[] encryptedBodyBytes,
|
EncryptedBodyPayload encryptedBody,
|
||||||
|
ReadReceiptPayload readReceiptBody,
|
||||||
byte[] signedBody,
|
byte[] signedBody,
|
||||||
byte[] signature64,
|
byte[] signature64,
|
||||||
byte[] rawPacket,
|
byte[] rawPacket
|
||||||
boolean legacyFormat
|
|
||||||
) {
|
) {
|
||||||
this.toLogin = toLogin;
|
this.toLogin = toLogin;
|
||||||
this.fromLogin = fromLogin;
|
this.fromLogin = fromLogin;
|
||||||
@@ -50,85 +56,27 @@ final class SignedMessageBlock {
|
|||||||
this.nonce = nonce;
|
this.nonce = nonce;
|
||||||
this.messageType = messageType;
|
this.messageType = messageType;
|
||||||
this.revisionTimeMs = revisionTimeMs;
|
this.revisionTimeMs = revisionTimeMs;
|
||||||
|
this.reencryptedAtMs = reencryptedAtMs;
|
||||||
this.formatVersionMajor = formatVersionMajor;
|
this.formatVersionMajor = formatVersionMajor;
|
||||||
this.formatVersionMinor = formatVersionMinor;
|
this.formatVersionMinor = formatVersionMinor;
|
||||||
this.payloadBytes = payloadBytes;
|
this.bodyBytes = bodyBytes;
|
||||||
this.encryptedBodyBytes = encryptedBodyBytes;
|
this.encryptedBody = encryptedBody;
|
||||||
|
this.readReceiptBody = readReceiptBody;
|
||||||
this.signedBody = signedBody;
|
this.signedBody = signedBody;
|
||||||
this.signature64 = signature64;
|
this.signature64 = signature64;
|
||||||
this.rawPacket = rawPacket;
|
this.rawPacket = rawPacket;
|
||||||
this.legacyFormat = legacyFormat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) {
|
static SignedMessageBlock parse(byte[] raw, int maxBodyBytes) {
|
||||||
if (raw == null || raw.length < 64) {
|
if (raw == null || raw.length < PREFIX.length + 2 + 1 + 1 + 8 + 4 + 1 + 8 + 8 + 4 + 64) {
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
}
|
}
|
||||||
|
if (!startsWith(raw, PREFIX)) {
|
||||||
|
throw new IllegalArgumentException("BAD_PREFIX");
|
||||||
|
}
|
||||||
|
|
||||||
if (startsWith(raw, LEGACY_PREFIX)) {
|
|
||||||
return parseLegacy(raw, maxEncryptedBodyBytes);
|
|
||||||
}
|
|
||||||
if (startsWith(raw, V1_PREFIX)) {
|
|
||||||
return parseV1(raw, maxEncryptedBodyBytes);
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("BAD_PREFIX");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) {
|
|
||||||
if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
|
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
|
||||||
}
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||||
bb.position(LEGACY_PREFIX.length);
|
bb.position(PREFIX.length);
|
||||||
|
|
||||||
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
|
||||||
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
|
||||||
|
|
||||||
long timeMs = bb.getLong();
|
|
||||||
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
|
||||||
|
|
||||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
|
||||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
|
||||||
ensureMessageType(messageType);
|
|
||||||
|
|
||||||
int payloadLen = Short.toUnsignedInt(bb.getShort());
|
|
||||||
if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
|
|
||||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
|
||||||
}
|
|
||||||
if (bb.remaining() != payloadLen + 64) {
|
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] payload = new byte[payloadLen];
|
|
||||||
bb.get(payload);
|
|
||||||
byte[] signature64 = new byte[64];
|
|
||||||
bb.get(signature64);
|
|
||||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
|
||||||
|
|
||||||
return new SignedMessageBlock(
|
|
||||||
toLogin,
|
|
||||||
fromLogin,
|
|
||||||
timeMs,
|
|
||||||
nonce,
|
|
||||||
messageType,
|
|
||||||
0L,
|
|
||||||
2,
|
|
||||||
0,
|
|
||||||
payload,
|
|
||||||
payload,
|
|
||||||
signedBody,
|
|
||||||
signature64,
|
|
||||||
raw,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) {
|
|
||||||
if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) {
|
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
|
||||||
}
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.position(V1_PREFIX.length);
|
|
||||||
|
|
||||||
int major = Byte.toUnsignedInt(bb.get());
|
int major = Byte.toUnsignedInt(bb.get());
|
||||||
int minor = Byte.toUnsignedInt(bb.get());
|
int minor = Byte.toUnsignedInt(bb.get());
|
||||||
@@ -141,32 +89,48 @@ final class SignedMessageBlock {
|
|||||||
long timeMs = bb.getLong();
|
long timeMs = bb.getLong();
|
||||||
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
||||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
int messageType = Byte.toUnsignedInt(bb.get());
|
||||||
ensureMessageType(messageType);
|
ensureMessageType(messageType);
|
||||||
long revisionTimeMs = bb.getLong();
|
long revisionTimeMs = bb.getLong();
|
||||||
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||||
|
long reencryptedAtMs = bb.getLong();
|
||||||
|
if (reencryptedAtMs < 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||||
|
|
||||||
int attachmentsCount = Byte.toUnsignedInt(bb.get());
|
long bodyLen = Integer.toUnsignedLong(bb.getInt());
|
||||||
if (attachmentsCount != 0) {
|
if (bodyLen > maxBodyBytes) {
|
||||||
throw new IllegalArgumentException("ATTACHMENTS_DISABLED");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bb.remaining() < 4 + 64) {
|
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
|
||||||
}
|
|
||||||
long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt());
|
|
||||||
if (encryptedBodyLen > maxEncryptedBodyBytes) {
|
|
||||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||||
}
|
}
|
||||||
if (bb.remaining() != encryptedBodyLen + 64) {
|
if (bb.remaining() != bodyLen + 64) {
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
}
|
}
|
||||||
byte[] encryptedBody = new byte[(int) encryptedBodyLen];
|
|
||||||
bb.get(encryptedBody);
|
byte[] bodyBytes = new byte[(int) bodyLen];
|
||||||
|
bb.get(bodyBytes);
|
||||||
byte[] signature64 = new byte[64];
|
byte[] signature64 = new byte[64];
|
||||||
bb.get(signature64);
|
bb.get(signature64);
|
||||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||||
|
|
||||||
|
EncryptedBodyPayload encryptedBody = null;
|
||||||
|
ReadReceiptPayload readReceiptBody = null;
|
||||||
|
|
||||||
|
if (messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY) {
|
||||||
|
if (bodyBytes.length == 0) throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||||
|
} else if (messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY) {
|
||||||
|
if (bodyBytes.length == 0) throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
||||||
|
if (revisionTimeMs != 0 || reencryptedAtMs != 0) {
|
||||||
|
throw new IllegalArgumentException("BAD_RECEIPT_REVISION");
|
||||||
|
}
|
||||||
|
readReceiptBody = ReadReceiptPayload.parse(bodyBytes);
|
||||||
|
} else if (messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT) {
|
||||||
|
if (bodyBytes.length != 0) throw new IllegalArgumentException("BAD_DELETE_BODY");
|
||||||
|
if (revisionTimeMs <= 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||||
|
if (reencryptedAtMs != 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||||
|
} else if (messageType == TYPE_CONVERSATION_DELETED_BY_SENDER || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
|
||||||
|
if (bodyBytes.length != 0) throw new IllegalArgumentException("BAD_DELETE_BODY");
|
||||||
|
if (revisionTimeMs != 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||||
|
if (reencryptedAtMs != 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||||
|
}
|
||||||
|
|
||||||
return new SignedMessageBlock(
|
return new SignedMessageBlock(
|
||||||
toLogin,
|
toLogin,
|
||||||
fromLogin,
|
fromLogin,
|
||||||
@@ -174,23 +138,24 @@ final class SignedMessageBlock {
|
|||||||
nonce,
|
nonce,
|
||||||
messageType,
|
messageType,
|
||||||
revisionTimeMs,
|
revisionTimeMs,
|
||||||
|
reencryptedAtMs,
|
||||||
major,
|
major,
|
||||||
minor,
|
minor,
|
||||||
|
bodyBytes,
|
||||||
encryptedBody,
|
encryptedBody,
|
||||||
encryptedBody,
|
readReceiptBody,
|
||||||
signedBody,
|
signedBody,
|
||||||
signature64,
|
signature64,
|
||||||
raw,
|
raw
|
||||||
false
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isIncomingType() {
|
boolean isIncomingCopyType() {
|
||||||
return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_READ_INCOMING;
|
return messageType == TYPE_INCOMING_TEXT;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isOutgoingCopyType() {
|
boolean isOutgoingCopyType() {
|
||||||
return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
|
return messageType == TYPE_OUTGOING_COPY;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isContentType() {
|
boolean isContentType() {
|
||||||
@@ -201,16 +166,25 @@ final class SignedMessageBlock {
|
|||||||
return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY;
|
return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isDeletedContent() {
|
boolean isMessageDeleteType() {
|
||||||
return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0;
|
return messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
String targetLogin() {
|
boolean isConversationDeleteType() {
|
||||||
return isIncomingType() ? toLogin : fromLogin;
|
return messageType == TYPE_CONVERSATION_DELETED_BY_SENDER || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isDeleteType() {
|
||||||
|
return isMessageDeleteType() || isConversationDeleteType();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isSignedByRecipient() {
|
||||||
|
return messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT
|
||||||
|
|| messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ensureMessageType(int messageType) {
|
private static void ensureMessageType(int messageType) {
|
||||||
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
|
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
|
||||||
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
package server.logic.ws_protocol.JSON.messages;
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
|
||||||
import shine.db.dao.SolanaUsersDAO;
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.entities.SolanaUserEntry;
|
||||||
import utils.crypto.Ed25519Util;
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
final class SignedMessagesCore {
|
final class SignedMessagesCore {
|
||||||
private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
|
private static final int MAX_BODY_BYTES = 16384;
|
||||||
|
|
||||||
private SignedMessagesCore() {}
|
private SignedMessagesCore() {}
|
||||||
|
|
||||||
static SignedMessageBlock parseFromB64(String blobB64) {
|
static SignedMessageBlock parseFromB64(String blobB64) {
|
||||||
try {
|
try {
|
||||||
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
|
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
|
||||||
return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES);
|
return SignedMessageBlock.parse(raw, MAX_BODY_BYTES);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
String code = e.getMessage();
|
String code = e.getMessage();
|
||||||
if (code == null || code.isBlank()) {
|
if (code == null || code.isBlank()) {
|
||||||
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
||||||
}
|
}
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case "ATTACHMENTS_DISABLED",
|
case "BAD_PREFIX",
|
||||||
"BAD_PREFIX",
|
|
||||||
"BAD_LEN",
|
"BAD_LEN",
|
||||||
"BAD_TO_LOGIN",
|
"BAD_TO_LOGIN",
|
||||||
"BAD_FROM_LOGIN",
|
"BAD_FROM_LOGIN",
|
||||||
@@ -32,24 +31,43 @@ final class SignedMessagesCore {
|
|||||||
"BAD_MESSAGE_TYPE",
|
"BAD_MESSAGE_TYPE",
|
||||||
"BAD_MESSAGE_LEN",
|
"BAD_MESSAGE_LEN",
|
||||||
"BAD_FORMAT_VERSION",
|
"BAD_FORMAT_VERSION",
|
||||||
"BAD_REVISION_TIME" -> throw e;
|
"BAD_REVISION_TIME",
|
||||||
|
"BAD_REENCRYPTED_TIME",
|
||||||
|
"BAD_DELETE_BODY",
|
||||||
|
"BAD_RECEIPT_PAYLOAD_LEN",
|
||||||
|
"BAD_RECEIPT_TO_LOGIN",
|
||||||
|
"BAD_RECEIPT_FROM_LOGIN",
|
||||||
|
"BAD_RECEIPT_TIME",
|
||||||
|
"BAD_RECEIPT_REVISION",
|
||||||
|
"BAD_ENCRYPTED_BODY",
|
||||||
|
"BAD_CRYPTO_METHOD" -> throw e;
|
||||||
default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception {
|
static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception {
|
||||||
SolanaUserEntry from = SolanaUsersDAO.getInstance().getByLogin(block.fromLogin);
|
SolanaUserEntry from = resolveUser(block.fromLogin);
|
||||||
SolanaUserEntry to = SolanaUsersDAO.getInstance().getByLogin(block.toLogin);
|
SolanaUserEntry to = resolveUser(block.toLogin);
|
||||||
if (from == null || to == null) {
|
if (from == null || to == null) {
|
||||||
throw new IllegalArgumentException("USER_NOT_FOUND");
|
throw new IllegalArgumentException("USER_NOT_FOUND");
|
||||||
}
|
}
|
||||||
byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getClientKey());
|
|
||||||
|
String signerLogin = block.isSignedByRecipient() ? block.toLogin : block.fromLogin;
|
||||||
|
SolanaUserEntry signer = signerLogin.equalsIgnoreCase(block.fromLogin) ? from : to;
|
||||||
|
byte[] pubKey32 = Ed25519Util.keyFromBase64(signer.getClientKey());
|
||||||
if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) {
|
if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) {
|
||||||
throw new IllegalArgumentException("BAD_SIGNATURE");
|
throw new IllegalArgumentException("BAD_SIGNATURE");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SolanaUserEntry resolveUser(String login) throws Exception {
|
||||||
|
SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
|
||||||
|
SolanaUserEntry user = usersDao.getByLogin(login);
|
||||||
|
if (user != null) return user;
|
||||||
|
return SolanaUserPdaImportService.findOrImportByLogin(login);
|
||||||
|
}
|
||||||
|
|
||||||
static void validatePair(SignedMessageBlock incoming, SignedMessageBlock outgoing) {
|
static void validatePair(SignedMessageBlock incoming, SignedMessageBlock outgoing) {
|
||||||
if (incoming.messageType % 2 == 0) throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
if (incoming.messageType % 2 == 0) throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
||||||
if (outgoing.messageType != incoming.messageType + 1) throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
if (outgoing.messageType != incoming.messageType + 1) throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
||||||
@@ -57,38 +75,28 @@ final class SignedMessagesCore {
|
|||||||
if (!incoming.fromLogin.equalsIgnoreCase(outgoing.fromLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
if (!incoming.fromLogin.equalsIgnoreCase(outgoing.fromLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||||
if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||||
if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||||
|
if (incoming.revisionTimeMs != outgoing.revisionTimeMs) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||||
if (incoming.isReadReceiptType()) {
|
if (incoming.reencryptedAtMs != outgoing.reencryptedAtMs) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||||
ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes);
|
|
||||||
ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
|
|
||||||
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
|
||||||
|| !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin)
|
|
||||||
|| inRef.refTimeMs != outRef.refTimeMs
|
|
||||||
|| inRef.refNonce != outRef.refNonce
|
|
||||||
|| inRef.refType != outRef.refType) {
|
|
||||||
throw new IllegalArgumentException("BAD_RECEIPT_REF");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incoming.legacyFormat || outgoing.legacyFormat) {
|
|
||||||
throw new IllegalArgumentException("BAD_CONTENT_FORMAT");
|
|
||||||
}
|
|
||||||
if (incoming.revisionTimeMs != outgoing.revisionTimeMs) {
|
|
||||||
throw new IllegalArgumentException("BAD_REVISION_TIME");
|
|
||||||
}
|
|
||||||
if (incoming.formatVersionMajor != outgoing.formatVersionMajor
|
if (incoming.formatVersionMajor != outgoing.formatVersionMajor
|
||||||
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) {
|
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) {
|
||||||
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) {
|
if (incoming.isReadReceiptType()) {
|
||||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
if (!outgoing.isReadReceiptType()) throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
||||||
}
|
ReadReceiptPayload inRef = incoming.readReceiptBody;
|
||||||
for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) {
|
ReadReceiptPayload outRef = outgoing.readReceiptBody;
|
||||||
if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) {
|
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
||||||
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
|| !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin)
|
||||||
|
|| inRef.refTimeMs != outRef.refTimeMs
|
||||||
|
|| inRef.refNonce != outRef.refNonce) {
|
||||||
|
throw new IllegalArgumentException("BAD_RECEIPT_REF");
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!incoming.isContentType() || !outgoing.isContentType()) {
|
||||||
|
throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,32 +107,40 @@ final class SignedMessagesCore {
|
|||||||
SignedMessageV2Entry entry = new SignedMessageV2Entry();
|
SignedMessageV2Entry entry = new SignedMessageV2Entry();
|
||||||
entry.setMessageKey(messageKey);
|
entry.setMessageKey(messageKey);
|
||||||
entry.setBaseKey(baseKey);
|
entry.setBaseKey(baseKey);
|
||||||
entry.setTargetLogin(block.targetLogin());
|
entry.setTargetLogin(primaryTargetLogin(block));
|
||||||
entry.setFromLogin(block.fromLogin);
|
entry.setFromLogin(block.fromLogin);
|
||||||
entry.setToLogin(block.toLogin);
|
entry.setToLogin(block.toLogin);
|
||||||
entry.setTimeMs(block.timeMs);
|
entry.setTimeMs(block.timeMs);
|
||||||
entry.setNonce(block.nonce);
|
entry.setNonce(block.nonce);
|
||||||
entry.setMessageType(block.messageType);
|
entry.setMessageType(block.messageType);
|
||||||
entry.setRevisionTimeMs(block.revisionTimeMs);
|
entry.setRevisionTimeMs(block.revisionTimeMs);
|
||||||
|
entry.setReencryptedAtMs(block.reencryptedAtMs);
|
||||||
entry.setRawBlock(block.rawPacket);
|
entry.setRawBlock(block.rawPacket);
|
||||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||||
entry.setSourceApi(sourceApi);
|
entry.setSourceApi(sourceApi);
|
||||||
entry.setOriginSessionId(originSessionId);
|
entry.setOriginSessionId(originSessionId);
|
||||||
|
|
||||||
if (block.messageType == SignedMessageBlock.TYPE_READ_INCOMING
|
if (block.isReadReceiptType()) {
|
||||||
|| block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
ReadReceiptPayload ref = block.readReceiptBody;
|
||||||
ReadReceiptPayload ref = ReadReceiptPayload.parse(block.payloadBytes);
|
|
||||||
entry.setReceiptRefBaseKey(ref.refBaseKey());
|
entry.setReceiptRefBaseKey(ref.refBaseKey());
|
||||||
entry.setReceiptRefType(ref.refType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String previewTextForPush(SignedMessageBlock block) {
|
static String previewTextForPush(SignedMessageBlock block) {
|
||||||
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
|
return "";
|
||||||
return "";
|
}
|
||||||
|
|
||||||
|
private static String primaryTargetLogin(SignedMessageBlock block) {
|
||||||
|
if (block.messageType == SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||||
|
|| block.messageType == SignedMessageBlock.TYPE_READ_INCOMING) {
|
||||||
|
return block.toLogin;
|
||||||
}
|
}
|
||||||
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
|
if (block.messageType == SignedMessageBlock.TYPE_OUTGOING_COPY
|
||||||
|
|| block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
||||||
|
return block.fromLogin;
|
||||||
|
}
|
||||||
|
return block.toLogin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,43 +13,58 @@ import shine.db.dao.SignedMessagesV2DAO;
|
|||||||
import shine.db.entities.ActiveSessionEntry;
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
public final class SignedMessagesRealtime {
|
public final class SignedMessagesRealtime {
|
||||||
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final ScheduledExecutorService BACKLOG_EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||||
|
@Override
|
||||||
|
public Thread newThread(Runnable r) {
|
||||||
|
Thread thread = new Thread(r, "signed-messages-backlog");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
private SignedMessagesRealtime() {}
|
private SignedMessagesRealtime() {}
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
static DeliveryCounters deliverToRelevantSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
||||||
return deliverToTargetSessions(message, block, null);
|
return deliverToRelevantSessions(message, block, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(
|
static DeliveryCounters deliverToRelevantSessions(
|
||||||
SignedMessageV2Entry message,
|
SignedMessageV2Entry message,
|
||||||
SignedMessageBlock block,
|
SignedMessageBlock block,
|
||||||
String excludeSessionId
|
String excludeSessionId
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
DeliveryCounters counters = new DeliveryCounters();
|
DeliveryCounters counters = new DeliveryCounters();
|
||||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(message.getTargetLogin());
|
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
for (ActiveSessionEntry s : sessions) {
|
for (String targetLogin : targetLoginsForMessage(message)) {
|
||||||
String sessionId = s.getSessionId();
|
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
||||||
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
for (ActiveSessionEntry s : sessions) {
|
||||||
continue;
|
String sessionId = s.getSessionId();
|
||||||
}
|
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||||
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
continue;
|
||||||
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, message, false);
|
}
|
||||||
if (deliveredOnline) {
|
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
||||||
counters.wsDelivered++;
|
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
|
||||||
continue;
|
if (deliveredOnline) {
|
||||||
}
|
counters.wsDelivered++;
|
||||||
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT
|
continue;
|
||||||
&& block != null
|
}
|
||||||
&& block.revisionTimeMs == 0
|
if (shouldPushNewIncomingMessage(targetLogin, message, block)) {
|
||||||
&& !block.isDeletedContent()) {
|
boolean pushed = pushNewMessageNotification(s, message);
|
||||||
boolean pushed = pushNewMessageNotification(s, message, block);
|
if (pushed) counters.pushDelivered++;
|
||||||
if (pushed) counters.pushDelivered++;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return counters;
|
return counters;
|
||||||
@@ -60,19 +75,36 @@ public final class SignedMessagesRealtime {
|
|||||||
String login = ctx.getLogin();
|
String login = ctx.getLogin();
|
||||||
String sessionId = ctx.getSessionId();
|
String sessionId = ctx.getSessionId();
|
||||||
if (isBlank(login) || isBlank(sessionId)) return;
|
if (isBlank(login) || isBlank(sessionId)) return;
|
||||||
|
dispatchPendingForSession(login, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void dispatchPendingForSessionAsync(ConnectionContext ctx, long delayMs) {
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser()) return;
|
||||||
|
String login = safeLower(ctx.getLogin());
|
||||||
|
String sessionId = String.valueOf(ctx.getSessionId() == null ? "" : ctx.getSessionId()).trim();
|
||||||
|
if (isBlank(login) || isBlank(sessionId)) return;
|
||||||
|
long safeDelayMs = Math.max(0L, delayMs);
|
||||||
|
BACKLOG_EXECUTOR.schedule(() -> dispatchPendingForSession(login, sessionId), safeDelayMs, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void dispatchPendingForSession(String login, String sessionId) {
|
||||||
try {
|
try {
|
||||||
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
||||||
.listPendingForSession(login, sessionId);
|
.listPendingForSession(login, sessionId);
|
||||||
for (SignedMessageV2Entry e : pending) {
|
for (SignedMessageV2Entry e : pending) {
|
||||||
sendEventToSessionIfOnline(sessionId, e, true);
|
sendEventToSessionIfOnline(sessionId, login, e, true);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to dispatch pending messages for sessionId={}", sessionId, e);
|
log.warn("Failed to dispatch pending messages for sessionId={}", sessionId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean sendEventToSessionIfOnline(String sessionId, SignedMessageV2Entry message, boolean backlog) {
|
private static boolean sendEventToSessionIfOnline(
|
||||||
|
String sessionId,
|
||||||
|
String actualTargetLogin,
|
||||||
|
SignedMessageV2Entry message,
|
||||||
|
boolean backlog
|
||||||
|
) {
|
||||||
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
|
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
|
||||||
if (targetCtx == null) return false;
|
if (targetCtx == null) return false;
|
||||||
|
|
||||||
@@ -82,7 +114,7 @@ public final class SignedMessagesRealtime {
|
|||||||
payload.put("baseKey", message.getBaseKey());
|
payload.put("baseKey", message.getBaseKey());
|
||||||
payload.put("fromLogin", message.getFromLogin());
|
payload.put("fromLogin", message.getFromLogin());
|
||||||
payload.put("toLogin", message.getToLogin());
|
payload.put("toLogin", message.getToLogin());
|
||||||
payload.put("targetLogin", message.getTargetLogin());
|
payload.put("targetLogin", actualTargetLogin);
|
||||||
payload.put("messageType", message.getMessageType());
|
payload.put("messageType", message.getMessageType());
|
||||||
payload.put("timeMs", message.getTimeMs());
|
payload.put("timeMs", message.getTimeMs());
|
||||||
payload.put("nonce", message.getNonce());
|
payload.put("nonce", message.getNonce());
|
||||||
@@ -91,27 +123,23 @@ public final class SignedMessagesRealtime {
|
|||||||
if (message.getReceiptRefBaseKey() != null) {
|
if (message.getReceiptRefBaseKey() != null) {
|
||||||
payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey());
|
payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey());
|
||||||
}
|
}
|
||||||
if (message.getReceiptRefType() != null) {
|
|
||||||
payload.put("receiptRefType", message.getReceiptRefType());
|
|
||||||
}
|
|
||||||
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean pushNewMessageNotification(
|
private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageV2Entry message, SignedMessageBlock block) {
|
||||||
ActiveSessionEntry session,
|
if (block == null) return false;
|
||||||
SignedMessageV2Entry message,
|
if (message.getMessageType() != SignedMessageBlock.TYPE_INCOMING_TEXT) return false;
|
||||||
SignedMessageBlock block
|
if (!targetLogin.equalsIgnoreCase(message.getToLogin())) return false;
|
||||||
) {
|
return block.revisionTimeMs == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
|
||||||
try {
|
try {
|
||||||
if (session == null) return false;
|
if (session == null) return false;
|
||||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim();
|
String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + ".";
|
||||||
if (preview.length() > 80) preview = preview.substring(0, 80) + "...";
|
|
||||||
String text = preview.isBlank()
|
|
||||||
? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."
|
|
||||||
: preview;
|
|
||||||
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
||||||
return WebPushSender.sendBase64Payload(
|
return WebPushSender.sendBase64Payload(
|
||||||
session.getPushEndpoint(),
|
session.getPushEndpoint(),
|
||||||
@@ -124,6 +152,28 @@ public final class SignedMessagesRealtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<String> targetLoginsForMessage(SignedMessageV2Entry message) {
|
||||||
|
Set<String> out = new LinkedHashSet<>();
|
||||||
|
int type = message.getMessageType();
|
||||||
|
if (type == SignedMessageBlock.TYPE_INCOMING_TEXT || type == SignedMessageBlock.TYPE_READ_INCOMING) {
|
||||||
|
out.add(safeLower(message.getToLogin()));
|
||||||
|
} else if (type == SignedMessageBlock.TYPE_OUTGOING_COPY || type == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
||||||
|
out.add(safeLower(message.getFromLogin()));
|
||||||
|
} else if (type >= SignedMessageBlock.TYPE_MESSAGE_DELETED_BY_SENDER
|
||||||
|
&& type <= SignedMessageBlock.TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
|
||||||
|
out.add(safeLower(message.getFromLogin()));
|
||||||
|
out.add(safeLower(message.getToLogin()));
|
||||||
|
} else if (!isBlank(message.getTargetLogin())) {
|
||||||
|
out.add(safeLower(message.getTargetLogin()));
|
||||||
|
}
|
||||||
|
out.remove("");
|
||||||
|
return new ArrayList<>(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeLower(String s) {
|
||||||
|
return s == null ? "" : s.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
private static String jsonEscape(String s) {
|
private static String jsonEscape(String s) {
|
||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
StringBuilder out = new StringBuilder();
|
StringBuilder out = new StringBuilder();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_DeleteConversation_Request extends Net_Request {
|
||||||
|
private String blobB64;
|
||||||
|
|
||||||
|
public String getBlobB64() { return blobB64; }
|
||||||
|
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_DeleteConversation_Response extends Net_Response {
|
||||||
|
private String messageKey;
|
||||||
|
private String baseKey;
|
||||||
|
private int deliveredWsSessions;
|
||||||
|
private int deliveredWebPushSessions;
|
||||||
|
|
||||||
|
public String getMessageKey() { return messageKey; }
|
||||||
|
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||||
|
public String getBaseKey() { return baseKey; }
|
||||||
|
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
|
||||||
|
public int getDeliveredWsSessions() { return deliveredWsSessions; }
|
||||||
|
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
|
||||||
|
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
|
||||||
|
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_DeleteMessage_Request extends Net_Request {
|
||||||
|
private String blobB64;
|
||||||
|
|
||||||
|
public String getBlobB64() { return blobB64; }
|
||||||
|
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_DeleteMessage_Response extends Net_Response {
|
||||||
|
private String messageKey;
|
||||||
|
private String baseKey;
|
||||||
|
private int deliveredWsSessions;
|
||||||
|
private int deliveredWebPushSessions;
|
||||||
|
|
||||||
|
public String getMessageKey() { return messageKey; }
|
||||||
|
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||||
|
public String getBaseKey() { return baseKey; }
|
||||||
|
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
|
||||||
|
public int getDeliveredWsSessions() { return deliveredWsSessions; }
|
||||||
|
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
|
||||||
|
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
|
||||||
|
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_SendSignal_Request extends Net_Request {
|
||||||
|
private String toLogin;
|
||||||
|
private String targetMode;
|
||||||
|
private String targetSessionId;
|
||||||
|
private String signalType;
|
||||||
|
private String signalRequestId;
|
||||||
|
private String data;
|
||||||
|
private Long timeMs;
|
||||||
|
private String sessionSignatureB64;
|
||||||
|
private String clientSignatureB64;
|
||||||
|
|
||||||
|
public String getToLogin() {
|
||||||
|
return toLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToLogin(String toLogin) {
|
||||||
|
this.toLogin = toLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTargetMode() {
|
||||||
|
return targetMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTargetMode(String targetMode) {
|
||||||
|
this.targetMode = targetMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTargetSessionId() {
|
||||||
|
return targetSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTargetSessionId(String targetSessionId) {
|
||||||
|
this.targetSessionId = targetSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSignalType() {
|
||||||
|
return signalType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSignalType(String signalType) {
|
||||||
|
this.signalType = signalType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSignalRequestId() {
|
||||||
|
return signalRequestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSignalRequestId(String signalRequestId) {
|
||||||
|
this.signalRequestId = signalRequestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getData() {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setData(String data) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTimeMs() {
|
||||||
|
return timeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeMs(Long timeMs) {
|
||||||
|
this.timeMs = timeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionSignatureB64() {
|
||||||
|
return sessionSignatureB64;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionSignatureB64(String sessionSignatureB64) {
|
||||||
|
this.sessionSignatureB64 = sessionSignatureB64;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getClientSignatureB64() {
|
||||||
|
return clientSignatureB64;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClientSignatureB64(String clientSignatureB64) {
|
||||||
|
this.clientSignatureB64 = clientSignatureB64;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_SendSignal_Response extends Net_Response {
|
||||||
|
private int deliveredCount;
|
||||||
|
private List<String> deliveredSessionIds = new ArrayList<>();
|
||||||
|
|
||||||
|
public int getDeliveredCount() {
|
||||||
|
return deliveredCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveredCount(int deliveredCount) {
|
||||||
|
this.deliveredCount = deliveredCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getDeliveredSessionIds() {
|
||||||
|
return deliveredSessionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
|
||||||
|
this.deliveredSessionIds = deliveredSessionIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.SyncServersDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.SyncServerEntry;
|
||||||
|
import utils.blockchain.BlockchainNameUtil;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.WebSocket;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers.
|
||||||
|
*/
|
||||||
|
public final class AddBlockSyncService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
|
.build();
|
||||||
|
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
|
||||||
|
1,
|
||||||
|
Math.max(2, Runtime.getRuntime().availableProcessors()),
|
||||||
|
60L,
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
new LinkedBlockingQueue<>(10_000),
|
||||||
|
new ThreadFactory() {
|
||||||
|
private final AtomicLong n = new AtomicLong(1);
|
||||||
|
@Override
|
||||||
|
public Thread newThread(Runnable r) {
|
||||||
|
Thread t = new Thread(r, "sync-addblock-" + n.getAndIncrement());
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new ThreadPoolExecutor.DiscardPolicy()
|
||||||
|
);
|
||||||
|
|
||||||
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance();
|
||||||
|
|
||||||
|
public void replicateAsync(String blockchainName, int blockNumber) {
|
||||||
|
EXECUTOR.execute(() -> {
|
||||||
|
try {
|
||||||
|
replicate(blockchainName, blockNumber);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock sync failed unexpectedly (blockchainName={}, blockNumber={})",
|
||||||
|
blockchainName, blockNumber, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replicate(String blockchainName, int blockNumber) throws Exception {
|
||||||
|
String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName));
|
||||||
|
if (ownerLogin == null) {
|
||||||
|
log.warn("AddBlock sync skipped: cannot derive owner login from blockchainName={}", blockchainName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SyncServerEntry> partners = syncServersDAO.listAll();
|
||||||
|
if (partners.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockEntry currentBlock = blocksDAO.getByNumber(blockchainName, blockNumber);
|
||||||
|
if (currentBlock == null || currentBlock.getBlockBytes() == null) {
|
||||||
|
log.warn("AddBlock sync skipped: block not found in DB (blockchainName={}, blockNumber={})",
|
||||||
|
blockchainName, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (SyncServerEntry partner : partners) {
|
||||||
|
if (partner == null) continue;
|
||||||
|
String partnerLogin = normalize(partner.getLogin());
|
||||||
|
if (partnerLogin == null) continue;
|
||||||
|
if (partnerLogin.equals(ownerLogin)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
replicateToPartner(partner, blockchainName, blockNumber, currentBlock);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("AddBlock sync aborted for partner login={} blockchainName={} blockNumber={} reason={}",
|
||||||
|
partnerLogin, blockchainName, blockNumber, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception {
|
||||||
|
String wsUrl = buildWsUrl(partner.getServerAddress());
|
||||||
|
if (wsUrl == null) {
|
||||||
|
log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}",
|
||||||
|
partner.getLogin(), partner.getServerAddress());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock);
|
||||||
|
if (firstTry.ok()) {
|
||||||
|
log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}",
|
||||||
|
partner.getLogin(), blockchainName, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstTry.needsBackfill()) {
|
||||||
|
log.warn("AddBlock sync failed without backfill: partner={} blockchainName={} blockNumber={} code={}",
|
||||||
|
partner.getLogin(), blockchainName, blockNumber, firstTry.code());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int remoteLast = firstTry.serverLastGlobalNumber();
|
||||||
|
int fromBlockNumber = remoteLast + 1;
|
||||||
|
if (fromBlockNumber > blockNumber) {
|
||||||
|
log.warn("AddBlock sync inconsistent backfill window: partner={} blockchainName={} remoteLast={} target={}",
|
||||||
|
partner.getLogin(), blockchainName, remoteLast, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BlockEntry> missingBlocks = blocksDAO.listRangeByNumber(blockchainName, fromBlockNumber, blockNumber);
|
||||||
|
if (missingBlocks.isEmpty()) {
|
||||||
|
log.warn("AddBlock sync backfill failed: local range empty partner={} blockchainName={} from={} to={}",
|
||||||
|
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (BlockEntry blockEntry : missingBlocks) {
|
||||||
|
AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry);
|
||||||
|
if (!backfillResult.ok()) {
|
||||||
|
log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}",
|
||||||
|
partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("AddBlock sync backfill ok: partner={} blockchainName={} from={} to={}",
|
||||||
|
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
|
JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry);
|
||||||
|
int status = response.path("status").asInt(500);
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
return AddBlockPushResult.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
String code = textOrEmpty(response, "code");
|
||||||
|
if (code.isBlank()) {
|
||||||
|
code = textOrEmpty(response, "error");
|
||||||
|
}
|
||||||
|
JsonNode payload = response.path("payload");
|
||||||
|
int serverLastGlobalNumber = payload.path("serverLastGlobalNumber").asInt(Integer.MIN_VALUE);
|
||||||
|
String serverLastGlobalHash = payload.path("serverLastGlobalHash").asText("");
|
||||||
|
|
||||||
|
return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
|
CompletableFuture<String> responseFuture = new CompletableFuture<>();
|
||||||
|
CountDownLatch openLatch = new CountDownLatch(1);
|
||||||
|
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
|
||||||
|
|
||||||
|
WebSocket webSocket = HTTP.newWebSocketBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
|
.buildAsync(URI.create(wsUrl), listener)
|
||||||
|
.get(8, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
if (!openLatch.await(8, TimeUnit.SECONDS)) {
|
||||||
|
tryAbort(webSocket);
|
||||||
|
throw new TimeoutException("WS open timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
String requestId = "sync-" + UUID.randomUUID();
|
||||||
|
String json = buildAddBlockJson(requestId, blockchainName, blockEntry);
|
||||||
|
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
|
||||||
|
tryAbort(webSocket);
|
||||||
|
return MAPPER.readTree(responseJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
|
String prevHashHex = blockEntry.getBlockNumber() <= 0
|
||||||
|
? ""
|
||||||
|
: toHex(extractPrevHash32(blockEntry.getBlockBytes()));
|
||||||
|
String blockBytesB64 = Base64Ws.encode(blockEntry.getBlockBytes());
|
||||||
|
|
||||||
|
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
|
||||||
|
String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex);
|
||||||
|
String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64);
|
||||||
|
String safeRequestId = MAPPER.writeValueAsString(requestId);
|
||||||
|
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op":"AddBlock",
|
||||||
|
"requestId":%s,
|
||||||
|
"payload":{
|
||||||
|
"blockchainName":%s,
|
||||||
|
"blockNumber":%d,
|
||||||
|
"prevBlockHash":%s,
|
||||||
|
"blockBytesB64":%s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] extractPrevHash32(byte[] blockBytes) {
|
||||||
|
if (blockBytes == null || blockBytes.length < 44) {
|
||||||
|
return new byte[32];
|
||||||
|
}
|
||||||
|
byte[] out = new byte[32];
|
||||||
|
System.arraycopy(blockBytes, 12, out, 0, 32);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textOrEmpty(JsonNode node, String field) {
|
||||||
|
return node == null ? "" : node.path(field).asText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildWsUrl(String serverAddressRaw) {
|
||||||
|
String host = normalizeHostLike(serverAddressRaw);
|
||||||
|
if (host == null) return null;
|
||||||
|
return "wss://" + host + "/ws";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeHostLike(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String raw = value.trim();
|
||||||
|
if (raw.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
|
||||||
|
URI uri = URI.create(withScheme);
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (host == null || host.isBlank()) return null;
|
||||||
|
return host.trim().toLowerCase(Locale.ROOT);
|
||||||
|
} catch (Exception e) {
|
||||||
|
String cleaned = raw
|
||||||
|
.replaceFirst("^[a-zA-Z]+://", "")
|
||||||
|
.replaceFirst("/.*$", "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(Locale.ROOT);
|
||||||
|
return cleaned.isEmpty() ? null : cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex(byte[] bytes) {
|
||||||
|
if (bytes == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) {
|
||||||
|
sb.append(Character.forDigit((b >>> 4) & 0xF, 16));
|
||||||
|
sb.append(Character.forDigit(b & 0xF, 16));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void tryAbort(WebSocket webSocket) {
|
||||||
|
try {
|
||||||
|
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
webSocket.abort();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AddBlockPushResult(
|
||||||
|
boolean ok,
|
||||||
|
int status,
|
||||||
|
String code,
|
||||||
|
int serverLastGlobalNumber,
|
||||||
|
String serverLastGlobalHash
|
||||||
|
) {
|
||||||
|
static AddBlockPushResult success() {
|
||||||
|
return new AddBlockPushResult(true, 200, "", Integer.MIN_VALUE, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean needsBackfill() {
|
||||||
|
return !ok && ("bad_prev_hash".equalsIgnoreCase(code) || "bad_block_number".equalsIgnoreCase(code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SyncWsListener implements WebSocket.Listener {
|
||||||
|
private final CompletableFuture<String> responseFuture;
|
||||||
|
private final CountDownLatch openLatch;
|
||||||
|
private final StringBuilder textBuffer = new StringBuilder();
|
||||||
|
|
||||||
|
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
|
||||||
|
this.responseFuture = responseFuture;
|
||||||
|
this.openLatch = openLatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(WebSocket webSocket) {
|
||||||
|
openLatch.countDown();
|
||||||
|
webSocket.request(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||||
|
textBuffer.append(data);
|
||||||
|
if (last && !responseFuture.isDone()) {
|
||||||
|
responseFuture.complete(textBuffer.toString());
|
||||||
|
}
|
||||||
|
webSocket.request(1);
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
|
||||||
|
webSocket.request(1);
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||||
|
if (!responseFuture.isDone()) {
|
||||||
|
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(WebSocket webSocket, Throwable error) {
|
||||||
|
if (!responseFuture.isDone()) {
|
||||||
|
responseFuture.completeExceptionally(error);
|
||||||
|
}
|
||||||
|
openLatch.countDown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import utils.files.FileStoreUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory guard для цепочек, которые сейчас находятся в полном resync.
|
||||||
|
*
|
||||||
|
* Задача guard-а:
|
||||||
|
* - не давать обычному AddBlock писать в цепочку, пока она пересобирается;
|
||||||
|
* - позволять внутреннему resync-потоку безопасно вызывать тот же AddBlock-путь
|
||||||
|
* через thread-local bypass.
|
||||||
|
*/
|
||||||
|
public final class BlockchainResyncGuard {
|
||||||
|
|
||||||
|
private static final Set<String> ACTIVE = ConcurrentHashMap.newKeySet();
|
||||||
|
private static final ThreadLocal<Set<String>> BYPASS = ThreadLocal.withInitial(HashSet::new);
|
||||||
|
|
||||||
|
private BlockchainResyncGuard() {}
|
||||||
|
|
||||||
|
public static boolean tryBegin(String blockchainName) {
|
||||||
|
String key = normalize(blockchainName);
|
||||||
|
if (key == null) return false;
|
||||||
|
return ACTIVE.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void end(String blockchainName) {
|
||||||
|
String key = normalize(blockchainName);
|
||||||
|
if (key == null) return;
|
||||||
|
ACTIVE.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isBlockedForExternalAddBlock(String blockchainName) {
|
||||||
|
String key = normalize(blockchainName);
|
||||||
|
if (key == null) return false;
|
||||||
|
if (isBypassed(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ACTIVE.contains(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return FileStoreUtil.getInstance().existsBlockchainResyncMarker(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> T withBypass(String blockchainName, ThrowingSupplier<T> supplier) throws Exception {
|
||||||
|
String key = normalize(blockchainName);
|
||||||
|
if (key == null) {
|
||||||
|
return supplier.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> bypassSet = BYPASS.get();
|
||||||
|
bypassSet.add(key);
|
||||||
|
try {
|
||||||
|
return supplier.get();
|
||||||
|
} finally {
|
||||||
|
bypassSet.remove(key);
|
||||||
|
if (bypassSet.isEmpty()) {
|
||||||
|
BYPASS.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void withBypass(String blockchainName, ThrowingRunnable runnable) throws Exception {
|
||||||
|
withBypass(blockchainName, () -> {
|
||||||
|
runnable.run();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBypassed(String blockchainName) {
|
||||||
|
return BYPASS.get().contains(blockchainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ThrowingSupplier<T> {
|
||||||
|
T get() throws Exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class DmFederationService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DmFederationService.class);
|
||||||
|
private static final String CONFIG_KEY = "server.SHiNE.login";
|
||||||
|
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
|
||||||
|
|
||||||
|
private DmFederationService() {}
|
||||||
|
|
||||||
|
public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) {
|
||||||
|
try {
|
||||||
|
Map<String, SolanaUserPdaImportService.ParsedServerRoute> senderRoutes =
|
||||||
|
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin));
|
||||||
|
Map<String, SolanaUserPdaImportService.ParsedServerRoute> recipientRoutes =
|
||||||
|
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin));
|
||||||
|
|
||||||
|
String ownServerLogin = ownServerLogin();
|
||||||
|
for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) {
|
||||||
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
|
REMOTE.sendMessagePair(route.serverAddress(), incomingBlobB64, outgoingBlobB64);
|
||||||
|
}
|
||||||
|
for (SolanaUserPdaImportService.ParsedServerRoute route : recipientRoutes.values()) {
|
||||||
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
|
if (senderRoutes.containsKey(route.login())) continue;
|
||||||
|
REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) {
|
||||||
|
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void fanOutDeleteConversation(String fromLogin, String toLogin, int messageType, String blobB64) {
|
||||||
|
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) {
|
||||||
|
try {
|
||||||
|
Map<String, SolanaUserPdaImportService.ParsedServerRoute> routes = routesByLogin(
|
||||||
|
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin),
|
||||||
|
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)
|
||||||
|
);
|
||||||
|
String ownServerLogin = ownServerLogin();
|
||||||
|
for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) {
|
||||||
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
|
if (oneMessageDelete) {
|
||||||
|
REMOTE.deleteMessage(route.serverAddress(), blobB64);
|
||||||
|
} else {
|
||||||
|
REMOTE.deleteConversation(route.serverAddress(), blobB64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("DM federation delete fan-out failed: from={} to={} type={}",
|
||||||
|
fromLogin, toLogin, messageType, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SafeVarargs
|
||||||
|
private static Map<String, SolanaUserPdaImportService.ParsedServerRoute> routesByLogin(
|
||||||
|
List<SolanaUserPdaImportService.ParsedServerRoute>... routeLists
|
||||||
|
) {
|
||||||
|
Map<String, SolanaUserPdaImportService.ParsedServerRoute> out = new LinkedHashMap<>();
|
||||||
|
for (List<SolanaUserPdaImportService.ParsedServerRoute> routeList : routeLists) {
|
||||||
|
for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) {
|
||||||
|
if (route == null) continue;
|
||||||
|
String login = normalize(route.login());
|
||||||
|
String address = route.serverAddress() == null ? "" : route.serverAddress().trim();
|
||||||
|
if (login == null || address.isBlank()) continue;
|
||||||
|
out.putIfAbsent(login, route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) {
|
||||||
|
return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ownServerLogin() {
|
||||||
|
return normalize(AppConfig.getInstance().getParam(CONFIG_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim().toLowerCase();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
}
|
||||||