Compare commits

...
Author SHA256 Message Date
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
462 changed files with 50071 additions and 12086 deletions
+5
View File
@@ -45,6 +45,11 @@
- `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`
## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
+5 -3
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, чтобы узнать адрес сервера и при необходимости подключиться.
@@ -49,7 +49,9 @@ shine-UI/server-ui.html
## Синхронизация с партнёрскими серверами
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
Сервер должен синхронизировать блоки пользовательских блокчейнов с
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
они доставляются только на первый access-сервер получателя.
Детали: `docs/Blockchain/sync-between-servers.md`
## Деплой
@@ -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,6 +59,8 @@ 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);
}
@@ -78,4 +80,11 @@ public final class AppConfig {
String v = properties.getProperty(name);
return v == null ? defaultValue : Boolean.parseBoolean(v);
}
private static String toEnvName(String name) {
return name
.replace('.', '_')
.replace('-', '_')
.toUpperCase();
}
}
@@ -14,6 +14,9 @@ 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:";
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
@@ -31,4 +34,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,846 +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 = 12;
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();
case 12 -> migrateToV12();
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 void migrateToV12() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureSignedMessagesReadAtColumn(c, st);
backfillSignedMessagesReadAt(st);
setSchemaVersion(c, 12);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v12 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v12 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");
}
}
private static void ensureSignedMessagesReadAtColumn(Connection c, Statement st) throws SQLException {
if (!tableExists(c, "signed_messages_v2")) return;
if (!columnExists(c, "signed_messages_v2", "read_at_ms")) {
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN read_at_ms INTEGER");
}
}
private static void backfillSignedMessagesReadAt(Statement st) throws SQLException {
st.executeUpdate("""
UPDATE signed_messages_v2 AS content
SET read_at_ms = (
SELECT MIN(receipt.time_ms)
FROM signed_messages_v2 AS receipt
WHERE receipt.message_type IN (3, 4)
AND receipt.receipt_ref_base_key = content.base_key
)
WHERE content.message_type IN (1, 2)
AND (content.read_at_ms IS NULL OR content.read_at_ms <= 0)
AND EXISTS (
SELECT 1
FROM signed_messages_v2 AS receipt
WHERE receipt.message_type IN (3, 4)
AND receipt.receipt_ref_base_key = content.base_key
);
""");
}
/**
* Временная одноразовая миграция на переходе к 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;
}
}
@@ -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<>();
@@ -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();
}
}
@@ -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) {}
}
@@ -0,0 +1,464 @@
package shine.db.dao;
import shine.db.MsgSubType;
import shine.db.entities.SignedMessageEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Base64;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class DmDialogStateDAO {
private static volatile DmDialogStateDAO instance;
private DmDialogStateDAO() {}
public static DmDialogStateDAO getInstance() {
if (instance == null) {
synchronized (DmDialogStateDAO.class) {
if (instance == null) instance = new DmDialogStateDAO();
}
}
return instance;
}
public void bootstrapIfEmpty(Connection c) throws SQLException {
if (c == null) return;
if (hasAnyRow(c)) return;
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
for (PairItem pair : listConversationPairs(c)) {
refreshConversationPair(c, pair.ownerLogin(), pair.peerLogin());
}
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
if (e instanceof SQLException sqlEx) throw sqlEx;
throw new SQLException("Failed to bootstrap dm_dialog_state", e);
} finally {
c.setAutoCommit(prevAutoCommit);
}
}
public void refreshConversationPair(Connection c, String ownerLogin, String peerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty()) return;
if (cleanOwner.equalsIgnoreCase(cleanPeer)) return;
refreshConversation(c, cleanOwner, cleanPeer);
refreshConversation(c, cleanPeer, cleanOwner);
}
public List<DialogSummary> listInboxDialogs(Connection c, String ownerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
if (cleanOwner.isEmpty()) return List.of();
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
List<String> friends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_FRIEND);
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
try (PreparedStatement ps = c.prepareStatement("""
SELECT owner_login, peer_login, relation_flag, last_message_blob_b64,
last_message_time_ms, unread_count, last_read_receipt_time_ms,
updated_at_ms
FROM dm_dialog_state
WHERE LOWER(owner_login) = LOWER(?)
""")) {
ps.setString(1, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
int unreadCount = rs.getInt("unread_count");
DialogSummary row = new DialogSummary(
rs.getString("owner_login"),
rs.getString("peer_login"),
normalizeRelationFlag(rs.getString("relation_flag")),
rs.getString("last_message_blob_b64"),
lastMessageTimeMs,
unreadCount,
rs.getLong("last_read_receipt_time_ms"),
rs.getLong("updated_at_ms"),
lastMessageTimeMs > 0 || unreadCount > 0
);
byPeer.put(normKey(row.peerLogin()), row);
}
}
}
for (String peer : contacts) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
}
for (String peer : friends) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "friend", false);
}
for (String peer : closeFriends) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
}
for (DialogSummary row : new ArrayList<>(byPeer.values())) {
String relationFlag = resolveRelationFlag(c, cleanOwner, row.peerLogin());
byPeer.put(normKey(row.peerLogin()), new DialogSummary(
row.ownerLogin(),
row.peerLogin(),
relationFlag,
row.lastMessageBlobB64(),
row.lastMessageTimeMs(),
row.unreadCount(),
row.lastReadReceiptTimeMs(),
row.updatedAtMs(),
row.hasDialog()
));
}
List<DialogSummary> out = new ArrayList<>(byPeer.values());
out.sort((a, b) -> {
int cmp = Long.compare(b.lastMessageTimeMs(), a.lastMessageTimeMs());
if (cmp != 0) return cmp;
return normalize(a.peerLogin()).compareToIgnoreCase(normalize(b.peerLogin()));
});
return out;
}
public void refreshFromEntry(Connection c, SignedMessageEntry entry) throws SQLException {
if (entry == null) return;
refreshConversationPair(c, entry.getFromLogin(), entry.getToLogin());
}
private void refreshConversation(Connection c, String ownerLogin, String peerLogin) throws SQLException {
DialogSummary summary = loadConversationSummary(c, ownerLogin, peerLogin);
upsert(c, summary);
if (summary.lastReadReceiptTimeMs() > 0) {
syncMessagesToWatermark(c, summary.ownerLogin(), summary.peerLogin(), summary.lastReadReceiptTimeMs());
}
}
private DialogSummary loadConversationSummary(Connection c, String ownerLogin, String peerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) {
return new DialogSummary(cleanOwner, cleanPeer, "none", "", 0L, 0, 0L, System.currentTimeMillis(), false);
}
String relationFlag = resolveRelationFlag(c, cleanOwner, cleanPeer);
long existingWatermark = loadExistingWatermark(c, cleanOwner, cleanPeer);
String latestSql = """
SELECT raw_block, time_ms
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
ORDER BY time_ms DESC, revision_time_ms DESC, reencrypted_at_ms DESC, created_at_ms DESC, message_key DESC
LIMIT 1
""";
String unreadSql = """
SELECT COUNT(*)
FROM signed_messages
WHERE LOWER(from_login) = LOWER(?)
AND LOWER(to_login) = LOWER(?)
AND message_type = 1
AND time_ms > ?
AND (read_at_ms IS NULL OR read_at_ms <= 0)
""";
String contentReadSql = """
SELECT MAX(read_at_ms)
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
AND read_at_ms IS NOT NULL
AND read_at_ms > 0
""";
String receiptWatermarkSql = """
SELECT MAX(time_ms)
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (3, 4)
""";
String lastMessageBlobB64 = "";
long lastMessageTimeMs = 0L;
int unreadCount = 0;
long lastReadReceiptTimeMs = existingWatermark;
try (PreparedStatement ps = c.prepareStatement(latestSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
lastMessageTimeMs = rs.getLong("time_ms");
byte[] rawBlock = rs.getBytes("raw_block");
if (rawBlock != null && rawBlock.length > 0) {
lastMessageBlobB64 = Base64.getEncoder().encodeToString(rawBlock);
}
}
}
}
try (PreparedStatement ps = c.prepareStatement(contentReadSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong(1);
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
}
}
}
try (PreparedStatement ps = c.prepareStatement(receiptWatermarkSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong(1);
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
}
}
}
try (PreparedStatement ps = c.prepareStatement(unreadSql)) {
ps.setString(1, cleanPeer);
ps.setString(2, cleanOwner);
ps.setLong(3, lastReadReceiptTimeMs);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) unreadCount = rs.getInt(1);
}
}
return new DialogSummary(
cleanOwner,
cleanPeer,
relationFlag,
lastMessageBlobB64,
lastMessageTimeMs,
unreadCount,
lastReadReceiptTimeMs,
System.currentTimeMillis(),
lastMessageTimeMs > 0 || unreadCount > 0 || lastReadReceiptTimeMs > 0
);
}
private void upsert(Connection c, DialogSummary summary) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_dialog_state (
owner_login, peer_login, relation_flag, last_message_blob_b64,
last_message_time_ms, unread_count, last_read_receipt_time_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (owner_login, peer_login) DO UPDATE SET
relation_flag = EXCLUDED.relation_flag,
last_message_blob_b64 = EXCLUDED.last_message_blob_b64,
last_message_time_ms = EXCLUDED.last_message_time_ms,
unread_count = EXCLUDED.unread_count,
last_read_receipt_time_ms = EXCLUDED.last_read_receipt_time_ms,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, summary.ownerLogin());
ps.setString(2, summary.peerLogin());
ps.setString(3, normalizeRelationFlag(summary.relationFlag()));
ps.setString(4, summary.lastMessageBlobB64());
ps.setLong(5, summary.lastMessageTimeMs());
ps.setInt(6, summary.unreadCount());
ps.setLong(7, summary.lastReadReceiptTimeMs());
ps.setLong(8, summary.updatedAtMs());
ps.executeUpdate();
}
}
private String resolveRelationFlag(Connection c, String ownerLogin, String peerLogin) throws SQLException {
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
return "close_friend";
}
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_FRIEND)) {
return "friend";
}
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
return "contact";
}
return "none";
}
private boolean hasAnyRow(Connection c) throws SQLException {
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT 1 FROM dm_dialog_state LIMIT 1")) {
return rs.next();
}
}
private List<PairItem> listConversationPairs(Connection c) throws SQLException {
String sql = """
SELECT DISTINCT owner_login, peer_login
FROM (
SELECT
m.target_login AS owner_login,
CASE
WHEN LOWER(m.target_login) = LOWER(m.to_login) THEN m.from_login
ELSE m.to_login
END AS peer_login
FROM signed_messages m
WHERE m.message_type IN (1, 2, 3, 4, 5, 6, 7, 8)
UNION ALL
SELECT m.from_login AS owner_login, m.to_login AS peer_login
FROM signed_messages m
WHERE m.message_type IN (5, 6, 7, 8)
UNION ALL
SELECT m.to_login AS owner_login, m.from_login AS peer_login
FROM signed_messages m
WHERE m.message_type IN (5, 6, 7, 8)
) pairs
WHERE owner_login IS NOT NULL
AND peer_login IS NOT NULL
AND BTRIM(owner_login) <> ''
AND BTRIM(peer_login) <> ''
AND LOWER(owner_login) <> LOWER(peer_login)
ORDER BY owner_login, peer_login
""";
List<PairItem> out = new ArrayList<>();
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
String ownerLogin = rs.getString("owner_login");
String peerLogin = rs.getString("peer_login");
if (normalize(ownerLogin).isEmpty() || normalize(peerLogin).isEmpty()) continue;
out.add(new PairItem(ownerLogin, peerLogin));
}
}
return out;
}
private void addOrUpdateSummary(Map<String, DialogSummary> map, String ownerLogin, String peerLogin, String relationFlag, boolean hasDialog) {
String owner = normalize(ownerLogin);
String peer = normalize(peerLogin);
if (owner.isEmpty() || peer.isEmpty() || owner.equalsIgnoreCase(peer)) return;
String key = normKey(peer);
DialogSummary current = map.get(key);
if (current == null) {
map.put(key, new DialogSummary(owner, peer, relationFlag, "", 0L, 0, 0L, System.currentTimeMillis(), hasDialog));
return;
}
String nextRelation = current.relationFlag();
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
nextRelation = "close_friend";
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
nextRelation = "friend";
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
nextRelation = "contact";
} else {
nextRelation = normalizeRelationFlag(nextRelation);
}
map.put(key, new DialogSummary(
current.ownerLogin().isEmpty() ? owner : current.ownerLogin(),
peer,
nextRelation,
current.lastMessageBlobB64(),
current.lastMessageTimeMs(),
current.unreadCount(),
current.lastReadReceiptTimeMs(),
current.updatedAtMs(),
current.hasDialog() || hasDialog
));
}
private void syncMessagesToWatermark(Connection c, String ownerLogin, String peerLogin, long watermark) throws SQLException {
if (watermark <= 0) return;
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) return;
try (PreparedStatement ps = c.prepareStatement("""
UPDATE signed_messages
SET read_at_ms = CASE
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
WHEN read_at_ms > ? THEN read_at_ms
ELSE read_at_ms
END
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
AND time_ms <= ?
""")) {
ps.setLong(1, watermark);
ps.setLong(2, watermark);
ps.setString(3, cleanOwner);
ps.setString(4, cleanPeer);
ps.setString(5, cleanPeer);
ps.setString(6, cleanOwner);
ps.setLong(7, watermark);
ps.executeUpdate();
}
}
private long loadExistingWatermark(Connection c, String ownerLogin, String peerLogin) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT last_read_receipt_time_ms
FROM dm_dialog_state
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(peer_login) = LOWER(?)
LIMIT 1
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, peerLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return 0L;
long value = rs.getLong(1);
return rs.wasNull() ? 0L : value;
}
}
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
private String normKey(String value) {
return normalize(value).toLowerCase(Locale.ROOT);
}
private String normalizeRelationFlag(String value) {
String clean = normalize(value).toLowerCase(Locale.ROOT);
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
return "none";
}
public record DialogSummary(
String ownerLogin,
String peerLogin,
String relationFlag,
String lastMessageBlobB64,
long lastMessageTimeMs,
int unreadCount,
long lastReadReceiptTimeMs,
long updatedAtMs,
boolean hasDialog
) {}
private record PairItem(String ownerLogin, String peerLogin) {}
}
@@ -0,0 +1,232 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmSyncOutboxEntry;
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.List;
/** Outbox событий DM для единственного второго access-сервера пользователя. */
public final class DmSyncOutboxDAO {
private static volatile DmSyncOutboxDAO instance;
private final DbController db = DbController.getInstance();
private DmSyncOutboxDAO() {}
public static DmSyncOutboxDAO getInstance() {
if (instance == null) {
synchronized (DmSyncOutboxDAO.class) {
if (instance == null) instance = new DmSyncOutboxDAO();
}
}
return instance;
}
public void upsert(
String ownerLogin,
String primaryMessageKey,
String eventId,
String secondaryMessageKey,
boolean synced,
long createdAtMs
) throws SQLException {
long now = System.currentTimeMillis();
String sql = """
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
) VALUES (LOWER(?), ?, ?, ?, ?, ?, ?)
ON CONFLICT (owner_login, primary_message_key) DO UPDATE SET
event_id = EXCLUDED.event_id,
secondary_message_key = EXCLUDED.secondary_message_key,
synced = CASE
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
THEN dm_sync_outbox.synced OR EXCLUDED.synced
ELSE EXCLUDED.synced
END,
created_at_ms = CASE
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
THEN dm_sync_outbox.created_at_ms
ELSE EXCLUDED.created_at_ms
END,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, primaryMessageKey);
ps.setString(3, eventId);
if (secondaryMessageKey == null || secondaryMessageKey.isBlank()) ps.setNull(4, Types.VARCHAR);
else ps.setString(4, secondaryMessageKey.trim());
ps.setBoolean(5, synced);
ps.setLong(6, createdAtMs);
ps.setLong(7, now);
ps.executeUpdate();
}
}
public List<DmSyncOutboxEntry> listUnsynced(String ownerLogin, int limit) throws SQLException {
return listUnsynced(ownerLogin, 0L, "", limit);
}
public List<DmSyncOutboxEntry> listUnsynced(
String ownerLogin, long afterCreatedAtMs, String afterPrimaryMessageKey, int limit
) throws SQLException {
String sql = """
SELECT owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
FROM dm_sync_outbox
WHERE LOWER(owner_login) = LOWER(?) AND synced = FALSE
AND (created_at_ms > ? OR (created_at_ms = ? AND primary_message_key > ?))
ORDER BY created_at_ms ASC, primary_message_key ASC
LIMIT ?
""";
List<DmSyncOutboxEntry> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setLong(2, Math.max(0L, afterCreatedAtMs));
ps.setLong(3, Math.max(0L, afterCreatedAtMs));
ps.setString(4, afterPrimaryMessageKey == null ? "" : afterPrimaryMessageKey);
ps.setInt(5, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public boolean hasUnsyncedForServer(String serverLogin) throws SQLException {
String sql = """
SELECT 1
FROM dm_sync_outbox o
JOIN user_access_servers_current r
ON LOWER(r.user_login) = LOWER(o.owner_login)
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
AND EXISTS (
SELECT 1
FROM user_access_servers_current peer
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
AND LOWER(peer.server_login) <> LOWER(?)
)
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
ps.setString(2, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
public List<String> listUnsyncedOwnersForServer(String serverLogin) throws SQLException {
String sql = """
SELECT DISTINCT o.owner_login
FROM dm_sync_outbox o
JOIN user_access_servers_current r
ON LOWER(r.user_login) = LOWER(o.owner_login)
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
AND EXISTS (
SELECT 1
FROM user_access_servers_current peer
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
AND LOWER(peer.server_login) <> LOWER(?)
)
ORDER BY o.owner_login
""";
List<String> result = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
ps.setString(2, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) result.add(rs.getString("owner_login"));
}
}
return result;
}
public int markSynced(String ownerLogin, String eventId) throws SQLException {
String sql = """
UPDATE dm_sync_outbox
SET synced = TRUE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, System.currentTimeMillis());
ps.setString(2, ownerLogin);
ps.setString(3, eventId);
return ps.executeUpdate();
}
}
public int markSynced(String ownerLogin, List<String> eventIds) throws SQLException {
if (eventIds == null || eventIds.isEmpty()) return 0;
int total = 0;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_sync_outbox
SET synced = TRUE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
""")) {
long now = System.currentTimeMillis();
for (String eventId : eventIds) {
if (eventId == null || eventId.isBlank()) continue;
ps.setLong(1, now);
ps.setString(2, ownerLogin);
ps.setString(3, eventId.trim());
ps.addBatch();
}
for (int changed : ps.executeBatch()) if (changed > 0) total += changed;
}
return total;
}
public int markAllUnsynced(String ownerLogin) throws SQLException {
String sql = """
UPDATE dm_sync_outbox
SET synced = FALSE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, System.currentTimeMillis());
ps.setString(2, ownerLogin);
return ps.executeUpdate();
}
}
public int markAllUnsynced() throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("UPDATE dm_sync_outbox SET synced = FALSE, updated_at_ms = ?")) {
ps.setLong(1, System.currentTimeMillis());
return ps.executeUpdate();
}
}
public int removeMissingMessages() throws SQLException {
String sql = """
DELETE FROM dm_sync_outbox o
WHERE NOT EXISTS (
SELECT 1 FROM signed_messages m WHERE m.message_key = o.primary_message_key
)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
return ps.executeUpdate();
}
}
private DmSyncOutboxEntry mapRow(ResultSet rs) throws SQLException {
DmSyncOutboxEntry e = new DmSyncOutboxEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setPrimaryMessageKey(rs.getString("primary_message_key"));
e.setEventId(rs.getString("event_id"));
e.setSecondaryMessageKey(rs.getString("secondary_message_key"));
e.setSynced(rs.getBoolean("synced"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
}
@@ -0,0 +1,173 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmSyncPeerStateEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class DmSyncPeerStateDAO {
private static volatile DmSyncPeerStateDAO instance;
private final DbController db = DbController.getInstance();
private DmSyncPeerStateDAO() {}
public static DmSyncPeerStateDAO getInstance() {
if (instance == null) {
synchronized (DmSyncPeerStateDAO.class) {
if (instance == null) instance = new DmSyncPeerStateDAO();
}
}
return instance;
}
public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin);
if (existing != null) {
if (remoteServerUrl != null && !remoteServerUrl.isBlank()
&& !remoteServerUrl.equals(existing.getRemoteServerUrl())) {
touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl);
existing.setRemoteServerUrl(remoteServerUrl);
}
return existing;
}
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
ON CONFLICT DO NOTHING
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setLong(4, nowMs);
ps.executeUpdate();
}
existing = get(ownerLogin, remoteServerLogin);
if (existing == null) {
throw new SQLException("Failed to create dm_sync_peer_state row");
}
return existing;
}
public void updateSuccess(
String ownerLogin,
String remoteServerLogin,
String remoteServerUrl,
long cursorStoredAtMs,
String cursorMessageKey,
boolean bootstrapCompleted
) throws SQLException {
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms,
cursor_message_key = EXCLUDED.cursor_message_key,
bootstrap_completed = EXCLUDED.bootstrap_completed,
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
last_error = NULL,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setLong(4, Math.max(0L, cursorStoredAtMs));
ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey);
ps.setBoolean(6, bootstrapCompleted);
ps.setLong(7, nowMs);
ps.setLong(8, nowMs);
ps.executeUpdate();
}
}
public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
last_error = EXCLUDED.last_error,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setString(4, truncate(error));
ps.setLong(5, nowMs);
ps.executeUpdate();
}
}
private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
FROM dm_sync_peer_state
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(remote_server_login) = LOWER(?)
LIMIT 1
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
DmSyncPeerStateEntry e = new DmSyncPeerStateEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setRemoteServerLogin(rs.getString("remote_server_login"));
e.setRemoteServerUrl(rs.getString("remote_server_url"));
e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms"));
e.setCursorMessageKey(rs.getString("cursor_message_key"));
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
long lastSyncAt = rs.getLong("last_sync_at_ms");
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt);
e.setLastError(rs.getString("last_error"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
}
}
private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_sync_peer_state
SET remote_server_url = ?, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(remote_server_login) = LOWER(?)
""")) {
ps.setString(1, remoteServerUrl);
ps.setLong(2, System.currentTimeMillis());
ps.setString(3, ownerLogin);
ps.setString(4, remoteServerLogin);
ps.executeUpdate();
}
}
private static String truncate(String value) {
if (value == null) return null;
String clean = value.trim();
if (clean.length() <= 500) return clean;
return clean.substring(0, 500);
}
}
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.EspPairingRequestEntry;
import java.sql.Connection;
@@ -13,7 +13,7 @@ import java.util.List;
public final class EspPairingRequestsDAO {
private static volatile EspPairingRequestsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private EspPairingRequestsDAO() { }
@@ -110,7 +110,7 @@ public final class EspPairingRequestsDAO {
payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason,
approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver
FROM esp_pairing_requests
WHERE login = ? COLLATE NOCASE
WHERE LOWER(login) = LOWER(?)
AND expires_at_ms > ?
AND status = 'created'
ORDER BY created_at_ms DESC
@@ -131,7 +131,7 @@ public final class EspPairingRequestsDAO {
StringBuilder sql = new StringBuilder("""
SELECT COUNT(*)
FROM esp_pairing_requests
WHERE login = ? COLLATE NOCASE
WHERE LOWER(login) = LOWER(?)
AND created_at_ms >= ?
AND status IN (
""");
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.EspPairingSettingsEntry;
import java.sql.Connection;
@@ -11,7 +11,7 @@ import java.sql.SQLException;
public final class EspPairingSettingsDAO {
private static volatile EspPairingSettingsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private EspPairingSettingsDAO() { }
@@ -74,7 +74,7 @@ public final class EspPairingSettingsDAO {
String sql = """
SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms
FROM esp_pairing_settings
WHERE login = ? COLLATE NOCASE
WHERE LOWER(login) = LOWER(?)
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.IpGeoCacheEntry;
import java.sql.*;
@@ -20,7 +20,7 @@ import java.sql.*;
public final class IpGeoCacheDAO {
private static volatile IpGeoCacheDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private IpGeoCacheDAO() { }
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.PushTokenEntry;
import java.sql.Connection;
@@ -11,7 +11,7 @@ import java.util.List;
public final class PushTokensDAO {
private static volatile PushTokensDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private PushTokensDAO() {}
@@ -1,47 +0,0 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.SignedDirectMessageHistoryEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDirectMessagesHistoryDAO {
private static volatile SignedDirectMessagesHistoryDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private SignedDirectMessagesHistoryDAO() {}
public static SignedDirectMessagesHistoryDAO getInstance() {
if (instance == null) {
synchronized (SignedDirectMessagesHistoryDAO.class) {
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
}
}
return instance;
}
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO signed_direct_messages_history (
message_id, from_login, to_login, target_mode, target_session_id,
message_type, time_ms, nonce, raw_packet, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getMessageId());
ps.setString(2, e.getFromLogin());
ps.setString(3, e.getToLogin());
ps.setInt(4, e.getTargetMode());
ps.setString(5, e.getTargetSessionId());
ps.setInt(6, e.getMessageType());
ps.setLong(7, e.getTimeMs());
ps.setLong(8, e.getNonce());
ps.setBytes(9, e.getRawPacket());
ps.setLong(10, e.getCreatedAtMs());
ps.executeUpdate();
}
}
}
}
@@ -1,50 +0,0 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDmReplayDAO {
private static volatile SignedDmReplayDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private SignedDmReplayDAO() {}
public static SignedDmReplayDAO getInstance() {
if (instance == null) {
synchronized (SignedDmReplayDAO.class) {
if (instance == null) instance = new SignedDmReplayDAO();
}
}
return instance;
}
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
cleanupExpired(nowMs - 15L * 60L * 1000L);
try (Connection c = db.getConnection()) {
String sql = """
INSERT OR IGNORE INTO signed_direct_message_replay (
from_login, time_ms, nonce, created_at_ms
) VALUES (?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setLong(2, timeMs);
ps.setLong(3, nonce);
ps.setLong(4, nowMs);
return ps.executeUpdate() > 0;
}
}
}
public void cleanupExpired(long minCreatedAtMs) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, minCreatedAtMs);
ps.executeUpdate();
}
}
}
}
@@ -1,7 +1,7 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.SignedMessageV2Entry;
import shine.db.DbController;
import shine.db.entities.SignedMessageEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
@@ -11,9 +11,7 @@ import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public final class SignedMessagesV2DAO {
private static final int SQLITE_BUSY_MAX_RETRIES = 6;
private static final long SQLITE_BUSY_RETRY_BASE_DELAY_MS = 40L;
public final class SignedMessagesDAO {
public enum ApplyStatus {
APPLIED,
@@ -26,39 +24,41 @@ public final class SignedMessagesV2DAO {
}
}
private static volatile SignedMessagesV2DAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private static volatile SignedMessagesDAO instance;
private final DbController db = DbController.getInstance();
private SignedMessagesV2DAO() {}
private SignedMessagesDAO() {}
public static SignedMessagesV2DAO getInstance() {
public static SignedMessagesDAO getInstance() {
if (instance == null) {
synchronized (SignedMessagesV2DAO.class) {
if (instance == null) instance = new SignedMessagesV2DAO();
synchronized (SignedMessagesDAO.class) {
if (instance == null) instance = new SignedMessagesDAO();
}
}
return instance;
}
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
public ApplyStatus insertIfAbsent(SignedMessageEntry e) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
}
String sql = """
INSERT OR IGNORE INTO signed_messages_v2 (
INSERT INTO signed_messages (
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type, read_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
if (status.applied()) {
markMessageReadByReceipt(c, e);
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
}
return status;
}
@@ -66,7 +66,7 @@ public final class SignedMessagesV2DAO {
});
}
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
public boolean insertPairBothOrNothing(SignedMessageEntry first, SignedMessageEntry second) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
@@ -77,6 +77,7 @@ public final class SignedMessagesV2DAO {
if (insertedFirst == 1 && insertedSecond == 1) {
markMessageReadByReceipt(c, first);
markMessageReadByReceipt(c, second);
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
c.commit();
return true;
}
@@ -95,7 +96,7 @@ public final class SignedMessagesV2DAO {
});
}
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
public ApplyStatus upsertContentPair(SignedMessageEntry incoming, SignedMessageEntry outgoing) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
@@ -123,6 +124,7 @@ public final class SignedMessagesV2DAO {
markMessageReadByReceipt(c, outgoing);
resetDeliveryRows(c, incoming.getMessageKey());
resetDeliveryRows(c, outgoing.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -136,7 +138,7 @@ public final class SignedMessagesV2DAO {
});
}
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
public ApplyStatus upsertIncomingCopy(SignedMessageEntry incoming) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
@@ -161,6 +163,7 @@ public final class SignedMessagesV2DAO {
upsertMessage(c, incoming);
markMessageReadByReceipt(c, incoming);
resetDeliveryRows(c, incoming.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
} catch (Exception ex) {
@@ -173,7 +176,7 @@ public final class SignedMessagesV2DAO {
});
}
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
public ApplyStatus applyDeleteMessage(SignedMessageEntry tombstone) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
@@ -191,6 +194,7 @@ public final class SignedMessagesV2DAO {
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
upsertMessage(c, tombstone);
resetDeliveryRows(c, tombstone.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -204,7 +208,7 @@ public final class SignedMessagesV2DAO {
});
}
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
public ApplyStatus applyDeleteConversation(SignedMessageEntry tombstone) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
@@ -219,6 +223,7 @@ public final class SignedMessagesV2DAO {
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
upsertMessage(c, tombstone);
resetDeliveryRows(c, tombstone.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -232,7 +237,7 @@ public final class SignedMessagesV2DAO {
});
}
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
public SignedMessageEntry getByMessageKey(String messageKey) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
SELECT
@@ -240,9 +245,9 @@ public final class SignedMessagesV2DAO {
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type, read_at_ms
FROM signed_messages_v2
FROM %s
WHERE message_key = ?
""";
""".formatted(messagesTable());
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, messageKey);
try (ResultSet rs = ps.executeQuery()) {
@@ -253,7 +258,7 @@ public final class SignedMessagesV2DAO {
}
}
public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
public SignedMessageEntry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
try (Connection c = db.getConnection()) {
return getLatestConversationDelete(c, fromLogin, toLogin);
}
@@ -268,9 +273,10 @@ public final class SignedMessagesV2DAO {
withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
String sql = """
INSERT OR IGNORE INTO signed_message_session_delivery (
INSERT INTO signed_message_session_delivery (
message_key, session_id, delivered, delivered_at_ms, created_at_ms
) VALUES (?, ?, 0, NULL, ?)
ON CONFLICT DO NOTHING
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (String sessionId : sessionIds) {
@@ -314,22 +320,23 @@ public final class SignedMessagesV2DAO {
});
}
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
public List<SignedMessageEntry> listPendingForSession(String login, String sessionId) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
String fillSql = """
INSERT OR IGNORE INTO signed_message_session_delivery (
INSERT INTO signed_message_session_delivery (
message_key, session_id, delivered, delivered_at_ms, created_at_ms
)
SELECT m.message_key, ?, 0, NULL, ?
FROM signed_messages_v2 m
FROM %s m
WHERE (
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
(m.message_type IN (1, 3) AND LOWER(m.to_login) = LOWER(?))
OR (m.message_type IN (2, 4) AND LOWER(m.from_login) = LOWER(?))
OR (m.message_type IN (5, 6, 7, 8)
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
AND (LOWER(m.from_login) = LOWER(?) OR LOWER(m.to_login) = LOWER(?)))
)
""";
ON CONFLICT DO NOTHING
""".formatted(messagesTable());
long now = System.currentTimeMillis();
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
ps.setString(1, sessionId);
@@ -347,13 +354,13 @@ public final class SignedMessagesV2DAO {
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms
FROM signed_messages_v2 m
FROM %s m
JOIN signed_message_session_delivery d
ON d.message_key = m.message_key
WHERE d.session_id = ? AND d.delivered = 0
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
""";
List<SignedMessageV2Entry> out = new ArrayList<>();
""".formatted(messagesTable());
List<SignedMessageEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, sessionId);
try (ResultSet rs = ps.executeQuery()) {
@@ -365,7 +372,7 @@ public final class SignedMessagesV2DAO {
});
}
public List<SignedMessageV2Entry> listConversationPage(
public List<SignedMessageEntry> listConversationPage(
String login,
String peerLogin,
long beforeTimeMs,
@@ -379,12 +386,12 @@ public final class SignedMessagesV2DAO {
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type, read_at_ms
FROM signed_messages_v2
WHERE target_login = ? COLLATE NOCASE
FROM %s
WHERE LOWER(target_login) = LOWER(?)
AND message_type IN (1, 2)
AND (
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND (
? <= 0
@@ -393,8 +400,8 @@ public final class SignedMessagesV2DAO {
)
ORDER BY time_ms DESC, message_key DESC
LIMIT ?
""";
List<SignedMessageV2Entry> out = new ArrayList<>();
""".formatted(messagesTable());
List<SignedMessageEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setString(2, login);
@@ -415,9 +422,75 @@ public final class SignedMessagesV2DAO {
}
}
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
public SyncBatch listSyncBatch(
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes
) throws Exception {
int safeLimit = Math.max(1, Math.min(limit, 500));
int sqlLimit = safeLimit + 1;
String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey;
try (Connection c = db.getConnection()) {
String sql = """
SELECT
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type, read_at_ms
FROM %s
WHERE (
LOWER(target_login) = LOWER(?)
OR (
message_type IN (5, 6, 7, 8)
AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?))
)
)
AND (
created_at_ms > ?
OR (created_at_ms = ? AND (? = '' OR message_key > ?))
)
ORDER BY created_at_ms ASC, message_key ASC
LIMIT ?
""".formatted(messagesTable());
List<SignedMessageEntry> out = new ArrayList<>();
boolean hasMore = false;
int usedBytes = 0;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, ownerLogin);
ps.setString(3, ownerLogin);
ps.setLong(4, Math.max(0L, afterStoredAtMs));
ps.setLong(5, Math.max(0L, afterStoredAtMs));
ps.setString(6, safeAfterMessageKey);
ps.setString(7, safeAfterMessageKey);
ps.setInt(8, sqlLimit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
SignedMessageEntry row = mapRow(rs);
byte[] raw = row.getRawBlock();
int rowBytes = raw == null ? 0 : raw.length;
if (out.size() >= safeLimit) {
hasMore = true;
break;
}
if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) {
hasMore = true;
break;
}
out.add(row);
usedBytes += rowBytes;
}
}
}
return new SyncBatch(out, hasMore, usedBytes);
}
}
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
String sql = """
INSERT INTO signed_messages_v2 (
INSERT INTO %s (
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
@@ -439,15 +512,15 @@ public final class SignedMessagesV2DAO {
origin_session_id = excluded.origin_session_id,
receipt_ref_base_key = excluded.receipt_ref_base_key,
receipt_ref_type = excluded.receipt_ref_type,
read_at_ms = COALESCE(signed_messages_v2.read_at_ms, excluded.read_at_ms)
""";
read_at_ms = COALESCE(%s.read_at_ms, excluded.read_at_ms)
""".formatted(messagesTable(), messagesTable());
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
ps.executeUpdate();
}
}
private void markMessageReadByReceipt(Connection c, SignedMessageV2Entry entry) throws SQLException {
private void markMessageReadByReceipt(Connection c, SignedMessageEntry entry) throws SQLException {
if (entry == null) return;
int messageType = entry.getMessageType();
if (messageType != 3 && messageType != 4) return;
@@ -456,7 +529,7 @@ public final class SignedMessagesV2DAO {
long readAtMs = entry.getTimeMs();
if (readAtMs <= 0) return;
try (PreparedStatement ps = c.prepareStatement("""
UPDATE signed_messages_v2
UPDATE %s
SET read_at_ms = CASE
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
WHEN read_at_ms > ? THEN ?
@@ -464,7 +537,7 @@ public final class SignedMessagesV2DAO {
END
WHERE base_key = ?
AND message_type IN (1, 2)
""")) {
""".formatted(messagesTable()))) {
ps.setLong(1, readAtMs);
ps.setLong(2, readAtMs);
ps.setLong(3, readAtMs);
@@ -476,10 +549,10 @@ public final class SignedMessagesV2DAO {
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
String sql = """
SELECT revision_time_ms, reencrypted_at_ms
FROM signed_messages_v2
FROM %s
WHERE message_key = ?
LIMIT 1
""";
""".formatted(messagesTable());
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, messageKey);
try (ResultSet rs = ps.executeQuery()) {
@@ -492,12 +565,12 @@ public final class SignedMessagesV2DAO {
private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException {
String sql = """
SELECT revision_time_ms, reencrypted_at_ms
FROM signed_messages_v2
FROM %s
WHERE base_key = ?
AND message_type IN (1, 2)
ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC
LIMIT 1
""";
""".formatted(messagesTable());
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, baseKey);
try (ResultSet rs = ps.executeQuery()) {
@@ -510,11 +583,11 @@ public final class SignedMessagesV2DAO {
private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException {
String sql = """
SELECT 1
FROM signed_messages_v2
FROM %s
WHERE base_key = ?
AND message_type IN (5, 6)
LIMIT 1
""";
""".formatted(messagesTable());
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, baseKey);
try (ResultSet rs = ps.executeQuery()) {
@@ -531,13 +604,13 @@ public final class SignedMessagesV2DAO {
private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException {
String sql = """
SELECT MAX(time_ms)
FROM signed_messages_v2
FROM %s
WHERE message_type IN (7, 8)
AND (
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
""";
""".formatted(messagesTable());
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setString(2, toLogin);
@@ -551,22 +624,22 @@ public final class SignedMessagesV2DAO {
}
}
private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception {
private SignedMessageEntry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception {
String sql = """
SELECT
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type
FROM signed_messages_v2
receipt_ref_base_key, receipt_ref_type, read_at_ms
FROM %s
WHERE message_type IN (7, 8)
AND (
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
ORDER BY time_ms DESC
LIMIT 1
""";
""".formatted(messagesTable());
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setString(2, toLogin);
@@ -582,16 +655,16 @@ public final class SignedMessagesV2DAO {
private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException {
deleteDeliveryRowsByMessageSelection(c, """
SELECT message_key
FROM signed_messages_v2
FROM %s
WHERE (base_key = ? AND message_type IN (1, 2))
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
""", baseKey, baseKey);
""".formatted(messagesTable()), baseKey, baseKey);
try (PreparedStatement ps = c.prepareStatement("""
DELETE FROM signed_messages_v2
DELETE FROM %s
WHERE (base_key = ? AND message_type IN (1, 2))
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
""")) {
""".formatted(messagesTable()))) {
ps.setString(1, baseKey);
ps.setString(2, baseKey);
ps.executeUpdate();
@@ -601,22 +674,22 @@ public final class SignedMessagesV2DAO {
private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
deleteDeliveryRowsByMessageSelection(c, """
SELECT message_key
FROM signed_messages_v2
FROM %s
WHERE time_ms < ?
AND (
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
""", boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
""".formatted(messagesTable()), boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
try (PreparedStatement ps = c.prepareStatement("""
DELETE FROM signed_messages_v2
DELETE FROM %s
WHERE time_ms < ?
AND (
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
""")) {
""".formatted(messagesTable()))) {
ps.setLong(1, boundaryTimeMs);
ps.setString(2, fromLogin);
ps.setString(3, toLogin);
@@ -645,22 +718,22 @@ public final class SignedMessagesV2DAO {
}
}
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
private int insertStrict(Connection c, SignedMessageEntry e) throws SQLException {
String sql = """
INSERT INTO signed_messages_v2 (
INSERT INTO %s (
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type, read_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
""".formatted(messagesTable());
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
return ps.executeUpdate();
}
}
private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException {
private void bindSignedMessage(PreparedStatement ps, SignedMessageEntry e) throws SQLException {
ps.setString(1, e.getMessageKey());
ps.setString(2, e.getBaseKey());
ps.setString(3, e.getTargetLogin());
@@ -703,43 +776,8 @@ public final class SignedMessagesV2DAO {
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
}
private boolean isBusyLock(SQLException ex) {
Throwable current = ex;
while (current != null) {
String msg = String.valueOf(current.getMessage()).toLowerCase();
if (msg.contains("sqlite_busy") || msg.contains("database is locked") || msg.contains("database table is locked")) {
return true;
}
current = current.getCause();
}
return false;
}
private void sleepBeforeBusyRetry(int attempt) throws SQLException {
long delayMs = SQLITE_BUSY_RETRY_BASE_DELAY_MS * (1L << Math.min(attempt, 4));
try {
Thread.sleep(delayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
SQLException sqlEx = new SQLException("Interrupted while retrying SQLite busy lock", ie);
throw sqlEx;
}
}
private <T> T withBusyRetry(SqlWork<T> work) throws Exception {
SQLException lastBusy = null;
for (int attempt = 0; attempt < SQLITE_BUSY_MAX_RETRIES; attempt++) {
try {
return work.run();
} catch (SQLException ex) {
if (!isBusyLock(ex) || attempt >= SQLITE_BUSY_MAX_RETRIES - 1) {
throw ex;
}
lastBusy = ex;
sleepBeforeBusyRetry(attempt);
}
}
throw lastBusy == null ? new SQLException("SQLite busy retry failed") : lastBusy;
return work.run();
}
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
@@ -748,8 +786,12 @@ public final class SignedMessagesV2DAO {
return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs);
}
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
SignedMessageV2Entry e = new SignedMessageV2Entry();
private String messagesTable() {
return "signed_messages";
}
private SignedMessageEntry mapRow(ResultSet rs) throws Exception {
SignedMessageEntry e = new SignedMessageEntry();
e.setMessageKey(rs.getString("message_key"));
e.setBaseKey(rs.getString("base_key"));
e.setTargetLogin(rs.getString("target_login"));
@@ -773,11 +815,13 @@ public final class SignedMessagesV2DAO {
}
private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) {
private static RevisionMarker of(SignedMessageV2Entry entry) {
private static RevisionMarker of(SignedMessageEntry entry) {
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
}
}
public record SyncBatch(List<SignedMessageEntry> items, boolean hasMore, int rawBytes) {}
@FunctionalInterface
private interface SqlWork<T> {
T run() throws Exception;
@@ -0,0 +1,71 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.KeyEncodingUtil;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
*/
public final class SolanaUserPdaCurrentDAO {
private static volatile SolanaUserPdaCurrentDAO instance;
private final DbController db = DbController.getInstance();
private SolanaUserPdaCurrentDAO() {}
public static SolanaUserPdaCurrentDAO getInstance() {
if (instance == null) {
synchronized (SolanaUserPdaCurrentDAO.class) {
if (instance == null) {
instance = new SolanaUserPdaCurrentDAO();
}
}
}
return instance;
}
public SolanaUserPdaCurrentEntry getByBlockchainName(String blockchainName) throws SQLException {
try (Connection c = db.getConnection()) {
return getByBlockchainName(c, blockchainName);
}
}
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT
login,
blockchain_name,
blockchain_key,
paid_limit_bytes
FROM solana_user_pda_current
WHERE blockchain_name = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
return null;
}
return mapRow(rs);
}
}
}
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
entry.setLogin(rs.getString("login"));
entry.setBlockchainName(rs.getString("blockchain_name"));
entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes"));
return entry;
}
}
@@ -1,7 +1,7 @@
package shine.db.dao;
import shine.db.MsgSubType;
import shine.db.SqliteDbController;
import shine.db.DbController;
import java.sql.*;
import java.util.ArrayList;
@@ -13,7 +13,7 @@ import java.util.List;
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
* - login цели (channelLogin)
* - blockchainName цели (channelBchName)
* - count публикаций (TEXT_POST)
* - count публикаций (видимые line-based TEXT-сообщения канала)
* - last publication: bytes оригинального блока (для timestamp)
* - last publication: bytes актуального блока (edit или orig) для текста превью
*
@@ -25,7 +25,7 @@ import java.util.List;
public final class SubscriptionsDAO {
private static volatile SubscriptionsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private SubscriptionsDAO() {}
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
/**
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
* - count публикаций (TEXT_POST)
* - count публикаций (видимые line-based TEXT-сообщения канала)
* - последнюю публикацию (orig bytes) + её edit (если есть)
*
* Поведение при 0 публикаций:
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
ON s.channel_login = b.login
AND s.channel_bch_name = b.bch_name
WHERE b.msg_type = ?
AND b.msg_sub_type IN (?, ?)
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
GROUP BY b.login, b.bch_name
),
last_pub AS (
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
ON s.channel_login = b.login
AND s.channel_bch_name = b.bch_name
WHERE b.msg_type = ?
AND b.msg_sub_type IN (?, ?)
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
GROUP BY b.login, b.bch_name
),
last_pub_block AS (
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
ps.setInt(i++, MSG_TYPE_TEXT);
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
// last_pub
ps.setInt(i++, MSG_TYPE_TEXT);
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.SyncServerEntry;
import java.sql.Connection;
@@ -17,7 +17,7 @@ import java.util.List;
public final class SyncServersDAO {
private static volatile SyncServersDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private SyncServersDAO() {}
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.TestFreeAvatarUploadEntry;
import java.sql.Connection;
@@ -11,7 +11,7 @@ import java.sql.SQLException;
public final class TestFreeAvatarUploadsDAO {
private static volatile TestFreeAvatarUploadsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private TestFreeAvatarUploadsDAO() {
}
@@ -29,7 +29,7 @@ public final class TestFreeAvatarUploadsDAO {
String sql = """
SELECT login, used_count, updated_at_ms, last_tx_id
FROM test_free_avatar_uploads
WHERE login = ? COLLATE NOCASE
WHERE LOWER(login) = LOWER(?)
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -0,0 +1,157 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.KeyEncodingUtil;
import shine.db.entities.UserAccessServerRouteEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* DAO локальной routing-проекции access servers пользователей.
*/
public final class UserAccessServersCurrentDAO {
private static volatile UserAccessServersCurrentDAO instance;
private final DbController db = DbController.getInstance();
private UserAccessServersCurrentDAO() {}
public static UserAccessServersCurrentDAO getInstance() {
if (instance == null) {
synchronized (UserAccessServersCurrentDAO.class) {
if (instance == null) instance = new UserAccessServersCurrentDAO();
}
}
return instance;
}
public List<UserAccessServerRouteEntry> listByUserLogin(String userLogin) throws SQLException {
try (Connection c = db.getConnection()) {
return listByUserLogin(c, userLogin);
}
}
/**
* Возвращает первый access server пользователя ровно в порядке
* solana_user_pda_current.access_servers_json (access_servers[0]).
*
* В user_access_servers_current порядок массива намеренно не хранится,
* поэтому для auth-routing первичный сервер читается напрямую из PDA snapshot.
*/
public UserAccessServerRouteEntry findPrimaryByUserLogin(String userLogin) throws SQLException {
String sql = """
SELECT
u.login AS user_login,
s.login AS server_login,
s.server_address AS server_url,
s.client_key AS server_client_key
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT BTRIM(a.login_value) AS server_login
FROM jsonb_array_elements_text(
CASE
WHEN BTRIM(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS a(login_value, ord)
WHERE a.ord = 1
) primary_access
JOIN solana_user_pda_current s
ON s.normalized_login = LOWER(primary_access.server_login)
AND s.is_server = TRUE
AND BTRIM(COALESCE(s.server_address, '')) <> ''
WHERE u.normalized_login = LOWER(BTRIM(?))
LIMIT 1
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, userLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
String sql = """
SELECT user_login, server_login, server_url, server_client_key
FROM user_access_servers_current
WHERE LOWER(user_login) = LOWER(?)
ORDER BY server_login
""";
List<UserAccessServerRouteEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, userLogin);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
}
}
return result;
}
public List<String> listUserLoginsByServerLogin(String serverLogin) throws SQLException {
String sql = """
SELECT user_login
FROM user_access_servers_current
WHERE LOWER(server_login) = LOWER(?)
ORDER BY user_login
""";
List<String> result = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(rs.getString("user_login"));
}
}
}
return result;
}
/**
* Возвращает уникальные физические серверы из текущей routing-проекции.
* Используется транспортным пулом: одно WSS-соединение держится на сервер,
* а не на каждого пользователя этого сервера.
*/
public List<UserAccessServerRouteEntry> listDistinctServers() throws SQLException {
String sql = """
SELECT DISTINCT ON (LOWER(server_login))
server_login, server_url
FROM user_access_servers_current
WHERE server_login IS NOT NULL AND BTRIM(server_login) <> ''
AND server_url IS NOT NULL AND BTRIM(server_url) <> ''
ORDER BY LOWER(server_login), server_login, server_url
""";
List<UserAccessServerRouteEntry> result = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin("");
entry.setServerLogin(rs.getString("server_login"));
entry.setServerUrl(rs.getString("server_url"));
result.add(entry);
}
}
return result;
}
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin(rs.getString("user_login"));
entry.setServerLogin(rs.getString("server_login"));
entry.setServerUrl(rs.getString("server_url"));
entry.setServerClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("server_client_key")));
return entry;
}
}
@@ -1,128 +0,0 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.SolanaUserEntry;
import java.sql.*;
/**
* UserCreateDAO атомарное добавление пользователя:
* - solana_users (login, blockchain_name, solana_key, blockchain_key, client_key)
* - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...)
*
* ВАЖНО:
* - только INSERT (без перезаписи существующих записей)
* - если login или blockchainName заняты возвращаем false (пользователь уже есть/занято)
*/
public final class UserCreateDAO {
private static volatile UserCreateDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
private UserCreateDAO() {}
public static UserCreateDAO getInstance() {
if (instance == null) {
synchronized (UserCreateDAO.class) {
if (instance == null) instance = new UserCreateDAO();
}
}
return instance;
}
/**
* @return true если добавили; false если занято (login уже есть или blockchainName уже существует).
*/
public boolean insertUserWithBlockchain(
String login,
String blockchainName,
String solanaKey,
String blockchainKey,
String clientKey,
long sizeLimit,
long nowMs
) throws SQLException {
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
// 1) solana_users
SolanaUserEntry u = new SolanaUserEntry();
u.setLogin(login);
u.setBlockchainName(blockchainName);
u.setSolanaKey(solanaKey);
u.setBlockchainKey(blockchainKey);
u.setClientKey(clientKey);
usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint
// 2) blockchain_state строго INSERT, без UPSERT (иначе можно перезаписать существующую цепочку)
insertBlockchainStateStrict(
c,
blockchainName,
login,
blockchainKey,
sizeLimit,
nowMs
);
c.commit();
return true;
} catch (SQLException e) {
c.rollback();
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase();
if (msg.contains("constraint")) {
return false;
}
throw e;
} finally {
c.setAutoCommit(oldAuto);
}
}
}
private static void insertBlockchainStateStrict(
Connection c,
String blockchainName,
String login,
String blockchainKey,
long sizeLimit,
long nowMs
) throws SQLException {
String sql = """
INSERT INTO blockchain_state (
blockchain_name,
login,
blockchain_key,
size_limit,
file_size_bytes,
last_block_number,
last_block_hash,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, blockchainName);
ps.setString(i++, login);
ps.setString(i++, blockchainKey);
ps.setLong(i++, sizeLimit);
ps.setLong(i++, 0L);
ps.setInt(i++, -1);
ps.setNull(i++, Types.BLOB); // старт: блоков ещё нет
ps.setLong(i++, nowMs);
ps.executeUpdate(); // если blockchainName занят -> constraint (PK)
}
}
}
@@ -0,0 +1,35 @@
package shine.db.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public final class UserNotificationSeenStateDAO {
private static final UserNotificationSeenStateDAO INSTANCE = new UserNotificationSeenStateDAO();
private UserNotificationSeenStateDAO() {}
public static UserNotificationSeenStateDAO getInstance() { return INSTANCE; }
public long getSeenAt(Connection c, String login, String category) throws Exception {
try (PreparedStatement ps = c.prepareStatement("SELECT seen_at_ms FROM user_notification_seen_state WHERE owner_login=? AND category=?")) {
ps.setString(1, login); ps.setString(2, category);
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
}
}
public long advance(Connection c, String login, String category, long seenAtMs, long signedAtMs, byte[] signedBlob) throws Exception {
String sql = """
INSERT INTO user_notification_seen_state(owner_login, category, seen_at_ms, signed_blob, signed_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(owner_login, category) DO UPDATE SET
seen_at_ms=EXCLUDED.seen_at_ms, signed_blob=EXCLUDED.signed_blob,
signed_at_ms=EXCLUDED.signed_at_ms, updated_at_ms=EXCLUDED.updated_at_ms
WHERE user_notification_seen_state.seen_at_ms < EXCLUDED.seen_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login); ps.setString(2, category); ps.setLong(3, seenAtMs);
ps.setBytes(4, signedBlob); ps.setLong(5, signedAtMs); ps.setLong(6, System.currentTimeMillis());
ps.executeUpdate();
}
return getSeenAt(c, login, category);
}
}
@@ -0,0 +1,132 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.UserNotificationEntry;
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.List;
public final class UserNotificationsStateDAO {
private static volatile UserNotificationsStateDAO instance;
private final DbController db = DbController.getInstance();
private UserNotificationsStateDAO() {}
public static UserNotificationsStateDAO getInstance() {
if (instance == null) {
synchronized (UserNotificationsStateDAO.class) {
if (instance == null) instance = new UserNotificationsStateDAO();
}
}
return instance;
}
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
String sql = """
INSERT INTO user_notifications_state (
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
source_block_number, source_block_hash, target_login, target_bch_name,
target_block_number, target_block_hash, source_msg_sub_type, source_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
created_at_ms = EXCLUDED.created_at_ms,
source_login = EXCLUDED.source_login,
target_login = EXCLUDED.target_login,
target_bch_name = EXCLUDED.target_bch_name,
target_block_number = EXCLUDED.target_block_number,
target_block_hash = EXCLUDED.target_block_hash,
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
source_text = EXCLUDED.source_text
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, e.getOwnerLogin());
ps.setString(i++, e.getNotificationKind());
ps.setLong(i++, e.getCreatedAtMs());
ps.setString(i++, e.getSourceLogin());
ps.setString(i++, e.getSourceBchName());
ps.setInt(i++, e.getSourceBlockNumber());
ps.setBytes(i++, e.getSourceBlockHash());
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
ps.setInt(i++, e.getSourceMsgSubType());
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
return ps.executeUpdate();
}
}
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
String sql = """
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
source_block_number, source_block_hash, target_login, target_bch_name,
target_block_number, target_block_hash, source_msg_sub_type, source_text
FROM user_notifications_state
WHERE owner_login = ? AND notification_kind = ?
ORDER BY created_at_ms DESC, source_block_number DESC
LIMIT ?
""";
List<UserNotificationEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, kind);
ps.setInt(3, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public List<UserNotificationEntry> listVisible(Connection c, String ownerLogin, String kind, long seenAtMs, long cutoffMs) throws SQLException {
String sql = """
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
source_block_number, source_block_hash, target_login, target_bch_name,
target_block_number, target_block_hash, source_msg_sub_type, source_text
FROM user_notifications_state
WHERE owner_login = ? AND notification_kind = ?
AND (created_at_ms > ? OR created_at_ms >= ?)
ORDER BY created_at_ms DESC, source_block_number DESC
""";
List<UserNotificationEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
ps.setLong(4, Math.max(0, cutoffMs));
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
}
return out;
}
public long countUnseen(Connection c, String ownerLogin, String kind, long seenAtMs) throws SQLException {
String sql = "SELECT COUNT(*) FROM user_notifications_state WHERE owner_login = ? AND notification_kind = ? AND created_at_ms > ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
}
}
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
UserNotificationEntry e = new UserNotificationEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setNotificationKind(rs.getString("notification_kind"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setSourceLogin(rs.getString("source_login"));
e.setSourceBchName(rs.getString("source_bch_name"));
e.setSourceBlockNumber(rs.getInt("source_block_number"));
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
e.setTargetLogin(rs.getString("target_login"));
e.setTargetBchName(rs.getString("target_bch_name"));
Object targetBlockNumber = rs.getObject("target_block_number");
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
e.setSourceText(rs.getString("source_text"));
return e;
}
}
@@ -1,6 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.DbController;
import shine.db.entities.UserParamEntry;
import java.sql.*;
@@ -21,7 +21,7 @@ import java.util.List;
public final class UserParamsDAO {
private static volatile UserParamsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private final DbController db = DbController.getInstance();
private UserParamsDAO() { }
@@ -55,6 +55,7 @@ public final class UserParamsDAO {
WHERE users_params.time_ms < excluded.time_ms
""";
int changed;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getLogin());
ps.setString(2, e.getParam());
@@ -67,8 +68,12 @@ public final class UserParamsDAO {
if (e.getSignature() != null) ps.setString(6, e.getSignature());
else ps.setNull(6, Types.VARCHAR);
return ps.executeUpdate();
changed = ps.executeUpdate();
}
if (changed > 0 && affectsFastProfile(e.getParam())) {
refreshFastProfileAndDerivedState(c, e.getLogin(), e.getParam());
}
return changed;
}
public int upsertIfNewer(UserParamEntry e) throws SQLException {
@@ -77,6 +82,32 @@ public final class UserParamsDAO {
}
}
private static boolean affectsFastProfile(String param) {
if (param == null) return false;
return switch (param) {
case "first_name", "last_name", "ava", "account_role", "shine" -> true;
default -> false;
};
}
private static void refreshFastProfileAndDerivedState(Connection c, String login, String param) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(?)")) {
ps.setString(1, login);
ps.execute();
}
if ("account_role".equals(param) || "shine".equals(param)) {
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_related_user_stats(?)")) {
ps.setString(1, login);
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_message_qualified_likes_for_actor(?)")) {
ps.setString(1, login);
ps.execute();
}
}
}
// -------------------- SELECT --------------------
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
@@ -89,7 +120,7 @@ public final class UserParamsDAO {
client_key,
signature
FROM users_params
WHERE login = ? COLLATE NOCASE AND param = ?
WHERE LOWER(login) = LOWER(?) AND param = ?
LIMIT 1
""";
@@ -120,7 +151,7 @@ public final class UserParamsDAO {
client_key,
signature
FROM users_params
WHERE login = ? COLLATE NOCASE
WHERE LOWER(login) = LOWER(?)
ORDER BY time_ms DESC
""";
@@ -0,0 +1,142 @@
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;
import shine.db.MsgSubType;
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
public final class UserProfileStateDAO {
private static volatile UserProfileStateDAO instance;
private UserProfileStateDAO() {}
public static UserProfileStateDAO getInstance() {
if (instance == null) synchronized (UserProfileStateDAO.class) {
if (instance == null) instance = new UserProfileStateDAO();
}
return instance;
}
public ProfileCard get(Connection c, String login) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, first_name, last_name, ava_ar, account_role, shine_status
FROM user_profile_state WHERE LOWER(login)=LOWER(?) LIMIT 1
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return new ProfileCard(login, "", "", "", null, null);
return map(rs);
}
}
}
/**
* Effective outgoing social relation with priority close_friend > friend > contact > none.
*
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
* older/current blocks may address a user by blockchain name, so a direct to_login-only
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
*/
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
ConnectionsStateDAO relations = ConnectionsStateDAO.getInstance();
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
return "close_friend";
}
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_FRIEND)) {
return "friend";
}
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CONTACT)) {
return "contact";
}
return "none";
}
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
int safeOffset = Math.max(0, offset);
String where = switch (String.valueOf(listType)) {
case "friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=14 AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(cs.login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)";
case "close_friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=10";
case "primary_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
case "primary_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
case "shine_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
case "shine_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
default -> throw new IllegalArgumentException("Unsupported listType: " + listType);
};
boolean received = "primary_received".equals(listType) || "shine_received".equals(listType);
String personExpr = received ? "cs.login" : "cs.to_login";
String sql = """
SELECT DISTINCT ON (LOWER(%s))
%s AS person_login,
COALESCE(up.first_name,'') AS first_name,
COALESCE(up.last_name,'') AS last_name,
COALESCE(up.ava_ar,'') AS ava_ar,
up.account_role, up.shine_status,
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=80
AND shine_is_primary(x.login) AND shine_target_accepts_primary_confirmation(x.to_login)) AS primary_confirmed,
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=70
AND shine_is_primary(x.login) AND shine_is_shining(x.login) AND shine_target_accepts_shine_confirmation(x.to_login)) AS shine_confirmed,
CASE
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=10) THEN 'close_friend'
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=14) THEN 'friend'
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=20) THEN 'contact'
ELSE 'none' END AS relation_type,
COALESCE(us.primary_confirmations_received_count,0) AS primary_confirmations_count,
COALESCE(us.shine_confirmations_received_count,0) AS shine_confirmations_count
FROM connections_state cs
LEFT JOIN user_profile_state up ON LOWER(up.login)=LOWER(%s)
LEFT JOIN user_stats_state us ON LOWER(us.login)=LOWER(%s)
WHERE %s
ORDER BY LOWER(%s), %s
LIMIT ? OFFSET ?
""".formatted(personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, where, personExpr, personExpr);
List<RelationCard> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i=1;
ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin);
ps.setString(i++, ownerLogin);
ps.setInt(i++, safeLimit); ps.setInt(i, safeOffset);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(new RelationCard(
rs.getString("person_login"), rs.getString("first_name"), rs.getString("last_name"), rs.getString("ava_ar"),
rs.getString("account_role"), rs.getString("shine_status"), rs.getBoolean("primary_confirmed"), rs.getBoolean("shine_confirmed"),
rs.getString("relation_type"), rs.getInt("primary_confirmations_count"), rs.getInt("shine_confirmations_count")
));
}
}
return out;
}
public List<ChannelCard> listPublicChannels(Connection c, String login, String mode, int limit, int offset) throws SQLException {
int safeLimit=Math.max(1,Math.min(limit<=0?100:limit,500)), safeOffset=Math.max(0,offset);
String sql;
if ("owned".equals(mode)) {
sql="""
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
""";
} else if ("following".equals(mode)) {
sql="""
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
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 LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
""";
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
List<ChannelCard> out=new ArrayList<>();
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,login);ps.setInt(2,safeLimit);ps.setInt(3,safeOffset);
try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(new ChannelCard(rs.getString("owner_login"),rs.getString("slug"),rs.getString("display_name"),rs.getString("ava_ar"),rs.getString("owner_bch_name"),rs.getInt("channel_root_block_number"),rs.getString("root_hash")));}}
return out;
}
private static ProfileCard map(ResultSet rs)throws SQLException{return new ProfileCard(rs.getString("login"),rs.getString("first_name"),rs.getString("last_name"),rs.getString("ava_ar"),rs.getString("account_role"),rs.getString("shine_status"));}
public record ProfileCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus){}
public record RelationCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus,boolean primaryConfirmed,boolean shineConfirmed,String relationType,int primaryConfirmationsCount,int shineConfirmationsCount){}
public record ChannelCard(String ownerLogin,String slug,String displayName,String avatarAr,String ownerBlockchainName,int rootBlockNumber,String rootBlockHashHex){}
}
@@ -0,0 +1,124 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.UserSettingEntry;
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.List;
/**
* Локальное хранение подписанных пользовательских настроек.
* Запись уникальна по login + setting_type + setting_key и обновляется только
* более новым time_ms. Межсерверной репликации настроек нет.
*/
public final class UserSettingsDAO {
private static volatile UserSettingsDAO instance;
private final DbController db = DbController.getInstance();
private UserSettingsDAO() {}
public static UserSettingsDAO getInstance() {
if (instance == null) {
synchronized (UserSettingsDAO.class) {
if (instance == null) instance = new UserSettingsDAO();
}
}
return instance;
}
public int upsertIfNewer(Connection c, UserSettingEntry e) throws SQLException {
String sql = """
INSERT INTO user_settings (
login, setting_type, setting_key, time_ms,
value_text, value_num, client_key, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (login, setting_type, setting_key)
DO UPDATE SET
time_ms = EXCLUDED.time_ms,
value_text = EXCLUDED.value_text,
value_num = EXCLUDED.value_num,
client_key = EXCLUDED.client_key,
signature = EXCLUDED.signature
WHERE user_settings.time_ms < EXCLUDED.time_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getLogin());
ps.setInt(2, e.getSettingType());
ps.setString(3, e.getSettingKey());
ps.setLong(4, e.getTimeMs());
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
ps.setLong(6, e.getValueNum());
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
else ps.setString(7, e.getClientKey());
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
else ps.setString(8, e.getSignature());
return ps.executeUpdate();
}
}
public UserSettingEntry getByLoginTypeKey(
Connection c, String login, int settingType, String settingKey
) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms,
value_text, value_num, client_key, signature
FROM user_settings
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, settingType);
ps.setString(3, settingKey);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public List<UserSettingEntry> getByLogin(Connection c, String login) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms,
value_text, value_num, client_key, signature
FROM user_settings
WHERE LOWER(login) = LOWER(?)
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
""";
List<UserSettingEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public List<UserSettingEntry> getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLogin(c, login);
}
}
private static UserSettingEntry mapRow(ResultSet rs) throws SQLException {
UserSettingEntry e = new UserSettingEntry();
e.setLogin(rs.getString("login"));
e.setSettingType(rs.getInt("setting_type"));
e.setSettingKey(rs.getString("setting_key"));
e.setTimeMs(rs.getLong("time_ms"));
e.setValueText(rs.getString("value_text"));
e.setValueNum(rs.getLong("value_num"));
String clientKey = rs.getString("client_key");
if (rs.wasNull()) clientKey = null;
e.setClientKey(clientKey);
String signature = rs.getString("signature");
if (rs.wasNull()) signature = null;
e.setSignature(signature);
return e;
}
}
@@ -0,0 +1,152 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.UserSettingsSyncPeerStateEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class UserSettingsSyncPeerStateDAO {
private static volatile UserSettingsSyncPeerStateDAO instance;
private final DbController db = DbController.getInstance();
private UserSettingsSyncPeerStateDAO() {}
public static UserSettingsSyncPeerStateDAO getInstance() {
if (instance == null) {
synchronized (UserSettingsSyncPeerStateDAO.class) {
if (instance == null) instance = new UserSettingsSyncPeerStateDAO();
}
}
return instance;
}
public UserSettingsSyncPeerStateEntry getOrCreate(Connection c, String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
UserSettingsSyncPeerStateEntry existing = get(c, ownerLogin, remoteServerLogin);
if (existing != null) return existing;
long nowMs = System.currentTimeMillis();
String sql = """
INSERT INTO user_settings_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_time_ms, cursor_setting_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl);
ps.setLong(4, nowMs);
ps.executeUpdate();
}
return get(c, ownerLogin, remoteServerLogin);
}
public UserSettingsSyncPeerStateEntry get(Connection c, String ownerLogin, String remoteServerLogin) throws SQLException {
String sql = """
SELECT owner_login, remote_server_login, remote_server_url, cursor_time_ms, cursor_setting_key,
bootstrap_completed, last_sync_at_ms, last_error, updated_at_ms
FROM user_settings_sync_peer_state
WHERE LOWER(owner_login) = LOWER(?) AND LOWER(remote_server_login) = LOWER(?)
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public int updateSuccess(String ownerLogin, String remoteServerLogin, String remoteServerUrl, long cursorTimeMs, String cursorSettingKey, boolean bootstrapCompleted) throws SQLException {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO user_settings_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_time_ms, cursor_setting_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
cursor_time_ms = EXCLUDED.cursor_time_ms,
cursor_setting_key = EXCLUDED.cursor_setting_key,
bootstrap_completed = EXCLUDED.bootstrap_completed,
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
last_error = NULL,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
long nowMs = System.currentTimeMillis();
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl);
ps.setLong(4, Math.max(0L, cursorTimeMs));
ps.setString(5, cursorSettingKey == null ? "" : cursorSettingKey);
ps.setBoolean(6, bootstrapCompleted);
ps.setLong(7, nowMs);
ps.setLong(8, nowMs);
return ps.executeUpdate();
}
}
}
public int updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO user_settings_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_time_ms, cursor_setting_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
last_error = EXCLUDED.last_error,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
long nowMs = System.currentTimeMillis();
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl);
ps.setString(4, error);
ps.setLong(5, nowMs);
return ps.executeUpdate();
}
}
}
public int clearBootstrap(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
return updateSuccess(ownerLogin, remoteServerLogin, remoteServerUrl, 0L, "", false);
}
public int deleteAllForOwner(String ownerLogin) throws SQLException {
try (Connection c = db.getConnection()) {
try (PreparedStatement ps = c.prepareStatement("DELETE FROM user_settings_sync_peer_state WHERE LOWER(owner_login) = LOWER(?)")) {
ps.setString(1, ownerLogin);
return ps.executeUpdate();
}
}
}
private static UserSettingsSyncPeerStateEntry mapRow(ResultSet rs) throws SQLException {
UserSettingsSyncPeerStateEntry e = new UserSettingsSyncPeerStateEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setRemoteServerLogin(rs.getString("remote_server_login"));
e.setRemoteServerUrl(rs.getString("remote_server_url"));
e.setCursorTimeMs(rs.getLong("cursor_time_ms"));
e.setCursorSettingKey(rs.getString("cursor_setting_key"));
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
long lastSyncAtMs = rs.getLong("last_sync_at_ms");
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAtMs);
e.setLastError(rs.getString("last_error"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
}
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
private int channelRootBlockNumber;
private byte[] channelRootBlockHash;
private long createdAtMs;
private String avaAr;
private String avaSha256;
private long avaSize;
private long metaUpdatedAtMs;
public String getSlug() {
return slug;
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
public void setCreatedAtMs(long createdAtMs) {
this.createdAtMs = createdAtMs;
}
public String getAvaAr() {
return avaAr;
}
public void setAvaAr(String avaAr) {
this.avaAr = avaAr;
}
public String getAvaSha256() {
return avaSha256;
}
public void setAvaSha256(String avaSha256) {
this.avaSha256 = avaSha256;
}
public long getAvaSize() {
return avaSize;
}
public void setAvaSize(long avaSize) {
this.avaSize = avaSize;
}
public long getMetaUpdatedAtMs() {
return metaUpdatedAtMs;
}
public void setMetaUpdatedAtMs(long metaUpdatedAtMs) {
this.metaUpdatedAtMs = metaUpdatedAtMs;
}
}
@@ -3,18 +3,19 @@ package shine.db.entities;
import java.util.Base64;
/**
* SolanaUserEntry локальная запись пользователя из Solana.
* CurrentUserEntry локальная runtime-проекция текущего состояния пользователя.
*
* Таблица: solana_users
* Источник:
* - solana_user_pda_current
*
* Поля:
* - login PRIMARY KEY (TEXT) (case-insensitive на уровне COLLATE NOCASE)
* - login PRIMARY KEY (TEXT)
* - blockchain_name TEXT NOT NULL
* - solana_key TEXT NOT NULL
* - blockchain_key TEXT NOT NULL
* - client_key TEXT NOT NULL
*/
public class SolanaUserEntry {
public class CurrentUserEntry {
private String login;
@@ -29,9 +30,9 @@ public class SolanaUserEntry {
/** Ключ устройства (публичный ключ устройства) */
private String clientKey;
public SolanaUserEntry() {}
public CurrentUserEntry() {}
public SolanaUserEntry(String login,
public CurrentUserEntry(String login,
String blockchainName,
String solanaKey,
String blockchainKey,
@@ -81,4 +82,4 @@ public class SolanaUserEntry {
return null;
}
}
}
@@ -1,24 +0,0 @@
package shine.db.entities;
public class DirectMessageEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private String text;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -0,0 +1,84 @@
package shine.db.entities;
public class DmDeliveryStateEntry {
public static final int ACCEPTED = 0;
public static final int DELIVERED = 1;
public static final int FAILED = 3;
// Совместимость со строками БД, созданными ранней экспериментальной миграцией.
public static final int PENDING_NONE = ACCEPTED;
public static final int DELIVERED_ONE = DELIVERED;
public static final int DELIVERED_ALL = 2;
public static final int FAILED_FINAL = FAILED;
private String outgoingMessageKey;
private String eventId;
private String baseKey;
private String fromLogin;
private String toLogin;
private String incomingMessageKey;
private long createdAtMs;
private long deliveryExpiresAtMs;
private int deliveryState;
private String deliveredServerLogin;
private String recipientRoutesHash;
private int attemptIndex;
private Long nextAttemptAtMs;
private Long lastAttemptAtMs;
private String lastError;
private long updatedAtMs;
public String getOutgoingMessageKey() { return outgoingMessageKey; }
public void setOutgoingMessageKey(String value) { this.outgoingMessageKey = value; }
public String getEventId() { return eventId; }
public void setEventId(String value) { this.eventId = value; }
public String getBaseKey() { return baseKey; }
public void setBaseKey(String value) { this.baseKey = value; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String value) { this.fromLogin = value; }
public String getToLogin() { return toLogin; }
public void setToLogin(String value) { this.toLogin = value; }
public String getIncomingMessageKey() { return incomingMessageKey; }
public void setIncomingMessageKey(String value) { this.incomingMessageKey = value; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
public long getDeliveryExpiresAtMs() { return deliveryExpiresAtMs; }
public void setDeliveryExpiresAtMs(long value) { this.deliveryExpiresAtMs = value; }
public int getDeliveryState() { return deliveryState; }
public void setDeliveryState(int value) { this.deliveryState = value; }
public String getDeliveredServerLogin() { return deliveredServerLogin; }
public void setDeliveredServerLogin(String value) { this.deliveredServerLogin = value; }
public String getRecipientRoutesHash() { return recipientRoutesHash; }
public void setRecipientRoutesHash(String value) { this.recipientRoutesHash = value; }
public int getAttemptIndex() { return attemptIndex; }
public void setAttemptIndex(int value) { this.attemptIndex = value; }
public Long getNextAttemptAtMs() { return nextAttemptAtMs; }
public void setNextAttemptAtMs(Long value) { this.nextAttemptAtMs = value; }
public Long getLastAttemptAtMs() { return lastAttemptAtMs; }
public void setLastAttemptAtMs(Long value) { this.lastAttemptAtMs = value; }
public String getLastError() { return lastError; }
public void setLastError(String value) { this.lastError = value; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
public String deliveryStateCode() {
return switch (deliveryState) {
case DELIVERED_ONE, DELIVERED_ALL -> "delivered";
case FAILED_FINAL -> "failed";
default -> "accepted";
};
}
public static int parseStateCode(String value) {
if (value == null) return ACCEPTED;
return switch (value.trim().toLowerCase()) {
case "delivered", "delivered_one", "delivered_all" -> DELIVERED;
case "failed", "failed_final" -> FAILED;
default -> ACCEPTED;
};
}
public boolean isDelivered() {
return deliveryState == DELIVERED_ONE || deliveryState == DELIVERED_ALL;
}
}
@@ -0,0 +1,26 @@
package shine.db.entities;
public class DmSyncOutboxEntry {
private String ownerLogin;
private String primaryMessageKey;
private String eventId;
private String secondaryMessageKey;
private boolean synced;
private long createdAtMs;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String value) { this.ownerLogin = value; }
public String getPrimaryMessageKey() { return primaryMessageKey; }
public void setPrimaryMessageKey(String value) { this.primaryMessageKey = value; }
public String getEventId() { return eventId; }
public void setEventId(String value) { this.eventId = value; }
public String getSecondaryMessageKey() { return secondaryMessageKey; }
public void setSecondaryMessageKey(String value) { this.secondaryMessageKey = value; }
public boolean isSynced() { return synced; }
public void setSynced(boolean value) { this.synced = value; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
}
@@ -0,0 +1,32 @@
package shine.db.entities;
public class DmSyncPeerStateEntry {
private String ownerLogin;
private String remoteServerLogin;
private String remoteServerUrl;
private long cursorStoredAtMs;
private String cursorMessageKey;
private boolean bootstrapCompleted;
private Long lastSyncAtMs;
private String lastError;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public String getRemoteServerUrl() { return remoteServerUrl; }
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
public long getCursorStoredAtMs() { return cursorStoredAtMs; }
public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; }
public String getCursorMessageKey() { return cursorMessageKey; }
public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; }
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
public Long getLastSyncAtMs() { return lastSyncAtMs; }
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}
@@ -1,35 +0,0 @@
package shine.db.entities;
public class SignedDirectMessageHistoryEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private int targetMode;
private String targetSessionId;
private int messageType;
private long timeMs;
private long nonce;
private byte[] rawPacket;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public int getTargetMode() { return targetMode; }
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
public String getTargetSessionId() { return targetSessionId; }
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
public int getMessageType() { return messageType; }
public void setMessageType(int messageType) { this.messageType = messageType; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public long getNonce() { return nonce; }
public void setNonce(long nonce) { this.nonce = nonce; }
public byte[] getRawPacket() { return rawPacket; }
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -1,6 +1,6 @@
package shine.db.entities;
public class SignedMessageV2Entry {
public class SignedMessageEntry {
private String messageKey;
private String baseKey;
private String targetLogin;
@@ -0,0 +1,46 @@
package shine.db.entities;
/**
* Минимальный срез текущей записи пользователя из PostgreSQL sync-таблицы.
*
* Источник: solana_user_pda_current
*/
public final class SolanaUserPdaCurrentEntry {
private String login;
private String blockchainName;
private String blockchainKey;
private long paidLimitBytes;
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public String getBlockchainName() {
return blockchainName;
}
public void setBlockchainName(String blockchainName) {
this.blockchainName = blockchainName;
}
public String getBlockchainKey() {
return blockchainKey;
}
public void setBlockchainKey(String blockchainKey) {
this.blockchainKey = blockchainKey;
}
public long getPaidLimitBytes() {
return paidLimitBytes;
}
public void setPaidLimitBytes(long paidLimitBytes) {
this.paidLimitBytes = paidLimitBytes;
}
}
@@ -0,0 +1,27 @@
package shine.db.entities;
/**
* Локальная routing-проекция access server пользователя.
*
* Источник:
* - user_access_servers_current
*/
public class UserAccessServerRouteEntry {
private String userLogin;
private String serverLogin;
private String serverUrl;
private String serverClientKey;
public String getUserLogin() { return userLogin; }
public void setUserLogin(String userLogin) { this.userLogin = userLogin; }
public String getServerLogin() { return serverLogin; }
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
public String getServerUrl() { return serverUrl; }
public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; }
public String getServerClientKey() { return serverClientKey; }
public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; }
}
@@ -0,0 +1,44 @@
package shine.db.entities;
public class UserNotificationEntry {
private String ownerLogin;
private String notificationKind;
private long createdAtMs;
private String sourceLogin;
private String sourceBchName;
private int sourceBlockNumber;
private byte[] sourceBlockHash;
private String targetLogin;
private String targetBchName;
private Integer targetBlockNumber;
private byte[] targetBlockHash;
private int sourceMsgSubType;
private String sourceText;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getNotificationKind() { return notificationKind; }
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public String getSourceLogin() { return sourceLogin; }
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
public String getSourceBchName() { return sourceBchName; }
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
public int getSourceBlockNumber() { return sourceBlockNumber; }
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
public byte[] getSourceBlockHash() { return sourceBlockHash; }
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
public String getTargetLogin() { return targetLogin; }
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
public String getTargetBchName() { return targetBchName; }
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
public Integer getTargetBlockNumber() { return targetBlockNumber; }
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
public byte[] getTargetBlockHash() { return targetBlockHash; }
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
public int getSourceMsgSubType() { return sourceMsgSubType; }
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
public String getSourceText() { return sourceText; }
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
}
@@ -0,0 +1,52 @@
package shine.db.entities;
/** Одна локально сохранённая подписанная пользовательская настройка. */
public class UserSettingEntry {
private String login;
private int settingType;
private String settingKey;
private long timeMs;
private String valueText;
private long valueNum;
private String clientKey;
private String signature;
public UserSettingEntry() {}
public UserSettingEntry(
String login,
int settingType,
String settingKey,
long timeMs,
String valueText,
long valueNum,
String clientKey,
String signature
) {
this.login = login;
this.settingType = settingType;
this.settingKey = settingKey;
this.timeMs = timeMs;
this.valueText = valueText;
this.valueNum = valueNum;
this.clientKey = clientKey;
this.signature = signature;
}
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public int getSettingType() { return settingType; }
public void setSettingType(int settingType) { this.settingType = settingType; }
public String getSettingKey() { return settingKey; }
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public String getValueText() { return valueText; }
public void setValueText(String valueText) { this.valueText = valueText; }
public long getValueNum() { return valueNum; }
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
public String getClientKey() { return clientKey; }
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
@@ -0,0 +1,40 @@
package shine.db.entities;
public class UserSettingsSyncPeerStateEntry {
private String ownerLogin;
private String remoteServerLogin;
private String remoteServerUrl;
private long cursorTimeMs;
private String cursorSettingKey;
private boolean bootstrapCompleted;
private Long lastSyncAtMs;
private String lastError;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public String getRemoteServerUrl() { return remoteServerUrl; }
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
public long getCursorTimeMs() { return cursorTimeMs; }
public void setCursorTimeMs(long cursorTimeMs) { this.cursorTimeMs = cursorTimeMs; }
public String getCursorSettingKey() { return cursorSettingKey; }
public void setCursorSettingKey(String cursorSettingKey) { this.cursorSettingKey = cursorSettingKey; }
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
public Long getLastSyncAtMs() { return lastSyncAtMs; }
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}
@@ -0,0 +1,25 @@
package shine.db.sql;
public final class CurrentUsersSql {
private CurrentUsersSql() {}
public static String usersSubquery(String alias) {
if (alias == null || alias.isBlank()) {
throw new IllegalArgumentException("alias is blank");
}
return """
(
SELECT
current_users.login AS login,
current_users.normalized_login AS normalized_login,
current_users.blockchain_name AS blockchain_name,
current_users.client_key AS solana_key,
current_users.blockchain_key AS blockchain_key,
current_users.client_key AS client_key,
current_users.is_server AS is_server
FROM solana_user_pda_current current_users
) %s
""".formatted(alias);
}
}
@@ -0,0 +1,32 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_notifications_state (
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
created_at_ms BIGINT NOT NULL,
source_login TEXT NOT NULL,
source_bch_name TEXT NOT NULL,
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
source_block_hash BYTEA NOT NULL,
target_login TEXT,
target_bch_name TEXT,
target_block_number INTEGER,
target_block_hash BYTEA,
source_msg_sub_type INTEGER NOT NULL,
source_text TEXT NOT NULL DEFAULT '',
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
ON user_notifications_state(owner_login, created_at_ms DESC);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 10, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,30 @@
BEGIN;
CREATE TABLE IF NOT EXISTS dm_dialog_state (
owner_login TEXT NOT NULL,
peer_login TEXT NOT NULL,
relation_flag TEXT NOT NULL DEFAULT 'none',
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
last_message_key TEXT NOT NULL DEFAULT '',
last_message_type INTEGER NOT NULL DEFAULT 0,
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
unread_count INTEGER NOT NULL DEFAULT 0,
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, peer_login)
);
ALTER TABLE IF EXISTS dm_dialog_state
ADD COLUMN IF NOT EXISTS last_message_blob_b64 TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 11, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,139 @@
BEGIN;
CREATE TABLE IF NOT EXISTS dm_delivery_state (
outgoing_message_key TEXT PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
base_key TEXT NOT NULL,
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
incoming_message_key TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
delivery_expires_at_ms BIGINT NOT NULL,
delivery_state INTEGER NOT NULL DEFAULT 0 CHECK (delivery_state IN (0, 1, 2, 3)),
delivered_server_login TEXT,
recipient_routes_hash TEXT,
attempt_index INTEGER NOT NULL DEFAULT 0,
next_attempt_at_ms BIGINT,
last_attempt_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
ON dm_delivery_state(base_key, from_login);
CREATE TABLE IF NOT EXISTS dm_sync_outbox (
owner_login TEXT NOT NULL,
primary_message_key TEXT NOT NULL,
event_id TEXT NOT NULL,
secondary_message_key TEXT,
synced BOOLEAN NOT NULL DEFAULT FALSE,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, primary_message_key)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_outbox_unsynced
ON dm_sync_outbox(owner_login, created_at_ms, primary_message_key)
WHERE synced = FALSE;
-- До v12 факт межсерверной доставки не сохранялся. Старые исходящие пары
-- считаем уже доставленными, чтобы обновление не вызвало повторную рассылку.
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
)
SELECT
outgoing.message_key,
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
outgoing.base_key,
outgoing.from_login,
outgoing.to_login,
incoming.message_key,
outgoing.created_at_ms,
outgoing.created_at_ms + 3600000,
2,
NULL,
NULL,
9,
NULL,
NULL,
'MIGRATED_AS_DELIVERED',
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages outgoing
JOIN signed_messages incoming
ON incoming.base_key = outgoing.base_key
AND incoming.message_type = outgoing.message_type - 1
WHERE outgoing.message_type IN (2, 4)
ON CONFLICT (outgoing_message_key) DO NOTHING;
-- Историю до v12 считаем подтверждённой, иначе само обновление вызовет
-- массовую повторную передачу. При замене сервера общий reset вернёт FALSE.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(outgoing.from_login),
outgoing.message_key,
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
incoming.message_key,
TRUE,
outgoing.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages outgoing
JOIN signed_messages incoming
ON incoming.base_key = outgoing.base_key
AND incoming.message_type = outgoing.message_type - 1
WHERE outgoing.message_type IN (2, 4)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
-- Входящая копия синхронизируется отдельно между серверами получателя.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(incoming.to_login),
incoming.message_key,
incoming.message_key || ':' || incoming.revision_time_ms || ':' || incoming.reencrypted_at_ms,
NULL,
TRUE,
incoming.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages incoming
WHERE incoming.message_type IN (1, 3)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
-- Tombstone принадлежит обоим участникам и должен попасть на второй сервер
-- каждого из них. Для self-DM конфликт безопасно схлопывается.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(owner_login),
tombstone.message_key,
tombstone.message_key || ':' || tombstone.revision_time_ms || ':' || tombstone.reencrypted_at_ms,
NULL,
TRUE,
tombstone.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages tombstone
CROSS JOIN LATERAL (VALUES (tombstone.from_login), (tombstone.to_login)) owners(owner_login)
WHERE tombstone.message_type IN (5, 6, 7, 8)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 12, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,46 @@
BEGIN;
-- Нормализация экспериментальной схемы доставки v12:
-- 0=accepted, 1=delivered, 3=failed. Старое delivered_all (2)
-- объединяется с delivered, поскольку ACK одного сервера теперь достаточен.
UPDATE dm_delivery_state
SET delivery_state = 1,
delivered_server_login = NULL,
next_attempt_at_ms = NULL,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE delivery_state = 2;
-- Для незавершённых записей перестраиваем только будущую очередь. Повторная
-- передача безопасна благодаря messageKey и идемпотентному приёму.
UPDATE dm_delivery_state
SET delivery_expires_at_ms = created_at_ms + 3600000,
attempt_index = CASE
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000 THEN 4
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000 THEN 3
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000 THEN 2
ELSE 1
END,
next_attempt_at_ms = CASE
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000
THEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000
THEN created_at_ms + 3600000
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000
THEN created_at_ms + 1500000
ELSE created_at_ms + 30000
END,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE delivery_state = 0;
DROP INDEX IF EXISTS idx_dm_delivery_state_due;
CREATE INDEX idx_dm_delivery_state_due
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 13, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,620 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_stats_state (
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
ON user_stats_state (following_users_count);
CREATE TABLE IF NOT EXISTS channel_stats_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
channel_root_block_hash BYTEA NOT NULL,
owner_login TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
ON channel_stats_state (owner_login);
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_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.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
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
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
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
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
) VALUES (
NEW.login,
NEW.msg_sub_type,
resolved_login,
NEW.to_bch_name,
COALESCE(NEW.to_block_number, 0),
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
);
RETURN NEW;
END IF;
positive_rel_type := CASE NEW.msg_sub_type
WHEN 11 THEN 10
WHEN 21 THEN 20
WHEN 31 THEN 30
WHEN 41 THEN 40
WHEN 51 THEN 50
WHEN 53 THEN 52
WHEN 55 THEN 54
WHEN 61 THEN 60
WHEN 71 THEN 70
WHEN 75 THEN 74
ELSE NULL
END;
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
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
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
FOR EACH ROW
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks;
CREATE TRIGGER trg_blocks_connection_state_ai
AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_connection_state_ai();
TRUNCATE TABLE user_stats_state, channel_stats_state;
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) AS owned_public_channels_count,
COALESCE(fu.following_users_count, 0) AS following_users_count,
COALESCE(fc.following_channels_count, 0) AS following_channels_count,
COALESCE(cf.close_friends_count, 0) AS close_friends_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
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);
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) AS updated_at_ms
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;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 14, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,105 @@
BEGIN;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
IF EXISTS (
SELECT 1
FROM solana_user_pda_current su
WHERE su.login = NEW.owner_login
) THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_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.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
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
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 15, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,112 @@
BEGIN;
-- У пользователя действует только первый access_server из Solana PDA.
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN
RETURN;
END IF;
DELETE FROM user_access_servers_current
WHERE LOWER(user_login) = LOWER(p_user_login);
INSERT INTO user_access_servers_current (
user_login,
server_login,
server_url,
server_client_key,
user_record_number,
user_updated_at_ms,
server_record_number,
server_updated_at_ms,
refreshed_at_ms
)
SELECT
u.login,
s.login,
s.server_address,
s.client_key,
u.record_number,
u.updated_at_ms,
s.record_number,
s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord = 1
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
AND btrim(COALESCE(s.server_address, '')) <> ''
WHERE LOWER(u.login) = LOWER(p_user_login)
ON CONFLICT (user_login, server_login) DO UPDATE SET
server_url = EXCLUDED.server_url,
server_client_key = EXCLUDED.server_client_key,
user_record_number = EXCLUDED.user_record_number,
user_updated_at_ms = EXCLUDED.user_updated_at_ms,
server_record_number = EXCLUDED.server_record_number,
server_updated_at_ms = EXCLUDED.server_updated_at_ms,
refreshed_at_ms = EXCLUDED.refreshed_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
affected_user RECORD;
BEGIN
IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN
RETURN;
END IF;
DELETE FROM user_access_servers_current
WHERE LOWER(server_login) = LOWER(p_server_login);
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord = 1
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
END LOOP;
END;
$$;
DROP TABLE IF EXISTS dm_sync_outbox;
DROP TABLE IF EXISTS dm_sync_peer_state;
DROP TABLE IF EXISTS user_settings_sync_peer_state;
DROP INDEX IF EXISTS idx_user_settings_synced;
ALTER TABLE user_settings DROP COLUMN IF EXISTS synced;
SELECT shine_refresh_user_access_servers_all();
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 16, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,381 @@
BEGIN;
-- Add active FRIEND 14/15 and OFFICIAL_ACCOUNT confirmation 80/81 to connection state.
-- Legacy 60/61 remain accepted at protocol level; this migration does not reinterpret existing rows.
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF NEW.msg_sub_type IN (10, 14, 20, 30, 40, 50, 52, 54, 60, 70, 74, 80) THEN
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
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
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
) VALUES (
NEW.login,
NEW.msg_sub_type,
resolved_login,
NEW.to_bch_name,
COALESCE(NEW.to_block_number, 0),
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
);
RETURN NEW;
END IF;
positive_rel_type := CASE NEW.msg_sub_type
WHEN 11 THEN 10
WHEN 15 THEN 14
WHEN 21 THEN 20
WHEN 31 THEN 30
WHEN 41 THEN 40
WHEN 51 THEN 50
WHEN 53 THEN 52
WHEN 55 THEN 54
WHEN 61 THEN 60
WHEN 71 THEN 70
WHEN 75 THEN 74
WHEN 81 THEN 80
ELSE NULL
END;
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
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
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
$$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 17, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,348 @@
BEGIN;
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
CREATE TABLE IF NOT EXISTS user_profile_state (
login TEXT PRIMARY KEY,
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
ava_ar TEXT NOT NULL DEFAULT '',
account_role TEXT,
shine_status TEXT,
updated_at_ms BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
ALTER TABLE user_stats_state
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
ALTER TABLE message_stats
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_first_name TEXT := '';
v_last_name TEXT := '';
v_ava_ar TEXT := '';
v_account_role TEXT := NULL;
v_shine TEXT := NULL;
v_updated BIGINT := 0;
BEGIN
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
MAX(value) FILTER (WHERE param = 'account_role'),
MAX(value) FILTER (WHERE param = 'shine'),
COALESCE(MAX(time_ms), 0)
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
FROM users_params
WHERE LOWER(login) = LOWER(p_login);
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
ON CONFLICT (login) DO UPDATE SET
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
ava_ar = EXCLUDED.ava_ar,
account_role = EXCLUDED.account_role,
shine_status = EXCLUDED.shine_status,
updated_at_ms = EXCLUDED.updated_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
$$;
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
$$;
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
$$;
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
$$;
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
BEGIN
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
following_channels_count, close_friends_count, friends_count,
primary_confirmations_received_count, primary_confirmations_given_count,
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
SELECT p_login,
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
0,
(SELECT COUNT(*)::INT 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 LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
v_now
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
following_users_count=0,
following_channels_count=EXCLUDED.following_channels_count,
close_friends_count=EXCLUDED.close_friends_count,
friends_count=EXCLUDED.friends_count,
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
updated_at_ms=EXCLUDED.updated_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE r RECORD;
BEGIN
PERFORM shine_refresh_user_stats(p_login);
FOR r IN
SELECT DISTINCT x.login FROM (
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
UNION
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
) x
LOOP
PERFORM shine_refresh_user_stats(r.login);
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
FROM reactions_state rs
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
LOOP
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
AND shine_is_primary(x.from_login)),
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_owner_login TEXT;
v_channel_type INTEGER;
v_count INTEGER := 0;
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
BEGIN
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
SELECT cn.owner_login, cn.channel_type_code
INTO v_owner_login, v_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name=p_bch_name
AND cn.channel_root_block_number=p_block_number
AND cn.channel_root_block_hash=p_block_hash
LIMIT 1;
IF v_channel_type IS NULL THEN RETURN; END IF;
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
INTO v_count
FROM connections_state cs
WHERE cs.rel_type=30
AND cs.to_bch_name=p_bch_name
AND cs.to_block_number=p_block_number
AND cs.to_block_hash=p_block_hash;
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)
VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET
owner_login=EXCLUDED.owner_login,
channel_type_code=EXCLUDED.channel_type_code,
subscribers_count=EXCLUDED.subscribers_count,
updated_at_ms=EXCLUDED.updated_at_ms;
END;
$$;
-- Replace incremental channel-name counter with idempotent recomputation.
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.channel_type_code = 1 THEN
PERFORM shine_refresh_user_stats(NEW.owner_login);
END IF;
RETURN NEW;
END;
$$;
-- Rebuild connection state without incremental user counters; then recompute affected users.
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN RETURN NEW; END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash)
VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')));
ELSE
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40
WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
IF positive_rel_type IS NULL THEN RETURN NEW; END IF;
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
END IF;
PERFORM shine_refresh_user_stats(NEW.login);
PERFORM shine_refresh_user_stats(resolved_login);
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE previous_sub_type INTEGER;
BEGIN
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
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 LIMIT 1;
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
UPDATE message_stats SET likes_count=likes_count+1,
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) 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;
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) 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;
END IF;
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
RETURN NEW;
END;
$$;
-- Initial projection/rebuild.
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
END $$;
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
END $$;
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
END LOOP;
END $$;
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INT 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(*)::INT 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));
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,18,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,83 @@
BEGIN;
-- v19: skip user_stats refresh for logins that are absent from solana_user_pda_current.
-- This keeps bootstrap resilient to legacy rows that are still present in blocks.
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
BEGIN
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
IF NOT EXISTS (
SELECT 1
FROM solana_user_pda_current
WHERE login = p_login
LIMIT 1
) THEN
RETURN;
END IF;
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
following_channels_count, close_friends_count, friends_count,
primary_confirmations_received_count, primary_confirmations_given_count,
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
SELECT p_login,
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
0,
(SELECT COUNT(*)::INT 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 LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
v_now
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
following_users_count=0,
following_channels_count=EXCLUDED.following_channels_count,
close_friends_count=EXCLUDED.close_friends_count,
friends_count=EXCLUDED.friends_count,
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
updated_at_ms=EXCLUDED.updated_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE r RECORD;
BEGIN
PERFORM shine_refresh_user_stats(p_login);
FOR r IN
SELECT DISTINCT x.login FROM (
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
UNION
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
) x
LOOP
PERFORM shine_refresh_user_stats(r.login);
END LOOP;
END;
$$;
COMMIT;
@@ -0,0 +1,186 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_access_servers_current (
user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
server_url TEXT NOT NULL,
server_client_key TEXT NOT NULL,
user_record_number INTEGER NOT NULL,
user_updated_at_ms BIGINT NOT NULL,
server_record_number INTEGER NOT NULL,
server_updated_at_ms BIGINT NOT NULL,
refreshed_at_ms BIGINT NOT NULL,
PRIMARY KEY (user_login, server_login)
);
CREATE INDEX IF NOT EXISTS idx_user_access_servers_user
ON user_access_servers_current(user_login);
CREATE INDEX IF NOT EXISTS idx_user_access_servers_server
ON user_access_servers_current(server_login);
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN
RETURN;
END IF;
DELETE FROM user_access_servers_current
WHERE LOWER(user_login) = LOWER(p_user_login);
INSERT INTO user_access_servers_current (
user_login,
server_login,
server_url,
server_client_key,
user_record_number,
user_updated_at_ms,
server_record_number,
server_updated_at_ms,
refreshed_at_ms
)
SELECT
u.login,
s.login,
s.server_address,
s.client_key,
u.record_number,
u.updated_at_ms,
s.record_number,
s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
AND btrim(COALESCE(s.server_address, '')) <> ''
WHERE LOWER(u.login) = LOWER(p_user_login)
ON CONFLICT (user_login, server_login) DO UPDATE SET
server_url = EXCLUDED.server_url,
server_client_key = EXCLUDED.server_client_key,
user_record_number = EXCLUDED.user_record_number,
user_updated_at_ms = EXCLUDED.user_updated_at_ms,
server_record_number = EXCLUDED.server_record_number,
server_updated_at_ms = EXCLUDED.server_updated_at_ms,
refreshed_at_ms = EXCLUDED.refreshed_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
affected_user RECORD;
BEGIN
IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN
RETURN;
END IF;
DELETE FROM user_access_servers_current
WHERE LOWER(server_login) = LOWER(p_server_login);
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_all()
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
affected_user RECORD;
BEGIN
TRUNCATE TABLE user_access_servers_current;
FOR affected_user IN
SELECT login
FROM solana_user_pda_current
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_row()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM shine_refresh_user_access_servers_for_user(OLD.login);
PERFORM shine_refresh_user_access_servers_for_server(OLD.login);
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE' AND LOWER(OLD.login) <> LOWER(NEW.login) THEN
PERFORM shine_refresh_user_access_servers_for_user(OLD.login);
PERFORM shine_refresh_user_access_servers_for_server(OLD.login);
END IF;
PERFORM shine_refresh_user_access_servers_for_user(NEW.login);
PERFORM shine_refresh_user_access_servers_for_server(NEW.login);
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
TRUNCATE TABLE user_access_servers_current;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_row ON solana_user_pda_current;
CREATE TRIGGER trg_refresh_user_access_servers_row
AFTER INSERT OR UPDATE OR DELETE ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_row();
DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_truncate ON solana_user_pda_current;
CREATE TRIGGER trg_refresh_user_access_servers_truncate
AFTER TRUNCATE ON solana_user_pda_current
FOR EACH STATEMENT
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
SELECT shine_refresh_user_access_servers_all();
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,19 @@
-- Notifications v2: three categories + signed seen watermarks.
ALTER TABLE user_notifications_state
DROP CONSTRAINT IF EXISTS user_notifications_state_notification_kind_check;
ALTER TABLE user_notifications_state
ADD CONSTRAINT user_notifications_state_notification_kind_check
CHECK (notification_kind IN ('reply', 'connection', 'event'));
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
owner_login VARCHAR(60) NOT NULL,
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
seen_at_ms BIGINT NOT NULL DEFAULT 0,
signed_blob BYTEA NOT NULL,
signed_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, category)
);
UPDATE db_schema_version SET schema_version = 20 WHERE id = 1;
@@ -0,0 +1,47 @@
BEGIN;
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
ON signed_messages(target_login, created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
ON signed_messages(from_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
ON signed_messages(to_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
cursor_message_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
ON dm_sync_peer_state(owner_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE channel_names_state
ADD COLUMN IF NOT EXISTS ava_ar TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ava_sha256 TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ava_size BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS meta_updated_at_ms BIGINT NOT NULL DEFAULT 0;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 4, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,83 @@
BEGIN;
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS normalized_login TEXT;
UPDATE solana_user_pda_current
SET normalized_login = LOWER(BTRIM(login))
WHERE normalized_login IS NULL
OR normalized_login <> LOWER(BTRIM(login));
ALTER TABLE solana_user_pda_current
ALTER COLUMN normalized_login SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
WITH bad_signed_messages AS (
SELECT DISTINCT sm.message_key
FROM signed_messages sm
LEFT JOIN solana_user_pda_current u_from
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
LEFT JOIN solana_user_pda_current u_to
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
)
DELETE FROM signed_message_session_delivery d
USING bad_signed_messages bad
WHERE d.message_key = bad.message_key;
WITH bad_signed_messages AS (
SELECT DISTINCT sm.message_key
FROM signed_messages sm
LEFT JOIN solana_user_pda_current u_from
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
LEFT JOIN solana_user_pda_current u_to
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
)
DELETE FROM signed_messages sm
USING bad_signed_messages bad
WHERE sm.message_key = bad.message_key;
DELETE FROM signed_direct_messages_history h
USING solana_user_pda_current u_from,
solana_user_pda_current u_to
WHERE u_from.normalized_login = LOWER(BTRIM(h.from_login))
AND u_to.normalized_login = LOWER(BTRIM(h.to_login))
AND (h.from_login <> u_from.login OR h.to_login <> u_to.login);
DELETE FROM signed_direct_message_replay r
USING solana_user_pda_current u_from
WHERE u_from.normalized_login = LOWER(BTRIM(r.from_login))
AND r.from_login <> u_from.login;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'direct_messages'
) THEN
DELETE FROM direct_messages d
USING solana_user_pda_current u_from,
solana_user_pda_current u_to
WHERE u_from.normalized_login = LOWER(BTRIM(d.from_login))
AND u_to.normalized_login = LOWER(BTRIM(d.to_login))
AND (d.from_login <> u_from.login OR d.to_login <> u_to.login);
END IF;
END $$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 5, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,21 @@
BEGIN;
ALTER TABLE signed_messages
DROP CONSTRAINT IF EXISTS signed_messages_from_login_fkey;
ALTER TABLE signed_messages
DROP CONSTRAINT IF EXISTS signed_messages_to_login_fkey;
ALTER TABLE signed_direct_messages_history
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_from_login_fkey;
ALTER TABLE signed_direct_messages_history
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_to_login_fkey;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 6, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
ALTER TABLE blocks
DROP CONSTRAINT IF EXISTS blocks_login_fkey;
ALTER TABLE blocks
ADD CONSTRAINT blocks_login_fkey
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 7, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
ALTER TABLE connections_state
DROP CONSTRAINT IF EXISTS connections_state_login_fkey;
ALTER TABLE connections_state
ADD CONSTRAINT connections_state_login_fkey
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 8, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,47 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_settings (
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
setting_type INTEGER NOT NULL,
setting_key TEXT NOT NULL,
time_ms BIGINT NOT NULL,
value_text TEXT NOT NULL DEFAULT '',
value_num BIGINT NOT NULL DEFAULT 0,
client_key TEXT NOT NULL DEFAULT '',
signature TEXT NOT NULL DEFAULT '',
synced BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (login, setting_type, setting_key)
);
CREATE INDEX IF NOT EXISTS idx_user_settings_login
ON user_settings(login);
CREATE INDEX IF NOT EXISTS idx_user_settings_synced
ON user_settings(login, synced, time_ms);
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
ON user_settings(setting_type, setting_key);
CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_time_ms BIGINT NOT NULL DEFAULT 0,
cursor_setting_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_user_settings_sync_peer_state_owner
ON user_settings_sync_peer_state(owner_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 9, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,11 @@
package server.logic.ws_protocol.JSON;
import org.eclipse.jetty.websocket.api.Session;
import shine.db.entities.SolanaUserEntry;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.ActiveSessionEntry;
import java.util.List;
/**
* ConnectionContext контекст состояния одного WebSocket-соединения.
* Живёт ровно столько же, сколько живёт подключение.
@@ -25,8 +27,13 @@ public class ConnectionContext {
public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge)
public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
// Полный пользователь из БД (solana_users)
private SolanaUserEntry solanaUserEntry;
// При успешной авторизации отдельно фиксируем отношение пользователя к этому серверу.
public static final int USER_SERVER_SCOPE_NONE = 0;
public static final int USER_SERVER_SCOPE_LOCAL = 1;
public static final int USER_SERVER_SCOPE_REMOTE = 2;
// Полный пользователь из runtime БД (current users / solana_user_pda_current)
private CurrentUserEntry currentUserEntry;
// Активная сессия из БД (active_sessions)
private ActiveSessionEntry activeSessionEntry;
@@ -71,12 +78,24 @@ public class ConnectionContext {
*/
private int authenticationStatus = AUTH_STATUS_NONE;
/**
* LOCAL этот сервер является первым access server пользователя.
* REMOTE пользователь криптографически авторизован, но его access server другой.
*/
private int userServerScope = USER_SERVER_SCOPE_NONE;
/**
* WebSocket-сессия Jetty для данного подключения.
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
*/
private Session wsSession;
/** Временная server-to-server роль, заявленная через ServerHello. */
private boolean serverConnection;
private String remoteServerLogin;
private int remoteServerProtocolVersion;
private List<String> remoteServerCapabilities = List.of();
// --- WebSocket Session ---
public Session getWsSession() {
@@ -87,14 +106,28 @@ public class ConnectionContext {
this.wsSession = wsSession;
}
// --- SolanaUser / ActiveSession ---
public boolean isServerConnection() { return serverConnection; }
public void setServerConnection(boolean serverConnection) { this.serverConnection = serverConnection; }
public SolanaUserEntry getSolanaUser() {
return solanaUserEntry;
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public int getRemoteServerProtocolVersion() { return remoteServerProtocolVersion; }
public void setRemoteServerProtocolVersion(int value) { this.remoteServerProtocolVersion = value; }
public List<String> getRemoteServerCapabilities() { return remoteServerCapabilities; }
public void setRemoteServerCapabilities(List<String> capabilities) {
this.remoteServerCapabilities = capabilities == null ? List.of() : List.copyOf(capabilities);
}
public void setSolanaUser(SolanaUserEntry solanaUserEntry) {
this.solanaUserEntry = solanaUserEntry;
// --- SolanaUser / ActiveSession ---
public CurrentUserEntry getCurrentUser() {
return currentUserEntry;
}
public void setCurrentUser(CurrentUserEntry currentUserEntry) {
this.currentUserEntry = currentUserEntry;
}
public ActiveSessionEntry getActiveSession() {
@@ -108,7 +141,7 @@ public class ConnectionContext {
// --- Удобный геттер для логина ---
public String getLogin() {
return solanaUserEntry != null ? solanaUserEntry.getLogin() : null;
return currentUserEntry != null ? currentUserEntry.getLogin() : null;
}
// --- sessionId ---
@@ -175,8 +208,30 @@ public class ConnectionContext {
return authenticationStatus == AUTH_STATUS_NONE;
}
public int getUserServerScope() {
return userServerScope;
}
public void setUserServerScope(int userServerScope) {
this.userServerScope = userServerScope;
}
public boolean isLocalAuthenticatedUser() {
return isAuthenticatedUser() && userServerScope == USER_SERVER_SCOPE_LOCAL;
}
public boolean isRemoteAuthenticatedUser() {
return isAuthenticatedUser() && userServerScope == USER_SERVER_SCOPE_REMOTE;
}
public String getUserServerScopeName() {
return userServerScope == USER_SERVER_SCOPE_LOCAL ? "LOCAL"
: userServerScope == USER_SERVER_SCOPE_REMOTE ? "REMOTE"
: "NONE";
}
public void reset() {
solanaUserEntry = null;
currentUserEntry = null;
activeSessionEntry = null;
sessionId = null;
@@ -187,7 +242,12 @@ public class ConnectionContext {
sessionLoginNonceExpiresAtMs = 0;
authenticationStatus = AUTH_STATUS_NONE;
userServerScope = USER_SERVER_SCOPE_NONE;
wsSession = null;
serverConnection = false;
remoteServerLogin = null;
remoteServerProtocolVersion = 0;
remoteServerCapabilities = List.of();
}
@Override
@@ -196,6 +256,7 @@ public class ConnectionContext {
"login='" + getLogin() + '\'' +
", sessionId=" + sessionId +
", authenticationStatus=" + authenticationStatus +
", userServerScope=" + getUserServerScopeName() +
'}';
}
}
}
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ResolveLoginForAuth_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
@@ -22,6 +23,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_UpsertEspPairingSettings_
// --- auth entities ---
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
@@ -43,9 +45,6 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
@@ -63,6 +62,12 @@ import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Han
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_GetUserSetting_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_ListUserSettings_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_UpsertUserSetting_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
// --- NEW: connections friends lists ---
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
@@ -70,6 +75,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
@@ -78,14 +84,23 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileRelations_Request;
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileChannels_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
import server.logic.ws_protocol.JSON.handlers.notifications.Net_SetNotificationState_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_Request;
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
@@ -94,7 +109,6 @@ import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
@@ -106,7 +120,6 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
@@ -120,6 +133,7 @@ import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ServerHello_Handler;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
@@ -128,6 +142,7 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Request;
import java.util.Map;
@@ -142,13 +157,13 @@ public final class JsonHandlerRegistry {
}
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
Map.entry("AddUser", new Net_AddUser_Handler()),
Map.entry("GetUser", new Net_GetUser_Handler()),
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth ---
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
@@ -182,23 +197,32 @@ public final class JsonHandlerRegistry {
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
Map.entry("ListUserParams", new Net_ListUserParams_Handler()),
// --- userSettings ---
Map.entry("UpsertUserSetting", new Net_UpsertUserSetting_Handler()),
Map.entry("GetUserSetting", new Net_GetUserSetting_Handler()),
Map.entry("ListUserSettings", new Net_ListUserSettings_Handler()),
// --- connections ---
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
// --- direct messages / push ---
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
@@ -209,6 +233,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendSignal", new Net_SendSignal_Handler()),
// --- system ---
Map.entry("ServerHello", new Net_ServerHello_Handler()),
Map.entry("Ping", new Net_Ping_Handler()),
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
@@ -223,13 +248,13 @@ public final class JsonHandlerRegistry {
);
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
Map.entry("AddUser", Net_AddUser_Request.class),
Map.entry("GetUser", Net_GetUser_Request.class),
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth ---
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class),
@@ -263,23 +288,32 @@ public final class JsonHandlerRegistry {
Map.entry("GetUserParam", Net_GetUserParam_Request.class),
Map.entry("ListUserParams", Net_ListUserParams_Request.class),
// --- userSettings ---
Map.entry("UpsertUserSetting", Net_UpsertUserSetting_Request.class),
Map.entry("GetUserSetting", Net_GetUserSetting_Request.class),
Map.entry("ListUserSettings", Net_ListUserSettings_Request.class),
// --- connections ---
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
// --- direct messages / push ---
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
@@ -290,6 +324,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendSignal", Net_SendSignal_Request.class),
// --- system ---
Map.entry("ServerHello", Net_ServerHello_Request.class),
Map.entry("Ping", Net_Ping_Request.class),
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
@@ -205,6 +205,22 @@ public final class JsonInboundProcessor {
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200));
}
// REMOTE-пользователь успешно авторизован, но этот сервер не является
// его access server. Такой контекст нужен для realtime/call signaling,
// однако обычные homeserver-операции здесь запрещены централизованно.
if (ctx != null
&& ctx.isRemoteAuthenticatedUser()
&& !RemoteUserOperationPolicy.isAllowed(op)) {
Net_Exception_Response err = NetExceptionResponseFactory.error(
op,
requestId,
403,
"USER_NOT_LOCAL",
"Пользователь авторизован, но относится к другому access server"
);
return writeResponse(err);
}
// 5) Вызываем хэндлер
Net_Response response;
try {
@@ -0,0 +1,31 @@
package server.logic.ws_protocol.JSON;
import java.util.Set;
/**
* REMOTE-пользователь полностью авторизован криптографически, но этот сервер
* не является его access server. Поэтому ему разрешён только минимальный набор
* realtime/звонковых операций.
*/
public final class RemoteUserOperationPolicy {
private static final Set<String> ALLOWED = Set.of(
"Ping",
"GetServerInfo",
"GetCallIceConfig",
"CallInviteBroadcast",
"CallSignalToSession",
"SendSignal",
"CallDeliveryReport",
"ClientErrorLog",
"ClientDebugLog",
"CloseActiveSession",
"ResolveLoginForAuth"
);
private RemoteUserOperationPolicy() {}
public static boolean isAllowed(String op) {
return op != null && ALLOWED.contains(op);
}
}
@@ -0,0 +1,81 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import server.logic.ws_protocol.JSON.ConnectionContext;
import shine.db.dao.CurrentUsersDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.sql.SQLException;
import java.util.Locale;
/**
* Единая серверная логика определения, является ли текущий сервер
* домашним access server для пользователя.
*/
public final class AuthAccessScopeSupport {
public static final String RESOLUTION_LOCAL = "LOCAL";
public static final String RESOLUTION_REMOTE = "REMOTE";
public static final String RESOLUTION_NOT_FOUND = "NOT_FOUND";
public static final String RESOLUTION_NO_ACCESS_SERVER = "NO_ACCESS_SERVER";
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private AuthAccessScopeSupport() {}
public static Resolution resolveLogin(String login) throws SQLException {
String cleanLogin = login == null ? "" : login.trim();
if (cleanLogin.isBlank()) {
return new Resolution(RESOLUTION_NOT_FOUND, null, null, null);
}
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(cleanLogin);
if (user == null) {
return new Resolution(RESOLUTION_NOT_FOUND, null, null, null);
}
UserAccessServerRouteEntry route =
UserAccessServersCurrentDAO.getInstance().findPrimaryByUserLogin(user.getLogin());
if (route == null) {
return new Resolution(RESOLUTION_NO_ACCESS_SERVER, user, null, null);
}
String ownServerLogin = currentServerLogin();
String routeServerLogin = normalize(route.getServerLogin());
String resolution = ownServerLogin != null && ownServerLogin.equals(routeServerLogin)
? RESOLUTION_LOCAL
: RESOLUTION_REMOTE;
return new Resolution(resolution, user, route, ownServerLogin);
}
public static int connectionScopeForLogin(String login) throws SQLException {
Resolution resolution = resolveLogin(login);
return RESOLUTION_LOCAL.equals(resolution.resolution())
? ConnectionContext.USER_SERVER_SCOPE_LOCAL
: ConnectionContext.USER_SERVER_SCOPE_REMOTE;
}
public static String connectionScopeName(int scope) {
return scope == ConnectionContext.USER_SERVER_SCOPE_LOCAL ? "LOCAL" : "REMOTE";
}
public static String currentServerLogin() {
return normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
public record Resolution(
String resolution,
CurrentUserEntry user,
UserAccessServerRouteEntry primaryAccessServer,
String currentServerLogin
) {}
}
@@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Re
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.SolanaUserEntry;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.CurrentUserEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,7 +26,7 @@ import java.security.SecureRandom;
*
* Что делает:
* 1) Проверяет login.
* 2) Находит пользователя (solana_users).
* 2) Находит пользователя в текущем PDA snapshot.
* 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS.
* 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce.
*/
@@ -60,33 +60,18 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
);
}
SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login);
if (solanaUserEntry == null) {
try {
solanaUserEntry = SolanaUserPdaImportService.findOrImportByLogin(login);
if (solanaUserEntry != null) {
log.info("AuthChallenge: пользователь {} импортирован из Solana PDA", solanaUserEntry.getLogin());
}
} catch (Exception e) {
log.error("AuthChallenge: ошибка lazy-import пользователя {} из Solana", login, e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"SOLANA_IMPORT_FAILED",
"Ошибка проверки пользователя в Solana"
);
}
}
CurrentUserEntry solanaUserEntry = CurrentUsersDAO.getInstance().getByLogin(login);
if (solanaUserEntry == null) {
return NetExceptionResponseFactory.error(
req,
req,
WireCodes.Status.UNVERIFIED,
"UNKNOWN_USER",
"Пользователь с таким логином не найден"
);
}
ctx.setSolanaUser(solanaUserEntry);
ctx.setCurrentUser(solanaUserEntry);
ctx.setUserServerScope(AuthAccessScopeSupport.connectionScopeForLogin(solanaUserEntry.getLogin()));
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
byte[] buf = new byte[32];
@@ -18,7 +18,7 @@ import server.logic.ws_protocol.WireCodes;
import server.ws.WsConnectionUtils;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry;
import shine.db.entities.CurrentUserEntry;
import java.sql.SQLException;
@@ -42,7 +42,7 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq;
if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) {
if (ctx == null || ctx.getCurrentUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
@@ -51,7 +51,7 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
);
}
SolanaUserEntry user = ctx.getSolanaUser();
CurrentUserEntry user = ctx.getCurrentUser();
String currentLogin = user.getLogin();
String targetSessionId = req.getSessionId();
@@ -152,4 +152,4 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
);
}
}
}
}
@@ -15,9 +15,9 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.ws.WsConnectionUtils;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry;
import shine.db.entities.CurrentUserEntry;
import shine.geo.ClientInfoService;
import shine.geo.GeoLookupService;
import utils.crypto.Ed25519Util;
@@ -58,7 +58,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq;
if (ctx == null
|| ctx.getSolanaUser() == null
|| ctx.getCurrentUser() == null
|| ctx.getAuthNonce() == null
|| ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) {
@@ -72,7 +72,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err;
}
SolanaUserEntry userFromContext = ctx.getSolanaUser();
CurrentUserEntry userFromContext = ctx.getCurrentUser();
String loginFromContext = userFromContext.getLogin();
String loginFromReq = req.getLogin();
if (loginFromReq == null || loginFromReq.isBlank()) {
@@ -97,9 +97,9 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err;
}
SolanaUserEntry user;
CurrentUserEntry user;
try {
user = SolanaUsersDAO.getInstance().getByLogin(loginFromContext);
user = CurrentUsersDAO.getInstance().getByLogin(loginFromContext);
} catch (SQLException e) {
Net_Response err = NetExceptionResponseFactory.error(
req,
@@ -414,10 +414,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err;
}
// Между AuthChallenge и CreateAuthSession routing мог измениться,
// поэтому окончательный LOCAL/REMOTE scope вычисляем повторно.
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(canonicalLogin);
// --- обновляем контекст ---
ctx.setActiveSession(activeSessionEntry);
ctx.setSessionId(sessionId);
ctx.setAuthNonce(null);
ctx.setUserServerScope(userServerScope);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx);
@@ -428,6 +433,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setSessionId(sessionId);
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
return resp;
}
@@ -14,7 +14,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry;
import shine.db.entities.CurrentUserEntry;
import shine.geo.GeoLookupService;
import java.sql.SQLException;
@@ -36,7 +36,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq;
if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) {
if (ctx == null || ctx.getCurrentUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
@@ -45,7 +45,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler {
);
}
SolanaUserEntry user = ctx.getSolanaUser();
CurrentUserEntry user = ctx.getCurrentUser();
String currentLogin = user.getLogin();
List<ActiveSessionEntry> sessions;
@@ -0,0 +1,45 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserAccessServerRouteEntry;
public class Net_ResolveLoginForAuth_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_ResolveLoginForAuth_Request req = (Net_ResolveLoginForAuth_Request) baseReq;
String login = req.getLogin() == null ? "" : req.getLogin().trim();
if (login.isBlank()) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "EMPTY_LOGIN", "Пустой login");
}
AuthAccessScopeSupport.Resolution resolved = AuthAccessScopeSupport.resolveLogin(login);
Net_ResolveLoginForAuth_Response resp = new Net_ResolveLoginForAuth_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setResolution(resolved.resolution());
CurrentUserEntry user = resolved.user();
if (user != null) {
resp.setLogin(user.getLogin());
}
UserAccessServerRouteEntry route = resolved.primaryAccessServer();
if (route != null) {
resp.setAccessServerLogin(route.getServerLogin());
resp.setAccessServerUrl(route.getServerUrl());
}
return resp;
}
}
@@ -14,9 +14,9 @@ import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry;
import shine.db.entities.CurrentUserEntry;
import shine.geo.ClientInfoService;
import shine.geo.GeoLookupService;
import utils.crypto.Ed25519Util;
@@ -188,9 +188,9 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
ctx.setSessionLoginNonceExpiresAtMs(0);
// подтягиваем пользователя
SolanaUserEntry user;
CurrentUserEntry user;
try {
user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin());
user = CurrentUsersDAO.getInstance().getByLogin(session.getLogin());
} catch (SQLException e) {
return NetExceptionResponseFactory.error(
req,
@@ -292,10 +292,13 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
session.setClientPlatform(clientPlatform);
session.setUserLanguage(userLanguage);
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(user.getLogin());
// ctx
ctx.setActiveSession(session);
ctx.setSolanaUser(user);
ctx.setCurrentUser(user);
ctx.setSessionId(sessionId);
ctx.setUserServerScope(userServerScope);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx);
@@ -306,6 +309,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setStoragePwd(session.getStoragePwd());
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
return resp;
}
@@ -15,10 +15,10 @@ import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.EspPairingRequestsDAO;
import shine.db.dao.EspPairingSettingsDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.EspPairingRequestEntry;
import shine.db.entities.EspPairingSettingsEntry;
import shine.db.entities.SolanaUserEntry;
import shine.db.entities.CurrentUserEntry;
import java.util.List;
@@ -60,12 +60,22 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PASSWORD_HASH_FORMAT", "passwordHash должен быть пустым или иметь формат sha256$<64 hex>");
}
SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login);
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(login);
if (user == null) {
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен");
}
String canonicalLogin = user.getLogin();
AuthAccessScopeSupport.Resolution authResolution = AuthAccessScopeSupport.resolveLogin(canonicalLogin);
if (!AuthAccessScopeSupport.RESOLUTION_LOCAL.equals(authResolution.resolution())) {
return NetExceptionResponseFactory.error(
req,
409,
"USER_NOT_LOCAL",
"Вход через доверенное устройство нужно начинать на access server пользователя"
);
}
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
boolean enabled = settings == null || settings.isEnabled();
if (!enabled) {
@@ -100,14 +110,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
if (approverConnections.isEmpty()) {
return NetExceptionResponseFactory.error(
req,
422,
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
"Нет ни одной активной доверенной сессии пользователя в сети"
);
}
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
canonicalLogin,
requesterSessionKey,

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