Compare commits

..
Author SHA256 Message Date
AidarKC 1be1d56599 Добавить импорт архивов с доверенных серверов 2026-09-12 16:59:35 +03:00
AidarKC 2df0a78eb2 мелкое Изменения формулировки в документах 2026-09-12 16:49:47 +03:00
AidarKC c3253dceac Архивация в Arweave 2026-09-12 10:17:23 +03:00
AidarKC ef068eca81 Доработать вложенные файлы и архив проекта 2026-09-11 14:33:21 +03:00
AidarKC f9ae811702 Первая версия вложенных файлов работает 2026-09-11 02:38:02 +03:00
AidarKC ea0098d705 Доработать граф связей 2026-09-11 00:43:39 +03:00
AidarKC 4991a146d5 Связи - интересная версия 2026-09-10 19:12:11 +03:00
AidarKC 849250bfa8 Связи - интересно но надо дорабатывать 2026-09-10 14:52:20 +03:00
AidarKC d83f1d4cce Исправить скрытие разделителя новых DM 2026-09-10 12:41:33 +03:00
AidarKC 4c7e71f21f Исправить синхронизацию прочитанных каналов 2026-09-10 12:33:24 +03:00
AidarKC 107f85b818 Исправить обновление непрочитанных в UI 2026-09-10 12:18:39 +03:00
AidarKC 3a466b4c38 Исправить подпись состояния чтения канала 2026-09-10 11:08:30 +03:00
AidarKC 08663fa339 Исправить статус сохранения чтения канала 2026-09-09 19:06:43 +03:00
AidarKC 8e86872aa7 Добавить состояние чтения каналов 2026-09-09 18:59:54 +03:00
AidarKC 2d059e9ff5 Добавить просмотр лайков сообщений канала 2026-09-09 17:01:13 +03:00
AidarKC 9f77c54955 Обновить каналы и перенести черновики 2026-09-09 15:17:31 +03:00
AidarKC f827c3e493 Исправить повторную авторизацию при входе 2026-09-09 10:23:36 +03:00
AidarKC ae2f2fac41 Доработать UI и счётчики 2026-09-08 22:33:06 +04:00
AidarKC 1f70d36e74 Убрать нижнюю кнопку в каналах 2026-09-08 17:54:34 +04:00
tram 7d27bfdcaf Обновить UI профиля и навигации 2026-09-08 16:13:04 +03:00
tram 023d61a1e9 Обновить профиль пользователя 2026-09-03 15:37:28 +03:00
AidarKC 85d90a7f95 Исправить синхронизацию прочтения каналов 2026-09-03 14:36:44 +04:00
AidarKC e0295eebde Добавить смену профилей, пока не работает 2026-09-03 12:51:46 +04:00
AidarKC ebc9143593 Добавить смену профилей, но она пока не работает 2026-09-03 12:37:23 +04:00
AidarKC e7c8fd748c Обновить DM, сеть и оффлайн-бандл 2026-09-03 12:26:50 +04:00
AidarKC aff601f61a Display new notification count 2026-09-02 19:20:04 +04:00
tram 0c5089fa79 Исправить профиль и список каналов 2026-09-02 16:32:22 +03:00
tram e5556f3706 Вернуть профиль пользователя 2026-09-02 15:31:04 +03:00
tram 09c7f8541e Вернуть профильные элементы 2026-09-02 15:02:48 +03:00
tram 8eb2ef438c Восстановить профиль пользователя 2026-09-02 14:53:11 +03:00
tram 34abe14151 Доработать профиль пользователя 2026-09-02 14:42:58 +03:00
AidarKC 90e37c198f Adjust DM contact card spacing 2026-09-02 14:58:17 +04:00
AidarKC 1373283947 Refine direct message profile data 2026-09-02 14:56:33 +04:00
AidarKC 065616a18b Update DM and profile handling 2026-09-02 14:24:44 +04:00
AidarKC 53cd55a2a0 Fix DM name fallbacks and chat header 2026-09-02 14:23:42 +04:00
AidarKC 079be37fff Починить ListUserProfileChannels для PostgreSQL 2026-09-02 13:09:05 +04:00
AidarKC 806a8c57d4 Merge remote-tracking branch 'origin/main' 2026-09-02 12:52:54 +04:00
tram 797e769cc3 Merge branch 'ui-artem'
# Conflicts:
#	shine-UI/styles/components.css
2026-09-02 11:45:49 +03:00
tram 822a72e246 Доработать профиль пользователя 2026-09-02 11:43:47 +03:00
AidarKC 58b564b385 Промежуточная версия: UI еще доделать и мелкие ошибки 2026-09-01 19:07:59 +04:00
AidarKC 7aa9e2f6a4 Merge branch 'Доделать-связи-и-профиль' 2026-09-01 19:07:41 +04:00
AidarKC f645686d87 Revert "Исправить отображение имени в личных сообщениях"
This reverts commit 6fcaa5d3d4.
2026-09-01 19:00:05 +04:00
AidarKC 6fcaa5d3d4 Исправить отображение имени в личных сообщениях 2026-09-01 18:55:20 +04:00
AidarKC 5d19e17a0b Исправить открытие личных сообщений 2026-09-01 18:50:18 +04:00
AidarKC 52a9f1ac1d Обновить UI сети и профиль 2026-09-01 18:45:32 +04:00
AidarKC 3dec5e3225 Merge branch 'main' into Доделать-связи-и-профиль 2026-09-01 18:35:21 +04:00
AidarKC 90ded1612a Merge remote-tracking branch 'origin/main' 2026-09-01 18:35:14 +04:00
tram 7ab9fb7c28 Обновить UI сети и серверные экраны 2026-09-01 17:23:31 +03:00
AidarKC 6f499c68e2 Merge branch 'main' into Доделать-связи-и-профиль 2026-09-01 18:19:47 +04:00
AidarKC 19beba8fd7 Обновить версии после merge в main 2026-09-01 18:17:42 +04:00
AidarKC f7e33802c8 Merge branch 'Доделать-связи-и-профиль' 2026-09-01 18:17:20 +04:00
AidarKC c03d94105c Доделать профиль, связи и серверные миграции 2026-09-01 18:14:55 +04:00
AidarKC 632d56737b UI: доработать профиль и связи 2026-09-01 15:24:39 +04:00
tram 8801b93973 Обновить UI сети и серверные экраны 2026-09-01 14:04:12 +03:00
AidarKC 390a2b988e Доделать связи и профиль 2026-09-01 14:59:42 +04:00
AidarKC a66d7a64e6 Исправить дублирование SVG_CHEVRON в личных сообщениях 2026-09-01 13:49:51 +04:00
AidarKC a8596d3350 Смерджить расширение функционала каналов в main 2026-09-01 13:14:18 +04:00
AidarKC 8a5dabee95 Переделал UI отображения связей 2026-09-01 12:57:04 +04:00
AidarKC da859468d3 Обновить стили списка каналов 2026-08-29 18:03:01 +04:00
AidarKC 3e4583bbe9 Обновить UI для меню и языка 2026-08-29 15:56:01 +04:00
AidarKC b47440dd72 Обновить UI и документы личных сообщений 2026-08-29 15:12:13 +04:00
AidarKC 357a05f7ec Улучшен вход пользователя в аккаунт 2026-08-28 16:51:14 +04:00
AidarKC f2fdb8195c Сделали кнопки стартовой страницы и настроек темнее
Перевёл общий синий акцент на более тёмный оттенок и применил его к кнопкам стартовой страницы и экранам настроек.

Проверено: deploy на t1 server/UI.
Не проверяли: ручной визуальный прогон в браузере и остальные экраны.
2026-08-28 15:07:43 +04:00
AidarKC 8a0275d962 Убрали старый sync и обновили bundle
Что сделано: вычистили неиспользуемый user-settings sync/DM sync хвост, сохранили сборку, обновили bundle.sh так, чтобы gradle-wrapper.jar всегда попадал в архив.

Проверено: compileJava и deploy на t2 (server + UI).
Не проверяли: полные интеграционные сценарии, ручные UI-флоу и продовый деплой.
2026-08-28 14:51:47 +04:00
AidarKC ef1fd9f579 Зафиксировали текущие изменения UI и документации 2026-08-28 12:55:28 +04:00
AidarKC f6b95d94bc Увеличили аватары в списках чатов и каналов 2026-08-26 12:39:49 +04:00
AidarKC 2a65e6d359 Стандартизировали размеры аватаров в UI 2026-08-26 12:33:18 +04:00
AidarKC 83f87ba22c Вернули логотип из исходного SVG 2026-08-26 12:05:42 +04:00
AidarKC 01321f5200 Добавили счётчик подписчиков в каналах и подписки пользователя 2026-08-26 11:11:46 +04:00
AidarKC 3a5851939e Починить межсерверную репликацию личных сообщений
Репликация личных сообщений между серверами теперь работает корректно.
2026-08-25 19:31:38 +04:00
AidarKC 7d929e626e Исправить определение клиентского DM-вызова 2026-08-25 19:30:44 +04:00
AidarKC d8e0c77951 Сохранить текущий снимок DM-синхронизации 2026-08-24 17:50:36 +04:00
AidarKC 78ddb8ffb6 Исправить рендер messages-list и поднять версию для main 2026-08-23 12:07:37 +04:00
AidarKC db37f35d09 Версии: поднять client и server для merge в main 2026-08-23 11:38:07 +04:00
AidarKC d8f5fe4fd0 Merge branch 'расширение-функционала-каналов' 2026-08-23 11:37:52 +04:00
AidarKC 015fade4c0 Смерджить main в ветку расширения функционала каналов 2026-08-22 20:16:16 +04:00
AidarKC da14b51e80 Исправить стартовый bootstrap DM 2026-08-22 20:13:14 +04:00
AidarKC 7a5ac01d1c UI: render DM inbox from dialogs 2026-08-22 18:47:14 +04:00
AidarKC 5e6d64e965 Split DM dialog migration to v11 2026-08-22 18:38:07 +04:00
AidarKC c425fa41aa UI: keep local shell updates 2026-08-22 18:34:40 +04:00
AidarKC 781157299f Server-side DM dialogs state 2026-08-22 18:31:32 +04:00
tram 0f30317bb4 Поднять бейдж канала и опустить время 2026-08-22 17:00:26 +03:00
tram 1f1bc0a7b9 Убрать меню из строк каналов 2026-08-22 16:51:37 +03:00
tram 60c8a6608a Вернуть channels-list к версии ui-artem 2026-08-22 16:47:12 +03:00
tram 86adaf8c6b Слить main с актуальными UI-изменениями 2026-08-22 16:36:47 +03:00
tram 731688d16e Merge ui-artem into main 2026-08-22 16:35:44 +03:00
tram 63b66c48d8 Доточить позиционирование UI 2026-08-22 16:35:41 +03:00
tram ea19e511c0 Доработать аватары и меню UI 2026-08-22 13:27:08 +03:00
tram f5e401cff8 Пересобрать верхний UI-бар 2026-08-22 12:34:07 +03:00
tram bc8c7f318b Обновить UI-кнопки и прокрутку 2026-08-22 11:28:12 +03:00
AidarKC 585750007d Merge remote-tracking branch 'origin/main' 2026-08-21 15:16:42 +04:00
AidarKC 6e80ac976a Merge branch 'расширение-функционала-каналов' 2026-08-21 15:16:13 +04:00
AidarKC c70f18fcf4 Remove bottom CTA from channel list 2026-08-21 15:12:36 +04:00
tram d3e6aa2be2 Добавить поддержку уведомлений 2026-08-21 14:11:47 +03:00
AidarKC b7a869c514 Make unread divider visible in channel view 2026-08-21 15:10:39 +04:00
tram 19362b950a Доработать UI уведомлений 2026-08-21 14:06:19 +03:00
AidarKC 0a4c31fb36 Refine unread marker and reset registration flow 2026-08-21 15:04:29 +04:00
AidarKC fef7694b48 Show unread count in channel list 2026-08-21 14:59:03 +04:00
AidarKC 60206e21df Accept user settings cursor writes on fallback 2026-08-21 14:52:45 +04:00
AidarKC b9b77c66ce Restore unread baseline and read tracking 2026-08-21 14:24:46 +04:00
AidarKC 745a0e39d7 Fix unread channel counts and badges 2026-08-21 13:45:49 +04:00
AidarKC 23bdadab56 Merge branch 'расширение-функционала-каналов' 2026-08-20 08:23:19 +04:00
AidarKC 47a4844ce8 Merge remote-tracking branch 'origin/main' into расширение-функционала-каналов 2026-08-20 08:14:52 +04:00
tram 8741be6cba Улучшить раскладку чата при клавиатуре 2026-08-20 07:12:46 +03:00
AidarKC 4656a03ea9 Исправить автоскролл и TDZ в канале 2026-08-19 20:38:03 +04:00
AidarKC 5763eb828e Исправить открытие канала и ключ настройки 2026-08-19 20:34:41 +04:00
AidarKC 6a5c20a165 Добавить user_settings и синхронизацию настроек 2026-08-19 19:45:09 +04:00
AidarKC fac166f186 Удалить legacy-стек прямых сообщений 2026-08-19 18:11:00 +04:00
AidarKC 091291bfa2 Синхронизировать локальный main с origin/main 2026-08-19 14:33:01 +04:00
AidarKC 288fde67e8 Merge ветки расширения функционала каналов в main 2026-08-19 14:31:03 +04:00
AidarKC ce30b77328 Синхронизировать регистр логинов и доработать UI каналов 2026-08-19 14:30:29 +04:00
AidarKC 8c4e404f23 Merge ветки расширения функционала каналов в main 2026-08-12 19:38:17 +04:00
AidarKC e8a4713f6e UI: свайпы вкладок и навигация тредов 2026-08-12 19:37:29 +04:00
AidarKC b4b23afc10 Доработать верхнее меню списка каналов 2026-08-12 17:12:57 +04:00
AidarKC 8f32e82d14 Сократить служебные теги SHiNE и сохранить обратную совместимость 2026-08-12 13:14:23 +04:00
AidarKC cf391bdd80 main: обновить версии до 1.6.0 и 1.5.0 2026-08-12 00:17:48 +04:00
AidarKC 7120d6a9db Merge branch 'расширение-функционала-каналов' into main 2026-08-12 00:17:34 +04:00
AidarKC 4d80ab639e Порядок навели с документацией ТУДУ 2026-08-11 20:20:10 +04:00
AidarKC 95bbd2852e Регистрация: пополнение в том же окне и тестовый top-up 2026-08-11 20:19:46 +04:00
AidarKC 4b0a934e51 Ограничил access servers до двух в UI и сервере 2026-08-11 17:21:07 +04:00
AidarKC ee5d86003b UI: уменьшение splash logo и правки diary preview 2026-08-11 17:03:46 +04:00
AidarKC bc11b60147 UI: правки клавиатуры и дневника 2026-08-11 16:26:31 +04:00
AidarKC 801a4697ea UI: общий верхний и нижний тулбар 2026-08-11 15:22:10 +04:00
AidarKC 628a970e6f Доработать UI звонков и нижний тулбар 2026-08-11 13:29:29 +04:00
AidarKC 566ea942dc Починить включение видео у принимающей стороны 2026-08-11 12:49:35 +04:00
AidarKC e047c2d54e Подготовить обычный звонок к позднему апгрейду в видео 2026-08-11 12:43:17 +04:00
AidarKC 3fdd3d22e2 Переименовать режимы видеозвонков в меню чата 2026-08-10 18:25:10 +04:00
AidarKC 08e0fa9206 Вернуть стабильный flow видеозвонка 2026-08-10 18:20:35 +04:00
AidarKC ed9e6ce676 Исправить отображение версий в дневнике и тредах 2026-08-10 17:55:27 +04:00
AidarKC a7bf07130e Добавить режимы аудио и видеозвонка в чат 2026-08-10 15:22:43 +04:00
AidarKC 7c31662698 Зафиксировать текущую схему видеозвонка 2026-08-10 15:16:57 +04:00
AidarKC 15df06a0bc Оптимизировать видеозаглушку звонка 2026-08-10 15:02:35 +04:00
AidarKC 47b5f8db7b Починить двустороннее видео в звонках 2026-08-10 15:00:44 +04:00
AidarKC 0e763987aa Добавить базовую поддержку видеозвонка 2026-08-10 13:53:04 +04:00
AidarKC 37e8ea27d0 Доработать оглавление канала и отображение дневника 2026-08-10 03:05:23 +04:00
AidarKC 67d63256c2 Добавить дневник действий и статусы материалов 2026-08-10 01:34:17 +04:00
AidarKC ee185cf20a Добавить рейтинг и типы материалов в каналах 2026-08-10 01:16:10 +04:00
AidarKC 3552e05e4c UI: обновить экран канала и треда 2026-08-09 21:26:08 +04:00
AidarKC 735bb55b78 Убрать отправку DM по Enter 2026-08-09 21:02:44 +04:00
AidarKC 60f8a50a97 Подправить иконку звонка в чате 2026-08-09 20:57:04 +04:00
AidarKC ffd01814b0 Уточнить уведомления и карточку звонка 2026-08-09 20:54:52 +04:00
AidarKC c511910b8f Перевести звонки на SendSignal с client key 2026-08-09 20:09:54 +04:00
AidarKC 5c38f8f0d8 Deploy: обновить backup-схему и игнор архивов 2026-08-09 19:56:25 +04:00
AidarKC d512442325 Сервер: добавить новые TEXT типы и STATUS_ACTION 2026-08-09 18:59:59 +04:00
aidar 43f54c90d2 Улучшить окно создания канала и окно загрузки в Arweave
Улучшенно окно создания канала и окно загрузки в Arweave/Turbo, исправленны мелкие баги UI и маршрутов SHiNE.
2026-08-07 11:16:44 +00:00
AidarKC 57a99b478a UI: улучшить создание канала и окно загрузки 2026-08-07 15:15:56 +04:00
AidarKC 3f402fbde7 UI: обновить ссылки SHiNE и форму канала 2026-08-07 14:38:27 +04:00
AidarKC 98140c1f71 UI: создание канала и ошибки Solana 2026-08-07 14:20:18 +04:00
aidar f00de85e38 Merge pull request 'Обновить запуск UI и упростить регистрацию' (#1) from milana-ui-redesign into main
добавлен стартовый экран UI до первого успешного подключения;
упрощена форма регистрации и удалён временный режим промокодов;
обновлены FAQ и документация переноса экранов в Figma;
версия клиента увеличена до 1.5.1.
2026-08-07 09:45:46 +00:00
malvviiina b686b12c6a Обновить запуск UI и упростить регистрацию 2026-08-06 22:53:44 +03:00
AidarKC e4b903b13c вкладки в каналах 2026-08-06 22:45:15 +04:00
AidarKC 72f576a70e Версия UI 1.5.0 для merge в main 2026-08-06 22:44:11 +04:00
AidarKC c4f2c7be63 UI вложений и черновики форматов каналов 2026-08-06 12:39:33 +04:00
AidarKC 75b75fd223 Добавить Turbo-загрузку и превью для вложений 2026-08-04 19:09:54 +04:00
AidarKC 391b18a5ac Deploy: задать production VAPID keys для UI 2026-08-03 08:34:34 +04:00
AidarKC 8de0f8e87c UI: писать SHiNE в ссылках каналов 2026-08-01 10:12:11 +04:00
AidarKC adb0b76559 UI: запоминать Arweave-кошелёк загрузчика 2026-08-01 09:59:25 +04:00
AidarKC b6df25c02c UI: скрыть неактивные стрелки вложений 2026-08-01 09:47:09 +04:00
AidarKC 620186db1c Документация: уточнить AddBlock для вложений и профиля каналов 2026-08-01 09:43:51 +04:00
AidarKC 21fac8046e UI: сделать плитки каналов почти во всю ширину 2026-08-01 02:59:15 +04:00
AidarKC de7c35f587 UI: расширить плитки каналов 2026-08-01 02:52:25 +04:00
AidarKC a23c979045 UI: обрезать аватары каналов кругом 2026-08-01 02:42:11 +04:00
AidarKC 13da7287b1 UI: скрыть аватары из истории вложений 2026-08-01 02:40:08 +04:00
AidarKC e8398df9fc Каналы: исправить редактирование профиля 2026-08-01 02:29:11 +04:00
AidarKC 8303682bd7 Каналы: создавать профиль одним блоком 2026-08-01 02:19:29 +04:00
AidarKC 8125d1a124 Каналы: исправить загрузку meta-событий 2026-08-01 02:04:48 +04:00
AidarKC 110ff5e12e Каналы: добавить профиль через TEXT_CHANNEL_META 2026-08-01 01:57:41 +04:00
AidarKC 24afcba20a UI: скрыть действия у удалённых сообщений 2026-08-01 01:02:49 +04:00
AidarKC d5f41d12e8 UI: добавить просмотр данных блокчейна сообщения 2026-07-31 17:41:30 +04:00
AidarKC d640dd66e2 UI: улучшить превью и файлы вложений 2026-07-31 17:22:48 +04:00
AidarKC 05bd9f57d6 UI: закрепить панель журнала загрузок 2026-07-31 17:14:01 +04:00
AidarKC 60ab476ec7 UI: сделать плитки загрузок тоньше 2026-07-31 17:07:33 +04:00
AidarKC bcae285ab3 UI: поправить экран загрузки файлов 2026-07-31 16:59:04 +04:00
AidarKC cd67176b41 UI: уплотнить журнал загрузок Arweave 2026-07-31 13:50:17 +04:00
AidarKC 98eba54621 UI: добавить журнал загрузок Arweave 2026-07-31 13:13:58 +04:00
AidarKC c530627078 UI: добавить карусель вложений 2026-07-31 12:49:24 +04:00
AidarKC f961947e1e Документация и UI: уточнить формат вложений 2026-07-30 11:14:19 +04:00
AidarKC e9e6628b21 UI: добавить вложения Arweave в каналы 2026-07-30 11:04:50 +04:00
AidarKC c7684d6fe1 Merge migration-postgres into main 2026-07-30 09:48:13 +04:00
AidarKC 7d475ab403 Merge remote-tracking branch 'git2/main' into migration-postgres
# Conflicts:
#	VERSION.properties
#	deploy/scripts/production_server2_ui.sh
2026-07-30 09:47:28 +04:00
AidarKC 143adcbbe4 Добавить догоняющую синхронизацию DM между access-серверами 2026-07-29 13:59:40 +04:00
AidarKC ca8b6a33ba UI: показать пополнение при нехватке rent 2026-07-29 12:51:11 +04:00
AidarKC 2f73cdb0f9 UI: реальная смена серверов доступа 2026-07-29 12:08:02 +04:00
AidarKC d1e06d9ef1 UI: исправить модалку серверов доступа 2026-07-28 16:40:22 +04:00
AidarKC 0ed78e2202 UI и API: экран серверов доступа и фильтр SearchUsers 2026-07-28 16:20:29 +04:00
AidarKC 64818c586b Сервер: локальный роутинг и relay DM по access servers 2026-07-28 15:53:24 +04:00
AidarKC 578f5cad4a Перевести deploy и PostgreSQL схемы на актуальное состояние 2026-07-28 15:32:43 +04:00
AidarKC d2ff277b16 Деплой: добавить VAPID override для server2 2026-07-27 20:29:39 +04:00
AidarKC df4f9b3f8e Деплой: убрать root-owned app.log из systemd 2026-07-27 19:57:27 +04:00
AidarKC be7f53a9ae Сервер: убрать мертвый AddUser хвост 2026-07-27 19:42:17 +04:00
AidarKC 6ed91a105e Сервер: дочистить legacy-слои и доки 2026-07-27 19:29:10 +04:00
AidarKC 0db3c3af5a Сервер: переименовать слой current users 2026-07-27 19:16:04 +04:00
AidarKC 23748504e6 Сервер: удалить manual import и AddUser слой 2026-07-27 19:06:08 +04:00
AidarKC f74fecddd8 Сервер: читать пользователей напрямую из PDA current 2026-07-27 18:04:39 +04:00
AidarKC 618a30c2ab Сервер: дочистить PostgreSQL runtime и документацию 2026-07-27 18:01:15 +04:00
AidarKC b5116474c7 Сервер: разрешить replay старых числовых каналов 2026-07-26 16:57:04 +04:00
AidarKC 91e7239866 Сервер: удалить SQLite runtime и оставить только PostgreSQL 2026-07-25 22:09:22 +04:00
AidarKC 1dddb5fb3c Сервер: уточнить правила имён каналов и поиск треда 2026-07-25 21:16:15 +04:00
AidarKC 8ddf592ffc Сервер: починить поиск сообщения в GetMessageThread 2026-07-25 20:31:06 +04:00
AidarKC 59a1117ed3 Сервер: починить обновление server.version в jar 2026-07-25 03:33:18 +04:00
AidarKC 3bb6fa1e59 Сервер: отвязать PostgreSQL DM runtime от signed_messages_v2 2026-07-25 03:29:58 +04:00
AidarKC 5af98ac911 Сервер: починить PostgreSQL schema v1 для пустой Solana sync базы 2026-07-24 22:30:36 +04:00
AidarKC f197196c21 Сервер: успокоить AddBlock sync при уже догнанном партнере 2026-07-24 22:26:59 +04:00
AidarKC ce5595bc16 Сервер: починить восстановление chain-state из PostgreSQL sync-таблиц 2026-07-24 22:22:37 +04:00
AidarKC 1406111f22 Сервер: исправить PostgreSQL schema v1 для автозапуска 2026-07-24 21:31:20 +04:00
AidarKC bfdada6792 Сервер: исправить автоинициализацию PostgreSQL schema v1 2026-07-24 21:24:06 +04:00
AidarKC 5326ad85d8 Сервер: начать перенос runtime БД на PostgreSQL 2026-07-24 21:20:36 +04:00
AidarKC f9dd245481 Deploy: расширить relay-range coturn 2026-07-24 19:43:52 +04:00
AidarKC 20677a9090 UI: исправить hidden для кнопок звонка 2026-07-24 19:34:24 +04:00
AidarKC d54cb7507a UI: разделить входящий и активный звонок 2026-07-24 19:28:50 +04:00
AidarKC d6b313b582 UI: входящий звонок без лишних кнопок 2026-07-24 19:18:42 +04:00
AidarKC f31cdb413b UI: стадии и звук звонка 2026-07-24 19:12:18 +04:00
AidarKC 69faf55b2b UI: иконки управления звонком 2026-07-24 15:02:56 +04:00
AidarKC 4d9e42205f UI: сворачивание активного звонка 2026-07-24 15:00:01 +04:00
AidarKC 5df69d73c7 Встроить синхронизацию Solana users в сервер 2026-07-23 23:49:39 +04:00
AidarKC 4704f8485b UI: понятная ошибка при нехватке SOL для пополнения лимита 2026-07-23 22:49:16 +04:00
AidarKC 809c216a5c Deploy: обновить production Solana RPC 2026-07-22 20:12:36 +04:00
AidarKC 415a40e436 Версии: поднять main до 1.3.0 2026-07-22 20:12:06 +04:00
AidarKC d233f1d73f Merge branch 'регитсрация' into main 2026-07-22 20:11:30 +04:00
AidarKC 9a7019a2ce Документация: вынести правила коммитов и версий 2026-07-22 20:10:20 +04:00
AidarKC 07b1623c5e UI: уточнить шаг пополнения регистрации 2026-07-22 19:28:46 +04:00
AidarKC 681437acb1 UI: убрать голосовой ввод и связанные настройки 2026-07-22 19:18:56 +04:00
AidarKC a183a586cb UI: индикатор загрузки истории чата 2026-07-22 19:07:15 +04:00
AidarKC af62b51ef5 UI: правки личного чата и настроек 2026-07-22 18:59:09 +04:00
AidarKC f640451257 Доработать профильные кнопки и навигацию 2026-07-22 15:20:05 +04:00
AidarKC a56252c361 Finalize DM docs and queue routing 2026-07-22 15:13:13 +04:00
AidarKC 38141ea5c7 Refresh profile header action icons 2026-07-22 15:12:48 +04:00
AidarKC d24d1c178b Shorten public UI routes 2026-07-22 14:12:10 +04:00
AidarKC 93673e5786 Improve direct message history and SQLite resilience 2026-07-22 13:09:42 +04:00
AidarKC 2f3b1571e5 Упростить просмотр билетов второй и третьей очереди 2026-07-20 22:13:19 +04:00
AidarKC 65703f0fc4 Вернуть прежний логотип стартового экрана 2026-07-20 21:34:44 +04:00
AidarKC 4e674af4ed Убрать артефакты анимации логотипа 2026-07-20 21:23:15 +04:00
AidarKC f9c2d7c63a Оптимизировать логотип стартового экрана 2026-07-20 21:19:55 +04:00
AidarKC 968d85d738 Показывать очередь билетов mainnet на тестовом UI 2026-07-20 21:04:11 +04:00
AidarKC d230b61c0b Сделать backup production устойчивым к отсутствующим файлам 2026-07-20 20:00:29 +04:00
AidarKC 59047a8d0e Вынести продуктовый репозиторий из локальной обвязки агента 2026-07-20 18:49:28 +04:00
AidarKC 81493ac4be Очистить репозиторий перед переносом продукта 2026-07-20 18:46:56 +04:00
AidarKC cf33f9a3bd Merge remote-tracking branch 'origin/main'
# Conflicts:
#	VERSION.properties
2026-07-20 17:30:31 +04:00
AidarKC d2f65b169c Навести порядок в deploy и документации проекта 2026-07-20 17:28:35 +04:00
AidarKC fa1f7358b7 Смержить production-структуру документации
# Conflicts:
#	VERSION.properties
#	shine-UI/js/pages/registration-payment-view.js
#	shine-UI/styles/components.css
2026-07-20 11:30:00 +04:00
AidarKC 8208bb0b9d Привести документацию и TODO к production-структуре 2026-07-20 11:29:03 +04:00
AidarKC daa516babe Регистрация: ожидание подтверждения Solana 2026-07-17 18:31:05 +04:00
PixelandClaude Opus 4.8 6c2a9836eb chore: вернуть server.version=1.2.298 (ошибочно откатил при мерже) + client 1.2.330
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:49:55 +03:00
PixelandClaude Opus 4.8 bd3e6a56ca Merge origin/main (финал регистрации для мобильного UI)
Единственный конфликт — VERSION (взято продолжение нумерации: client 1.2.329).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:49:22 +03:00
PixelandClaude Opus 4.8 ea2ee1711c Эмодзи/мобильный чат: убрать загрузку с GitHub, клавиатура, тихий Script error
- Пикер: анимированные webp грузились с raw.githubusercontent.com (в репо их
  нет) — у пользователей с VPN/операторскими блокировками тап по эмодзи давал
  сетевую ошибку и «моргание»/подмену картинки. Теперь всегда локальные
  статичные превью, без внешних запросов и pointer capture; мёртвая константа
  GitHub-URL удалена. Возврат анимаций — только локальным паком.
- Чат (тач): открытие эмодзи-пикера прячет клавиатуру (blur), вставка эмодзи
  не фокусирует textarea на сенсорных устройствах — поле больше не
  перекрывается клавиатурой (на десктопе фокус как раньше).
- Глобальный обработчик ошибок: кросс-ориджин «Script error.» без файла/стека
  больше не показывается алертом (лог и captureClientError остаются) —
  устраняет пугающую плашку на Xiaomi/Safari.
- VERSION: client 1.2.327.

Проверено в превью: тап по эмодзи — 0 запросов к githubusercontent, 😈
вставляется ровно как 😈, картинка кнопки стабильна; фильтр алерта: Script
error. — тихо, реальная ошибка — алерт показывается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:48:22 +03:00
AidarKC 2890cdd457 Добавить экран проверки public Solana RPC 2026-07-16 20:33:48 +04:00
AidarKC e4dfb43b5e Переименовать server2 deploy-скрипт 2026-07-16 19:19:59 +04:00
AidarKC febdbc059e Доработать финал регистрации для мобильного UI 2026-07-16 19:03:54 +04:00
AidarKC 3926d561c0 Доработать финал регистрации для мобильного UI 2026-07-16 19:03:04 +04:00
PixelandClaude Opus 4.8 d6bc883520 Merge origin/main (+5: финал регистрации, автовход, порядок в deploy)
- register-view: принят вариант агента для видимости пароля — поле видно
  всегда, слова «12 слов» автоматически склеиваются в поле (проверено:
  оба режима, значение сохраняется при переключении).
- Остальное авто-слито (deploy-схема server2, AGENTS, entry-settings, state).
- VERSION: client 1.2.326, server 1.2.297.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:02:51 +03:00
PixelandClaude Opus 4.8 b2d20671cd Ассеты: сжать иконки бара и стекло орбов (2.4 МБ → 0.4 МБ)
Иконки нижнего бара рендерятся 27-44px, а весили 174 КБ - 1 МБ каждая
(icon_svyazi 1210x1210, 1 МБ) — из-за этого при первом заходе бар долго
оставался пустым. Ресайз до 256px (glass_overlay до 512px) + оптимизация
PNG, альфа сохранена: -84% веса, визуально на этих размерах без изменений.
VERSION: client 1.2.317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:58:29 +03:00
PixelandClaude Opus 4.8 a72e2e7014 Регистрация: вернуть поле ввода пароля (инверсия видимости passwordInputRow)
Условие показа строки ввода пароля было перевёрнуто (баг из доработки
регистрации в main): поле скрывалось в обычном режиме и «показывалось»
в режиме 12 слов внутри скрытого родителя — т.е. не было видно никогда.
Проверено: обычный режим — поле видно, режим 12 слов — сетка слов.
VERSION: client 1.2.316.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:42:17 +03:00
PixelandClaude Opus 4.8 cf6ca1d96b UI: вернуть наш компактный вид регистрации, Enter-отправку и шапку «Связей»
По итогам сверки после мержа с origin/main:
- Регистрация: убрана кнопка «Проверить логин» (работает автопроверка через
  1.5 с после ввода + повторная проверка на «Далее»), убрана кнопка «Назад»
  (есть ← в шапке), убран блок «Первый сервер SHiNE»; компактные классы
  registration-screen/registration-form, статус логина скрыт пока пуст.
  Промокоды, «глаз» пароля и заглушка коротких логинов origin — не тронуты.
  Осиротевший импорт defaultServerHttp удалён.
- Чат: Enter снова отправляет сообщение, Ctrl+Enter — перенос строки
  (голосовой ввод и кнопка ➤ работают как раньше).
- Шапка «Связей»: возвращён наш вариант (absolute, width:auto).
- VERSION: client 1.2.315.

Проверено в превью: регистрация — автопроверка вернула «Логин свободен »
без кнопки, FAQ-ссылка работает; чат — Enter отправляет (поле очищается),
Ctrl+Enter вставляет перенос; консоль чистая.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:05:33 +03:00
PixelandClaude Opus 4.8 9d1e49949a 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>
2026-07-16 11:41:41 +03:00
PixelandClaude Opus 4.8 ea3b34e11e WIP: эмодзи-пикер — чекпоинт 2 (доводка перед мержем с origin)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:21:00 +03:00
AidarKC 09dc55db0e Смерджить ветку регитсрация в main 2026-07-16 12:16:10 +04:00
AidarKC 85133db483 Удалить проверенные pending-фичи 2026-07-16 12:15:22 +04:00
AidarKC 6fc7d75e17 Улучшить финал регистрации и автовход 2026-07-16 12:08:59 +04:00
AidarKC be321813fa Убрать test.shineup.me из deploy-схемы 2026-07-16 11:54:21 +04:00
AidarKC d2c0ecdf62 Исправить регистрацию и навести порядок в UI deploy 2026-07-16 11:46:42 +04:00
PixelandClaude Opus 4.8 266a74ef79 WIP: эмодзи-пикер (Telegram-набор) — чекпоинт незавершённой работы
- Эмодзи-пикер: js/components/emoji-picker.js + каталог telegram-emoji-*.js
  (smileys/people/animals_nature/food_drink/travel_places/activity/objects/
  symbols/flags) + 631 ассет в assets/emoji (7.2 МБ).
- Интеграция пикера: chat-view.js, app.js, index.html, styles/components.css.
- Дев-скрипты генерации превью + THIRD_PARTY_NOTICES.
- Работа НЕ завершена (WIP), сохранено как чекпоинт. С origin (+56) пока НЕ
  мержится — большой мерж будет после завершения, отдельным шагом.
- VERSION: client 1.2.263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:28:45 +03:00
AidarKC 18d27c0146 Слить feature/pda-recovery-key в main 2026-07-15 19:05:13 +04:00
PixelandClaude Opus 4.8 8c4997ee2e UI: адаптация шапки «Связи», кнопка поиска каналов, локальный тестовый вход
- Связи: верхняя панель не выходит за границы экрана — кнопки назад/Найти/«?»
  не обрезаются на узких устройствах; заголовок сокращается вместо сдвига кнопок.
- Каналы: emoji-иконка поиска заменена на контурную кнопку-лупу в стиле панели
  (подсказка «Найти канал»); окно поиска без изменений.
- Локальный тестовый вход: на localhost/127.0.0.1/::1 — кнопка демо-входа
  (сеанс local-tester, автономные локальные состояния ЛС/каналов/профиля, без
  сервера и пароля); на shineup.me и тестовых доменах кнопка отсутствует.
- Заметки на ручную проверку добавлены в Dev_Docs/Pending_Features.
- VERSION: client 1.2.262.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:40:16 +03:00
AidarKC 127c561a41 Слить обновления UI каналов и кошелька 2026-06-24 15:00:02 +04:00
724 changed files with 84871 additions and 29692 deletions
+7 -3
View File
@@ -13,6 +13,7 @@ build/
.kotlin
### IntelliJ IDEA ###
.idea/
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
@@ -102,10 +103,13 @@ ESP32/**/*.d
ESP32/**/*.a
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
server-backup/archive/**
!server-backup/archive/.gitkeep
deploy/backup/archive/**
!deploy/backup/archive/.gitkeep
SHiNE-bundle-*.zip
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
.agents/
.codex/
.claude/
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
*.bak.png
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
-1
View File
@@ -1 +0,0 @@
shine-server-server
-10
View File
@@ -1,10 +0,0 @@
<component name="ArtifactManager">
<artifact type="jar" build-on-make="true" name="server:jar">
<output-path>$PROJECT_DIR$/out/artifacts/server_jar</output-path>
<root id="archive" name="server.jar">
<element id="directory" name="META-INF">
<element id="file-copy" path="$PROJECT_DIR$/META-INF/MANIFEST.MF" />
</element>
</root>
</artifact>
</component>
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/shine-server-blockchain" />
<option value="$PROJECT_DIR$/shine-server-config" />
<option value="$PROJECT_DIR$/shine-server-crypto" />
<option value="$PROJECT_DIR$/shine-server-db" />
<option value="$PROJECT_DIR$/shine-server-geo" />
<option value="$PROJECT_DIR$/shine-server-log" />
<option value="$PROJECT_DIR$/shine-server-net-protocol" />
<option value="$PROJECT_DIR$/shine-server-net-server" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17 (2)" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+38 -53
View File
@@ -14,14 +14,15 @@
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
- точка входа `shine-UI/server-ui.html`;
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
- Локальный Telegram-бот агента-кодера живёт рядом с репозиторием продукта, обычно в `../SHiNE-agent-bot-coder/`, и не входит в публичный код основного приложения.
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
## Сервис агента-кодера
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
- Локальный Telegram-бот-сервис агента-кодера находится вне этого git-репозитория, обычно в `../SHiNE-agent-bot-coder/`.
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`.
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
- Рабочая папка Codex для сервиса должна указывать на этот продуктовый репозиторий: `CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product`.
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `../SHiNE-agent-bot-coder/AGENTS.md`.
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `../SHiNE-agent-bot-coder/AGENT.md`.
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
## ESP32 UI homeserver
@@ -36,70 +37,67 @@
- Модуль логически связан с SHiNE, но не должен автоматически подключаться к сборке или деплою основного сервера без отдельного решения.
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
- В git добавлять исходники, lock-файлы, настройки проекта и документацию Solana-модуля, но не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля с `deployServer`/`deployUI` основного проекта.
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля со скриптами основного server/UI deploy из `deploy/scripts/`.
- Для регистрации пользователей в Solana (программа `shine_users`) единая актуальная инструкция по деплою/инициализации, адресам программ, и куда их прописывать в UI/сервере находится в:
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- `docs/Инициализация_Solana_регистрации/README.md`
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
- `Dev_Docs/Solana_Architecture/README.md`
- `docs/Solana_Architecture/README.md`
- Документ формата пользовательской PDA-записи `shine_users` находится в:
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
- `deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md`
## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`.
- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `docs/Blockchain/`.
- Дополнительно обязательно вести `docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
- Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён.
- Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять).
- Добавление любых данных в блокчейн выполнять только через операцию `AddBlock`.
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
## Документация личных сообщений (DM)
- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`.
- Точный байтовый формат DM находится в `Dev_Docs/Personal_Messages/Формат_DM_v1.md`.
- Актуальная документация по логике личных сообщений находится в `docs/Personal_Messages/Протокол_DM_v1.md`.
- Точный байтовый формат DM находится в `docs/Personal_Messages/Формат_DM_v1.md`.
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять оба документа:
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`
- `docs/Personal_Messages/Протокол_DM_v1.md`
- `docs/Personal_Messages/Формат_DM_v1.md`
- Логика личных сообщений в коде должна всегда соответствовать этим документам.
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
## Документация API сервера
- Актуальная документация по публичному JSON/WebSocket API сервера находится в `Dev_Docs/API/`.
- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `Dev_Docs/API/`.
- Актуальная документация по публичному JSON/WebSocket API сервера находится в `docs/API/`.
- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `docs/API/`.
- Перед изменением самого серверного API обязательно явно предупредить пользователя, какие операции, поля запросов/ответов или коды ошибок будут изменены, и запросить отдельное подтверждение.
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
- Если добавляется новая операция `op`, нужно обновить общий список операций в `docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
## Документация Figma
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `Dev_Docs/Figma/`.
- Точка входа: `Dev_Docs/Figma/README.md`.
- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`.
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `docs/Figma/`.
- Точка входа: `docs/Figma/README.md`.
- Подробный рабочий регламент: `docs/Figma/TRANSFER_UI_SCREENS.md`.
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
## Версионирование
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
- `client.version` — версия клиентского UI.
- `server.version` — версия серверной части.
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
- Обычные коммиты делать стандартным `git commit`; переменная `$GITEA_TOKEN` для коммитов не нужна и не используется.
- Все правила по коммитам, merge в `main`, `git push` и обновлению `VERSION.properties` находятся в `COMMIT_AND_VERSION_RULES.md`.
- Этот файл считать единым источником истины по правилам версионирования и коммитов для данного репозитория.
## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
- Production-хост SHiNE: `player@shineup.me` (`178.208.64.62`).
- Основной test-хост SHiNE: `player@193.8.215.70` (`t.shineup.me`).
- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
- Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`.
- Подробные правила для агента по деплою находятся в `deploy/AGENTS.md`; перед любым deploy читать этот файл.
- Production-серверы SHiNE: `shineup.me` и `server2.shineup.me`.
- Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`.
- В deploy-документах и скриптах использовать домены, а не IP.
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `t.shineup.me`.
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
- Production server deploy: `./gradlew deployServerProduction`.
- Production UI deploy: `./gradlew deployUIProduction`.
- Резервный test deploy на `test.shineup.me`: `./gradlew deployServerTest` и `./gradlew deployUITest`, но пока их не использовать без отдельной причины.
- Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя.
- Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`.
- Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически.
- Deploy выполнять shell-скриптами из `deploy/scripts/`; Gradle deploy-задачи не использовать.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
- Для временной бесплатной загрузки аватаров в Arweave секретный JWK нельзя хранить в git и нельзя прописывать в репозиторный `application.properties`.
@@ -125,26 +123,13 @@
- `unknown_error`
- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
## Недопроверенные фичи (обязательно)
- Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`.
- По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`.
- Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_<short-feature-name>.md`.
- Имена новых файлов и краткие описания фич по возможности писать на русском языке.
- Внутри файла обязательно указывать:
- краткое описание фичи;
- что именно проверять;
- ожидаемый результат;
- статус (например: `pending`, `in_progress`, `done`).
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
## Будущие фичи / TODO
- Папка для задач, сознательно отложенных на будущее: `TODO/`.
- Точка входа по планам: `TODO/README.md`.
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/` и тематическим подпапкам.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `TODO/README.md`, затем при необходимости конкретные файлы из подпапок.
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
- Старую папку `Dev_Docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей.
- Старую папку `docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей.
- Если часть кода временно отключена или закомментирована, либо удалена как временная заглушка, в TODO-файле подробно описывать:
- какие файлы и участки отключены;
- что осталось в коде как заготовка;
+1 -1
View File
@@ -5,7 +5,7 @@
@shine-UI/AGENTS.md
## Справка по подпроектам
- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`.
- При работе с локальным агентом-кодером — читать внешние файлы `../SHiNE-agent-bot-coder/AGENTS.md` и `../SHiNE-agent-bot-coder/AGENT.md`.
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
+47
View File
@@ -0,0 +1,47 @@
# Правила коммитов, merge и версионирования
Этот файл является единым источником правил для:
- коммитов;
- merge в `main`;
- изменения версий в `VERSION.properties`;
- `git push` из этого репозитория.
## Язык
- Пояснения к коммитам, PR и merge-запросам писать на русском языке.
## Где хранится версия
- Единый файл версий проекта: `VERSION.properties` в корне репозитория.
- Основные поля:
- `client.version` — версия клиентского UI.
- `server.version` — версия серверной части.
## Базовое правило для обычных коммитов
- Перед каждым новым коммитом обязательно обновлять версии в `VERSION.properties`.
- Если менялся только UI, увеличивать только `client.version`.
- Если менялся только сервер, увеличивать только `server.version`.
- Если менялись и UI, и сервер, увеличивать обе версии.
- Для обычных коммитов вне `main` использовать стандартный patch-инкремент: `+1` к последнему числовому сегменту.
- Пример: `1.2.346``1.2.347`.
## Правило для `main`
- Ветка `main` предназначена только для стабильных версий.
- По умолчанию в `main` нужно не коммитить напрямую, а мержить готовые изменения из рабочей ветки.
- Если пользователь просит сделать прямой коммит в `main`, нужно отдельно и явно предупредить, что это обход обычного стабильного процесса, и обязательно переспросить подтверждение.
## Версионирование при merge или прямом коммите в `main`
- Для попадания изменений в `main` действует отдельная схема инкремента.
- Нужно увеличивать вторую цифру версии и обнулять третью.
- Пример: `1.2.346``1.3.0`.
- Если в наборе изменений менялся только UI, обновлять только `client.version`.
- Если в наборе изменений менялся только сервер, обновлять только `server.version`.
- Если соответствующая часть не менялась, её версию не трогать.
## Правила для git commit и git push
- Обычные коммиты делать стандартным `git commit`; токен для локального коммита не нужен и не используется.
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
-96
View File
@@ -1,96 +0,0 @@
# 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`
-41
View File
@@ -1,41 +0,0 @@
# 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` делать только после отдельного подтверждения пользователя.
-150
View File
@@ -1,150 +0,0 @@
# Тестовые серверы 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
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
@@ -1,71 +0,0 @@
# Задание для Айдара: навести порядок в инструкциях агентов SHiNE
## Кратко
Нужно согласовать и оформить единый порядок инструкций для Codex/Telegram-агентов в проекте SHiNE, чтобы агенты стабильно понимали структуру проекта, границы ответственности и правила работы с сервером, UI, Solana-модулем, Telegram-ботом и игроками.
## Зачем это нужно
Сейчас проект состоит из нескольких связанных, но разных частей:
- основной сервер `SHiNE-server/`;
- UI `shine-UI/`;
- Solana/Anchor-модуль `shine-solana/shine/`;
- Telegram-агент-кодер `SHiNE-agent-bot-coder/`;
- TURN-сервер;
- документация `Dev_Docs/`;
- отдельные рабочие папки игроков `Players/`.
Без явных инструкций агент может путать эти зоны: например, смешать деплой Solana с деплоем сервера, изменить код от имени игрока, не обновить документацию API/DM/блокчейна или неправильно трактовать файл инструкций.
## Что предлагается сделать
1. Утвердить корневой `AGENTS.md` как главный набор правил проекта.
2. Проверить и при необходимости уточнить локальный `AGENTS.md` внутри `shine-solana/shine/`.
3. Оставить отдельные служебные инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENT.md`.
4. Оставить автоматически читаемые инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENTS.md`.
5. Явно закрепить режим игроков:
- игроки могут задавать вопросы, просить анализ, идеи и ТЗ;
- игроки не меняют код проекта напрямую;
- материалы игроков сохраняются только в `Players/<username>/`.
6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`.
7. Добавить простой процесс согласования изменений инструкций:
- Дима или другой участник готовит предложение;
- Айдар получает уведомление/заявку;
- Айдар отвечает: одобрить, отклонить или попросить доработать;
- только после одобрения агент вносит изменения в проектные инструкции.
## Предлагаемая логика уведомления Айдару
Минимальный вариант без сложной разработки:
1. Агент готовит текст заявки.
2. Текст отправляется Айдару в Telegram или в общий рабочий чат.
3. В заявке явно указаны варианты ответа:
- `одобрить`;
- `отклонить`;
- `доработать: ...`.
4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка.
Более удобный вариант на будущее:
- добавить в Telegram-бота команду или сценарий согласования задач, например:
- `/approve <id>`;
- `/reject <id> причина`;
- `/revise <id> комментарий`.
Но для начала достаточно простого текстового согласования через Telegram.
## Что нужно от Айдара
Подтвердить, что такой порядок подходит:
1. Корневой `AGENTS.md` остается главным правилом проекта.
2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила.
3. Игроки не меняют код напрямую, а готовят материалы и предложения.
4. Изменения инструкций выполняются только после явного одобрения Айдара.
5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок.
## Ожидаемый результат
После одобрения:
- агенты будут стабильнее понимать границы проекта;
- снизится риск случайных изменений не в той части системы;
- появится понятный порядок согласования задач от игроков;
- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов.
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-19
View File
@@ -1,19 +0,0 @@
TELEGRAM_BOT_TOKEN=replace_me
OPENAI_API_KEY=replace_me
ALLOWED_TELEGRAM_USERNAME=AidarKC
ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
BOT_USERNAME=aidar_su_bot
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
OPENAI_TTS_RESPONSE_FORMAT=opus
OPENAI_TTS_TIMEOUT_SECONDS=180
OPENAI_TTS_CHUNK_CHARS=3500
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
CODEX_TIMEOUT_SECONDS=900
MAX_RETRIES=3
DATA_DIR=./data
-5
View File
@@ -1,5 +0,0 @@
.env
data/
logs/
run/
__pycache__/
-81
View File
@@ -1,81 +0,0 @@
# AGENT.md для SHiNE-agent-bot-coder
Ты запущен как обработчик входящего Telegram-сообщения от пользователя.
## Контекст
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с этим проектом.
- Сервис принимает входящие сообщения от пользователя Telegram, сохраняет историю, ставит задачи в очередь и последовательно запускает Codex CLI в рабочем проекте.
- Текстовые сообщения обрабатываются напрямую, voice/audio сначала распознаются через OpenAI transcription, затем передаются как текстовая задача.
- История диалога хранится в JSONL-файле, путь передаётся в промпте.
- Сообщение может быть текстом или результатом распознавания голосового.
- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение.
- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара.
- В репозитории также есть отдельный Solana/Anchor-модуль `shine-solana/shine/`; он логически связан с SHiNE, но не должен автоматически подключаться к основному серверному deploy без отдельной команды.
- Перед изменениями внутри `shine-solana/shine/` читать локальные инструкции `shine-solana/shine/AGENTS.md`; в git не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
## Авторитет команд и история
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players/<username>/`.
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
## Очередь и состояние
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
- Архив истории после `/new`: `data/history/<username>/archive/`.
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
- Для просмотра истории игрока открывать файлы в его папке истории по username.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию.
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа.
- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше.
- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения.
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке.
## Правила голосовой версии ответа
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
- Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. Основной механизм — персонально включаемая адаптация текста через дополнительный OpenAI-вызов перед TTS.
- В голосовой версии не зачитывать длинные хэши коммитов, токены, file_id, длинные команды, полные пути и другие строки, которые человек всё равно не сможет надёжно запомнить на слух.
- Для commit/push в голосовой версии достаточно сказать краткий итог: что коммит сделан, что именно изменено, проверки прошли без ошибок, push выполнен, рабочее дерево чистое.
- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе.
## Планы и отложенные фичи
- Планы проекта по отложенным фичам хранятся в `TODO/`.
- Внутри есть три горизонта:
- `near/` - ближайшие планы, обычно сегодня/завтра;
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
- `far/` - дальнее будущее без понятного срока.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
- Файлы из `TODO/` не начинать реализовывать без явной команды пользователя.
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
## Центр задач и предложений
- Сервис хранит простые задачи и предложения в `data/task_center/items.json`.
- Айдар может смотреть список через `/tasks` или естественные фразы вроде «покажи мои задачи», «покажи задачи Миланы».
- Айдар может ставить задачи игрокам фразой вида «поставь задачу Милане: ...».
- Игроки могут отправлять предложения Айдару фразой вида `предложение: ...`, `идея: ...` или `заявка: ...`.
- Статусы меняются фразами с ID: `одобрить TC-0001`, `отклонить TC-0001`, `доработать TC-0001`, `закрыть TC-0001`.
- После финального ответа в личном чате сервис добавляет короткое напоминание, если у пользователя есть активные задачи или предложения.
## Локальный запуск и systemd
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
- Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится.
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
- Команда Telegram `/restart` (`/restart_service`) доступна только Айдару и выполняет отложенный рестарт после текущей задачи, до взятия следующей. Аварийный жёсткий рестарт доступен только Айдару командами `/restart_hard`, `/restart_now`, `/restart_force`.
## Правила ответа
- Пиши содержательно и коротко.
- Не упоминай внутренние служебные детали, файловую систему и технические логи.
- Если запрос требует действий с кодом/проектом, выполняй их в рабочей директории.
- Если для ответа данных недостаточно, задай ровно один уточняющий вопрос.
- Если была ошибка предыдущего запуска, в промпте будет пометка retry — учти это и продолжи с учётом текущего состояния проекта.
-25
View File
@@ -1,25 +0,0 @@
# AGENTS
## Назначение
- Это автоматически читаемые инструкции Codex для папки `SHiNE-agent-bot-coder/`.
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с проектом SHiNE.
- Если пользователь говорит «агент MD», «агент с MD» или похожим образом про файл инструкций Codex, считать, что имеется в виду `AGENTS.md`.
## Связанные инструкции
- Подробные служебные правила Telegram-обработчика лежат в `AGENT.md`.
- `AGENT.md` используется самим сервисом как файл инструкций, который передаётся в промпт обработчика входящих Telegram-сообщений.
- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`.
## Планы и задачи
- Отложенные задачи проекта лежат в `../TODO/`.
- Точка входа по планам: `../TODO/README.md`.
- Горизонты планов:
- `near/` - ближайшие планы;
- `medium/` - среднесрочные планы;
- `far/` - дальнее будущее.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
- Не начинать реализацию задач из `TODO` без явной команды пользователя.
## Проверка после изменений
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
-2
View File
@@ -1,2 +0,0 @@
@AGENTS.md
@AGENT.md
@@ -1,26 +0,0 @@
# Промпты для режима игроков (на согласование)
## 1) Базовый служебный промпт (добавка к задаче игрока)
```text
Режим игрока (обязательно):
- Пользователь: <Имя> (@<username>).
- Рабочая папка игрока: <project>/Players/<username>
- Код проекта не изменять.
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
```
## 2) Приветственное сообщение игроку (один раз)
```text
Привет, <Имя>.
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
Команда /new начинает новую сессию и архивирует текущую историю.
```
## 3) Отказ неизвестному пользователю
```text
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
```
-100
View File
@@ -1,100 +0,0 @@
# SHiNE-agent-bot-coder
Локальный Telegram-бот-сервис для пользователя `ai`:
- принимает сообщения от `@AidarKC`;
- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
- ведёт историю диалога в `JSONL`;
- ставит задачи в файловую очередь;
- обрабатывает задачи строго последовательно;
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
- вызывает Codex CLI и отправляет ответ в Telegram;
- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам;
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
- при рестарте восстанавливает незавершённые задачи;
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
## Структура
- `.env` — локальные секреты и параметры запуска (не коммитится);
- `data/py_queue.jsonl` — очередь Python-сервиса;
- `data/py_state.json` — текущее состояние Python-сервиса;
- `data/py_processed_updates.log` — дедуп входящих update;
- `data/history/<username>/*.jsonl` — активные истории по пользователям;
- `data/history/<username>/archive/*.jsonl` — архивы после `/new`.
## Локальный запуск
1. Скопировать пример:
- `cp .env.example .env`
2. Заполнить секреты в `.env`.
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
- `TELEGRAM_API_BASE_URL` — базовый URL Bot API; по умолчанию `https://api.telegram.org`. Для очень больших voice/audio можно поднять локальный `telegram-bot-api` и направить бота туда.
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
- `OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES` — безопасный лимит размера одного куска для OpenAI transcription, по умолчанию `24 MiB`.
- `OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS` — максимальная длина одного куска при длинном аудио, по умолчанию `900` секунд.
- `OPENAI_TRANSCRIBE_OVERLAP_SECONDS` — перекрытие соседних кусков для более ровной склейки текста, по умолчанию `2` секунды.
- `OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS` — битрейт локального пережатия длинного аудио через `ffmpeg`, по умолчанию `24`.
- `OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS` — тайм-аут локальной обработки длинного аудио через `ffmpeg`/`ffprobe`, по умолчанию `1800`.
- `FFMPEG_BIN` и `FFPROBE_BIN` — пути к локальным бинарям `ffmpeg`/`ffprobe`, если они не лежат в `PATH`.
- `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`.
- `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`.
- `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`.
- `OPENAI_TTS_TIMEOUT_SECONDS` — тайм-аут генерации одного фрагмента речи, по умолчанию 180 секунд.
- `OPENAI_TTS_CHUNK_CHARS` — максимальный размер одного фрагмента озвучки, по умолчанию 3500 символов.
3. Запуск:
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
## Быстрый self-test Codex (без Telegram)
```bash
python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"
```
## Длинные voice/audio
- Если аудио короткое, бот отправляет его в OpenAI как раньше.
- Если аудио большое или длинное, бот локально пережимает его через `ffmpeg`, при необходимости режет на куски и распознаёт последовательно.
- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI.
- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`.
## Статусы в личке
- Для `private`-чата бот поддерживает персональную настройку режима ответа.
- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам.
- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно.
- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа.
- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением.
## Запуск как systemd-сервис
Файлы для установки:
- `scripts/systemd/shine-agent-bot-coder.service`
- `scripts/systemd/install-local-systemd.sh`
Установка:
- `bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
Проверка:
- `systemctl --user status shine-agent-bot-coder --no-pager`
- `journalctl --user -u shine-agent-bot-coder -f`
Перезапуск после изменений:
- `systemctl --user restart shine-agent-bot-coder`
## Telegram-команды
- `/status` — активная задача и размер очереди.
- `/settings` — текущие пользовательские настройки и команды для их изменения.
- `/queue` — список задач в очереди.
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение.
- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ.
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/home/ai/work/SHiNE/SHiNE-server-sha256"
SERVICE_DIR="${ROOT_DIR}/SHiNE-agent-bot-coder"
UNIT_SRC="${SERVICE_DIR}/scripts/systemd/shine-agent-bot-coder.service"
UNIT_DST="${HOME}/.config/systemd/user/shine-agent-bot-coder.service"
echo "[1/6] Проверка python3..."
command -v python3 >/dev/null 2>&1 || { echo "python3 не найден"; exit 1; }
echo "[2/6] Подготовка папки логов..."
mkdir -p "${SERVICE_DIR}/logs"
echo "[3/6] Копирование user systemd unit..."
mkdir -p "$(dirname "${UNIT_DST}")"
cp "${UNIT_SRC}" "${UNIT_DST}"
echo "[4/6] daemon-reload..."
systemctl --user daemon-reload
echo "[5/6] enable + start..."
systemctl --user enable --now shine-agent-bot-coder
echo "[6/6] Статус:"
systemctl --user status shine-agent-bot-coder --no-pager
echo "Готово. Логи: journalctl --user -u shine-agent-bot-coder -f"
@@ -1,19 +0,0 @@
[Unit]
Description=SHiNE Agent Bot Coder (Telegram + Codex queue worker)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder
EnvironmentFile=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/.env
ExecStart=/usr/bin/python3 /home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/py_bot_service.py
Restart=always
RestartSec=5
TimeoutStopSec=20
SuccessExitStatus=143 0
StandardOutput=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log
StandardError=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log
[Install]
WantedBy=default.target
+12 -29
View File
@@ -9,7 +9,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
- `shine-server-db/` — DAO, SQL-схема, SQLite
- `shine-server-db/` — DAO, SQL-схема, PostgreSQL runtime
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
- `shine-server-crypto/` — криптографические утилиты
- `shine-server-config/` — конфигурация сервера
@@ -23,7 +23,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
с которыми синхронизируются блоки и DM;
с которыми синхронизируются пользовательские блокчейны;
- **корневой ключ** сервера (`root_key`).
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
@@ -45,39 +45,22 @@ shine-UI/server-ui.html
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
## Синхронизация с партнёрскими серверами
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
Детали: `Dev_Docs/Blockchain/sync-between-servers.md`
Сервер должен синхронизировать блоки пользовательских блокчейнов с
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
они доставляются только на первый access-сервер получателя.
Детали: `docs/Blockchain/sync-between-servers.md`
## Деплой
```
./gradlew deployServer
./gradlew deployUI
```
Default deploy по умолчанию идёт на `t.shineup.me` (`player@193.8.215.70`).
Production deploy:
```
./gradlew deployServerProduction
./gradlew deployUIProduction
```
Любые изменения на `shineup.me` делать только после отдельного явного подтверждения пользователя.
Резервный test-контур:
```
./gradlew deployServerTest
./gradlew deployUITest
```
`test.shineup.me` считается резервным тестовым сервером и в обычный deploy не включается.
- Основные инструкции по деплою находятся в `../deploy/AGENTS.md`.
- Deploy выполнять shell-скриптами из `../deploy/scripts/`.
- Gradle deploy-задачи не использовать: Gradle остаётся для сборки и локального запуска.
- Любые изменения на production (`shineup.me`, `server2.shineup.me`) делать только после отдельного явного подтверждения пользователя.
- Перед production deploy обязательно обновить/проверить backup в `deploy/backup/archive/`.
Логи на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`
@@ -0,0 +1,31 @@
plugins {
id 'java'
}
group = 'shine'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories { mavenCentral() }
dependencies {
implementation project(':shine-server-config')
implementation project(':shine-server-db')
implementation project(':shine-server-crypto')
implementation project(':shine-server-blockchain')
implementation project(':shine-server-net-protocol')
implementation project(':shine-server-solana-users-sync')
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
implementation 'org.slf4j:slf4j-api:2.0.16'
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
}
test { useJUnitPlatform() }
@@ -0,0 +1,56 @@
package server.archive;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/** Единые локальные имена больших архивных файлов. */
public final class ArchiveFileNames {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy");
private ArchiveFileNames() {}
/**
* Временное имя полностью собранного файла до появления Arweave TX ID.
* Пример: archive01.00001.11.09.26.tmp.SHiNE-archive
*/
public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive";
}
/**
* Постоянное имя после успешной загрузки в Arweave.
* Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive
*/
public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes");
String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId);
return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive";
}
public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone));
}
public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId));
}
private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) {
throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber);
}
String safeLogin = sanitize(login);
String number = String.format("%05d", bigBlockNumber);
String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone));
return safeLogin + "." + number + "." + date;
}
static String sanitize(String login) {
String s = login == null ? "server" : login.trim();
if (s.isEmpty()) s = "server";
return s.replaceAll("[^A-Za-z0-9._-]", "_");
}
}
@@ -0,0 +1,37 @@
package server.archive;
import utils.config.AppConfig;
import java.nio.file.Path;
import java.util.*;
/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */
public record ArchiveImportConfig(
Set<String> allowedPublishers,
int intervalMinutes,
String arweaveGateway,
Path workDir
) {
public static ArchiveImportConfig load() {
AppConfig c = AppConfig.getInstance();
Set<String> approved = new LinkedHashSet<>();
String raw = c.getParam("archive.import.allowedPublishers");
if (raw != null) {
for (String item : raw.split(",")) {
String login = item.trim().toLowerCase(Locale.ROOT);
if (!login.isEmpty()) approved.add(login);
}
}
String gateway = c.getParam("archive.arweave.gateway");
if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net";
String dir = c.getParam("archive.import.workDir");
if (dir == null || dir.isBlank()) dir = "data/archive-import";
return new ArchiveImportConfig(
Set.copyOf(approved),
Math.max(1, c.getInt("archive.import.intervalMinutes", 60)),
gateway.trim(),
Path.of(dir));
}
public boolean enabled() { return !allowedPublishers.isEmpty(); }
}
@@ -0,0 +1,54 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.*;
/**
* Фоновая проверка уже синхронизированной локальной таблицы User PDA.
* Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта,
* затем по умолчанию раз в час.
*/
public final class ArchiveImportScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class);
private static ScheduledExecutorService executor;
private ArchiveImportScheduler() {}
public static synchronized void startOrLog() {
if (executor != null) return;
ArchiveImportConfig cfg = ArchiveImportConfig.load();
if (!cfg.enabled()) {
log.info("Archive importer выключен: archive.import.allowedPublishers пуст");
return;
}
try {
ArchiveImportService service = new ArchiveImportService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "archive-importer");
t.setDaemon(true);
return t;
});
executor.scheduleWithFixedDelay(
() -> {
try { service.pollOnce(); }
catch (Exception e) { log.error("Archive importer cycle failed", e); }
},
10,
Math.multiplyExact((long) cfg.intervalMinutes(), 60L),
TimeUnit.SECONDS);
log.info("Archive importer включён. approvedPublishers={} interval={}min",
cfg.allowedPublishers(), cfg.intervalMinutes());
} catch (Exception e) {
log.error("Archive importer не запущен", e);
}
}
public static synchronized void close() {
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
}
@@ -0,0 +1,229 @@
package server.archive;
import blockchain.BchBlockEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.*;
import shine.db.dao.*;
import shine.db.entities.BlockchainStateEntry;
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
/** Импорт archive head только от серверов из server-side whitelist. */
public final class ArchiveImportService {
private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class);
private final ArchiveImportConfig cfg;
private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance();
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
private final ShineArchiveReader reader = new ShineArchiveReader();
private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler();
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public ArchiveImportService(ArchiveImportConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
Files.createDirectories(cfg.workDir());
}
/**
* Никаких отдельных Solana RPC запросов здесь нет.
* Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync.
*/
public void pollOnce() throws Exception {
if (!cfg.enabled()) return;
for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) {
try {
importHead(head);
} catch (Exception e) {
log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e);
}
}
}
private void importHead(ArchivePublisherHead publisher) throws Exception {
String headTxId = publisher.archiveHeadTxId();
byte[] headHash = hex(publisher.archiveHeadHash());
// Если процесс успел импортировать head, но упал до установки archive_imported=true,
// следующий цикл просто завершает отметку без повторного скачивания всей истории.
if (headTxId.equals(publisher.lastImportedArchiveTxId())) {
if (!dao.markHeadImported(publisher.login(), headTxId)) {
log.info("Archive head changed while finalizing import marker publisher={} oldHead={}",
publisher.login(), headTxId);
}
return;
}
Path headFile = download(headTxId);
ShineArchiveReader.Document head;
try {
head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey());
} finally {
Files.deleteIfExists(headFile);
}
// FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен.
List<ShineArchiveReader.Reference> previous = new ArrayList<>(head.references());
previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber));
int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId);
for (int i = startIndex; i < previous.size(); i++) {
ShineArchiveReader.Reference ref = previous.get(i);
importOne(publisher, ref.arweaveTxId(), ref.archiveHash());
if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) {
throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login());
}
}
// Head уже скачан и проверен выше — второй раз его из Arweave не качаем.
importDocument(publisher, headTxId, head);
if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) {
throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login());
}
if (!dao.markHeadImported(publisher.login(), headTxId)) {
throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login());
}
log.info("Archive head fully imported publisher={} bigBlock={} tx={}",
publisher.login(), head.bigBlockNumber(), headTxId);
}
/**
* Возвращает индекс первого ещё не импортированного previous big block.
* Пустой cursor = новый сервер, импортируем всю FULL историю.
*/
private static int findResumeIndex(List<ShineArchiveReader.Reference> previous,
String lastImportedTxId,
String currentHeadTxId) {
String last = lastImportedTxId == null ? "" : lastImportedTxId.trim();
if (last.isEmpty()) return 0;
if (last.equals(currentHeadTxId)) return previous.size();
for (int i = 0; i < previous.size(); i++) {
if (last.equals(previous.get(i).arweaveTxId())) return i + 1;
}
throw new IllegalStateException(
"Последний импортированный archive TX не найден в FULL history текущего head: " + last);
}
private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception {
Path file = download(txId);
try {
ShineArchiveReader.Document doc = reader.readAndVerify(
file, publisher.login(), expectedHash, publisher.rootKey());
importDocument(publisher, txId, doc);
} finally {
Files.deleteIfExists(file);
}
}
private void importDocument(ArchivePublisherHead publisher, String txId,
ShineArchiveReader.Document doc) throws Exception {
for (ShineArchiveReader.Chunk chunk : doc.chunks()) {
processChunk(publisher.login(), txId, doc, chunk);
}
log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}",
publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size());
}
private void processChunk(String publisherLogin, String txId,
ShineArchiveReader.Document doc,
ShineArchiveReader.Chunk chunk) throws Exception {
ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName());
lock.lock();
try {
ensureState(chunk.blockchainName());
BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName());
int localLast = state == null ? -1 : state.getLastBlockNumber();
long archiveLast = -1;
for (byte[] raw : chunk.rawRecords()) {
BchBlockEntry block = new BchBlockEntry(raw);
archiveLast = Integer.toUnsignedLong(block.blockNumber);
if (block.blockNumber <= localLast) {
byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber);
if (!Arrays.equals(localHash, block.getHash32())) {
throw new IllegalStateException(
"Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber);
}
continue;
}
if (block.blockNumber != localLast + 1) {
throw new IllegalStateException(
"Archive gap " + chunk.blockchainName() + ": local=" + localLast +
" next=" + block.blockNumber);
}
var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw);
if (!result.ok()) {
throw new IllegalStateException(
"Archive AddBlock rejected " + chunk.blockchainName() + "#" +
block.blockNumber + ": " + result.reasonCode());
}
localLast = block.blockNumber;
}
if (archiveLast >= 0) {
dao.upsertLocation(new ArchiveBlockchainLocation(
chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(),
chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis()));
}
} finally {
lock.unlock();
}
}
private void ensureState(String blockchainName) throws Exception {
if (stateDao.getByBlockchainName(blockchainName) != null) return;
ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName);
if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName);
BlockchainStateEntry s = new BlockchainStateEntry();
s.setBlockchainName(id.blockchainName());
s.setLogin(id.login());
s.setBlockchainKey(id.blockchainKey());
s.setSizeLimit(id.sizeLimit());
s.setFileSizeBytes(0);
s.setLastBlockNumber(-1);
s.setLastBlockHash(null);
s.setUpdatedAtMs(System.currentTimeMillis());
stateDao.insertIfMissing(s);
}
private Path download(String txId) throws Exception {
String gateway = cfg.arweaveGateway().replaceAll("/+$", "");
Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download");
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId))
.timeout(Duration.ofMinutes(10))
.GET()
.build();
HttpResponse<Path> resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
Files.deleteIfExists(target);
throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId);
}
return target;
}
private static byte[] hex(String value) {
String s = String.valueOf(value == null ? "" : value).trim();
if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64");
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}
@@ -0,0 +1,75 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import sync.util.Base58Util;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */
final class ArchiveKeyLoader {
private static final ObjectMapper JSON = new ObjectMapper();
private ArchiveKeyLoader() {}
static byte[] loadSeed32(Path path) throws IOException {
if (path == null) throw new IllegalArgumentException("Путь ключа не задан");
byte[] raw = Files.readAllBytes(path);
if (raw.length == 32) return raw.clone();
if (raw.length == 64) return first32(raw);
String text = new String(raw, StandardCharsets.UTF_8).trim();
if (text.isEmpty()) throw new IOException("Пустой key file: " + path);
// Solana keypair JSON: [64 чисел].
if (text.startsWith("[")) {
JsonNode arr = JSON.readTree(text);
if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) {
throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path);
}
byte[] bytes = new byte[arr.size()];
for (int i = 0; i < arr.size(); i++) {
int v = arr.get(i).asInt(-1);
if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path);
bytes[i] = (byte) v;
}
return bytes.length == 32 ? bytes : first32(bytes);
}
// Base58 seed32 или Solana secret key 64 bytes.
try {
byte[] decoded = Base58Util.decode(text);
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
} catch (IllegalArgumentException ignored) {
// Пробуем остальные текстовые форматы ниже.
}
// Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах.
try {
byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", ""));
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
if (decoded.length > 32) {
byte[] seed = new byte[32];
System.arraycopy(decoded, decoded.length - 32, seed, 0, 32);
return seed;
}
} catch (IllegalArgumentException ignored) {
// Ниже выдадим понятную общую ошибку.
}
throw new IOException("Не удалось распознать Ed25519 key file: " + path
+ ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8.");
}
private static byte[] first32(byte[] bytes) {
byte[] out = new byte[32];
System.arraycopy(bytes, 0, out, 0, 32);
return out;
}
}
@@ -0,0 +1,98 @@
package server.archive;
import utils.config.AppConfig;
import utils.config.SolanaProgramsConfig;
import java.nio.file.Path;
import java.time.LocalTime;
import java.time.ZoneId;
/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */
public record ArchivePublisherConfig(
boolean enabled,
LocalTime publishTime,
ZoneId publishZone,
long maxFileBytes,
Path workDir,
String serverLogin,
String arweaveGateway,
Path arweaveWalletJwkPath,
int arweaveMinConfirmations,
int arweaveConfirmPollSeconds,
int arweaveConfirmTimeoutMinutes,
Path solanaRootKeyPath,
Path solanaClientKeyPath,
String solanaRpcUrl,
String solanaUsersProgramId,
String solanaPaymentsProgramId,
int solanaConfirmPollSeconds,
int solanaConfirmTimeoutMinutes,
String solanaCommitment
) {
public static ArchivePublisherConfig load() {
AppConfig c = AppConfig.getInstance();
return new ArchivePublisherConfig(
c.getBoolean("archive.publish.enabled", false),
parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")),
parseZone(c.getParam("archive.publish.zoneId")),
positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"),
Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")),
requiredIfEnabled(c, "server.SHiNE.login"),
orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"),
optionalPath(c.getParam("archive.arweave.walletJwkPath")),
nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"),
positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"),
positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"),
optionalPath(c.getParam("archive.solana.rootKeyPath")),
optionalPath(c.getParam("archive.solana.clientKeyPath")),
firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL),
firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID),
SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID,
positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"),
positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"),
orDefault(c.getParam("archive.solana.commitment"), "finalized")
);
}
public void validateForStart() {
if (!enabled) return;
if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher");
if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен");
if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен");
if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен");
if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен");
if (maxFileBytes >= 0x1_0000_0000L) {
throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296");
}
}
private static String requiredIfEnabled(AppConfig c, String name) {
String v = c.getParam(name);
return v == null ? "" : v.trim();
}
private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); }
private static String firstNonBlank(String... values) {
if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim();
return "";
}
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); }
private static LocalTime parseTime(String value) {
try {
return LocalTime.parse(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e);
}
}
private static ZoneId parseZone(String value) {
if (value == null || value.isBlank()) return ZoneId.systemDefault();
try {
return ZoneId.of(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e);
}
}
private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; }
private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
}
@@ -0,0 +1,90 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */
public final class ArchivePublisherScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArchivePublisherScheduler() {}
public static void startOrLog() {
ArchivePublisherConfig cfg;
try {
cfg = ArchivePublisherConfig.load();
} catch (Exception e) {
log.error("Не удалось прочитать archive publisher config", e);
return;
}
if (!cfg.enabled()) {
log.info("Archive publisher выключен (archive.publish.enabled=false)");
return;
}
if (!STARTED.compareAndSet(false, true)) return;
try {
ArchivePublisherService service = new ArchivePublisherService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "shine-archive-publisher");
t.setDaemon(true);
return t;
}
});
// Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot
// создаём только в очередное назначенное суточное время.
executor.execute(() -> {
try {
service.resumeUnfinishedJob();
} catch (Exception e) {
log.error("Не удалось продолжить незавершённый archive job", e);
}
});
scheduleNextDaily(service, cfg);
log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}",
cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone());
} catch (Exception e) {
STARTED.set(false);
log.error("Archive publisher НЕ запущен из-за ошибки preflight", e);
}
}
private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) {
ScheduledExecutorService e = executor;
if (e == null || e.isShutdown()) return;
ZonedDateTime now = ZonedDateTime.now(cfg.publishZone());
ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone());
if (!next.isAfter(now)) next = next.plusDays(1);
long delayMs = Math.max(1L, Duration.between(now, next).toMillis());
e.schedule(() -> {
try {
service.runCycle();
} catch (Exception ex) {
log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex);
} finally {
scheduleNextDaily(service, cfg);
}
}, delayMs, TimeUnit.MILLISECONDS);
log.info("Следующая архивная публикация запланирована на {}", next);
}
public static void close() {
ScheduledExecutorService e = executor;
if (e != null) e.shutdownNow();
}
}
@@ -0,0 +1,341 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.archive.ArchivePublishJob;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.dao.ArchivePublicationDAO;
import shine.db.dao.ArchiveImportDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */
public final class ArchivePublisherService {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class);
private final ArchivePublisherConfig cfg;
private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance();
private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance();
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
private final ShineArchiveWriter writer = new ShineArchiveWriter();
private final ArweaveArchiveService arweave;
private final SolanaArchiveHeadWriter solana;
private final byte[] archiveSigningSeed32;
private final AtomicBoolean running = new AtomicBoolean(false);
public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception {
this.cfg = Objects.requireNonNull(cfg);
cfg.validateForStart();
Files.createDirectories(cfg.workDir());
this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.arweave = new ArweaveArchiveService(cfg);
this.solana = new SolanaArchiveHeadWriter(cfg);
this.solana.validateKeysAgainstPda();
backfillOwnLocationIndex();
}
/**
* После обновления старого publisher-сервера заполняет универсальный location index
* из уже существующих finalized cursors, не дожидаясь следующего суточного archive block.
*/
private void backfillOwnLocationIndex() throws Exception {
Map<Long, ArchiveBigBlockRef> finalized = new HashMap<>();
for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref);
if (finalized.isEmpty()) return;
long now = System.currentTimeMillis();
int updated = 0;
for (BlockchainStateEntry state : stateDao.listAll()) {
if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName());
if (cursor == null) continue;
ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber());
if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue;
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()),
cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now));
updated++;
}
if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated);
}
/** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */
public boolean resumeUnfinishedJob() throws Exception {
if (!running.compareAndSet(false, true)) return false;
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) return false;
log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
process(job.id());
return true;
} finally {
running.set(false);
}
}
/** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */
public boolean runCycle() throws Exception {
if (!running.compareAndSet(false, true)) {
log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен");
return false;
}
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) {
job = createFrozenJob();
if (job == null) {
log.info("Archive publisher: новых SHiNE-блоков нет");
return false;
}
} else {
log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
}
process(job.id());
return true;
} finally {
running.set(false);
}
}
private ArchivePublishJob createFrozenJob() throws Exception {
List<ArchivePublishJobChain> chains = new ArrayList<>();
for (BlockchainStateEntry listed : stateDao.listAll()) {
if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue;
String name = listed.getBlockchainName();
ReentrantLock lock = BlockchainLocks.lockFor(name);
lock.lock();
try {
BlockchainStateEntry state = stateDao.getByBlockchainName(name);
if (state == null || state.getLastBlockNumber() < 0) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(name);
long from;
byte[] previousSourceHash;
Long prevBig = null;
byte[] prevBigHash = null;
Long prevChunkOffset = null;
Long prevChunkSize = null;
if (cursor == null) {
from = 0L;
previousSourceHash = new byte[32];
} else {
long cursorBlock = cursor.lastArchivedSourceBlockNumber();
if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) {
throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock);
}
byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock);
if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) {
throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock);
}
from = cursorBlock + 1L;
previousSourceHash = cursor.lastArchivedSourceBlockHash();
prevBig = cursor.lastArchiveBigBlockNumber();
prevBigHash = cursor.lastArchiveBigBlockHash();
prevChunkOffset = cursor.lastChunkOffset();
prevChunkSize = cursor.lastChunkSize();
}
long to = Integer.toUnsignedLong(state.getLastBlockNumber());
if (from > to) continue;
if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name);
byte[] lastHash = state.getLastBlockHash();
if (lastHash == null || lastHash.length != 32) {
lastHash = blocksDao.getHashByNumber(name, (int) to);
}
require32(lastHash, "last source block hash " + name);
chains.add(new ArchivePublishJobChain(
0L, name, from, to, previousSourceHash, lastHash,
prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null));
} finally {
lock.unlock();
}
}
if (chains.isEmpty()) return null;
chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long bigBlock = archiveDao.nextBigBlockNumber();
if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number");
long createdAt = System.currentTimeMillis();
long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains);
log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size());
return archiveDao.getJob(id);
}
private void process(long jobId) throws Exception {
while (true) {
ArchivePublishJob job = archiveDao.getJob(jobId);
if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId);
switch (job.status()) {
case "SNAPSHOT_CREATED" -> buildFile(job);
case "FILE_BUILT" -> uploadArweave(job);
case "ARWEAVE_UPLOADED" -> waitArweave(job);
case "ARWEAVE_CONFIRMED" -> submitSolana(job);
case "SOLANA_SUBMITTED" -> waitSolana(job);
case "SOLANA_FINALIZED" -> commitCursors(job);
case "CURSORS_COMMITTED" -> {
log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId()));
return;
}
case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText());
default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status());
}
}
}
private void buildFile(ArchivePublishJob job) throws Exception {
List<ArchivePublishJobChain> chains = archiveDao.listJobChains(job.id());
if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id());
Map<String,List<BlockEntry>> records = new LinkedHashMap<>();
for (ArchivePublishJobChain ch : chains) {
ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName());
lock.lock();
try {
if (ch.fromSourceBlockNumber() > 0) {
byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1));
if (!Arrays.equals(prev, ch.previousSourceBlockHash())) {
throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName());
}
}
List<BlockEntry> range = blocksDao.listRangeByNumber(
ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber());
if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName());
byte[] last = range.get(range.size()-1).getBlockHash();
if (!Arrays.equals(last, ch.lastSourceBlockHash())) {
throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName());
}
records.put(ch.blockchainName(), range);
} finally {
lock.unlock();
}
}
List<ArchiveBigBlockRef> previous = archiveDao.listFinalizedBigBlocks();
Path file = ArchiveFileNames.pendingFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone());
ShineArchiveWriter.BuildResult result = writer.build(
file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous,
chains, records, archiveSigningSeed32, cfg.maxFileBytes());
for (Map.Entry<String, ShineArchiveWriter.ChunkPosition> e : result.chunks().entrySet()) {
archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size());
}
archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash());
log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}",
job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash()));
}
private void uploadArweave(ArchivePublishJob job) throws Exception {
Path file = requireLocalFile(job);
byte[] verified = ShineArchiveWriter.verifyLocalFile(file);
if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB");
ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash());
// Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename,
// recovery на следующем шаге переименует тот же временный файл без повторной загрузки.
archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes());
Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes());
log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}",
job.bigBlockNumber(), upload.txId(), uploadedFile);
}
private void waitArweave(ArchivePublishJob job) throws Exception {
ensureUploadedLocalName(job);
String tx = b64url(job.arweaveTxId());
int confirmations = arweave.waitForConfirmations(tx);
archiveDao.markArweaveConfirmed(job.id(), confirmations);
log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations);
}
/** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */
private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception {
require32(txId, "arweave tx id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId);
Files.createDirectories(target.toAbsolutePath().getParent());
if (!currentFile.equals(target)) {
try {
Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING);
}
}
archiveDao.updateLocalArchivePath(job.id(), target.toString());
return target;
}
/** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */
private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId());
if (Files.isRegularFile(target)) {
if (!target.toString().equals(job.localArchivePath())) {
archiveDao.updateLocalArchivePath(job.id(), target.toString());
}
return;
}
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return;
Path current = Path.of(job.localArchivePath());
if (Files.isRegularFile(current)) {
markLocalFileUploaded(job, current, job.arweaveTxId());
}
}
private void submitSolana(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
require32(job.archiveHash(), "job.archive_hash");
SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash());
archiveDao.markSolanaSubmitted(job.id(), submitted.signature());
}
private void waitSolana(ArchivePublishJob job) throws Exception {
if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature");
solana.waitFinalized(job.solanaSignature());
if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) {
throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job");
}
archiveDao.markSolanaFinalized(job.id());
log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature());
}
private void commitCursors(ArchivePublishJob job) throws Exception {
archiveDao.commitCursors(job.id(), job.archiveHash());
// Тот же универсальный индекс используется и для собственных опубликованных архивов.
// Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist.
String txId = b64url(job.arweaveTxId());
long now = System.currentTimeMillis();
for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) {
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue;
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(),
ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now));
}
}
private static Path requireLocalFile(ArchivePublishJob job) {
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path");
Path p = Path.of(job.localArchivePath());
if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p);
return p;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes");
}
private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
}
@@ -0,0 +1,259 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.*;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.time.Duration;
import java.util.*;
/**
* Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2.
* Поддерживает стандартный data Merkle tree и загрузку через /chunk.
*/
public final class ArweaveArchiveService {
private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
private final ArchivePublisherConfig cfg;
private final HttpClient http;
private volatile Wallet wallet;
public ArweaveArchiveService(ArchivePublisherConfig cfg) {
this.cfg = Objects.requireNonNull(cfg);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
}
public record UploadResult(String txId, byte[] txIdBytes, String gateway) {}
public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash)
throws Exception {
long dataSize = Files.size(archiveFile);
if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE");
Wallet w = wallet();
String gateway = trimGateway(cfg.arweaveGateway());
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile);
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + dataSize);
if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward);
verifyBalance(gateway, w.address(), reward);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String,String>> jsonTags = new ArrayList<>();
addTag(jsonTags, tagList, "Content-Type", "application/octet-stream");
addTag(jsonTags, tagList, "App-Name", "SHiNE");
addTag(jsonTags, tagList, "SHiNE-Type", "archive");
addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0");
addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin);
addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber));
addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash));
byte[] signaturePayload = deepHash(List.of(
utf8("2"),
b64Decode(w.owner()),
new byte[0],
utf8("0"),
utf8(reward),
b64Decode(anchor),
tagList,
utf8(Long.toString(dataSize)),
merkle.dataRoot()
));
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
byte[] txIdBytes = sha256(rawSignature);
String txId = b64(txIdBytes);
Map<String,Object> tx = new LinkedHashMap<>();
tx.put("format", 2);
tx.put("id", txId);
tx.put("last_tx", anchor);
tx.put("owner", w.owner());
tx.put("tags", jsonTags);
tx.put("target", "");
tx.put("quantity", "0");
tx.put("data_root", b64(merkle.dataRoot()));
tx.put("data_size", Long.toString(dataSize));
tx.put("data", "");
tx.put("reward", reward);
tx.put("signature", b64(rawSignature));
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize);
for (int i=0; i<merkle.chunks().size(); i++) {
ArweaveMerkle.Chunk chunkMeta = merkle.chunks().get(i);
ArweaveMerkle.Proof proof = merkle.proofs().get(i);
byte[] chunk = ArweaveMerkle.readChunk(archiveFile, chunkMeta);
Map<String,Object> body = new LinkedHashMap<>();
body.put("data_root", b64(merkle.dataRoot()));
body.put("data_size", Long.toString(dataSize));
body.put("data_path", b64(proof.proof()));
body.put("offset", Long.toString(proof.offset()));
body.put("chunk", b64(chunk));
postChunkWithRetry(gateway, body, i, merkle.chunks().size());
}
return new UploadResult(txId, txIdBytes, gateway);
}
/** Ждёт включения tx в блок и заданного количества подтверждений. */
public int waitForConfirmations(String txId) throws Exception {
if (cfg.arweaveMinConfirmations() <= 0) return 0;
String gateway = trimGateway(cfg.arweaveGateway());
long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L;
int last = 0;
while (System.currentTimeMillis() < deadline) {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status"))
.timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() == 200) {
JsonNode json = MAPPER.readTree(resp.body());
last = json.path("number_of_confirmations").asInt(0);
if (last >= cfg.arweaveMinConfirmations()) return last;
} else if (resp.statusCode() != 202 && resp.statusCode() != 404) {
log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body()));
}
Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L);
}
throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last);
}
private void postChunkWithRetry(String gateway, Map<String,Object> body, int index, int total) throws Exception {
Exception last = null;
for (int attempt=1; attempt<=12; attempt++) {
try {
postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60));
if ((index + 1) % 25 == 0 || index + 1 == total) {
log.info("Arweave chunks: {}/{}", index + 1, total);
}
return;
} catch (Exception e) {
last = e;
if (attempt == 12) break;
log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage());
Thread.sleep(5_000L);
}
}
throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last);
}
private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception {
String json = MAPPER.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(timeout)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body()));
}
}
private void verifyBalance(String gateway, String address, String reward) throws Exception {
if (address == null || address.isBlank()) return;
String balance = getRequiredText(gateway, "/wallet/" + address + "/balance");
if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) {
throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward);
}
}
private String getRequiredText(String gateway, String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body()));
}
String s = resp.body() == null ? "" : resp.body().trim();
if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path);
return s;
}
private Wallet wallet() throws Exception {
Wallet local = wallet;
if (local != null) return local;
synchronized (this) {
if (wallet != null) return wallet;
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8));
String owner = required(jwk, "n");
PrivateKey pk = buildPrivateKey(jwk);
String address = b64(sha256(b64Decode(owner)));
wallet = new Wallet(owner, address, pk);
return wallet;
}
}
private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException {
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")),
big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")),
big(required(jwk,"dq")), big(required(jwk,"qi")));
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
Signature signature = Signature.getInstance("RSASSA-PSS");
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signature.initSign(key);
signature.update(payload);
return signature.sign();
}
private static byte[] deepHash(Object data) throws GeneralSecurityException {
if (data instanceof List<?> list) {
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
return deepHashChunks(list, sha384(tag));
}
if (!(data instanceof byte[] bytes)) throw new IllegalArgumentException("deepHash: ожидается byte[]/List");
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
return sha384(concat(sha384(tag), sha384(bytes)));
}
private static byte[] deepHashChunks(List<?> list, byte[] acc) throws GeneralSecurityException {
byte[] cur = acc;
for (Object item : list) cur = sha384(concat(cur, deepHash(item)));
return cur;
}
private static void addTag(List<Map<String,String>> json, List<List<byte[]>> raw, String name, String value) {
byte[] n=utf8(name), v=utf8(value);
Map<String,String> item = new LinkedHashMap<>();
item.put("name", b64(n)); item.put("value", b64(v));
json.add(item); raw.add(List.of(n,v));
}
private static String required(JsonNode node, String field) {
String s = node == null ? "" : node.path(field).asText("").trim();
if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field);
return s;
}
private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); }
private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); }
private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); }
private static byte[] b64Decode(String s) { return B64D.decode(s); }
private static String b64(byte[] b) { return B64.encodeToString(b); }
private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); }
private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); }
private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); }
private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; }
private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; }
private record Wallet(String owner, String address, PrivateKey privateKey) {}
}
@@ -0,0 +1,157 @@
package server.archive;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts.
*/
final class ArweaveMerkle {
static final int MAX_CHUNK_SIZE = 256 * 1024;
static final int MIN_CHUNK_SIZE = 32 * 1024;
record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) {
long size() { return maxByteRange - minByteRange; }
}
record Proof(long offset, byte[] proof) {}
record Prepared(byte[] dataRoot, List<Chunk> chunks, List<Proof> proofs) {}
private sealed interface Node permits Leaf, Branch {
byte[] id();
long maxByteRange();
}
private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {}
private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {}
private ArweaveMerkle() {}
static Prepared prepare(Path file) throws IOException {
long fileSize = Files.size(file);
List<Chunk> chunks = chunkFile(file, fileSize);
List<Node> leaves = new ArrayList<>(chunks.size());
for (Chunk c : chunks) {
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
}
if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves");
Node root = buildLayers(leaves);
List<Proof> proofs = new ArrayList<>();
resolveProofs(root, new byte[0], proofs);
// arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB,
// но сам нулевой chunk/proof не загружает.
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
chunks = new ArrayList<>(chunks);
chunks.remove(chunks.size() - 1);
proofs.remove(proofs.size() - 1);
}
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
}
private static List<Chunk> chunkFile(Path file, long fileSize) throws IOException {
List<Chunk> out = new ArrayList<>();
long remaining = fileSize;
long cursor = 0;
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
while (remaining >= MAX_CHUNK_SIZE) {
long chunkSize = MAX_CHUNK_SIZE;
long next = remaining - MAX_CHUNK_SIZE;
if (next > 0 && next < MIN_CHUNK_SIZE) {
chunkSize = (remaining + 1) / 2;
}
byte[] data = readExact(in, (int)chunkSize);
out.add(new Chunk(sha256(data), cursor, cursor + chunkSize));
cursor += chunkSize;
remaining -= chunkSize;
}
byte[] rest = readExact(in, (int)remaining);
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
}
return out;
}
private static Node buildLayers(List<Node> nodes) {
if (nodes.size() < 2) return nodes.get(0);
List<Node> next = new ArrayList<>((nodes.size() + 1) / 2);
for (int i = 0; i < nodes.size(); i += 2) {
Node left = nodes.get(i);
Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null;
if (right == null) {
next.add(left);
continue;
}
long byteRange = left.maxByteRange();
byte[] id = sha256(concat(
sha256(left.id()),
sha256(right.id()),
sha256(note(byteRange))
));
next.add(new Branch(id, byteRange, right.maxByteRange(), left, right));
}
return buildLayers(next);
}
private static void resolveProofs(Node node, byte[] prefix, List<Proof> out) {
if (node instanceof Leaf leaf) {
out.add(new Proof(leaf.maxByteRange() - 1,
concat(prefix, leaf.dataHash(), note(leaf.maxByteRange()))));
return;
}
Branch b = (Branch) node;
byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange()));
resolveProofs(b.left(), partial, out);
resolveProofs(b.right(), partial, out);
}
static byte[] readChunk(Path file, Chunk c) throws IOException {
long len = c.size();
if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len);
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(c.minByteRange());
byte[] data = new byte[(int)len];
raf.readFully(data);
return data;
}
}
private static byte[] readExact(InputStream in, int len) throws IOException {
byte[] out = new byte[len];
int pos = 0;
while (pos < len) {
int n = in.read(out, pos, len - pos);
if (n < 0) throw new EOFException("Неожиданный EOF");
pos += n;
}
return out;
}
private static byte[] note(long value) {
if (value < 0) throw new IllegalArgumentException("negative note");
byte[] out = new byte[32];
long v = value;
for (int i = 31; i >= 0 && v != 0; i--) {
out[i] = (byte)(v & 0xff);
v >>>= 8;
}
return out;
}
private static byte[] sha256(byte[] data) {
try { return MessageDigest.getInstance("SHA-256").digest(data); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static byte[] concat(byte[]... arrays) {
int len = 0;
for (byte[] a : arrays) len = Math.addExact(len, a.length);
byte[] out = new byte[len];
int p=0;
for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; }
return out;
}
}
@@ -0,0 +1,101 @@
package server.archive;
import sync.util.Base58Util;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.MessageDigest;
import java.util.*;
/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */
public final class ShineArchiveReader {
public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {}
public record Chunk(long offset, long size, String blockchainName, List<byte[]> rawRecords,
long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {}
public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin,
List<Reference> references, List<Chunk> chunks, byte[] archiveHash) {}
public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception {
long fileSize = Files.size(file);
if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий");
if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается");
byte[] computedHash = digestPrefix(file, fileSize - 96);
try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) {
byte[] magic = new byte[ShineArchiveWriter.MAGIC.length];
in.readFully(magic);
if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic");
int major=in.readUnsignedByte(), minor=in.readUnsignedByte();
if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor);
long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong();
int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte();
long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in);
long chunksCount=u32(in); long recordsCount=u32(in);
if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) {
throw new IOException("Unsupported references table");
}
String creator=readUtf8(in,creatorLen);
if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) {
throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin);
}
if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size");
if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks");
List<Reference> refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE));
for(long i=0;i<refsCount;i++) {
long number=u32(in); byte[] hash=readFixed(in,32); byte[] tx=readFixed(in,32);
refs.add(new Reference(number,hash,Base64.getUrlEncoder().withoutPadding().encodeToString(tx)));
}
if (refs.isEmpty() && parentRef != ShineArchiveWriter.NO_REFERENCE_U32) throw new IOException("Некорректный parent reference");
if (!refs.isEmpty() && parentRef >= refs.size()) throw new IOException("parent_reference_index вне таблицы");
List<Chunk> chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE));
long parsedRecords=0;
for(long ci=0;ci<chunksCount;ci++) {
long chunkOffset=in.getFilePointer(); long chunkSize=u32(in); long chunkEnd=chunkOffset+chunkSize;
if (chunkSize < 21 || chunkEnd > fileSize-96) throw new IOException("Некорректный chunk_size");
int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in);
if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch);
List<byte[]> records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE));
for(long ri=0;ri<count;ri++) {
long n=u32(in); if(n<=0 || n>Integer.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size");
records.add(readFixed(in,(int)n)); parsedRecords++;
}
long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in);
if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы");
if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch);
chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize));
}
if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch");
int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen);
if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login");
if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash");
byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64);
if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature");
if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer");
if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash");
byte[] root=Base58Util.decode(rootPublicKeyBase58);
if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а");
byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32];
System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length);
System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32);
if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а");
return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash);
}
}
private static byte[] digestPrefix(Path file,long bytes) throws Exception {
MessageDigest md=MessageDigest.getInstance("SHA-256");
try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) {
byte[] buf=new byte[1024*1024]; long left=bytes;
while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;}
}
return md.digest();
}
private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); }
private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; }
private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); }
}
@@ -0,0 +1,310 @@
package server.archive;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Writer бинарного SHINE-ARCHIVE v1.0.
*
* Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки.
*/
public final class ShineArchiveWriter {
public static final byte VERSION_MAJOR = 1;
public static final byte VERSION_MINOR = 0;
public static final byte REFERENCES_FULL = 0;
public static final long NO_REFERENCE_U32 = 0xffff_ffffL;
public static final int REFERENCE_ENTRY_SIZE = 68;
public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII);
public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII);
public record ChunkPosition(long offset, long size) {}
public record BuildResult(Path file, byte[] archiveHash, Map<String, ChunkPosition> chunks, long fileSize) {}
public BuildResult build(
Path finalPath,
String creatorLogin,
long bigBlockNumber,
long createdAtMs,
List<ArchiveBigBlockRef> previousBigBlocks,
List<ArchivePublishJobChain> chains,
Map<String, List<BlockEntry>> recordsByBlockchain,
byte[] signingPrivateSeed32,
long maxFileBytes
) throws IOException {
Objects.requireNonNull(finalPath, "finalPath");
Objects.requireNonNull(previousBigBlocks, "previousBigBlocks");
Objects.requireNonNull(chains, "chains");
Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain");
requireU32(bigBlockNumber, "big_block_number");
if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) {
throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed");
}
byte[] loginBytes = utf8U8(creatorLogin, "creator_login");
if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) {
throw new IllegalArgumentException("Слишком много references/chunks для v1");
}
List<ArchivePublishJobChain> sortedChains = new ArrayList<>(chains);
sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long totalRecords = 0;
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> rs = recordsByBlockchain.get(ch.blockchainName());
if (rs == null || rs.isEmpty()) {
throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName());
}
totalRecords += rs.size();
requireU32(totalRecords, "total_user_records_count");
}
Map<Long, Integer> referenceIndexByBlock = new HashMap<>();
for (int i = 0; i < previousBigBlocks.size(); i++) {
ArchiveBigBlockRef ref = previousBigBlocks.get(i);
requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number");
require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash");
require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id");
referenceIndexByBlock.put(ref.bigBlockNumber(), i);
}
Files.createDirectories(finalPath.toAbsolutePath().getParent());
Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");
Files.deleteIfExists(tmp);
Map<String, ChunkPosition> positions = new LinkedHashMap<>();
MessageDigest digest = sha256();
try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
CountingOutputStream counting = new CountingOutputStream(fileOut);
DigestOutputStream digestOut = new DigestOutputStream(counting, digest);
DataOutputStream out = new DataOutputStream(digestOut)) {
// ---------- header ----------
out.write(MAGIC);
out.writeByte(VERSION_MAJOR);
out.writeByte(VERSION_MINOR);
long headerSize = fixedHeaderSize() + loginBytes.length;
writeU32(out, headerSize);
writeU32(out, bigBlockNumber);
out.writeLong(createdAtMs);
out.writeByte(loginBytes.length);
out.writeByte(REFERENCES_FULL);
writeU32(out, previousBigBlocks.size());
out.writeShort(REFERENCE_ENTRY_SIZE);
long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L;
writeU32(out, parentIndex);
writeU32(out, sortedChains.size());
writeU32(out, totalRecords);
out.write(loginBytes);
// ---------- полный список предыдущих больших блоков ----------
for (ArchiveBigBlockRef ref : previousBigBlocks) {
writeU32(out, ref.bigBlockNumber());
out.write(ref.archiveHash());
out.write(ref.arweaveTxId());
}
// ---------- по одному chunk на blockchain_name ----------
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName()));
records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber));
validateFrozenRange(ch, records);
byte[] name = utf8U8(ch.blockchainName(), "blockchain_name");
long chunkSize = calculateChunkSize(name.length, records);
requireU32(chunkSize, "chunk_size");
long chunkOffset = counting.getCount();
requireU32(chunkOffset, "chunk_offset");
if (chunkOffset + chunkSize > maxFileBytes) {
throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize));
}
writeU32(out, chunkSize);
out.writeByte(name.length);
out.write(name);
writeU32(out, records.size());
for (BlockEntry record : records) {
byte[] raw = record.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber());
writeU32(out, raw.length);
out.write(raw);
}
if (ch.previousArchiveBigBlockNumber() == null) {
writeU32(out, NO_REFERENCE_U32);
writeU32(out, 0);
writeU32(out, 0);
} else {
Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber());
if (refIndex == null) {
throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex);
require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName());
if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) {
throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) {
throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName());
}
writeU32(out, refIndex);
writeU32(out, ch.previousChunkOffset());
writeU32(out, ch.previousChunkSize());
}
positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize));
}
// ---------- footer, login входит в hash ----------
out.writeByte(loginBytes.length);
out.write(loginBytes);
out.flush();
byte[] archiveHash = digest.digest();
byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length];
System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length);
System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length);
byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32);
// hash/signature не должны попасть в hash самого блока.
digestOut.on(false);
out.write(archiveHash);
out.write(signature);
out.flush();
long finalSize = counting.getCount();
if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) {
throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize);
}
} catch (Exception e) {
Files.deleteIfExists(tmp);
if (e instanceof IOException ioe) throw ioe;
throw new IOException("Не удалось собрать SHINE-ARCHIVE", e);
}
// До Arweave upload существует уже готовый локальный файл с окончательным именем.
try {
Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
}
byte[] hash = readStoredArchiveHash(finalPath);
long size = Files.size(finalPath);
return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size);
}
/** Проверяет локальный файл перед оплатой Arweave. */
public static byte[] verifyLocalFile(Path file) throws IOException {
long size = Files.size(file);
if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file);
byte[] stored = readStoredArchiveHash(file);
MessageDigest md = sha256();
long hashInputSize = size - 96; // [archive_hash 32][signature 64]
try (InputStream in = Files.newInputStream(file)) {
byte[] buf = new byte[1024 * 1024];
long left = hashInputSize;
while (left > 0) {
int n = in.read(buf, 0, (int)Math.min(buf.length, left));
if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива");
md.update(buf, 0, n);
left -= n;
}
}
byte[] actual = md.digest();
if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer");
return actual;
}
public static byte[] readStoredArchiveHash(Path file) throws IOException {
long size = Files.size(file);
if (size < 96) throw new EOFException("Файл слишком короткий");
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(size - 96);
byte[] hash = new byte[32];
raf.readFully(hash);
return hash;
}
}
private static long fixedHeaderSize() {
// magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount
return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4;
}
private static long calculateChunkSize(int nameLen, List<BlockEntry> records) {
long size = 4L + 1L + nameLen + 4L + 12L;
for (BlockEntry e : records) {
byte[] raw = e.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("raw record is null");
size += 4L + raw.length;
}
return size;
}
private static void validateFrozenRange(ArchivePublishJobChain ch, List<BlockEntry> records) {
long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1;
if (expected <= 0 || expected != records.size()) {
throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName()
+ ": ожидалось=" + expected + ", получено=" + records.size());
}
long n = ch.fromSourceBlockNumber();
for (BlockEntry e : records) {
if (Integer.toUnsignedLong(e.getBlockNumber()) != n) {
throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName()
+ ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber()));
}
n++;
}
}
private static byte[] utf8U8(String value, String name) {
if (value == null) throw new IllegalArgumentException(name + " == null");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт");
return bytes;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes");
}
private static void requireU32(long v, String name) {
if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v);
}
private static void writeU32(DataOutputStream out, long v) throws IOException {
requireU32(v, "u32");
out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31.
}
private static MessageDigest sha256() {
try { return MessageDigest.getInstance("SHA-256"); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** OutputStream со счётчиком без ограничения int. */
private static final class CountingOutputStream extends FilterOutputStream {
private long count;
CountingOutputStream(OutputStream out) { super(out); }
long getCount() { return count; }
@Override public void write(int b) throws IOException { out.write(b); count++; }
@Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; }
}
public static final class ArchiveTooLargeException extends IOException {
public ArchiveTooLargeException(String message) { super(message); }
}
}
@@ -0,0 +1,373 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sync.codec.ShineUsersCodec;
import sync.codec.ShineUsersCodec.UserPdaSnapshot;
import sync.util.Base58Util;
import sync.util.SolanaPdaUtil;
import utils.crypto.Ed25519Util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.*;
/**
* Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda.
* Отдельной Solana-инструкции для архива нет.
*/
public final class SolanaArchiveHeadWriter {
private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class);
private static final ObjectMapper JSON = new ObjectMapper();
private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111";
private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111";
private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111";
private static final String USER_SEED_PREFIX = "user_login=";
private static final String ECONOMY_SEED = "shine_users_economy_config";
private static final String INFLOW_SEED = "shine_payments_inflow_vault";
private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8);
private final ArchivePublisherConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private final byte[] rootSeed32;
private final byte[] clientSeed32;
public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath());
}
public record SubmitResult(String signature, String userPda) {}
/** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */
public void validateKeysAgainstPda() throws Exception {
Current current = loadCurrent();
byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey());
byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey());
if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) {
throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin());
}
if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) {
throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin());
}
}
public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception {
require32(arweaveTxId32, "Arweave TX ID");
require32(archiveHash32, "archive hash");
Current current = loadCurrent();
UserPdaSnapshot old = current.snapshot;
byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32);
byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32);
if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) {
throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA");
}
if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) {
throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA");
}
byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64());
int currentRecordLen = u16le(currentRaw, 7);
byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64);
byte[] prevHash = sha256(currentUnsigned);
long updatedAt = System.currentTimeMillis();
String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32);
String archiveHashHex = hex(archiveHash32);
String zeroSig = Base58Util.encode(new byte[64]);
UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot(
old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "",
old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(),
old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(),
old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex,
old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(),
old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(),
old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, ""
);
byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot));
int nextRecordLen = u16le(nextFull, 7);
byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64);
byte[] nextUnsignedHash = sha256(nextUnsigned);
byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32);
byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature);
byte[] lastStateHash = sha256(buildLastBlockState(old));
byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature());
byte[] blockchainPub = Base58Util.decode(old.blockchainKey());
byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash);
byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash);
String blockhash = getLatestBlockhash();
byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub);
byte[] clientSignature = Ed25519Util.sign(message, clientSeed32);
byte[] transaction = serializeTransaction(clientSignature, message);
String signature = sendTransaction(transaction);
log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature);
return new SubmitResult(signature, current.userPda);
}
public void waitFinalized(String signature) throws Exception {
long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L;
while (System.currentTimeMillis() < deadline) {
JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result");
JsonNode values = result.path("value");
JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null;
if (status != null && !status.isNull()) {
if (status.hasNonNull("err")) {
throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err"));
}
String confirmation = status.path("confirmationStatus").asText("");
if ("finalized".equals(confirmation)) return;
}
Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L);
}
throw new IOException("Timeout ожидания Solana finalized: " + signature);
}
public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception {
Current current = loadCurrent();
if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false;
byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId());
byte[] currentHash = unhex(current.snapshot.archiveHeadHash());
return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32);
}
private Current loadCurrent() throws Exception {
String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT);
String userPda = SolanaPdaUtil.findProgramAddress(
List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)),
cfg.solanaUsersProgramId());
JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result");
JsonNode value = accountResult.path("value");
if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin());
String owner = value.path("owner").asText("");
if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner);
JsonNode data = value.path("data");
if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data");
String rawB64 = data.get(0).asText();
UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, "");
return new Current(userPda, snapshot);
}
private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash,
byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(4); // IX_UPDATE_USER_PDA
strU8(out, s.login());
out.write(Base58Util.decode(s.recoveryKey()));
out.write(Base58Util.decode(s.rootKey()));
u64le(out, s.createdAtMs());
u64le(out, updatedAt);
u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1));
out.write(prevHash);
u64le(out, 0L); // additional_limit
out.write(Base58Util.decode(s.clientKey()));
out.write(Base58Util.decode(s.blockchainKey()));
strU8(out, s.blockchainName());
u64le(out, s.usedBytes());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
out.write(Base58Util.decode(s.lastBlockSignature()));
strU8(out, s.arweaveTxId());
out.write(s.isServer() ? 1 : 0);
if (s.isServer()) {
out.write(s.addressFormatType() & 0xff);
out.write(s.addressFormatVersion() & 0xff);
strU8(out, s.serverAddress());
if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers");
out.write(s.syncServers().size());
for (String v : s.syncServers()) strU8(out, v);
}
if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers");
out.write(s.accessServers().size());
for (String v : s.accessServers()) strU8(out, v);
out.write(s.sessionsMode() & 0xff);
if (s.sessions().size() > 255) throw new IOException("Слишком много sessions");
out.write(s.sessions().size());
for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) {
out.write(session.sessionType() & 0xff);
out.write(session.sessionVersion() & 0xff);
strU8(out, session.sessionName());
out.write(Base58Util.decode(session.sessionPubKey()));
}
out.write(s.trustedCount() & 0xff);
// Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32.
out.write(1);
out.write(archiveTx);
out.write(archiveHash);
out.write(rootSig);
return out.toByteArray();
}
private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(LAST_BLOCK_STATE_PREFIX);
strU8(out, s.login());
strU8(out, s.blockchainName());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
u64le(out, s.usedBytes());
return out.toByteArray();
}
private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd,
String recentBlockhash, byte[] clientPub) throws IOException {
String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId());
String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId());
List<Account> accounts = List.of(
new Account(Base58Util.encode(clientPub), true, true),
new Account(userPda, false, true),
new Account(inflow, false, true),
new Account(SYSTEM_PROGRAM, false, false),
new Account(SYSVAR_INSTRUCTIONS, false, false),
new Account(economy, false, false),
new Account(COMPUTE_BUDGET_PROGRAM, false, false),
new Account(ED25519_PROGRAM, false, false),
new Account(cfg.solanaUsersProgramId(), false, false)
);
Map<String,Integer> ix = new HashMap<>();
for (int i=0;i<accounts.size();i++) ix.put(accounts.get(i).pubkey, i);
List<CompiledInstruction> instructions = new ArrayList<>();
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144)));
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000)));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd));
instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{
ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow),
ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy)
}, updateData));
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(1); // numRequiredSignatures
out.write(0); // numReadonlySignedAccounts
out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users
shortVec(out, accounts.size());
for (Account a : accounts) {
byte[] pk = Base58Util.decode(a.pubkey);
if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey);
out.write(pk);
}
byte[] bh = Base58Util.decode(recentBlockhash);
if (bh.length != 32) throw new IOException("recentBlockhash !=32");
out.write(bh);
shortVec(out, instructions.size());
for (CompiledInstruction ci : instructions) {
out.write(ci.programIdIndex);
shortVec(out, ci.accounts.length);
for (int a : ci.accounts) out.write(a);
shortVec(out, ci.data.length);
out.write(ci.data);
}
return out.toByteArray();
}
private String getLatestBlockhash() throws Exception {
JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result");
String blockhash = result.path("value").path("blockhash").asText("");
if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash");
return blockhash;
}
private String sendTransaction(byte[] tx) throws Exception {
String txB64 = Base64.getEncoder().encodeToString(tx);
JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of(
"encoding", "base64",
"skipPreflight", false,
"preflightCommitment", "confirmed",
"maxRetries", 5
))).path("result");
String sig = result.asText("");
if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature");
return sig;
}
private JsonNode rpc(String method, List<?> params) throws Exception {
Map<String,Object> body = new LinkedHashMap<>();
body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params);
HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl()))
.timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body()));
JsonNode root = JSON.readTree(resp.body());
if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error"));
return root;
}
private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException {
if (signature64.length != 64) throw new IOException("Solana signature !=64");
ByteArrayOutputStream out = new ByteArrayOutputStream();
shortVec(out, 1);
out.write(signature64);
out.write(message);
return out.toByteArray();
}
private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) {
if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature");
int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32;
if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный");
byte[] data = new byte[msgOff + message.length];
data[0]=1; data[1]=0;
putU16le(data,2,sigOff); putU16le(data,4,0xffff);
putU16le(data,6,pkOff); putU16le(data,8,0xffff);
putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff);
System.arraycopy(signature64,0,data,sigOff,64);
System.arraycopy(publicKey32,0,data,pkOff,32);
System.arraycopy(message,0,data,msgOff,message.length);
return data;
}
private static byte[] computeHeapData(int bytes) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray();
}
private static byte[] computeLimitData(int units) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray();
}
private static void shortVec(ByteArrayOutputStream out, int value) {
int rem=value;
while (true) {
int elem=rem & 0x7f; rem >>>=7;
if (rem==0) { out.write(elem); return; }
out.write(elem | 0x80);
}
}
private static void strU8(ByteArrayOutputStream out, String value) throws IOException {
byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8);
if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b);
}
private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);}
private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);}
private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;}
private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");}
private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";}
private record Current(String userPda, UserPdaSnapshot snapshot) {}
private record Account(String pubkey, boolean signer, boolean writable) {}
private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {}
}
@@ -0,0 +1,34 @@
package server.archive;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArchiveFileNamesTest {
private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli();
private static final ZoneId UTC = ZoneId.of("UTC");
@Test
void usesTemporaryAndFinalArweaveNames() {
byte[] tx = new byte[32];
for (int i = 0; i < tx.length; i++) tx[i] = (byte) i;
String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx);
assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC));
assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive",
ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx));
assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC));
}
@Test
void sanitizesLoginForFileSystemOnly() {
assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC));
}
}
@@ -0,0 +1,83 @@
package server.archive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import java.io.DataInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ShineArchiveWriterTest {
@TempDir Path dir;
@Test
void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception {
Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC"));
ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]);
ArchivePublishJobChain chain = new ArchivePublishJobChain(
1, "alice-001", 10, 11, new byte[32], new byte[32],
1L, new byte[32], 100L, 55L, null, null);
ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build(
file, "serverA", 2, 123456789L,
List.of(previous), List.of(chain),
Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))),
new byte[32], 4_000_000_000L);
assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file));
assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString());
try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) {
assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII));
assertEquals(1, in.readUnsignedByte());
assertEquals(0, in.readUnsignedByte());
long headerSize = Integer.toUnsignedLong(in.readInt());
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals(123456789L, in.readLong());
int creatorLen = in.readUnsignedByte();
assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte());
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
assertEquals(68, in.readUnsignedShort());
assertEquals(0, Integer.toUnsignedLong(in.readInt()));
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8));
assertEquals(headerSize, 51L + creatorLen);
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
in.skipNBytes(64); // previous hash + txid
long chunkSize = Integer.toUnsignedLong(in.readInt());
int nameLen = in.readUnsignedByte();
assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals(3, Integer.toUnsignedLong(in.readInt()));
assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertArrayEquals(new byte[]{4,5}, in.readNBytes(2));
assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index
assertEquals(100, Integer.toUnsignedLong(in.readInt()));
assertEquals(55, Integer.toUnsignedLong(in.readInt()));
assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize);
}
}
private static BlockEntry block(int number, byte[] bytes) {
BlockEntry e = new BlockEntry();
e.setBlockNumber(number);
e.setBlockBytes(bytes);
e.setBlockHash(new byte[32]);
e.setBlockSignature(new byte[64]);
return e;
}
}
@@ -36,7 +36,12 @@ public final class BodyRecordParser {
case TextBody.KEY -> {
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes);
}
@@ -45,12 +50,17 @@ public final class BodyRecordParser {
yield new TextReplyBody(subType, version, bodyBytes);
}
if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) {
yield new TextRatingBody(subType, version, bodyBytes);
}
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
}
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes);
default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version from header: type=%d ver=%d subType=%d",
@@ -64,11 +64,29 @@ public final class MsgSubType {
*/
public static final short TEXT_EDIT_REPLY = 21;
/** RATING — target-based отзыв на конкретный блок. */
public static final short TEXT_RATING = 30;
/**
* REPOST репост сообщения в линии канала.
* REPOST отложенная будущая заготовка репоста сообщения в линии канала.
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
*/
public static final short TEXT_REPOST = 30;
public static final short TEXT_REPOST = 50;
/**
* CHANNEL_META скрытый технический снимок профиля канала.
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
*/
public static final short TEXT_CHANNEL_META = 90;
/** ENTRYPOINT — входная страница канала (line-based). */
public static final short TEXT_ENTRYPOINT = 100;
/** EXERCISE — упражнение/комплекс (line-based). */
public static final short TEXT_EXERCISE = 110;
/** SERVICE — услуга/процедура (line-based). */
public static final short TEXT_SERVICE = 120;
/** COURSE — курс (line-based). */
public static final short TEXT_COURSE = 130;
/* ===================== REACTION (msg_type=2) ===================== */
@@ -80,25 +98,27 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
/** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10;
public static final short CONNECTION_CLOSE_FRIEND = 10;
/** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11;
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
/** Добавить в друзья. */
public static final short CONNECTION_FRIEND = 14;
/** Удалить из друзей. */
public static final short CONNECTION_UNFRIEND = 15;
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
/** Удалить из контактов. */
public static final short CONNECTION_UNCONTACT = 21;
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя (target header/block 0) считается legacy. DO NOT REMOVE.
/** Подписаться (follow). */
public static final short CONNECTION_FOLLOW = 30;
/** Отписаться (unfollow). */
public static final short CONNECTION_UNFOLLOW = 31;
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
/** Добавить связь "жена/муж". */
public static final short CONNECTION_SPOUSE = 40;
/** Удалить связь "жена/муж". */
@@ -119,9 +139,8 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */
public static final short CONNECTION_UNSIBLING = 55;
/** Просто знаю этого человека. */
// Legacy. Допускаем старые блоки 60/61, но новый UI их не создаёт и не использует. DO NOT REMOVE.
public static final short CONNECTION_KNOWN_PERSON = 60;
/** Не знаю этого человека. */
public static final short CONNECTION_UNKNOWN_PERSON = 61;
/** Точно уверен, что сияющий. */
@@ -129,13 +148,31 @@ public final class MsgSubType {
/** Не подтверждаю, что сияющий. */
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
// Пока не используется в UI. Сохраняем для совместимости протокола/блокчейна. DO NOT REMOVE.
/** Мало знаком, но видел сияющим. */
public static final short CONNECTION_SHINE_SEEN = 74;
/** Не отмечаю, что видел сияющим. */
public static final short CONNECTION_SHINE_UNSEEN = 75;
/** Подтверждаю, что это действительно официальный аккаунт этого человека. */
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
/** Снимаю подтверждение официального аккаунта этого человека. */
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
/* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_LEARNED = 20;
public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_INTERESTED = 110;
public static final short STATUS_STARTED = 120;
public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
}
@@ -20,9 +20,11 @@ import java.util.Objects;
* PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53
* SIBLING=54, UNSIBLING=55
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
* FRIEND=14, UNFRIEND=15
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
* SHINE_SEEN=74, SHINE_UNSEEN=75
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
*
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* [4] lineCode
@@ -66,7 +68,7 @@ import java.util.Objects;
* toBlockHash32=hash32(CREATE_CHANNEL)
*
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
*
@@ -183,7 +185,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
@@ -202,7 +206,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
}
@Override
@@ -19,7 +19,7 @@ import java.util.Objects;
* [1] channelNameLen
* [N] channelName UTF-8
* [2] channelDescriptionLen
* [M] channelDescription UTF-8 (0..200 bytes)
* [M] channelDescription UTF-8 (0..2048 bytes)
* [2] channelTypeCode (uint16)
* [2] channelTypeVersion (uint16)
*/
@@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static final byte[] ZERO32 = new byte[32];
private static final int MAX_NAME_LENGTH = 32;
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
public static final int MAX_DESCRIPTION_UTF8_LEN = 2048;
public final short subType;
public final short version;
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
int descriptionLen = Short.toUnsignedInt(bb.getShort());
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
if (bb.remaining() != descriptionLen + 4) {
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
@@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
String normalizedDescription = normalizeDescription(channelDescription);
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
int typeCode = Short.toUnsignedInt(channelTypeCode);
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static String normalizeDescription(String value) {
if (value == null) return "";
return value.trim().replaceAll("\\s+", " ");
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
@Override
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
}
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
@Override
public int lineSeq() { return thisLineNumber; }
}
@@ -0,0 +1,166 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* StatusActionBody type=5, ver=1.
*
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 5;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF));
}
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF));
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
ensureNoTail(bb, "StatusActionBody");
}
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public StatusActionBody check() {
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null) throw new IllegalArgumentException("message is null");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
private static boolean isSupportedSubType(short subType) {
int st = subType & 0xFFFF;
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
}
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) return "";
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}
@@ -16,11 +16,16 @@ import java.util.Objects;
* subType:
* - POST (10)
* - EDIT_POST (11)
* - REPOST (30)
* - REPOST (50)
* - CHANNEL_META (90)
* - ENTRYPOINT (100)
* - EXERCISE (110)
* - SERVICE (120)
* - COURSE (130)
*
* Формат bodyBytes (BigEndian):
*
* POST:
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
@@ -89,8 +94,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
@@ -133,7 +143,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.toBlockHash32 = null;
}
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
ensureNoTail(bb, "TextLineBody");
}
@@ -155,12 +166,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
}
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
if (requiresNonBlankMessage(st) && message.isBlank()) {
throw new IllegalArgumentException("message is blank");
}
@@ -206,7 +222,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
@@ -231,10 +252,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
} else {
if (message == null || message.isBlank())
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
throw new IllegalArgumentException("Text message is blank");
} else if (message == null) {
throw new IllegalArgumentException("Text message is null");
}
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST must not contain target fields");
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
}
return this;
@@ -246,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
int cap;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST
@@ -310,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
}
private static boolean requiresNonBlankMessage(int st) {
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) {
@@ -0,0 +1,157 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TextRatingBody type=1, ver=1.
*
* subType:
* - RATING (30)
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
public TextRatingBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
}
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody");
}
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = MsgSubType.TEXT_RATING;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("RATING toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}
@@ -59,23 +59,33 @@ public final class AppConfig {
public String getParam(String name) {
String fromSystem = System.getProperty(name);
if (fromSystem != null) return fromSystem;
String fromEnv = System.getenv(toEnvName(name));
if (fromEnv != null && !fromEnv.isBlank()) return fromEnv.trim();
return properties.getProperty(name);
}
/** Вернёт строку или пустую строку, если параметр не найден. */
public String getStringOrEmpty(String name) {
String value = properties.getProperty(name);
String value = getParam(name);
return value == null ? "" : value.trim();
}
/** Можно добавить методы для удобства */
/** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */
public int getInt(String name, int defaultValue) {
String v = properties.getProperty(name);
return v == null ? defaultValue : Integer.parseInt(v);
String v = getParam(name);
return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim());
}
/** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */
public boolean getBoolean(String name, boolean defaultValue) {
String v = properties.getProperty(name);
return v == null ? defaultValue : Boolean.parseBoolean(v);
String v = getParam(name);
return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim());
}
private static String toEnvName(String name) {
return name
.replace('.', '_')
.replace('-', '_')
.toUpperCase();
}
}
@@ -14,6 +14,12 @@ public final class ShineSignatureConstants {
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
@@ -31,4 +37,4 @@ public final class ShineSignatureConstants {
/** Длина публичного ключа Ed25519. */
public static final int ED25519_PUBLIC_KEY32_LEN = 32;
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ repositories {
}
dependencies {
implementation 'org.xerial:sqlite-jdbc:3.47.0.0' // sqlite
implementation 'org.postgresql:postgresql:42.7.7'
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
File diff suppressed because it is too large Load Diff
@@ -1,569 +0,0 @@
package shine.db;
import java.sql.SQLException;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/**
* DatabaseTriggersInstaller †усСР°РЅР°РІР»РёРІР°РµС СриггерС, РєРѕСРѕСЂСРµ поддерРРёРІР°СЋС Р±РёР·РЅРµСЃ-логику РР.
*
* РњС СЃРїРµСиально сделали ССЂРёРіРіРµСЂС РјР°РєСЃРёРјР°Р»СЊРЅРѕ "совместимыми":
* - РќРРў динамиСРµСЃРєРёС СЃРѕРѕР±СениРв RAISE(...): Солько СиксированнСРµ СЃССЂРѕРєРё.
* (НекоСРѕСЂСРµ SQLite-СЃР±РѕСЂРєРё / РїСЂРѕСЃРјРѕССЂСРёРєРё РїР°РґР°СЋС РЅР° "||" РІРЅСѓССЂРё RAISE.)
* - РќРРў UPSERT "ON CONFLICT DO UPDATE" †вмесСРѕ него:
* INSERT OR IGNORE + UPDATE
* (РЎСарСРµ SQLite РЅРµ Р·РЅР°СЋС UPSERT.)
*
* =============================================================================
* ОПИСАНИРТРИРРРР РћР
* =============================================================================
*
* [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks)
* РљРѕРЅСроль СелосСРЅРѕСЃСРё "линий" (line_code / prev_line_number / prev_line_hash / this_line_number).
*
* РР°Сем СЌСРѕ РЅСѓРРЅРѕ:
* - РканалаС/РІРµСРєР°С/РґРµРСЃСРІРёСЏС СС СРѕСРµССЊ РёРјРµССЊ "линейную" последоваСельносССЊ,
* РіРґРµ РєР°РРґСРследуюСиРблок СЏРІРЅРѕ СЃСЃСлаеССЃСЏ РЅР° предСРґСѓСиРблок линии
* Рё РїРѕРґСверРдаеС, ССРѕ СЃСЃСлка РЅРµ подменена.
*
* РљРѕРіРґР° срабаССваеС:
* - РўРћРЬКО если РїСЂРё РІСЃСавке передано РҐРћРўРЇ РР« РћРРќРћ РёР· line-полеР.
* - Рсли line-поля РЅРµ РїРµСЂРµРґР°РЅС вЂ Сриггер РІРѕРѕР±СРµ РЅРµ рабоСР°РµС (СЌСРѕ РІР°РРЅРѕ).
*
* Р§СРѕ проверяеС:
* A) line-поля допускаюССЃСЏ Солько для msg_type:
* 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM)
* B) Рсли РїСЂРёСло СРѕССЊ РѕРґРЅРѕ line-поле вЂ РѕР±СЏР·Р°РЅС РїСЂРёРСРё РРЎР 4 (РЅРёРєР°РєРёС "частичных")
* C) prev-блок линии СЃСѓСесСРІСѓРµС РІ СРѕР РРµ СепоСРєРµ bch_name
* D) prev_hash СЃРѕРІРїР°РґР°РµС СЃ block_hash РЅР°Рденного prev-блока
* E) line_code коррекСРЅСР:
* - либо первСР Саг после root: prev_line_number == line_code
* - либо prev СѓРРµ принадлеРРёС СЌСоРлинии: p.line_code == NEW.line_code
* F) this_line_number:
* - первСР Саг после root:
* TEXT: this_line_number = 0
* TECH/CONNECTION/USER_PARAM: this_line_number = 1
* - РѕР±ССРЅСР Саг:
* TEXT: допускаем same или +1 (ССРѕР±С "edit" РјРѕРі РЅРµ РґРІРёРіР°ССЊ Саг)
* TECH/CONNECTION/USER_PARAM: СЃССЂРѕРіРѕ prev.this + 1
*
* Какие РѕСРёР±РєРё кидаеС:
* - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE
* - LINE_ERR_PARTIAL_FIELDS
* - LINE_ERR_NO_PREV
* - LINE_ERR_PREV_HASH_MISMATCH
* - LINE_ERR_LINE_CODE_MISMATCH
* - LINE_ERR_FIRST_STEP_BAD_THIS
* - LINE_ERR_THIS_LINE_BAD_STEP
*
* [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3)
* ПоддерРРёРІР°РµС СаблиССѓ connections_state как "текущее состояние" РѕСРЅРѕСениР:
* - FRIEND/CONTACT/FOLLOW -> добавиССЊ/РѕР±РЅРѕРІРёССЊ СЃРѕСЃСРѕСЏРЅРёРµ
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалиССЊ СЃРѕРѕСРІРµССЃСРІСѓСЋСее "позитивное" СЃРѕСЃСРѕСЏРЅРёРµ
*
* [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE)
* ПоддерРРёРІР°РµС likes_count РІ message_stats для Сели (to_*).
*
* [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY)
* ПоддерРРёРІР°РµС replies_count РІ message_stats.
*
* [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT)
* РРѕРіРёРєР° edit:
* - РїРѕРјРµСР°РµС РёСЃСРѕРґРЅСРблок edited_by_block_number = NEW.block_number
* - увелиСРёРІР°РµС edits_count РІ message_stats
*/
public final class DatabaseTriggersInstaller {
private DatabaseTriggersInstaller() {}
public static void createAllTriggers(Statement st) throws SQLException {
dropTriggersByPrefix(st, "trg_blocks_");
// РќР° всякиРслуСаРубираем СЃСарСРµ "РєСЂРёРІРѕ названные" СриггерС,
// если РѕРЅРё РєРѕРіРґР°-СРѕ попадали РІ РР.
st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;");
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;");
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;");
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;");
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;");
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;");
createLineIntegrityTrigger(st);
createConnectionStateTrigger(st);
createMessageStatsLikeTrigger(st);
createMessageStatsReplyTrigger(st);
createEditApplyTrigger(st);
}
private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException {
List<String> triggerNames = new ArrayList<>();
String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'";
try (ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
String name = rs.getString("name");
if (name != null && !name.isBlank()) {
triggerNames.add(name);
}
}
}
for (String name : triggerNames) {
String safeName = name.replace("\"", "\"\"");
st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\"");
}
}
private static void createLineIntegrityTrigger(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
BEFORE INSERT ON blocks
WHEN
NEW.line_code IS NOT NULL
OR NEW.prev_line_number IS NOT NULL
OR NEW.prev_line_hash IS NOT NULL
OR NEW.this_line_number IS NOT NULL
BEGIN
SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE')
WHERE NOT (NEW.msg_type IN (0, 1, 3, 4));
SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS')
WHERE NEW.line_code IS NULL
OR NEW.prev_line_number IS NULL
OR NEW.prev_line_hash IS NULL
OR NEW.this_line_number IS NULL;
SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV')
WHERE NOT EXISTS(
SELECT 1
FROM blocks p
WHERE p.bch_name = NEW.bch_name
AND p.block_number = NEW.prev_line_number
LIMIT 1
);
SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH')
WHERE NOT EXISTS(
SELECT 1
FROM blocks p
WHERE p.bch_name = NEW.bch_name
AND p.block_number = NEW.prev_line_number
AND p.block_hash = NEW.prev_line_hash
LIMIT 1
);
SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH')
WHERE NEW.prev_line_number <> NEW.line_code
AND NOT EXISTS(
SELECT 1
FROM blocks p
WHERE p.bch_name = NEW.bch_name
AND p.block_number = NEW.prev_line_number
AND p.line_code = NEW.line_code
LIMIT 1
);
SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS')
WHERE NEW.prev_line_number = NEW.line_code
AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END);
SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP')
WHERE NEW.prev_line_number <> NEW.line_code
AND NOT EXISTS(
SELECT 1
FROM blocks p
WHERE p.bch_name = NEW.bch_name
AND p.block_number = NEW.prev_line_number
AND p.this_line_number IS NOT NULL
AND (
(NEW.msg_type = 1 AND
(NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1)
)
OR
(NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1)
)
LIMIT 1
);
END;
""");
}
private static void createConnectionStateTrigger(Statement st) throws SQLException {
int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND;
int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT;
int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW;
int SPOUSE = (int) DatabaseInitializer.CONNECTION_SPOUSE;
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING;
int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON;
int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED;
int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN;
int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW;
int UNSPOUSE = (int) DatabaseInitializer.CONNECTION_UNSPOUSE;
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING;
int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON;
int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED;
int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN;
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 3
BEGIN
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*:
-- 1) если записи РЅРµС вЂ СЃРѕР·РґР°СРј
INSERT OR IGNORE INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
)
SELECT
NEW.login,
NEW.msg_sub_type,
COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
ELSE NULL
END
),
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
AND COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
ELSE NULL
END
) IS NOT NULL
AND NEW.to_bch_name IS NOT NULL;
-- 2) если запись есССЊ †обновляем акСуальнСРµ to_*
UPDATE connections_state
SET
to_bch_name = NEW.to_bch_name,
to_block_number = NEW.to_block_number,
to_block_hash = NEW.to_block_hash
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
ELSE NULL
END
)
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
AND COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
ELSE NULL
END
) IS NOT NULL
AND NEW.to_bch_name IS NOT NULL;
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*:
-- удаляем СЃРѕРѕСРІРµССЃСРІСѓСЋСее "позитивное" СЃРѕСЃСРѕСЏРЅРёРµ
DELETE FROM connections_state
WHERE login = NEW.login
AND to_login = COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
ELSE NULL
END
)
AND rel_type = CASE NEW.msg_sub_type
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
ELSE rel_type
END
AND COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
ELSE NULL
END
) IS NOT NULL
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d);
END;
""".formatted(
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
UNFRIEND, FRIEND,
UNCONTACT, CONTACT,
UNFOLLOW, FOLLOW,
UNSPOUSE, SPOUSE,
UNPARENT, PARENT,
UNCHILD, CHILD,
UNSIBLING, SIBLING,
UNKNOWN, KNOWN,
SHINE_UNCONF, SHINE_CONF,
SHINE_UNSEEN, SHINE_SEEN,
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN
));
}
private static void createMessageStatsLikeTrigger(Statement st) throws SQLException {
int LIKE = (int) DatabaseInitializer.REACTION_LIKE;
int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE;
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d)
BEGIN
-- ensure target stats row exists
INSERT OR IGNORE INTO message_stats (
to_login, to_bch_name, to_block_number, to_block_hash,
likes_count, replies_count, edits_count
)
SELECT
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
0, 0, 0
WHERE NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL;
-- apply delta by state transition (none/unlike->like = +1, like->unlike = -1)
UPDATE message_stats
SET likes_count = MAX(
0,
likes_count + (
CASE
WHEN NEW.msg_sub_type = %d
AND COALESCE((
SELECT b.msg_sub_type
FROM blocks b
WHERE b.login = NEW.login
AND b.bch_name = NEW.bch_name
AND b.msg_type = 2
AND b.to_login = NEW.to_login
AND b.to_bch_name = NEW.to_bch_name
AND b.to_block_number = NEW.to_block_number
AND b.to_block_hash = NEW.to_block_hash
AND b.block_number < NEW.block_number
ORDER BY b.block_number DESC
LIMIT 1
), -1) <> %d
THEN 1
WHEN NEW.msg_sub_type = %d
AND COALESCE((
SELECT b.msg_sub_type
FROM blocks b
WHERE b.login = NEW.login
AND b.bch_name = NEW.bch_name
AND b.msg_type = 2
AND b.to_login = NEW.to_login
AND b.to_bch_name = NEW.to_bch_name
AND b.to_block_number = NEW.to_block_number
AND b.to_block_hash = NEW.to_block_hash
AND b.block_number < NEW.block_number
ORDER BY b.block_number DESC
LIMIT 1
), -1) = %d
THEN -1
ELSE 0
END
)
)
WHERE to_login = NEW.to_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = NEW.to_block_number
AND to_block_hash = NEW.to_block_hash
AND NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL;
-- persist latest actor->target reaction state
INSERT OR IGNORE INTO reactions_state (
from_login, from_bch_name, reaction_type,
to_login, to_bch_name, to_block_number, to_block_hash,
last_sub_type
)
SELECT
NEW.login, NEW.bch_name, %d,
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
NEW.msg_sub_type
WHERE NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL;
UPDATE reactions_state
SET last_sub_type = NEW.msg_sub_type
WHERE from_login = NEW.login
AND from_bch_name = NEW.bch_name
AND reaction_type = %d
AND to_login = NEW.to_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = NEW.to_block_number
AND to_block_hash = NEW.to_block_hash
AND NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL;
END;
""".formatted(
LIKE, UNLIKE,
LIKE, LIKE,
UNLIKE, LIKE,
LIKE,
LIKE
));
}
private static void createMessageStatsReplyTrigger(Statement st) throws SQLException {
int REPLY = (int) DatabaseInitializer.TEXT_REPLY;
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d
BEGIN
INSERT OR IGNORE INTO message_stats (
to_login, to_bch_name, to_block_number, to_block_hash,
likes_count, replies_count, edits_count
)
SELECT
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
0, 0, 0
WHERE NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL;
UPDATE message_stats
SET replies_count = replies_count + 1
WHERE to_login = NEW.to_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = NEW.to_block_number
AND to_block_hash = NEW.to_block_hash
AND NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL;
END;
""".formatted(REPLY));
}
private static void createEditApplyTrigger(Statement st) throws SQLException {
int EDIT_POST = (int) DatabaseInitializer.TEXT_EDIT_POST;
int EDIT_REPLY = (int) DatabaseInitializer.TEXT_EDIT_REPLY;
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d)
BEGIN
-- 1) РїРѕРјРµСаем РёСЃСРѕРґРЅСРблок, ССРѕ его "перекрыл" СЌСРѕС edit
UPDATE blocks
SET edited_by_block_number = NEW.block_number
WHERE login = NEW.login
AND bch_name = NEW.bch_name
AND block_number = NEW.to_block_number
AND NEW.to_block_number IS NOT NULL;
-- 2) СЃРѕР·РґР°СРј stats-СЃССЂРѕРєСѓ если РµС РЅРµ Р±Сло
INSERT OR IGNORE INTO message_stats (
to_login, to_bch_name, to_block_number, to_block_hash,
likes_count, replies_count, edits_count
)
SELECT
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
0, 0, 0
WHERE NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL;
-- 3) +1 edit
UPDATE message_stats
SET edits_count = edits_count + 1
WHERE to_login = NEW.to_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = NEW.to_block_number
AND to_block_hash = NEW.to_block_hash
AND NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL;
END;
""".formatted(EDIT_POST, EDIT_REPLY));
}
}
@@ -0,0 +1,61 @@
package shine.db;
import shine.db.connection.DbProvider;
import shine.db.dao.DmDialogStateDAO;
import java.sql.Connection;
import java.sql.SQLException;
/**
* Нейтральная точка входа в runtime БД сервера.
*
* Runtime-сервер теперь поддерживает только PostgreSQL.
*/
public final class DbController implements DbProvider {
private static volatile DbController instance;
private final PostgresDbController delegate;
private volatile boolean dmDialogStateBootstrapped;
private DbController() {
this.delegate = PostgresDbController.getInstance();
}
public static DbController getInstance() {
if (instance == null) {
synchronized (DbController.class) {
if (instance == null) {
DbController created = new DbController();
instance = created;
created.bootstrapDmDialogStateIfNeeded();
}
}
}
return instance;
}
@Override
public Connection getConnection() throws SQLException {
return delegate.getConnection();
}
@Override
public void close() {
delegate.close();
}
private void bootstrapDmDialogStateIfNeeded() {
if (dmDialogStateBootstrapped) return;
synchronized (this) {
if (dmDialogStateBootstrapped) return;
try (Connection connection = delegate.getConnection()) {
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
dmDialogStateBootstrapped = true;
} catch (SQLException e) {
instance = null;
throw new RuntimeException("DM dialog state bootstrap failed", e);
}
}
}
}
@@ -0,0 +1,115 @@
package shine.db;
import java.util.Base64;
/**
* Нормализация ключей из разных storage-слоёв к каноническому Base64(32).
*/
public final class KeyEncodingUtil {
private static final String BASE58_ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
private static final int[] BASE58_INDEXES = new int[128];
static {
for (int i = 0; i < BASE58_INDEXES.length; i++) {
BASE58_INDEXES[i] = -1;
}
for (int i = 0; i < BASE58_ALPHABET.length(); i++) {
BASE58_INDEXES[BASE58_ALPHABET.charAt(i)] = i;
}
}
private KeyEncodingUtil() {}
public static String normalizeKeyToBase64_32(String rawKey) {
if (rawKey == null) {
return null;
}
String value = rawKey.trim();
if (value.isEmpty()) {
return value;
}
byte[] asBase64 = tryDecodeBase64_32(value);
if (asBase64 != null) {
return Base64.getEncoder().encodeToString(asBase64);
}
byte[] asBase58 = tryDecodeBase58_32(value);
if (asBase58 != null) {
return Base64.getEncoder().encodeToString(asBase58);
}
return value;
}
private static byte[] tryDecodeBase64_32(String value) {
try {
byte[] decoded = Base64.getDecoder().decode(value);
return decoded.length == 32 ? decoded : null;
} catch (IllegalArgumentException ignore) {
return null;
}
}
private static byte[] tryDecodeBase58_32(String value) {
byte[] decoded = decodeBase58(value);
return decoded.length == 32 ? decoded : null;
}
private static byte[] decodeBase58(String input) {
if (input.isEmpty()) {
return new byte[0];
}
byte[] input58 = new byte[input.length()];
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
if (c >= BASE58_INDEXES.length || BASE58_INDEXES[c] < 0) {
return new byte[0];
}
input58[i] = (byte) BASE58_INDEXES[c];
}
int zeros = 0;
while (zeros < input58.length && input58[zeros] == 0) {
zeros++;
}
byte[] decoded = new byte[input.length()];
int outputStart = decoded.length;
int inputStart = zeros;
while (inputStart < input58.length) {
int mod = divmod256(input58, inputStart);
if (input58[inputStart] == 0) {
inputStart++;
}
decoded[--outputStart] = (byte) mod;
}
while (outputStart < decoded.length && decoded[outputStart] == 0) {
outputStart++;
}
byte[] result = new byte[decoded.length - outputStart + zeros];
for (int i = 0; i < zeros; i++) {
result[i] = 0;
}
System.arraycopy(decoded, outputStart, result, zeros, decoded.length - outputStart);
return result;
}
private static int divmod256(byte[] number58, int startAt) {
int remainder = 0;
for (int i = startAt; i < number58.length; i++) {
int digit58 = number58[i] & 0xFF;
int temp = remainder * 58 + digit58;
number58[i] = (byte) (temp / 256);
remainder = temp % 256;
}
return remainder;
}
}
@@ -30,8 +30,23 @@ public final class MsgSubType {
/** EDIT_REPLY — редактирование исходного ответа. */
public static final short TEXT_EDIT_REPLY = 21;
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
public static final short TEXT_REPOST = 30;
/** RATING — target-based отзыв на конкретный блок. */
public static final short TEXT_RATING = 30;
/** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */
public static final short TEXT_REPOST = 50;
/** CHANNEL_META — скрытый технический снимок профиля канала. */
public static final short TEXT_CHANNEL_META = 90;
/** ENTRYPOINT — входная страница канала (line-based). */
public static final short TEXT_ENTRYPOINT = 100;
/** EXERCISE — упражнение/комплекс (line-based). */
public static final short TEXT_EXERCISE = 110;
/** SERVICE — услуга/процедура (line-based). */
public static final short TEXT_SERVICE = 120;
/** COURSE — курс (line-based). */
public static final short TEXT_COURSE = 130;
/* ===================== REACTION (msg_type=2) ===================== */
@@ -43,23 +58,23 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
/**
* Совпадает с ConnectionBody:
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
* SET: CLOSE_FRIEND=10, FRIEND=14, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
* KNOWN_PERSON(legacy)=60, SHINE_CONFIRMED=70, SHINE_SEEN=74, OFFICIAL_ACCOUNT_CONFIRMED=80
* UNSET: UNCLOSE_FRIEND=11, UNFRIEND=15, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNKNOWN_PERSON(legacy)=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75, OFFICIAL_ACCOUNT_UNCONFIRMED=81
*/
/** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10;
public static final short CONNECTION_CLOSE_FRIEND = 10;
/** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11;
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
/** Добавить в друзья. */
public static final short CONNECTION_FRIEND = 14;
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
/** Удалить из друзей. */
public static final short CONNECTION_UNFRIEND = 15;
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
@@ -67,12 +82,14 @@ public final class MsgSubType {
/** Удалить из контактов. */
public static final short CONNECTION_UNCONTACT = 21;
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя считается legacy. DO NOT REMOVE.
/** Подписаться (follow). */
public static final short CONNECTION_FOLLOW = 30;
/** Отписаться (unfollow). */
public static final short CONNECTION_UNFOLLOW = 31;
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
/** Добавить связь "жена/муж". */
public static final short CONNECTION_SPOUSE = 40;
@@ -97,10 +114,8 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */
public static final short CONNECTION_UNSIBLING = 55;
/** Просто знаю этого человека. */
// Legacy 60/61: принимаем старые данные, но новый UI их не использует. DO NOT REMOVE.
public static final short CONNECTION_KNOWN_PERSON = 60;
/** Не знаю этого человека. */
public static final short CONNECTION_UNKNOWN_PERSON = 61;
/** Точно уверен, что сияющий. */
@@ -109,17 +124,36 @@ public final class MsgSubType {
/** Не подтверждаю, что сияющий. */
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
// Пока не используется в UI. Сохраняем для совместимости. DO NOT REMOVE.
/** Мало знаком, но видел сияющим. */
public static final short CONNECTION_SHINE_SEEN = 74;
/** Не отмечаю, что видел сияющим. */
public static final short CONNECTION_SHINE_UNSEEN = 75;
/** Подтверждение официального аккаунта человека. */
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
/** Снятие подтверждения официального аккаунта человека. */
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
/* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_LEARNED = 20;
public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_INTERESTED = 110;
public static final short STATUS_STARTED = 120;
public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
// Если позже захочешь BLOCK/UNBLOCK лучше добавить новые значения,
// не трогая уже занятые коды.
@@ -0,0 +1,63 @@
package shine.db;
import shine.db.connection.DriverManagerDbProvider;
import utils.config.AppConfig;
import java.sql.Connection;
import java.sql.SQLException;
public final class PostgresDbController {
private static volatile PostgresDbController instance;
private final DriverManagerDbProvider delegate;
private PostgresDbController() {
AppConfig config = AppConfig.getInstance();
String jdbcUrl = trimToNull(config.getParam("db.url"));
String dbUser = trimToNull(config.getParam("db.user"));
String dbPassword = trimToNull(config.getParam("db.password"));
if (jdbcUrl == null) {
throw new IllegalStateException("Config param 'db.url' is required and must point to PostgreSQL");
}
if (!jdbcUrl.startsWith("jdbc:postgresql:")) {
throw new IllegalStateException("Only PostgreSQL runtime is supported. Unsupported db.url=" + jdbcUrl);
}
try {
DatabaseInitializer.ensurePostgresSchemaInitialized(jdbcUrl, dbUser, dbPassword);
} catch (SQLException e) {
throw new RuntimeException("PostgreSQL schema auto-init failed", e);
}
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
connection.setAutoCommit(true);
});
}
public static PostgresDbController getInstance() {
if (instance == null) {
synchronized (PostgresDbController.class) {
if (instance == null) {
instance = new PostgresDbController();
}
}
}
return instance;
}
public Connection getConnection() throws SQLException {
return delegate.getConnection();
}
public void close() {
// no-op
}
private static String trimToNull(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -1,798 +0,0 @@
package shine.db;
import utils.config.AppConfig;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = 11;
private final String jdbcUrl;
private SqliteDbController() {
try {
Class.forName("org.sqlite.JDBC");
} catch (ClassNotFoundException e) {
throw new RuntimeException("SQLite JDBC driver not found", e);
}
String dbPath = AppConfig.getInstance().getParam("db.path");
if (dbPath == null || dbPath.isBlank()) {
throw new RuntimeException("Config param 'db.path' is not set in application.properties");
}
Path dbFile = Paths.get(dbPath);
if (!Files.exists(dbFile)) {
System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath());
System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer...");
DatabaseInitializer.createNewDB(new String[0]);
}
this.jdbcUrl = "jdbc:sqlite:" + dbPath;
ensureSchemaMigrations();
}
public static SqliteDbController getInstance() {
if (instance == null) {
synchronized (SqliteDbController.class) {
if (instance == null) {
instance = new SqliteDbController();
}
}
}
return instance;
}
public Connection getConnection() throws SQLException {
Connection conn = DriverManager.getConnection(jdbcUrl);
conn.setAutoCommit(true);
try (Statement st = conn.createStatement()) {
st.execute("PRAGMA foreign_keys = ON");
st.execute("PRAGMA journal_mode = WAL");
st.execute("PRAGMA synchronous = NORMAL");
st.execute("PRAGMA busy_timeout = 5000");
}
return conn;
}
public void close() {
// no-op
}
private void ensureSchemaMigrations() {
int currentVersion = getCurrentSchemaVersion();
while (currentVersion < LATEST_SCHEMA_VERSION) {
int nextVersion = currentVersion + 1;
applyMigration(nextVersion);
currentVersion = nextVersion;
}
}
private void applyMigration(int targetVersion) {
switch (targetVersion) {
case 1 -> migrateToV1();
case 2 -> migrateToV2();
case 3 -> migrateToV3();
case 4 -> migrateToV4();
case 5 -> migrateToV5();
case 6 -> migrateToV6();
case 7 -> migrateToV7();
case 8 -> migrateToV8();
case 9 -> migrateToV9();
case 10 -> migrateToV10();
case 11 -> migrateToV11();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
private void migrateToV1() {
try {
DatabaseInitializer.ensureSchemaV1Structure(jdbcUrl);
} catch (SQLException e) {
throw new RuntimeException("DB migration to v1 failed (base schema)", e);
}
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
st.execute("PRAGMA foreign_keys = OFF");
if (tableExists(c, "connections_state") && needsConnectionsStateUpgrade(c)) {
rebuildConnectionsStateTable(st);
}
ensureChannelNamesDescriptionColumn(c, st);
ensureChannelNamesTypeColumns(c, st);
ensureSignedMessageReceiptUniq(c, st);
DatabaseTriggersInstaller.createAllTriggers(st);
setSchemaVersion(c, 1);
st.execute("PRAGMA foreign_keys = ON");
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v1 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v1 failed", e);
}
}
private void migrateToV2() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
st.execute("PRAGMA foreign_keys = OFF");
st.executeUpdate("DROP TABLE IF EXISTS message_views_state");
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target");
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel");
setSchemaVersion(c, 2);
st.execute("PRAGMA foreign_keys = ON");
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v2 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v2 failed", e);
}
}
private void migrateToV3() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureChat200StateTables(st);
setSchemaVersion(c, 3);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v3 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v3 failed", e);
}
}
private void migrateToV4() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureActiveSessionsSessionTypeColumn(c, st);
ensureActiveSessionsClientPlatformColumn(c, st);
setSchemaVersion(c, 4);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v4 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v4 failed", e);
}
}
private void migrateToV5() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureEspPairingTables(st);
setSchemaVersion(c, 5);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v5 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v5 failed", e);
}
}
private void migrateToV6() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureSignedMessagesRevisionColumn(c, st);
setSchemaVersion(c, 6);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v6 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v6 failed", e);
}
}
private void migrateToV7() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
dropDmFileTables(st);
setSchemaVersion(c, 7);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v7 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v7 failed", e);
}
}
private void migrateToV8() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureTestFreeAvatarUploadsTable(st);
setSchemaVersion(c, 8);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v8 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v8 failed", e);
}
}
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 {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL,
channel_root_block_hash BLOB NOT NULL,
channel_name TEXT NOT NULL,
channel_type_version INTEGER NOT NULL,
chat_title TEXT NOT NULL DEFAULT '',
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number)
);
""");
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_members_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL,
member_login TEXT NOT NULL,
member_channel_name TEXT NOT NULL,
is_active INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
updated_by_block_number INTEGER NOT NULL,
PRIMARY KEY (
owner_bch_name,
channel_root_block_number,
member_login,
member_channel_name
)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_chat200_state_owner
ON chat200_state (owner_login, owner_bch_name);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_chat200_members_owner
ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active);
""");
}
private int getCurrentSchemaVersion() {
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
return 0;
}
try (var ps = c.prepareStatement("""
SELECT schema_version
FROM db_schema_version
WHERE id = 1
LIMIT 1
""");
ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return rs.getInt(1);
}
}
return 0;
} catch (SQLException e) {
throw new RuntimeException("Cannot read DB schema version", e);
}
}
private static void ensureActiveSessionsSessionTypeColumn(Connection c, Statement st) throws SQLException {
if (columnExists(c, "active_sessions", "session_type")) return;
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN session_type INTEGER NOT NULL DEFAULT 1");
}
private static void ensureActiveSessionsClientPlatformColumn(Connection c, Statement st) throws SQLException {
if (columnExists(c, "active_sessions", "client_platform")) return;
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''");
}
private static void ensureEspPairingTables(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS esp_pairing_settings (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
enabled INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '',
ttl_seconds INTEGER NOT NULL DEFAULT 300,
failed_attempts INTEGER NOT NULL DEFAULT 0,
first_failed_at_ms INTEGER NOT NULL DEFAULT 0,
blocked_until_ms INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL,
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS esp_pairing_requests (
pairing_id TEXT NOT NULL PRIMARY KEY,
login TEXT NOT NULL,
requester_session_key TEXT NOT NULL,
requester_session_type INTEGER NOT NULL DEFAULT 1,
requester_client_platform TEXT NOT NULL DEFAULT '',
payload_type INTEGER NOT NULL,
status TEXT NOT NULL,
short_code TEXT NOT NULL,
fingerprint_b58 TEXT NOT NULL,
encrypted_payload TEXT,
reject_reason TEXT,
approved_by_session_id TEXT,
created_at_ms INTEGER NOT NULL,
expires_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
delivered_to_homeserver INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status
ON esp_pairing_requests (login, status, expires_at_ms);
""");
}
private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException {
if (!tableExists(c, "signed_messages_v2")) return;
if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) {
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0");
}
}
private static void 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 {
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login");
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links");
st.executeUpdate("DROP TABLE IF EXISTS dm_files");
}
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
try (Statement probe = c.createStatement();
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
while (rs.next()) {
if (columnName.equalsIgnoreCase(rs.getString("name"))) {
return true;
}
}
return false;
}
}
private static void setSchemaVersion(Connection c, int version) throws SQLException {
try (var ps = c.prepareStatement("""
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, ?, CAST(strftime('%s','now') AS INTEGER) * 1000)
ON CONFLICT(id) DO UPDATE SET
schema_version = excluded.schema_version,
updated_at_ms = excluded.updated_at_ms
""")) {
ps.setInt(1, version);
ps.executeUpdate();
}
}
private static void ensureReactionsStateTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS reactions_state (
from_login TEXT NOT NULL,
from_bch_name TEXT NOT NULL,
reaction_type INTEGER NOT NULL,
to_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
to_block_number INTEGER NOT NULL,
to_block_hash BLOB NOT NULL,
last_sub_type INTEGER NOT NULL,
UNIQUE (
from_login,
from_bch_name,
reaction_type,
to_login,
to_bch_name,
to_block_number,
to_block_hash
)
);
""");
}
private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
used_count INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL,
last_tx_id TEXT NOT NULL DEFAULT '',
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
ON test_free_avatar_uploads (updated_at_ms);
""");
}
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 {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS connections_state (
login TEXT NOT NULL,
rel_type INTEGER NOT NULL,
to_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
to_block_number INTEGER NOT NULL,
to_block_hash BLOB NOT NULL,
FOREIGN KEY (login) REFERENCES solana_users(login),
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
);
""");
}
private static void ensureConnectionsIndexes(Statement st) throws SQLException {
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_connections_state_login
ON connections_state (login);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_connections_state_to_login
ON connections_state (to_login);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_connections_state_pair
ON connections_state (login, to_login);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_connections_state_target
ON connections_state (login, rel_type, to_bch_name, to_block_number);
""");
}
private static void ensureReactionsIndexes(Statement st) throws SQLException {
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
ON reactions_state (from_login, from_bch_name, reaction_type);
""");
}
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS channel_names_state (
slug TEXT NOT NULL,
display_name TEXT NOT NULL,
channel_description TEXT NOT NULL DEFAULT '',
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
channel_type_version INTEGER NOT NULL DEFAULT 1,
channel_root_block_number INTEGER NOT NULL,
channel_root_block_hash BLOB NOT NULL,
created_at_ms INTEGER NOT NULL
);
""");
}
private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException {
boolean hasDescription = false;
try (Statement probe = c.createStatement();
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
while (rs.next()) {
String name = rs.getString("name");
if ("channel_description".equalsIgnoreCase(name)) {
hasDescription = true;
break;
}
}
}
if (!hasDescription) {
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''");
}
}
private static void ensureChannelNamesTypeColumns(Connection c, Statement st) throws SQLException {
boolean hasTypeCode = false;
boolean hasTypeVersion = false;
try (Statement probe = c.createStatement();
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
while (rs.next()) {
String name = rs.getString("name");
if ("channel_type_code".equalsIgnoreCase(name)) hasTypeCode = true;
if ("channel_type_version".equalsIgnoreCase(name)) hasTypeVersion = true;
}
}
if (!hasTypeCode) {
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_code INTEGER NOT NULL DEFAULT 1");
}
if (!hasTypeVersion) {
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_version INTEGER NOT NULL DEFAULT 1");
}
}
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_slug");
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_owner_slug");
st.executeUpdate("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
ON channel_names_state (owner_bch_name, channel_type_code, slug);
""");
st.executeUpdate("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
ON channel_names_state (owner_login, owner_bch_name);
""");
}
private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException {
if (!tableExists(c, "signed_messages_v2")) return;
if (tableExists(c, "signed_message_session_delivery")) {
st.executeUpdate("""
DELETE FROM signed_message_session_delivery
WHERE message_key IN (
SELECT message_key
FROM signed_messages_v2
WHERE message_type IN (3, 4)
AND receipt_ref_base_key IS NOT NULL
AND rowid NOT IN (
SELECT MIN(rowid)
FROM signed_messages_v2
WHERE message_type IN (3, 4)
AND receipt_ref_base_key IS NOT NULL
GROUP BY target_login, message_type, receipt_ref_base_key
)
);
""");
}
st.executeUpdate("""
DELETE FROM signed_messages_v2
WHERE message_type IN (3, 4)
AND receipt_ref_base_key IS NOT NULL
AND rowid NOT IN (
SELECT MIN(rowid)
FROM signed_messages_v2
WHERE message_type IN (3, 4)
AND receipt_ref_base_key IS NOT NULL
GROUP BY target_login, message_type, receipt_ref_base_key
);
""");
st.executeUpdate("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
ON signed_messages_v2 (target_login, receipt_ref_base_key)
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
""");
st.executeUpdate("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing
ON signed_messages_v2 (target_login, receipt_ref_base_key)
WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL;
""");
}
private static void rebuildConnectionsStateTable(Statement st) throws SQLException {
st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2");
st.executeUpdate("""
CREATE TABLE connections_state_v2 (
login TEXT NOT NULL,
rel_type INTEGER NOT NULL,
to_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
to_block_number INTEGER NOT NULL,
to_block_hash BLOB NOT NULL,
FOREIGN KEY (login) REFERENCES solana_users(login),
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
);
""");
st.executeUpdate("""
INSERT OR IGNORE INTO connections_state_v2
(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
SELECT
login,
rel_type,
to_login,
to_bch_name,
COALESCE(to_block_number, 0),
COALESCE(to_block_hash, zeroblob(32))
FROM connections_state
WHERE login IS NOT NULL
AND to_login IS NOT NULL
AND to_bch_name IS NOT NULL;
""");
st.executeUpdate("DROP TABLE connections_state");
st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state");
}
private static boolean tableExists(Connection c, String tableName) throws SQLException {
String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1";
try (var ps = c.prepareStatement(sql)) {
ps.setString(1, tableName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException {
boolean toBlockNumberNotNull = false;
boolean toBlockHashNotNull = false;
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) {
while (rs.next()) {
String name = rs.getString("name");
int notNull = rs.getInt("notnull");
if ("to_block_number".equalsIgnoreCase(name)) {
toBlockNumberNotNull = notNull == 1;
}
if ("to_block_hash".equalsIgnoreCase(name)) {
toBlockHashNotNull = notNull == 1;
}
}
}
return !toBlockNumberNotNull || !toBlockHashNotNull;
}
}
@@ -0,0 +1,8 @@
package shine.db.archive;
/** Ссылка на уже успешно опубликованный большой архивный блок. */
public record ArchiveBigBlockRef(
long bigBlockNumber,
byte[] archiveHash,
byte[] arweaveTxId
) {}
@@ -0,0 +1,14 @@
package shine.db.archive;
/** Последний известный архивный chunk конкретной blockchain на этом сервере. */
public record ArchiveBlockchainLocation(
String blockchainName,
String publisherLogin,
String arweaveTxId,
byte[] archiveHash,
long bigBlockNumber,
long chunkOffset,
long chunkSize,
long sourceLastBlockNumber,
long updatedAtMs
) {}
@@ -0,0 +1,15 @@
package shine.db.archive;
/**
* Последнее окончательно опубликованное архивное положение одной SHiNE blockchain.
*/
public record ArchiveChainCursor(
String blockchainName,
long lastArchivedSourceBlockNumber,
byte[] lastArchivedSourceBlockHash,
long lastArchiveBigBlockNumber,
byte[] lastArchiveBigBlockHash,
long lastChunkOffset,
long lastChunkSize,
long updatedAtMs
) {}
@@ -0,0 +1,16 @@
package shine.db.archive;
/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */
public record ArchivePublishJob(
long id,
long bigBlockNumber,
String status,
long createdAtMs,
String localArchivePath,
byte[] archiveHash,
byte[] arweaveTxId,
Integer arweaveConfirmations,
String solanaSignature,
String errorText,
long updatedAtMs
) {}
@@ -0,0 +1,17 @@
package shine.db.archive;
/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */
public record ArchivePublishJobChain(
long jobId,
String blockchainName,
long fromSourceBlockNumber,
long toSourceBlockNumber,
byte[] previousSourceBlockHash,
byte[] lastSourceBlockHash,
Long previousArchiveBigBlockNumber,
byte[] previousArchiveBigBlockHash,
Long previousChunkOffset,
Long previousChunkSize,
Long newChunkOffset,
Long newChunkSize
) {}
@@ -0,0 +1,14 @@
package shine.db.archive;
/**
* Текущая archive head trusted publisher-а из локальной таблицы,
* которую уже заполнил обычный Solana users sync.
*/
public record ArchivePublisherHead(
String login,
String rootKey,
String archiveHeadTxId,
String archiveHeadHash,
boolean archiveImported,
String lastImportedArchiveTxId
) {}
@@ -8,6 +8,8 @@ public final class ChannelNameRules {
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
private static final Pattern DISPLAY_ALLOWED_PATTERN =
Pattern.compile("^[A-Za-z0-9_-]+$");
private static final Pattern PUBLIC_CHANNEL_ALLOWED_PATTERN =
Pattern.compile("^[A-Za-z0-9_-]+$");
private ChannelNameRules() {}
@@ -34,6 +36,14 @@ public final class ChannelNameRules {
return normalized;
}
public static String requireValidPublicDisplayNameForCreate(String rawName) {
String normalized = requireValidDisplayNameForCreate(rawName);
if (!PUBLIC_CHANNEL_ALLOWED_PATTERN.matcher(normalized).matches()) {
throw new IllegalArgumentException("channelName contains unsupported characters");
}
return normalized;
}
public static String toCanonicalSlug(String rawName) {
String normalized = normalizeDisplayName(rawName);
if (normalized.isEmpty()) {
@@ -0,0 +1,9 @@
package shine.db.connection;
import java.sql.Connection;
import java.sql.SQLException;
@FunctionalInterface
public interface ConnectionFactory {
Connection getConnection() throws SQLException;
}
@@ -0,0 +1,21 @@
package shine.db.connection;
import java.sql.Connection;
import java.sql.SQLException;
/**
* Нейтральная точка доступа к JDBC-соединениям без привязки к конкретной БД.
*
* На первом этапе переносов этот интерфейс нужен как новая опора для DAO,
* чтобы постепенно убрать прямую зависимость от DbController.
*/
public interface DbProvider extends ConnectionFactory, AutoCloseable {
@Override
Connection getConnection() throws SQLException;
@Override
default void close() throws Exception {
// По умолчанию ресурсов на уровне provider нет.
}
}
@@ -0,0 +1,58 @@
package shine.db.connection;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
/**
* Базовый JDBC provider поверх DriverManager.
*
* Не знает ничего про конкретный доменный режим БД:
* конкретные параметры и post-connect инициализация задаются снаружи.
*/
public final class DriverManagerDbProvider implements DbProvider {
private final String jdbcUrl;
private final String user;
private final String password;
private final ConnectionInitializer initializer;
public DriverManagerDbProvider(String jdbcUrl,
String user,
String password,
ConnectionInitializer initializer) {
if (jdbcUrl == null || jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl is blank");
}
this.jdbcUrl = jdbcUrl;
this.user = blankToNull(user);
this.password = blankToNull(password);
this.initializer = initializer;
}
@Override
public Connection getConnection() throws SQLException {
Connection connection;
if (user == null) {
connection = DriverManager.getConnection(jdbcUrl);
} else {
connection = DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password);
}
if (initializer != null) {
initializer.initialize(connection);
}
return connection;
}
private static String blankToNull(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
@FunctionalInterface
public interface ConnectionInitializer {
void initialize(Connection connection) throws SQLException;
}
}
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.ActiveSessionEntry;
import java.sql.*;
@@ -17,7 +17,7 @@ import java.util.List;
public final class ActiveSessionsDAO {
private static volatile ActiveSessionsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private ActiveSessionsDAO() { }
@@ -137,7 +137,7 @@ public final class ActiveSessionsDAO {
client_platform,
user_language
FROM active_sessions
WHERE login = ? COLLATE NOCASE
WHERE LOWER(login) = LOWER(?)
""";
List<ActiveSessionEntry> result = new ArrayList<>();
@@ -0,0 +1,150 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.archive.ArchiveBlockchainLocation;
import shine.db.archive.ArchivePublisherHead;
import java.sql.*;
import java.util.*;
/** БД индекс последних archive chunks и локальное состояние импорта archive head. */
public final class ArchiveImportDAO {
private static volatile ArchiveImportDAO instance;
private final DbController db = DbController.getInstance();
private ArchiveImportDAO() {}
public static ArchiveImportDAO getInstance() {
if (instance == null) synchronized (ArchiveImportDAO.class) {
if (instance == null) instance = new ArchiveImportDAO();
}
return instance;
}
/**
* Возвращает только доверенных publisher-ов, для которых обычный Solana users sync
* уже увидел archive head, но этот head ещё не был полностью импортирован локально.
*/
public List<ArchivePublisherHead> listPendingPublisherHeads(Set<String> approvedLogins) throws SQLException {
if (approvedLogins == null || approvedLogins.isEmpty()) return List.of();
List<ArchivePublisherHead> out = new ArrayList<>();
String sql = """
SELECT login, root_key, archive_head_tx_id, archive_head_hash,
archive_imported, archive_last_imported_tx_id
FROM solana_user_pda_current
WHERE LOWER(login)=LOWER(?)
AND is_server=TRUE
AND archive_head_tx_id<>''
AND archive_head_hash<>''
AND archive_imported=FALSE
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
for (String login : approvedLogins) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
out.add(new ArchivePublisherHead(
rs.getString("login"),
rs.getString("root_key"),
rs.getString("archive_head_tx_id"),
rs.getString("archive_head_hash"),
rs.getBoolean("archive_imported"),
rs.getString("archive_last_imported_tx_id")));
}
}
}
}
return out;
}
/**
* Сдвигает локальный курсор импорта внутри FULL archive chain.
* Update выполняется только если текущий head всё ещё тот же, с которым работает importer.
*/
public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException {
String sql = """
UPDATE solana_user_pda_current
SET archive_last_imported_tx_id=?
WHERE LOWER(login)=LOWER(?)
AND archive_head_tx_id=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, importedTxId == null ? "" : importedTxId);
ps.setString(2, publisherLogin);
ps.setString(3, expectedHeadTxId);
return ps.executeUpdate() == 1;
}
}
/**
* Помечает именно текущий head как полностью импортированный.
* Если Solana users sync успел заменить head на более новый, update не произойдёт.
*/
public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException {
String sql = """
UPDATE solana_user_pda_current
SET archive_imported=TRUE,
archive_last_imported_tx_id=archive_head_tx_id
WHERE LOWER(login)=LOWER(?)
AND archive_head_tx_id=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, publisherLogin);
ps.setString(2, expectedHeadTxId);
return ps.executeUpdate() == 1;
}
}
public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException {
String sql="""
INSERT INTO archive_blockchain_location(
blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
chunk_offset,chunk_size,source_last_block_number,updated_at_ms)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(blockchain_name) DO UPDATE SET
publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id,
archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number,
chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size,
source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms
WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId());
ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset());
ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate();
}
}
public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException {
String sql="""
SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
chunk_offset,chunk_size,source_last_block_number,updated_at_ms
FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?)
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,blockchainName);
try(ResultSet rs=ps.executeQuery()) {
if(!rs.next()) return null;
return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4),
rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9));
}
}
}
/** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */
public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException {
String sql="""
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,blockchainName);
try(ResultSet rs=ps.executeQuery()) {
if(!rs.next()) return null;
return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4));
}
}
}
public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {}
}
@@ -0,0 +1,472 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.archive.ArchivePublishJob;
import shine.db.archive.ArchivePublishJobChain;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized.
*/
public final class ArchivePublicationDAO {
private static volatile ArchivePublicationDAO instance;
private final DbController db = DbController.getInstance();
private ArchivePublicationDAO() {}
public static ArchivePublicationDAO getInstance() {
if (instance == null) {
synchronized (ArchivePublicationDAO.class) {
if (instance == null) instance = new ArchivePublicationDAO();
}
}
return instance;
}
public ArchiveChainCursor getCursor(String blockchainName) throws SQLException {
try (Connection c = db.getConnection()) {
return getCursor(c, blockchainName);
}
}
public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
last_archive_big_block_number, last_archive_big_block_hash,
last_chunk_offset, last_chunk_size, updated_at_ms
FROM archive_chain_cursor
WHERE blockchain_name = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapCursor(rs);
}
}
}
public long nextBigBlockNumber() throws SQLException {
String sql = """
SELECT COALESCE(MAX(big_block_number), 0) + 1
FROM archive_publish_job
WHERE status = 'CURSORS_COMMITTED'
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
public List<ArchiveBigBlockRef> listFinalizedBigBlocks() throws SQLException {
String sql = """
SELECT big_block_number, archive_hash, arweave_tx_id
FROM archive_publish_job
WHERE status = 'CURSORS_COMMITTED'
AND archive_hash IS NOT NULL
AND arweave_tx_id IS NOT NULL
ORDER BY big_block_number ASC
""";
List<ArchiveBigBlockRef> out = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
out.add(new ArchiveBigBlockRef(
rs.getLong("big_block_number"),
rs.getBytes("archive_hash"),
rs.getBytes("arweave_tx_id")
));
}
}
return out;
}
public ArchivePublishJob findUnfinishedJob() throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job
WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED')
ORDER BY id ASC
LIMIT 1
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapJob(rs) : null;
}
}
public ArchivePublishJob getJob(long id) throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job
WHERE id = ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapJob(rs) : null;
}
}
}
/** Атомарно создаёт frozen archive job и все его blockchain ranges. */
public long createJobWithChains(long bigBlockNumber, long createdAtMs, List<ArchivePublishJobChain> chains) throws SQLException {
if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся");
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
long jobId;
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
RETURNING id
""")) {
ps.setLong(1, bigBlockNumber);
ps.setLong(2, createdAtMs);
ps.setLong(3, createdAtMs);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
jobId = rs.getLong(1);
}
}
String sql = """
INSERT INTO archive_publish_job_chain(
job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (ArchivePublishJobChain ch : chains) {
bindJobChain(ps, new ArchivePublishJobChain(
jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(),
ch.previousSourceBlockHash(), ch.lastSourceBlockHash(),
ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(),
ch.previousChunkOffset(), ch.previousChunkSize(), null, null));
ps.addBatch();
}
ps.executeBatch();
}
c.commit();
return jobId;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException se) throw se;
throw new SQLException("Не удалось создать frozen archive job", e);
} finally {
c.setAutoCommit(oldAuto);
}
}
}
public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException {
String sql = """
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
RETURNING id
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, bigBlockNumber);
ps.setLong(2, createdAtMs);
ps.setLong(3, createdAtMs);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
return rs.getLong(1);
}
}
}
public void insertJobChain(ArchivePublishJobChain e) throws SQLException {
String sql = """
INSERT INTO archive_publish_job_chain(
job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
bindJobChain(ps, e);
ps.executeUpdate();
}
}
public List<ArchivePublishJobChain> listJobChains(long jobId) throws SQLException {
String sql = """
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
FROM archive_publish_job_chain
WHERE job_id = ?
ORDER BY blockchain_name ASC
""";
List<ArchivePublishJobChain> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, jobId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapJobChain(rs));
}
}
return out;
}
public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException {
String sql = """
UPDATE archive_publish_job_chain
SET new_chunk_offset=?, new_chunk_size=?
WHERE job_id=? AND blockchain_name=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, offset);
ps.setLong(2, size);
ps.setLong(3, jobId);
ps.setString(4, blockchainName);
if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName);
}
}
public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException {
updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> {
ps.setString(1, localPath);
ps.setBytes(2, hash);
});
}
public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException {
updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId));
}
/** Обновляет только локальное имя/путь уже собранного archive-файла. */
public void updateLocalArchivePath(long jobId, String localPath) throws SQLException {
updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath));
}
public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException {
updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations));
}
public void markSolanaSubmitted(long jobId, String signature) throws SQLException {
updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature));
}
public void markSolanaFinalized(long jobId) throws SQLException {
updateJob(jobId, "SOLANA_FINALIZED", null, null);
}
public void markFailed(long jobId, String error) throws SQLException {
updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error));
}
/**
* После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job.
*/
public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException {
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
ArchivePublishJob job = getJob(c, jobId);
if (job == null) throw new SQLException("Archive job не найден: " + jobId);
List<ArchivePublishJobChain> chains = listJobChains(c, jobId);
long now = System.currentTimeMillis();
String upsert = """
INSERT INTO archive_chain_cursor(
blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
last_archive_big_block_number, last_archive_big_block_hash,
last_chunk_offset, last_chunk_size, updated_at_ms
) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(blockchain_name) DO UPDATE SET
last_archived_source_block_number=EXCLUDED.last_archived_source_block_number,
last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash,
last_archive_big_block_number=EXCLUDED.last_archive_big_block_number,
last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash,
last_chunk_offset=EXCLUDED.last_chunk_offset,
last_chunk_size=EXCLUDED.last_chunk_size,
updated_at_ms=EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(upsert)) {
for (ArchivePublishJobChain ch : chains) {
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) {
throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName());
}
ps.setString(1, ch.blockchainName());
ps.setLong(2, ch.toSourceBlockNumber());
ps.setBytes(3, ch.lastSourceBlockHash());
ps.setLong(4, job.bigBlockNumber());
ps.setBytes(5, bigBlockHash);
ps.setLong(6, ch.newChunkOffset());
ps.setLong(7, ch.newChunkSize());
ps.setLong(8, now);
ps.addBatch();
}
ps.executeBatch();
}
try (PreparedStatement ps = c.prepareStatement(
"UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) {
ps.setLong(1, now);
ps.setLong(2, jobId);
ps.executeUpdate();
}
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException se) throw se;
throw new SQLException("Не удалось commit archive cursors", e);
} finally {
c.setAutoCommit(oldAuto);
}
}
}
private ArchivePublishJob getJob(Connection c, long id) throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job WHERE id=?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; }
}
}
private List<ArchivePublishJobChain> listJobChains(Connection c, long jobId) throws SQLException {
String sql = """
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
FROM archive_publish_job_chain
WHERE job_id=? ORDER BY blockchain_name ASC
""";
List<ArchivePublishJobChain> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, jobId);
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); }
}
return out;
}
@FunctionalInterface
private interface Binder { void bind(PreparedStatement ps) throws SQLException; }
private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException {
if (setFragment != null && !setFragment.isBlank()) {
try (Connection c = db.getConnection()) {
updateJobWithExtra(c, jobId, status, setFragment, binder);
}
return;
}
String sql = status == null
? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?"
: "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
if (status != null) ps.setString(i++, status);
ps.setLong(i++, System.currentTimeMillis());
ps.setLong(i, jobId);
ps.executeUpdate();
}
}
private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException {
String statusSql = status == null ? "" : ", status=?";
String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
binder.bind(ps);
int count = countParameters(setFragment);
int i = count + 1;
if (status != null) ps.setString(i++, status);
ps.setLong(i++, System.currentTimeMillis());
ps.setLong(i, jobId);
ps.executeUpdate();
}
}
private static int countParameters(String sqlPart) {
int n = 0;
for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++;
return n;
}
private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException {
int i=1;
ps.setLong(i++, e.jobId());
ps.setString(i++, e.blockchainName());
ps.setLong(i++, e.fromSourceBlockNumber());
ps.setLong(i++, e.toSourceBlockNumber());
ps.setBytes(i++, e.previousSourceBlockHash());
ps.setBytes(i++, e.lastSourceBlockHash());
setLongNullable(ps, i++, e.previousArchiveBigBlockNumber());
ps.setBytes(i++, e.previousArchiveBigBlockHash());
setLongNullable(ps, i++, e.previousChunkOffset());
setLongNullable(ps, i++, e.previousChunkSize());
setLongNullable(ps, i++, e.newChunkOffset());
setLongNullable(ps, i, e.newChunkSize());
}
private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException {
return new ArchiveChainCursor(
rs.getString("blockchain_name"),
rs.getLong("last_archived_source_block_number"),
rs.getBytes("last_archived_source_block_hash"),
rs.getLong("last_archive_big_block_number"),
rs.getBytes("last_archive_big_block_hash"),
rs.getLong("last_chunk_offset"),
rs.getLong("last_chunk_size"),
rs.getLong("updated_at_ms")
);
}
private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException {
int conf = rs.getInt("arweave_confirmations");
Integer confObj = rs.wasNull() ? null : conf;
return new ArchivePublishJob(
rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"),
rs.getLong("created_at_ms"), rs.getString("local_archive_path"),
rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj,
rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms")
);
}
private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException {
return new ArchivePublishJobChain(
rs.getLong("job_id"), rs.getString("blockchain_name"),
rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"),
rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"),
getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"),
getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"),
getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size")
);
}
private static Long getLongNullable(ResultSet rs, String name) throws SQLException {
long v = rs.getLong(name);
return rs.wasNull() ? null : v;
}
private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException {
if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v);
}
}
@@ -1,7 +1,7 @@
package shine.db.dao;
import shine.db.DatabaseInitializer;
import shine.db.SqliteDbController;
import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
@@ -24,12 +24,12 @@ import java.sql.SQLException;
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
* SQLite не может атомарно закоммитить и SQL, и файловую систему сразу;
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
* отдельным recovery/resync-слоем после успешного commit.
*
* Важный смысл текущей реализации:
* - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы;
* - мы НЕ трогаем current users слой (`solana_user_pda_current`) и НЕ трогаем DM-таблицы;
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
*/
@@ -39,7 +39,7 @@ public final class BlockchainResyncCleanupDAO {
private static volatile BlockchainResyncCleanupDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private BlockchainResyncCleanupDAO() {}
@@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO {
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
rebuildStatsState(c);
c.commit();
return new CleanupResult(
@@ -185,10 +187,10 @@ public final class BlockchainResyncCleanupDAO {
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
String sql = """
UPDATE message_stats
SET likes_count = MAX(
SET likes_count = GREATEST(
0,
likes_count - (
SELECT COUNT(*)
likes_count - COALESCE((
SELECT COUNT(*)::int
FROM reactions_state rs
WHERE rs.from_bch_name = ?
AND rs.reaction_type = ?
@@ -198,7 +200,7 @@ public final class BlockchainResyncCleanupDAO {
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 <> ?
)
), 0)
)
WHERE EXISTS (
SELECT 1
@@ -237,10 +239,10 @@ public final class BlockchainResyncCleanupDAO {
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
String sql = """
UPDATE message_stats
SET replies_count = MAX(
SET replies_count = GREATEST(
0,
replies_count - COALESCE((
SELECT COUNT(*)
SELECT COUNT(*)::int
FROM blocks b
WHERE b.bch_name = ?
AND b.msg_type = 1
@@ -307,7 +309,7 @@ public final class BlockchainResyncCleanupDAO {
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
return executeDelete(c, """
DELETE FROM connections_state
WHERE login = ? COLLATE NOCASE
WHERE LOWER(login) = LOWER(?)
""", login);
}
@@ -317,7 +319,7 @@ public final class BlockchainResyncCleanupDAO {
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
return executeDelete(c, """
DELETE FROM users_params
WHERE login = ? COLLATE NOCASE
WHERE LOWER(login) = LOWER(?)
""", login);
}
@@ -366,6 +368,119 @@ public final class BlockchainResyncCleanupDAO {
""", blockchainName);
}
private void rebuildStatsState(Connection c) throws SQLException {
try (PreparedStatement truncate = c.prepareStatement("""
TRUNCATE TABLE user_stats_state, channel_stats_state
""")) {
truncate.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
)
SELECT
u.login,
COALESCE(own.owned_public_channels_count, 0),
COALESCE(fu.following_users_count, 0),
COALESCE(fc.following_channels_count, 0),
COALESCE(cf.close_friends_count, 0),
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
LEFT JOIN (
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
FROM channel_names_state
WHERE channel_type_code = 1
GROUP BY owner_login
) own ON LOWER(own.owner_login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS following_users_count
FROM connections_state
WHERE rel_type = 30
AND to_block_number = 0
GROUP BY login
) fu ON LOWER(fu.login) = LOWER(u.login)
LEFT JOIN (
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.channel_type_code = 1
GROUP BY cs.login
) fc ON LOWER(fc.login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS close_friends_count
FROM connections_state
WHERE rel_type = 10
GROUP BY login
) cf ON LOWER(cf.login) = LOWER(u.login)
""")) {
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(login) FROM solana_user_pda_current")) {
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
AND shine_is_primary(rs.from_login)),
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login))
""")) {
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
)
SELECT
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code,
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM channel_names_state cn
LEFT JOIN connections_state cs
ON cs.rel_type = 30
AND cs.to_bch_name = cn.owner_bch_name
AND cs.to_block_number = cn.channel_root_block_number
AND cs.to_block_hash = cn.channel_root_block_hash
WHERE cn.channel_type_code = 1
GROUP BY
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code
""")) {
ps.executeUpdate();
}
}
private int executeDelete(Connection c, String sql, String value) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, value);
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.BlockchainStateEntry;
import java.sql.*;
@@ -10,7 +10,7 @@ import java.util.List;
public final class BlockchainStateDAO {
private static volatile BlockchainStateDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private BlockchainStateDAO() {}
@@ -75,7 +75,7 @@ public final class BlockchainStateDAO {
last_block_hash,
updated_at_ms
FROM blockchain_state
ORDER BY blockchain_name COLLATE NOCASE
ORDER BY LOWER(blockchain_name)
""";
List<BlockchainStateEntry> result = new ArrayList<>();
@@ -142,7 +142,7 @@ public final class BlockchainStateDAO {
* Строгая вставка state только если записи ещё нет.
*
* Нужна для recovery / resync:
* - identity пользователя уже может существовать в solana_users;
* - runtime-проекция пользователя уже может существовать в current users слое;
* - в таком случае нам надо восстановить только blockchain_state;
* - если запись уже есть, метод просто ничего не меняет.
*/
@@ -229,7 +229,7 @@ public final class BlockchainStateDAO {
private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException {
if (b != null) ps.setBytes(index, b);
else ps.setNull(index, Types.BLOB);
else ps.setNull(index, Types.BINARY);
}
private static String nn(String s) { return s == null ? "" : s; }
@@ -2,7 +2,7 @@ package shine.db.dao;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.BlockEntry;
import java.sql.*;
@@ -22,7 +22,7 @@ import java.util.List;
public final class BlocksDAO {
private static volatile BlocksDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
private BlocksDAO() { }
@@ -90,7 +90,7 @@ public final class BlocksDAO {
else ps.setNull(i++, Types.INTEGER);
if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash());
else ps.setNull(i++, Types.BLOB);
else ps.setNull(i++, Types.BINARY);
ps.setBytes(i++, e.getBlockHash());
ps.setBytes(i++, e.getBlockSignature());
@@ -106,7 +106,7 @@ public final class BlocksDAO {
else ps.setNull(i++, Types.INTEGER);
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
else ps.setNull(i++, Types.BLOB);
else ps.setNull(i++, Types.BINARY);
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
else ps.setNull(i++, Types.INTEGER);
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.ChannelNameStateEntry;
import java.sql.Connection;
@@ -11,7 +11,7 @@ import java.util.List;
public final class ChannelNameStateDAO {
private static volatile ChannelNameStateDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private ChannelNameStateDAO() {}
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
channel_type_version,
channel_root_block_number,
channel_root_block_hash,
created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
created_at_ms,
ava_ar,
ava_sha256,
ava_size,
meta_updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getSlug());
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
ps.setInt(8, entry.getChannelRootBlockNumber());
ps.setBytes(9, entry.getChannelRootBlockHash());
ps.setLong(10, entry.getCreatedAtMs());
ps.setString(11, entry.getAvaAr() == null ? "" : entry.getAvaAr());
ps.setString(12, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
ps.setLong(13, Math.max(0L, entry.getAvaSize()));
ps.setLong(14, Math.max(0L, entry.getMetaUpdatedAtMs()));
ps.executeUpdate();
}
}
public void updateMeta(Connection c, ChannelNameStateEntry entry) throws SQLException {
String sql = """
UPDATE channel_names_state
SET display_name = ?,
channel_description = ?,
ava_ar = ?,
ava_sha256 = ?,
ava_size = ?,
meta_updated_at_ms = ?
WHERE owner_bch_name = ? AND channel_root_block_number = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getDisplayName() == null ? "" : entry.getDisplayName());
ps.setString(2, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
ps.setString(3, entry.getAvaAr() == null ? "" : entry.getAvaAr());
ps.setString(4, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
ps.setLong(5, Math.max(0L, entry.getAvaSize()));
ps.setLong(6, Math.max(0L, entry.getMetaUpdatedAtMs()));
ps.setString(7, entry.getOwnerBlockchainName());
ps.setInt(8, entry.getChannelRootBlockNumber());
ps.executeUpdate();
}
}
@@ -0,0 +1,298 @@
package shine.db.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* Server projection for per-user channel read/unread state.
*
* Important rules:
* - rows are created only by a signed client SetChannelReadState;
* - channel publications increment unread_count only for rows that already exist;
* - read_count is monotonic and unread_count is reduced by the accepted read delta;
* - unread_count is never part of the client signature and is server-owned.
*/
public final class ChannelReadStateDAO {
private static volatile ChannelReadStateDAO instance;
private ChannelReadStateDAO() {}
public static ChannelReadStateDAO getInstance() {
if (instance == null) {
synchronized (ChannelReadStateDAO.class) {
if (instance == null) instance = new ChannelReadStateDAO();
}
}
return instance;
}
public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs,
String clientKey, String readSignature) {}
public record UpsertResult(boolean applied, long readCount, long unreadCount) {}
public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
String sql = """
SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature
FROM channel_read_state
WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBchName);
ps.setString(3, channelName);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return new ReadState(
Math.max(0L, rs.getLong("read_count")),
Math.max(0L, rs.getLong("unread_count")),
rs.getLong("read_updated_at_ms"),
rs.getString("client_key"),
rs.getString("read_signature"));
}
}
}
public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
ReadState state = get(c, viewerLogin, ownerBchName, channelName);
return state == null ? 0L : state.unreadCount();
}
public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException {
String sql = """
SELECT COALESCE(SUM(crs.unread_count),0)
FROM channel_read_state crs
WHERE LOWER(crs.viewer_login)=LOWER(?)
AND EXISTS (
SELECT 1
FROM channel_names_state cns
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(crs.viewer_login)
AND cs.rel_type=30
AND cs.to_bch_name=cns.owner_bch_name
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=crs.owner_bch_name
AND cns.slug=crs.channel_name
AND cns.channel_type_code=1
)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
}
}
}
/**
* Validates that the requested public channel exists and that viewer currently follows it.
*/
public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName,
int followRelType) throws SQLException {
String sql = """
SELECT 1
FROM channel_names_state cns
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(?)
AND cs.rel_type=?
AND cs.to_bch_name=cns.owner_bch_name
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=?
AND cns.slug=?
AND cns.channel_type_code=1
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setInt(2, followRelType);
ps.setString(3, ownerBchName);
ps.setString(4, channelName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException {
String sql = """
SELECT slug
FROM channel_names_state
WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBchName);
ps.setInt(2, rootBlockNumber);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("slug") : null;
}
}
}
public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
String sql = """
SELECT channel_root_block_number
FROM channel_names_state
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBchName);
ps.setString(2, channelName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("channel_root_block_number") : null;
}
}
}
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
String sql = """
SELECT owner_login
FROM channel_names_state
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBchName);
ps.setString(2, channelName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("owner_login") : null;
}
}
}
/**
* Inserts the first client baseline with unread=0, or advances an existing read watermark.
* Older timestamps and backwards read_count values are ignored.
*/
public UpsertResult upsertSignedReadIfNewer(Connection c,
String viewerLogin,
String ownerLogin,
String ownerBchName,
String channelName,
long readCount,
long readUpdatedAtMs,
String clientKey,
String readSignature,
long nowMs) throws SQLException {
String sql = """
INSERT INTO channel_read_state (
viewer_login, owner_login, owner_bch_name, channel_name,
read_count, unread_count,
read_updated_at_ms, client_key, read_signature, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
ON CONFLICT (viewer_login, owner_bch_name, channel_name)
DO UPDATE SET
owner_login = EXCLUDED.owner_login,
unread_count = GREATEST(
0,
channel_read_state.unread_count -
GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count)
),
read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count),
read_updated_at_ms = EXCLUDED.read_updated_at_ms,
client_key = EXCLUDED.client_key,
read_signature = EXCLUDED.read_signature,
updated_at_ms = EXCLUDED.updated_at_ms
WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms
AND EXCLUDED.read_count >= channel_read_state.read_count
RETURNING read_count, unread_count
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerLogin);
ps.setString(3, ownerBchName);
ps.setString(4, channelName);
ps.setLong(5, Math.max(0L, readCount));
ps.setLong(6, readUpdatedAtMs);
ps.setString(7, clientKey);
ps.setString(8, readSignature);
ps.setLong(9, nowMs);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return new UpsertResult(true,
Math.max(0L, rs.getLong("read_count")),
Math.max(0L, rs.getLong("unread_count")));
}
}
}
ReadState current = get(c, viewerLogin, ownerBchName, channelName);
return new UpsertResult(false,
current == null ? 0L : current.readCount(),
current == null ? 0L : current.unreadCount());
}
/**
* Increments existing rows for current subscribers of one public channel.
* Missing channel_read_state rows are deliberately NOT created.
* Returns affected viewer logins for post-commit counter pushes.
*/
public List<String> incrementUnreadForSubscribers(Connection c,
String ownerBchName,
String channelName,
int followRelType,
long nowMs) throws SQLException {
String sql = """
UPDATE channel_read_state crs
SET unread_count = crs.unread_count + 1,
updated_at_ms = ?
WHERE crs.owner_bch_name = ?
AND crs.channel_name = ?
AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login)
AND EXISTS (
SELECT 1
FROM channel_names_state cns
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(crs.viewer_login)
AND cs.rel_type=?
AND cs.to_bch_name=cns.owner_bch_name
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=crs.owner_bch_name
AND cns.slug=crs.channel_name
AND cns.channel_type_code=1
)
RETURNING viewer_login
""";
List<String> affected = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setString(2, ownerBchName);
ps.setString(3, channelName);
ps.setInt(4, followRelType);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) affected.add(rs.getString("viewer_login"));
}
}
return affected;
}
/** Delete the projection row when a channel FOLLOW is explicitly removed. */
public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName,
int rootBlockNumber) throws SQLException {
String sql = """
DELETE FROM channel_read_state crs
USING channel_names_state cns
WHERE LOWER(crs.viewer_login)=LOWER(?)
AND crs.owner_bch_name=?
AND cns.owner_bch_name=crs.owner_bch_name
AND cns.slug=crs.channel_name
AND cns.channel_root_block_number=?
AND cns.channel_type_code=1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBchName);
ps.setInt(3, rootBlockNumber);
return ps.executeUpdate() > 0;
}
}
}
@@ -1,6 +1,7 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.sql.CurrentUsersSql;
import java.sql.Connection;
import java.sql.PreparedStatement;
@@ -13,16 +14,14 @@ import java.util.List;
* ConnectionsStateDAO чтение текущего состояния связей из connections_state.
*
* ВАЖНО:
* - login в запросах может быть в любом регистре, поэтому в WHERE используем COLLATE NOCASE
* - в ответах возвращаем логины в каноническом регистре через JOIN на solana_users
*
* ПРИМЕЧАНИЕ:
* Таблица пользователей тут названа "solana_users". Если у тебя иначе поменяй в SQL.
* - login в запросах может быть в любом регистре;
* - в ответах возвращаем логины в каноническом регистре через прямой lookup
* по актуальным PDA-данным и manual fallback.
*/
public final class ConnectionsStateDAO {
private static volatile ConnectionsStateDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private ConnectionsStateDAO() {}
@@ -42,14 +41,17 @@ public final class ConnectionsStateDAO {
String sql = """
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
FROM connections_state cs
LEFT JOIN solana_users u_login
ON u_login.login = cs.to_login COLLATE NOCASE
LEFT JOIN solana_users u_bch
ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE
WHERE cs.login = ? COLLATE NOCASE
LEFT JOIN %s
ON LOWER(u_login.login) = LOWER(cs.to_login)
LEFT JOIN %s
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
WHERE LOWER(cs.login) = LOWER(?)
AND cs.rel_type = ?
ORDER BY friend_login
""";
""".formatted(
CurrentUsersSql.usersSubquery("u_login"),
CurrentUsersSql.usersSubquery("u_bch")
);
List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -65,6 +67,36 @@ public final class ConnectionsStateDAO {
return out;
}
public boolean hasOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, String peerLoginAnyCase, int relType) throws SQLException {
String sql = """
SELECT 1
FROM connections_state cs
LEFT JOIN %s
ON LOWER(u_login.login) = LOWER(cs.to_login)
LEFT JOIN %s
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
WHERE LOWER(cs.login) = LOWER(?)
AND cs.rel_type = ?
AND (
LOWER(cs.to_login) = LOWER(?)
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
)
LIMIT 1
""".formatted(
CurrentUsersSql.usersSubquery("u_login"),
CurrentUsersSql.usersSubquery("u_bch")
);
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
ps.setInt(2, relType);
ps.setString(3, peerLoginAnyCase);
ps.setString(4, peerLoginAnyCase);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
/**
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
*/
@@ -72,17 +104,20 @@ public final class ConnectionsStateDAO {
String sql = """
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
FROM connections_state cs
LEFT JOIN solana_users u_actor
ON u_actor.login = cs.login COLLATE NOCASE
LEFT JOIN solana_users u_target
ON u_target.login = ? COLLATE NOCASE
LEFT JOIN %s
ON LOWER(u_actor.login) = LOWER(cs.login)
LEFT JOIN %s
ON LOWER(u_target.login) = LOWER(?)
WHERE (
cs.to_login = ? COLLATE NOCASE
OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE)
LOWER(cs.to_login) = LOWER(?)
OR (u_target.blockchain_name IS NOT NULL AND LOWER(cs.to_bch_name) = LOWER(u_target.blockchain_name))
)
AND cs.rel_type = ?
ORDER BY friend_login
""";
""".formatted(
CurrentUsersSql.usersSubquery("u_actor"),
CurrentUsersSql.usersSubquery("u_target")
);
List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -106,19 +141,19 @@ public final class ConnectionsStateDAO {
String sql = """
SELECT u.login AS friend_login
FROM connections_state a
JOIN solana_users u
ON u.login = a.to_login COLLATE NOCASE
WHERE a.login = ? COLLATE NOCASE
JOIN %s
ON LOWER(u.login) = LOWER(a.to_login)
WHERE LOWER(a.login) = LOWER(?)
AND a.rel_type = ?
AND EXISTS (
SELECT 1
FROM connections_state b
WHERE b.login = a.to_login COLLATE NOCASE
AND b.to_login = a.login COLLATE NOCASE
WHERE LOWER(b.login) = LOWER(a.to_login)
AND LOWER(b.to_login) = LOWER(a.login)
AND b.rel_type = a.rel_type
)
ORDER BY u.login
""";
""".formatted(CurrentUsersSql.usersSubquery("u"));
List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -141,10 +176,22 @@ public final class ConnectionsStateDAO {
String toBchName,
Integer toBlockNumber,
byte[] toBlockHash) throws SQLException {
try (PreparedStatement deletePs = c.prepareStatement("""
DELETE FROM connections_state
WHERE LOWER(login) = LOWER(?)
AND rel_type = ?
AND LOWER(to_login) = LOWER(?)
""")) {
deletePs.setString(1, login);
deletePs.setInt(2, relType);
deletePs.setString(3, toLogin);
deletePs.executeUpdate();
}
String sql = """
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(login, rel_type, to_login) DO UPDATE SET
ON CONFLICT(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO UPDATE SET
to_bch_name=excluded.to_bch_name,
to_block_number=excluded.to_block_number,
to_block_hash=excluded.to_block_hash
@@ -1,19 +1,22 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.SolanaUserEntry;
import shine.db.DbController;
import shine.db.KeyEncodingUtil;
import shine.db.entities.CurrentUserEntry;
import shine.db.sql.CurrentUsersSql;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* SolanaUsersDAO локальная таблица пользователей из Solana.
* CurrentUsersDAO совместимый runtime-доступ к текущему срезу пользователей.
*
* Таблица: solana_users
* Источник:
* - solana_user_pda_current
*
* Колонки:
* - login TEXT PRIMARY KEY (COLLATE NOCASE)
* - login TEXT PRIMARY KEY
* - blockchain_name TEXT NOT NULL
* - solana_key TEXT NOT NULL
* - blockchain_key TEXT NOT NULL
@@ -23,59 +26,32 @@ import java.util.List;
* - методы с Connection НЕ закрывают соединение
* - методы без Connection сами открывают и закрывают соединение
*/
public final class SolanaUsersDAO {
public final class CurrentUsersDAO {
private static volatile SolanaUsersDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private static volatile CurrentUsersDAO instance;
private final DbController db = DbController.getInstance();
private SolanaUsersDAO() {}
private CurrentUsersDAO() {}
public static SolanaUsersDAO getInstance() {
public static CurrentUsersDAO getInstance() {
if (instance == null) {
synchronized (SolanaUsersDAO.class) {
if (instance == null) instance = new SolanaUsersDAO();
synchronized (CurrentUsersDAO.class) {
if (instance == null) instance = new CurrentUsersDAO();
}
}
return instance;
}
// -------------------- INSERT --------------------
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
public void insert(Connection c, SolanaUserEntry user) throws SQLException {
String sql = """
INSERT INTO solana_users (
login, blockchain_name, solana_key, blockchain_key, client_key
) VALUES (?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, user.getLogin());
ps.setString(2, user.getBlockchainName());
ps.setString(3, user.getSolanaKey());
ps.setString(4, user.getBlockchainKey());
ps.setString(5, user.getClientKey());
ps.executeUpdate();
}
}
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
public void insert(SolanaUserEntry user) throws SQLException {
try (Connection c = db.getConnection()) {
insert(c, user);
}
}
// -------------------- EXISTS --------------------
/** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
public boolean existsByLogin(Connection c, String login) throws SQLException {
String sql = """
SELECT 1
FROM solana_users
WHERE LOWER(login) = LOWER(?)
FROM %s
WHERE normalized_login = LOWER(BTRIM(?))
LIMIT 1
""";
""".formatted(CurrentUsersSql.usersSubquery("su"));
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
@@ -96,10 +72,10 @@ public final class SolanaUsersDAO {
public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT 1
FROM solana_users
FROM %s
WHERE blockchain_name = ?
LIMIT 1
""";
""".formatted(CurrentUsersSql.usersSubquery("su"));
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
@@ -119,7 +95,7 @@ public final class SolanaUsersDAO {
// -------------------- SELECT --------------------
/** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
public SolanaUserEntry getByLogin(Connection c, String login) throws SQLException {
public CurrentUserEntry getByLogin(Connection c, String login) throws SQLException {
String sql = """
SELECT
login,
@@ -127,9 +103,9 @@ public final class SolanaUsersDAO {
solana_key,
blockchain_key,
client_key
FROM solana_users
WHERE LOWER(login) = LOWER(?)
""";
FROM %s
WHERE normalized_login = LOWER(BTRIM(?))
""".formatted(CurrentUsersSql.usersSubquery("su"));
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
@@ -141,14 +117,14 @@ public final class SolanaUsersDAO {
}
/** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */
public SolanaUserEntry getByLogin(String login) throws SQLException {
public CurrentUserEntry getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLogin(c, login);
}
}
/** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */
public SolanaUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
public CurrentUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT
login,
@@ -156,9 +132,9 @@ public final class SolanaUsersDAO {
solana_key,
blockchain_key,
client_key
FROM solana_users
FROM %s
WHERE blockchain_name = ?
""";
""".formatted(CurrentUsersSql.usersSubquery("su"));
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
@@ -170,14 +146,19 @@ public final class SolanaUsersDAO {
}
/** Получить по blockchain_name без внешнего соединения. */
public SolanaUserEntry getByBlockchainName(String blockchainName) throws SQLException {
public CurrentUserEntry getByBlockchainName(String blockchainName) throws SQLException {
try (Connection c = db.getConnection()) {
return getByBlockchainName(c, blockchainName);
}
}
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
public List<SolanaUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
return searchByLoginPrefix(c, prefix, null);
}
/** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException {
String sql = """
SELECT
login,
@@ -185,16 +166,24 @@ public final class SolanaUsersDAO {
solana_key,
blockchain_key,
client_key
FROM solana_users
WHERE LOWER(login) LIKE ?
FROM %s
WHERE normalized_login LIKE LOWER(BTRIM(?))
AND (? IS NULL OR is_server = ?)
ORDER BY login
LIMIT 5
""";
""".formatted(CurrentUsersSql.usersSubquery("su"));
List<SolanaUserEntry> result = new ArrayList<>();
List<CurrentUserEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, prefix.toLowerCase() + "%");
ps.setString(1, prefix.trim() + "%");
if (isServer == null) {
ps.setNull(2, Types.BOOLEAN);
ps.setNull(3, Types.BOOLEAN);
} else {
ps.setBoolean(2, isServer);
ps.setBoolean(3, isServer);
}
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) result.add(mapRow(rs));
}
@@ -204,23 +193,28 @@ public final class SolanaUsersDAO {
}
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
public List<SolanaUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
return searchByLoginPrefix(prefix, null);
}
/** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */
public List<CurrentUserEntry> searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException {
try (Connection c = db.getConnection()) {
return searchByLoginPrefix(c, prefix);
return searchByLoginPrefix(c, prefix, isServer);
}
}
// -------------------- MAPPER --------------------
private SolanaUserEntry mapRow(ResultSet rs) throws SQLException {
SolanaUserEntry e = new SolanaUserEntry();
private CurrentUserEntry mapRow(ResultSet rs) throws SQLException {
CurrentUserEntry e = new CurrentUserEntry();
e.setLogin(rs.getString("login"));
e.setBlockchainName(rs.getString("blockchain_name"));
e.setSolanaKey(rs.getString("solana_key"));
e.setBlockchainKey(rs.getString("blockchain_key"));
e.setClientKey(rs.getString("client_key"));
e.setSolanaKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("solana_key")));
e.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
e.setClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key")));
return e;
}
}
}
@@ -1,53 +0,0 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.DirectMessageEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class DirectMessagesDAO {
private static volatile DirectMessagesDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private DirectMessagesDAO() {}
public static DirectMessagesDAO getInstance() {
if (instance == null) {
synchronized (DirectMessagesDAO.class) {
if (instance == null) instance = new DirectMessagesDAO();
}
}
return instance;
}
public void insert(DirectMessageEntry entry) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO direct_messages (
message_id, from_login, to_login, text, created_at_ms
) VALUES (?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getMessageId());
ps.setString(2, entry.getFromLogin());
ps.setString(3, entry.getToLogin());
ps.setString(4, entry.getText());
ps.setLong(5, entry.getCreatedAtMs());
ps.executeUpdate();
}
}
}
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setString(2, toLogin);
return ps.executeQuery().next();
}
}
}
}
@@ -0,0 +1,518 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmDeliveryStateEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Хранилище изменяемого состояния межсерверной доставки DM-пары. */
public final class DmDeliveryStateDAO {
private static volatile DmDeliveryStateDAO instance;
private final DbController db = DbController.getInstance();
private DmDeliveryStateDAO() {}
public static DmDeliveryStateDAO getInstance() {
if (instance == null) {
synchronized (DmDeliveryStateDAO.class) {
if (instance == null) instance = new DmDeliveryStateDAO();
}
}
return instance;
}
public DmDeliveryStateEntry upsertPair(
String outgoingMessageKey,
String eventId,
String baseKey,
String fromLogin,
String toLogin,
String incomingMessageKey,
long createdAtMs,
long expiresAtMs,
int initialState,
String deliveredServerLogin,
String routesHash,
boolean assistImmediately
) throws SQLException {
long now = System.currentTimeMillis();
int safeState = normalizeState(initialState, deliveredServerLogin);
if (expiresAtMs <= now && safeState == DmDeliveryStateEntry.PENDING_NONE) {
safeState = DmDeliveryStateEntry.FAILED_FINAL;
}
Long nextAttemptAt = safeState == DmDeliveryStateEntry.ACCEPTED
? now
: null;
String safeDeliveredLogin = safeState == DmDeliveryStateEntry.DELIVERED_ONE
? normalize(deliveredServerLogin)
: null;
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO dm_delivery_state (
outgoing_message_key, event_id, base_key, from_login, to_login,
incoming_message_key, created_at_ms, delivery_expires_at_ms,
delivery_state, delivered_server_login, recipient_routes_hash,
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, NULL, ?)
ON CONFLICT (outgoing_message_key) DO UPDATE SET
event_id = EXCLUDED.event_id,
base_key = EXCLUDED.base_key,
from_login = EXCLUDED.from_login,
to_login = EXCLUDED.to_login,
incoming_message_key = EXCLUDED.incoming_message_key,
created_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.created_at_ms
ELSE EXCLUDED.created_at_ms
END,
delivery_expires_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_expires_at_ms
ELSE EXCLUDED.delivery_expires_at_ms
END,
delivery_state = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_state
ELSE EXCLUDED.delivery_state
END,
delivered_server_login = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivered_server_login
ELSE EXCLUDED.delivered_server_login
END,
recipient_routes_hash = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN COALESCE(dm_delivery_state.recipient_routes_hash, EXCLUDED.recipient_routes_hash)
ELSE EXCLUDED.recipient_routes_hash
END,
attempt_index = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.attempt_index
ELSE 0
END,
next_attempt_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.next_attempt_at_ms
ELSE EXCLUDED.next_attempt_at_ms
END,
last_attempt_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_attempt_at_ms
ELSE NULL
END,
last_error = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_error
ELSE NULL
END,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, outgoingMessageKey);
ps.setString(2, eventId);
ps.setString(3, baseKey);
ps.setString(4, normalize(fromLogin));
ps.setString(5, normalize(toLogin));
ps.setString(6, incomingMessageKey);
ps.setLong(7, createdAtMs);
ps.setLong(8, expiresAtMs);
ps.setInt(9, safeState);
setNullableString(ps, 10, safeDeliveredLogin);
setNullableString(ps, 11, normalizeBlank(routesHash));
setNullableLong(ps, 12, nextAttemptAt);
ps.setLong(13, now);
ps.executeUpdate();
}
}
DmDeliveryStateEntry result;
if (initialState != DmDeliveryStateEntry.PENDING_NONE || !isBlank(deliveredServerLogin)) {
result = mergeRemote(eventId, initialState, deliveredServerLogin, routesHash, now);
} else {
result = getByEventId(eventId);
}
if (result != null && result.getDeliveryExpiresAtMs() <= now
&& result.getDeliveryState() == DmDeliveryStateEntry.PENDING_NONE) {
return finishAtExpiry(eventId, now);
}
return result;
}
public DmDeliveryStateEntry getByEventId(String eventId) throws SQLException {
if (isBlank(eventId)) return null;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ?")) {
ps.setString(1, eventId.trim());
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public DmDeliveryStateEntry getByOutgoingMessageKey(String outgoingMessageKey) throws SQLException {
if (isBlank(outgoingMessageKey)) return null;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE outgoing_message_key = ?")) {
ps.setString(1, outgoingMessageKey.trim());
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public Map<String, DmDeliveryStateEntry> listByOutgoingMessageKeys(List<String> keys) throws SQLException {
Map<String, DmDeliveryStateEntry> out = new HashMap<>();
if (keys == null || keys.isEmpty()) return out;
String placeholders = String.join(",", java.util.Collections.nCopies(keys.size(), "?"));
String sql = selectColumns() + " WHERE outgoing_message_key IN (" + placeholders + ")";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
for (int i = 0; i < keys.size(); i++) ps.setString(i + 1, keys.get(i));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
DmDeliveryStateEntry row = mapRow(rs);
out.put(row.getOutgoingMessageKey(), row);
}
}
}
return out;
}
public List<DmDeliveryStateEntry> listDue(long nowMs, int limit) throws SQLException {
String sql = selectColumns() + """
WHERE delivery_state = 0
AND next_attempt_at_ms IS NOT NULL
AND next_attempt_at_ms <= ?
ORDER BY next_attempt_at_ms ASC, created_at_ms ASC
LIMIT ?
""";
List<DmDeliveryStateEntry> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setInt(2, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public boolean claimAttempt(DmDeliveryStateEntry row, long nowMs, Long nextAttemptAtMs) throws SQLException {
String sql = """
UPDATE dm_delivery_state
SET attempt_index = attempt_index + 1,
next_attempt_at_ms = ?,
last_attempt_at_ms = ?,
last_error = NULL,
updated_at_ms = ?
WHERE outgoing_message_key = ?
AND event_id = ?
AND attempt_index = ?
AND delivery_state = 0
AND next_attempt_at_ms IS NOT NULL
AND next_attempt_at_ms <= ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
long networkLeaseUntilMs = nowMs + 60_000L;
Long claimedNextAttemptAtMs = nextAttemptAtMs == null
? networkLeaseUntilMs
: Math.max(nextAttemptAtMs, networkLeaseUntilMs);
setNullableLong(ps, 1, claimedNextAttemptAtMs);
ps.setLong(2, nowMs);
ps.setLong(3, nowMs);
ps.setString(4, row.getOutgoingMessageKey());
ps.setString(5, row.getEventId());
ps.setInt(6, row.getAttemptIndex());
ps.setLong(7, nowMs);
return ps.executeUpdate() == 1;
}
}
public DmDeliveryStateEntry mergeRemote(
String eventId,
int remoteState,
String remoteDeliveredLogin,
String remoteRoutesHash,
long nowMs
) throws SQLException {
try (Connection c = db.getConnection()) {
boolean previousAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
if (current == null) {
c.rollback();
return null;
}
MergeResult merged = merge(
current.getDeliveryState(),
current.getDeliveredServerLogin(),
current.getRecipientRoutesHash(),
remoteState,
remoteDeliveredLogin,
remoteRoutesHash
);
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
merged.routesHash(), current.getNextAttemptAtMs(), current.getLastError(), nowMs);
c.commit();
return getByEventId(eventId);
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw e;
} finally {
c.setAutoCommit(previousAutoCommit);
}
}
}
public DmDeliveryStateEntry updateAfterAttempt(
String eventId,
int attemptedState,
String deliveredServerLogin,
String routesHash,
Long nextAttemptAtMs,
String lastError,
long nowMs
) throws SQLException {
try (Connection c = db.getConnection()) {
boolean previousAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
if (current == null) {
c.rollback();
return null;
}
MergeResult merged = merge(
current.getDeliveryState(), current.getDeliveredServerLogin(), current.getRecipientRoutesHash(),
attemptedState, deliveredServerLogin, routesHash
);
Long effectiveNext = merged.state() == DmDeliveryStateEntry.ACCEPTED
? nextAttemptAtMs
: null;
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
merged.routesHash(), effectiveNext, lastError, nowMs);
c.commit();
return getByEventId(eventId);
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw e;
} finally {
c.setAutoCommit(previousAutoCommit);
}
}
}
public DmDeliveryStateEntry finishAtExpiry(String eventId, long nowMs) throws SQLException {
try (Connection c = db.getConnection()) {
String sql = """
UPDATE dm_delivery_state
SET delivery_state = CASE WHEN delivery_state = 0 THEN 3 ELSE delivery_state END,
delivered_server_login = CASE WHEN delivery_state = 0 THEN NULL ELSE delivered_server_login END,
next_attempt_at_ms = NULL,
last_error = CASE WHEN delivery_state = 0 THEN 'DELIVERY_EXPIRED' ELSE last_error END,
updated_at_ms = ?
WHERE event_id = ? AND delivery_state = 0
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setString(2, eventId);
ps.executeUpdate();
}
}
return getByEventId(eventId);
}
/** Read-only peer confirmed that at least one recipient server accepted the message. */
public DmDeliveryStateEntry markDeliveredFromPeer(String eventId, long nowMs) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_delivery_state
SET delivery_state = 1,
delivered_server_login = NULL,
next_attempt_at_ms = NULL,
last_error = NULL,
updated_at_ms = ?
WHERE event_id = ? AND delivery_state = 0
""")) {
ps.setLong(1, nowMs);
ps.setString(2, eventId);
ps.executeUpdate();
}
return getByEventId(eventId);
}
public int removeByBaseKey(String baseKey) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("DELETE FROM dm_delivery_state WHERE base_key = ?")) {
ps.setString(1, baseKey);
return ps.executeUpdate();
}
}
public int removeConversationBefore(String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
String sql = """
DELETE FROM dm_delivery_state
WHERE created_at_ms < ?
AND ((LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)))
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, boundaryTimeMs);
ps.setString(2, fromLogin);
ps.setString(3, toLogin);
ps.setString(4, toLogin);
ps.setString(5, fromLogin);
return ps.executeUpdate();
}
}
public int removeMissingMessages() throws SQLException {
String sql = """
DELETE FROM dm_delivery_state d
WHERE NOT EXISTS (
SELECT 1 FROM signed_messages m WHERE m.message_key = d.outgoing_message_key
)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
return ps.executeUpdate();
}
}
private DmDeliveryStateEntry getByEventIdForUpdate(Connection c, String eventId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ? FOR UPDATE")) {
ps.setString(1, eventId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
private void updateMutableState(
Connection c,
DmDeliveryStateEntry current,
int state,
String deliveredLogin,
String routesHash,
Long nextAttemptAtMs,
String lastError,
long nowMs
) throws SQLException {
String sql = """
UPDATE dm_delivery_state
SET delivery_state = ?,
delivered_server_login = ?,
recipient_routes_hash = ?,
next_attempt_at_ms = ?,
last_error = ?,
updated_at_ms = ?
WHERE outgoing_message_key = ? AND event_id = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, state);
setNullableString(ps, 2, state == DmDeliveryStateEntry.DELIVERED_ONE ? normalize(deliveredLogin) : null);
setNullableString(ps, 3, normalizeBlank(routesHash));
setNullableLong(ps, 4, nextAttemptAtMs);
setNullableString(ps, 5, normalizeBlank(lastError));
ps.setLong(6, nowMs);
ps.setString(7, current.getOutgoingMessageKey());
ps.setString(8, current.getEventId());
ps.executeUpdate();
}
}
private MergeResult merge(
int localState,
String localLogin,
String localHash,
int remoteStateRaw,
String remoteLoginRaw,
String remoteHashRaw
) {
int remoteState = normalizeState(remoteStateRaw, remoteLoginRaw);
String remoteLogin = normalize(remoteLoginRaw);
String localNormalizedLogin = normalize(localLogin);
String localRoutesHash = normalizeBlank(localHash);
String remoteRoutesHash = normalizeBlank(remoteHashRaw);
String resultHash = remoteRoutesHash != null ? remoteRoutesHash : localRoutesHash;
if (localState == DmDeliveryStateEntry.DELIVERED_ONE || localState == DmDeliveryStateEntry.DELIVERED_ALL) {
return new MergeResult(DmDeliveryStateEntry.DELIVERED, localNormalizedLogin,
localRoutesHash != null ? localRoutesHash : remoteRoutesHash);
}
if (remoteState == DmDeliveryStateEntry.DELIVERED_ONE || remoteState == DmDeliveryStateEntry.DELIVERED_ALL) {
return new MergeResult(DmDeliveryStateEntry.DELIVERED, remoteLogin, resultHash);
}
if (localState == DmDeliveryStateEntry.FAILED_FINAL || remoteState == DmDeliveryStateEntry.FAILED_FINAL) {
return new MergeResult(DmDeliveryStateEntry.FAILED_FINAL, null, resultHash);
}
return new MergeResult(DmDeliveryStateEntry.PENDING_NONE, null, resultHash);
}
private int normalizeState(int state, String deliveredLogin) {
if (state < DmDeliveryStateEntry.PENDING_NONE || state > DmDeliveryStateEntry.FAILED_FINAL) {
return DmDeliveryStateEntry.PENDING_NONE;
}
if (state == DmDeliveryStateEntry.DELIVERED_ALL) return DmDeliveryStateEntry.DELIVERED;
return state;
}
private String selectColumns() {
return """
SELECT outgoing_message_key, event_id, base_key, from_login, to_login,
incoming_message_key, created_at_ms, delivery_expires_at_ms,
delivery_state, delivered_server_login, recipient_routes_hash,
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
FROM dm_delivery_state
""";
}
private DmDeliveryStateEntry mapRow(ResultSet rs) throws SQLException {
DmDeliveryStateEntry e = new DmDeliveryStateEntry();
e.setOutgoingMessageKey(rs.getString("outgoing_message_key"));
e.setEventId(rs.getString("event_id"));
e.setBaseKey(rs.getString("base_key"));
e.setFromLogin(rs.getString("from_login"));
e.setToLogin(rs.getString("to_login"));
e.setIncomingMessageKey(rs.getString("incoming_message_key"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setDeliveryExpiresAtMs(rs.getLong("delivery_expires_at_ms"));
e.setDeliveryState(rs.getInt("delivery_state"));
e.setDeliveredServerLogin(rs.getString("delivered_server_login"));
e.setRecipientRoutesHash(rs.getString("recipient_routes_hash"));
e.setAttemptIndex(rs.getInt("attempt_index"));
long next = rs.getLong("next_attempt_at_ms");
e.setNextAttemptAtMs(rs.wasNull() ? null : next);
long last = rs.getLong("last_attempt_at_ms");
e.setLastAttemptAtMs(rs.wasNull() ? null : last);
e.setLastError(rs.getString("last_error"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
private static void setNullableString(PreparedStatement ps, int index, String value) throws SQLException {
if (isBlank(value)) ps.setNull(index, Types.VARCHAR);
else ps.setString(index, value.trim());
}
private static void setNullableLong(PreparedStatement ps, int index, Long value) throws SQLException {
if (value == null) ps.setNull(index, Types.BIGINT);
else ps.setLong(index, value);
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase();
return normalized.isEmpty() ? null : normalized;
}
private static String normalizeBlank(String value) {
if (value == null) return null;
String normalized = value.trim();
return normalized.isEmpty() ? null : normalized;
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record MergeResult(int state, String deliveredLogin, String routesHash) {}
}

Some files were not shown because too many files have changed in this diff Show More