Compare commits

..

177 Commits

Author SHA256 Message Date
AidarKC
eb5593c7be 30 03 25
Сделал адекватное отображение ключей / и при регистрации ключи спрашивают какие сохранять

(что то работает что то сложно)
2026-03-30 03:11:09 +03:00
ai5590
089146a137 Merge pull request #5 from ai5590/codex/connect-ui-client-to-server-for-authentication-8njihj
Add AuthService, WS client and key-vault; implement session-based auth flow and update auth UI/pages
2026-03-30 02:25:59 +03:00
ai5590
d4c8201a88 Merge branch 'plus_ui' into codex/connect-ui-client-to-server-for-authentication-8njihj 2026-03-30 02:25:51 +03:00
ai5590
538ec8ec73 Auto-resume active session on app reload 2026-03-30 02:25:14 +03:00
ai5590
52fa631733 Merge pull request #4 from ai5590/codex/connect-ui-client-to-server-for-authentication-xqvn1u
Add AuthService + crypto/key-vault + WS client and integrate real auth/session flows into UI
2026-03-30 02:16:35 +03:00
ai5590
284e962910 Merge branch 'plus_ui' into codex/connect-ui-client-to-server-for-authentication-xqvn1u 2026-03-30 02:16:20 +03:00
ai5590
6ba7a54921 Restore registration step flow and create new session on login 2026-03-30 02:09:44 +03:00
ai5590
ecd059ced2 Merge pull request #3 from ai5590/codex/connect-ui-client-to-server-for-authentication
Implement authentication backend (AuthService, WS client, crypto, key vault) and integrate session flows in UI
2026-03-30 01:52:31 +03:00
ai5590
4f825e2a86 Derive root/device/blockchain keys from password SHA-256 2026-03-30 01:52:12 +03:00
AidarKC
1bf1c768dd 30 03 25
Удалил наверное устаревшую  документацию
2026-03-30 00:53:45 +03:00
AidarKC
b33fa4aeaa 30 03 25
Добавил сайт с UI прямо сюда
2026-03-30 00:43:49 +03:00
ai5590
889ce0d921 Merge pull request #2 from ai5590/codex/analyze-block-addition-functionality-and-create-api-docs
Документация: добавление API `AddBlock` и раздела Blockchain в Dev_Docs
2026-03-30 00:38:39 +03:00
ai5590
1c9841b4a6 Merge branch 'main' into codex/analyze-block-addition-functionality-and-create-api-docs 2026-03-30 00:36:29 +03:00
AidarKC
99cf000f24 30 03 25
Добавил АПИ функцию которая возвращает информацию о версии сервера и о том что он работает
2026-03-30 00:34:16 +03:00
ai5590
3d780a2605 Split blockchain block-format docs by block type 2026-03-28 11:11:16 +03:00
AidarKC
1aabcf4d80 27 03 25
Доделал API функции для авторификации и работы с сессиями сервер и документ для разработчиков по

Авторификациии и серверам

Всё работает
2026-03-27 22:06:19 +03:00
AidarKC
51de9779e3 27 03 25
Доделал сервер и документ для разработчиков по

Авторификациии и серверам

Всё работает
2026-03-27 16:29:19 +03:00
AidarKC
2f9cf2bff1 27 03 25
Добавил документ для разработчиков (про сессии но не закончил) и исправил мекую ошибку с несопостовлениеминдексов 2
2026-03-27 14:59:52 +03:00
AidarKC
6d3719ba71 27 03 25
Добавил документ для разработчиков (про сессии но не закончил) и исправил мекую ошибку с несопостовлениеминдексов
2026-03-27 14:44:01 +03:00
ai5590
dabda362e6 Merge pull request #1 from ai5590/codex/create-dev_docs-folder-with-documentation
Добавил документацию от кодекса
2026-03-26 15:29:22 +03:00
ai5590
b23ecdfdf2 Add Dev_Docs with protocol, blockchain, and API design analysis 2026-03-26 15:27:59 +03:00
AidarKC
18bf5d65d7 Initial commit 2026-03-18 22:28:13 +03:00
AidarKC
37c36ffdba 19 02 25
сделал единый формат протокола в случае ошибок (Наверное сделал удобнее)
2026-02-19 18:06:03 +03:00
AidarKC
c7440e2b5c 19 02 25
Лобавил пинг
2026-02-19 17:33:58 +03:00
AidarKC
6949fd8a2f 06 02 25
Сделал отдельную команду что бы всё на сервер заливать
2026-02-06 16:51:18 +03:00
AidarKC
ef72719502 06 02 25
Сделал отдельную команду что бы всё на сервер заливать
2026-02-06 16:49:58 +03:00
AidarKC
a647091a3f 06 02 25
Сделал отдельную команду что бы всё на сервер заливать
2026-02-06 16:47:28 +03:00
AidarKC
b5706d3ed5 29 01 25
Зделал всё только на база64 без всяких урл сафе

Вроде всё работает
тест весь проходит
2026-01-29 19:29:08 +03:00
AidarKC
0b2bee0a3d 29 01 25
Удалил старые черновики
тест весь проходит
2026-01-29 18:30:55 +03:00
AidarKC
4cee326a25 28 01 25
додела запрос связи с друзьями
тест весь проходит
2026-01-29 17:33:30 +03:00
AidarKC
bf4cecde05 28 01 25
додела запрос связи с друзьями
тест весь проходит
2026-01-29 17:28:50 +03:00
AidarKC
84bef3365e 28 01 25
доработки по запросу связи друг
тест пока не весь проходит
2026-01-29 17:26:54 +03:00
AidarKC
922c18db4b 28 01 25
Добавил запрос связей пользователя - друзей - для построения графа друзей

И тест добавил.

но тест пока не весь проходит
2026-01-28 22:31:20 +03:00
AidarKC
22fb35d1d4 28 01 25
Добавил запрос поиска пользователей по начаоу логина.
И тест добавил.

Все тесты проходят.
2026-01-28 21:23:01 +03:00
AidarKC
ebf7c9f18e 28 01 25
Добавил запрос проверить есть ли в системе такой пользователь и получить его данные.

И тесты добавил.

Все тесты проходят
2026-01-28 20:33:06 +03:00
AidarKC
43b0efb4d3 23 01 25
Ура прошли все тесты новой версии авторификации!!

Осталось что то доделать поправить с лишними закрытиями сервака
2026-01-23 22:10:14 +03:00
AidarKC
4430615117 23 01 25
Промежуточный комит.
Ужас как устал сегодня, узнал что и запросы у меня постоянно закрывают сессию. Надо переделать
2026-01-23 21:52:45 +03:00
AidarKC
e84c63c3d1 23 01 25
Сделал авторификацию новую через sessionKey

(Но пока тесты сессии падают)
2026-01-23 20:50:58 +03:00
AidarKC
580695b486 23 01 25
Сделал ещё более два поля в общем формате блоков блокчейна (перед самим блоком данных) и перед его цп

(все тесты проходят)
2026-01-23 17:49:13 +03:00
AidarKC
9f1ca37977 23 01 25
Сделал более понятный названия у интерфейса  BodyHasLine

(всё работает)
2026-01-23 13:05:29 +03:00
AidarKC
c1964adb58 22 01 25
перенёс класс у вдругую папку
Вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 02:19:25 +03:00
AidarKC
ad5525d88b 22 01 25
увеличел количество тестовых запросов добавления блоков

И вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 02:18:12 +03:00
AidarKC
98d478531b 22 01 25
добавил комент
Да вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 02:09:28 +03:00
AidarKC
a2495afa44 22 01 25
Да вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 01:59:49 +03:00
AidarKC
3f5f94a53f 22 01 25
Да вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 01:57:02 +03:00
AidarKC
97840a45d6 22 01 25
Счас попробую новое просто добавить от гпт
Патч работает добавление линий - ситуация сложная

тест падает
2026-01-22 01:20:51 +03:00
AidarKC
69cd33479b 15 01 25
Потч работает добавление линий - ситуация сложная

тест падает
2026-01-21 18:37:05 +03:00
AidarKC
376d42cd79 15 01 25
Доделал типы сообщений посты в линии и едиты на них.ответы на них
И ответы в другие блокчейны

(Все тесты тесты проходят)
2026-01-15 18:55:03 +03:00
AidarKC
b69075cbac 15 01 25
Добавил мелких доп проверок

(Все тесты тесты проходят)
2026-01-15 15:24:25 +03:00
AidarKC
bbca821dcd 15 01 25
Исправил что бы в интерфейсе BodyHasTarget не требывалось хранить в блоках  BodyHasTarget
и в блоках коннекстин не зранилась поле тоЛогин в байты блока.

(Все тесты тесты проходят)
2026-01-15 15:13:29 +03:00
AidarKC
d9fe1f02b8 15 01 25
Исправил что бы в интерфейсе BodyHasTarget не требывалось хранить в блоках  BodyHasTarget
и в блоках коннекстин не зранилась поле тоЛогин в байты блока.

(Все тесты тесты проходят)
2026-01-15 15:09:45 +03:00
AidarKC
5fe41c7656 13 01 25
мелкие исправления. Убрал оставшиеся странные связи линии
2026-01-13 17:54:10 +03:00
AidarKC
9cf6fabe64 13 01 25
мелкие исправления 2
2026-01-13 17:46:23 +03:00
AidarKC
cd0352f904 13 01 25
мелкие исправления
2026-01-13 17:34:30 +03:00
AidarKC
fa30bd2a49 13 01 25
Перевёл блокчен на новый формат!
Все тесты проходят!!

Может в каких то деталях/мелочах ещё что то не так (не смотрел подробно), но в общем выглядит всё хорошо.

И главное работает!
2026-01-13 16:28:30 +03:00
AidarKC
e9e05c1192 13 01 25
Переписал код кучи классов перешёл на новый надеюсь теперь подходящий формат блоков

и тесты переделал.

Но пока остались баги и тесты не проходят (в частности пользователи не создаются - ошибка в бд)
2026-01-13 16:18:38 +03:00
AidarKC
b7025dde59 13 01 25
Запрос подписок, но это версия уже не актуальна тк дальше буду переделывать блоки под новый формат
2026-01-13 12:44:45 +03:00
AidarKC
973a632b85 09 01 25
На этом с форматом разобрались и отложили всё на праздничные выходные
2026-01-10 01:47:00 +03:00
AidarKC
9d0da4b39f 08 01 25
Список каналов возвращает - хотя сырое всё как то - но всё работает :)
2026-01-09 00:56:05 +03:00
AidarKC
aba86fc687 08 01 25
ещё помелочи
2026-01-09 00:03:40 +03:00
AidarKC
4c87207129 08 01 25
Помелоги поменял
2026-01-08 23:32:17 +03:00
AidarKC
7a167b470a 08 01 25
Сделал что бы при создании пользователя передавались три ключа пользователя. И имя блокчейна сделал через "-"
2026-01-08 15:02:01 +03:00
AidarKC
1ea5390771 08 01 25
Переименовал везде loginKey в solanaKey
2026-01-08 14:44:47 +03:00
AidarKC
a218f6586d 08 01 25
Навёл порядок в тестах. и доки дописал.
Всё красиво и работает!
2026-01-08 14:25:37 +03:00
AidarKC
4753b83831 08 01 25
Навёл порядок в тестах.
Всё красиво и работает!
2026-01-08 14:21:55 +03:00
AidarKC
a2626dfdd0 08 01 25
Добавил счётчик сколько раз изменялось сообщение
2026-01-08 14:13:44 +03:00
AidarKC
8e19486cf5 08 01 25
Тесты почти переделал
2026-01-08 14:12:16 +03:00
AidarKC
e2b89da2fa 08 01 25
Вынес константы, начал переделаывать тесты
2026-01-08 13:24:55 +03:00
AidarKC
f1af2bd4d4 07 01 25
вынес константы SHiNe
2026-01-08 00:02:43 +03:00
AidarKC
1c94bb25a6 07 01 25
сделал тест и он работает на то что бы изменить тект сообщения
2026-01-07 23:50:16 +03:00
AidarKC
06c77b1c1f 07 01 25
refactor: перевели хэши на BLOB и добавили поля block_hash / block_signature / edited_by_block_global_number

и главное добавили тип блока изменение сообщение и сслку на последнее изменение в табл блокс
2026-01-07 19:58:50 +03:00
AidarKC
8bcaa192c5 05 01 25
Добавил текст описание форматов блокчейна
2026-01-07 18:16:14 +03:00
AidarKC
8fd7f4676b 05 01 25
поменял все названия таблиц и полей в таблицах на стиль только маленькие буквы и разделение через "_"  . Все тесты проходят норм
2026-01-06 01:49:26 +03:00
AidarKC
93c007b2b9 05 01 25
добавил таблицу message_state и тригеры который считает в ней актуальное количество всех лайков и ответов на сообщения! (и всё рабоатет - тесты проходят)
2026-01-06 01:11:29 +03:00
AidarKC
eb922d918b 05 01 25
добавил таблицу connections_state и тригер который ведёт в ней актуальное состояние всех связей! (и всё рабоатет - тесты проходят)
2026-01-06 00:30:37 +03:00
AidarKC
7ba333bf6c 05 01 25
добавил новые типы связи - тоесть возможность добавлять убирать друга, контакт или подписку  (и тесты и всё работает)
2026-01-06 00:24:24 +03:00
AidarKC
94777c58c6 05 01 25
Документы: Описание БД и всех запросов актуализировал!
2026-01-05 17:17:39 +03:00
AidarKC
a6a5089379 05 01 25
Запрос для работы с параметрами пользователя работают!! И тесты на них проходят!!
2026-01-05 16:45:37 +03:00
AidarKC
eb122456ab 05 01 25
Дабавил и два запроса на получение параметров, но пока не работает
2026-01-05 16:42:32 +03:00
AidarKC
55d34e2a87 05 01 25
Дабавил и два запроса на получение параметров, но пока не проверял
2026-01-05 16:14:14 +03:00
AidarKC
bfffe44c4a 05 01 25
Дабавил соранение параметра пользователя. (бд и запрос на добавление), но пока не проверял
2026-01-05 16:11:54 +03:00
AidarKC
dd49c4de00 02 01 25
Сделал что бы в базу писался msgSubType и поля to (to login, toBlockchainName и т.д.)
2026-01-02 20:15:59 +03:00
AidarKC
eef760d776 02 01 25
улучшил скрипт склейки файлов (что бы он сразу в буфер кидал результат)
2026-01-02 19:52:53 +03:00
AidarKC
fa019bcb4f 02 01 25
ОГО доделал и тесты (теперь два пользователя добавляется и меж ними есть связи) вроде всё работает
2026-01-02 19:09:56 +03:00
AidarKC
432b574592 02 01 25
ОГО доделал и тесты (теперь два пользователя добавляется и меж ними есть связи) вроде всё работает
2026-01-02 19:09:17 +03:00
AidarKC
c3d20ba338 02 01 25
Доделал тесты и названия линий сделал в константы

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 18:52:19 +03:00
AidarKC
be7a3ab7a6 02 01 25
Добавил боди для параметров пользователя

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 18:44:35 +03:00
AidarKC
272d7ca1be 02 01 25
Добавил боди для связей

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 18:16:59 +03:00
AidarKC
05a4714fb1 02 01 25
Добавил боди для связей

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 17:18:13 +03:00
AidarKC
59e5df0dd3 02 01 25
Сделал тесты с ответами на сообщения

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 17:06:07 +03:00
AidarKC
771758c831 02 01 25
Переделал тест добавления блоков в новый формат ( стло удобнее)

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 16:54:13 +03:00
AidarKC
ca55bfca93 02 01 25
Добавил поле subType и исправил мелкие баги (все тесты работают)

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 16:42:15 +03:00
AidarKC
71f1a6179c 31 12 25
Стабильная версия сервера  0.2
Пакует блокченй в файл и бд, проверяет форматы.

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2025-12-31 21:50:16 +03:00
AidarKC
c13940216b 31 12 25
тест
2025-12-31 21:41:58 +03:00
AidarKC
f17d077f25 31 12 25
Сделал что бы запускалось. Поправил мелкие ошибки
2025-12-31 21:10:05 +03:00
AidarKC
62ea49d1fc 30 12 25
Ура работает всё под новую таблицу. И все тесты проходят!!
2025-12-30 13:03:03 +03:00
AidarKC
f653689112 30 12 25
Ура работает всё под новую таблицу 2
2025-12-30 12:55:21 +03:00
AidarKC
df03f3f4ba 30 12 25
Ну типо переделал Всё под короткую таблицу солана юзерс, но теперь не надо поправить баги
2025-12-30 12:41:06 +03:00
AidarKC
34e8640e78 30 12 25
Ну типо переделал Всё под короткую таблицу солана юзерс, но теперь не надо поправить баги
2025-12-30 12:39:55 +03:00
AidarKC
b6b50557a7 29 12 25
добавил toString для классов Body
2025-12-29 15:31:20 +03:00
AidarKC
08d90b6e8e 29 12 25
исправил микро баг
2025-12-29 15:10:14 +03:00
AidarKC
43a26007d6 29 12 25
Тесты работают - запускаются из специальной кнопки - возле билд.

Не совсем всё по стандарту но и это очень хорошо и удобно!
2025-12-29 15:03:42 +03:00
AidarKC
7fdc890a85 29 12 25
В общем тесты работают. Только запускать их надо из специального класса :)
2025-12-29 14:51:13 +03:00
AidarKC
ae3838ccf2 29 12 25
Ура линии работают тесты прошли! Третий тест в лог выводиться!
2025-12-29 14:14:53 +03:00
AidarKC
783b5b08e3 29 12 25
Ура линии работают тесты прошли! 2
2025-12-29 14:07:20 +03:00
AidarKC
015caec01c 29 12 25
Ура линии работают тесты прошли!
2025-12-29 13:53:22 +03:00
AidarKC
526e2d9cc4 28 12 25
Всё ещё не работает проверка линий.
Переделываю тесты понял что нетак в сервере. Дальше буду исправлять сервак.
2025-12-29 13:33:26 +03:00
AidarKC
3f374f48e1 28 12 25
Всё ещё не работает проверка линий.
Переделал первые два теста! Третий (АддБлокс) ещё не работает
2025-12-29 13:16:00 +03:00
AidarKC
795341dd8d 28 12 25
Всё ещё не работает проверка линий. Залил старые тесты какие есть - каке есть
2025-12-29 12:13:12 +03:00
AidarKC
c523816cdf 28 12 25
Вроде как сделал работу с линиями :) но ещё не тестил
2025-12-28 20:11:31 +03:00
AidarKC
b26e09904a 28 12 25
Старая версия. Вроде там тесты доделал что бы работали
2025-12-28 19:48:23 +03:00
AidarKC
1526392ca5 28 12 25
Старая версия. Вроде там тесты доделал что бы работали
2025-12-28 18:39:13 +03:00
AidarKC
809d897da6 26 12 25
Исправил баг что пир повторном подключении по одной сессии всё будет норм работать
2025-12-26 13:12:53 +03:00
AidarKC
eeb8ee9069 25 12 25
Сделал три теста на общий формат
2025-12-25 17:16:15 +03:00
AidarKC
25aa57dc5e 25 12 25
Добавил восстановление на случай застрявших темп файлов.  Оно работает!  И уведомление админа о критических ощибках, если файлы блокчейна поврежденны
2025-12-25 17:08:08 +03:00
AidarKC
6c2449f623 25 12 25
Добавил восстановление на случай застрявших темп файлов. Оно работает!
2025-12-25 16:42:40 +03:00
AidarKC
d8057807a3 25 12 25
Вроде заработало!!
2025-12-25 16:10:33 +03:00
AidarKC
e532401a75 25 12 25
Добавил логгер в настройки.2
2025-12-25 16:05:25 +03:00
AidarKC
f8cc12560e 25 12 25
Добавил логгер в настройки.2
2025-12-25 16:00:57 +03:00
AidarKC
c8ee9925a1 25 12 25
Добавил логгер в настройки.Омталось созранение стате в бд поправить
2025-12-25 14:53:08 +03:00
AidarKC
d460ea2952 25 12 25
Дорабатываю добавление блоков. Промежуточный комит2.Омталось созранение стате в бд поправить
2025-12-25 14:32:58 +03:00
AidarKC
e1b2c62231 25 12 25
Дорабатываю добавление блоков. Промежуточный комит
2025-12-25 14:15:26 +03:00
AidarKC
bead78b372 24 12 25
Дорабатываю добавление блоков.
2025-12-24 17:29:50 +03:00
AidarKC
4e14f300f9 24 12 25
Дорабатываю добавление блоков.
Убрал лишние старые классы
2025-12-24 17:11:29 +03:00
AidarKC
4759521176 24 12 25
Дорабатываю добавление блоков.
Улучшил класс записыватель в БД.
2025-12-24 17:01:10 +03:00
AidarKC
a309b6f3ef 24 12 25
Дорабатываю добавление блоков. Ура добавилось.
Объеденил в один Хэндлер и сделал атомарную запись в БД.
2025-12-24 16:42:26 +03:00
AidarKC
834cf98ef9 24 12 25
Дорабатываю добавление блоков. Ура добавилось. Осталось порядок навести и добавление файлов сделать и откат.
2025-12-24 14:55:30 +03:00
AidarKC
80ea016687 24 12 25
Дорабатываю добавление блоков. Промежуточный исправления (не работают)
2025-12-24 14:22:50 +03:00
AidarKC
5ecaf67bcb 24 12 25
Дорабатываю добавление блоков. Поставил todo что доделать
2025-12-24 14:08:40 +03:00
AidarKC
33635886e0 23 12 25
Дорабатываю добавление блоков! Вроде всё.  (но ещё не проверял и тестов нету)
2025-12-24 11:05:25 +03:00
AidarKC
bba4b7fb41 23 12 25
Дорабатываю добавление блоков! Вроде всё.  (но ещё не проверял и тестов нету)
2025-12-23 16:14:25 +03:00
AidarKC
26afcb892a 23 12 25
Дорабатываю добавление блоков! Вроде всё.Осталось ещё размер уточнить что без хэш и пподписи  2
2025-12-23 16:12:36 +03:00
AidarKC
9633e3528d 23 12 25
Дорабатываю добавление блоков! Вроде всё.Осталось ещё размер уточнить что без хэш и пподписи
2025-12-23 15:58:54 +03:00
AidarKC
62e4338e88 23 12 25
Дорабатываю добавление блоков!
2025-12-23 15:48:23 +03:00
AidarKC
d949895fec 23 12 25
Сессии работают пользователи добавляются. И тесты красиво выводият коменты. Старый класс теста больше не нужен
2025-12-23 14:55:18 +03:00
AidarKC
b5fa05a660 23 12 25
Сессии работают пользователи добавляются. И тесты красиво выводият коменты. Старый класс теста больше не нужен
2025-12-23 14:00:08 +03:00
AidarKC
c515d5287e 23 12 25
Сессии работают пользователи добавляются. Плюс сделал автоматические тесты как положенно
2025-12-23 13:51:20 +03:00
AidarKC
ae63a653c8 23 12 25
Прошли тесты на создание сессии - посути всё работает (но добавление блоков пока не работает)
2025-12-23 12:59:12 +03:00
AidarKC
03b6ff3c32 22 12 25
Не работающая версия в странном состоянии
2025-12-23 11:32:26 +03:00
AidarKC
935ffecbb0 19 12 25
Переделал и запросы и тесты (но ещё не тетил)
2025-12-22 13:16:17 +03:00
AidarKC
c140e3aae4 19 12 25
Переделал и запросы и тесты (но ещё не тетил)
2025-12-22 13:15:39 +03:00
AidarKC
7f92dc5f51 19 12 25
Ещё поправил мелкие детали в библиотеке работы с БД
2025-12-22 12:47:17 +03:00
AidarKC
627321d4ae 19 12 25
Передел библиотеку работы с БД под только login и bchName
2025-12-19 14:08:05 +03:00
AidarKC
0c49cae055 19 12 25
Минисально переименовал классы (убрал лишнее _new)
2025-12-19 13:56:18 +03:00
AidarKC
3cafd29ee5 19 12 25
В тот день Вроде дописал добавление но так и не заработало
2025-12-19 11:06:25 +03:00
AidarKC
6c4d8cd51b 19 12 25
Добавил таблицу для хранения блоков
2025-12-18 17:24:22 +03:00
AidarKC
d6d2bfeb73 19 12 25
Все ДАО получили перезагруженный метод для того что бы вызываться с передачей соединения и без передачи соединия
2025-12-18 15:58:43 +03:00
AidarKC
1b1da19d3d 17 12 25
Заработало
2025-12-18 15:37:28 +03:00
AidarKC
4fb6b10a97 17 12 25
Заработало блок добавляется в файл и в статус блокчена в БД!!
Но ещё надо сделать таблицу с записями :)    2
2025-12-17 19:17:10 +03:00
AidarKC
e9c11d6b75 17 12 25
Заработало блок добавляется в файл и в статус блокчена в БД!!
Но ещё надо сделать таблицу с записями :)
2025-12-17 18:35:34 +03:00
AidarKC
2037ebaa8b 17 12 25
Ещё промежуточный комит верии - не работает :)   4
2025-12-17 18:17:21 +03:00
AidarKC
45a862b11f 17 12 25
Ещё промежуточный комит верии - не работает :)   3
2025-12-17 17:21:10 +03:00
AidarKC
29c6e5a0f6 17 12 25
Ещё промежуточный комит верии - не работает :)   2
2025-12-17 17:15:52 +03:00
AidarKC
aa2caf1f10 17 12 25
Ещё промежуточный комит верии - не работает :)
2025-12-17 15:57:05 +03:00
AidarKC
8188b91f86 17 12 25
Промежуточный комит верии которая может быть работает :)
2025-12-17 13:32:54 +03:00
AidarKC
eb37b43de4 17 12 25
Промежуточный комит верии которая может быть работает :)
2025-12-17 13:32:06 +03:00
AidarKC
eaf1affb27 17 12 25
Промежуточная версия
2025-12-17 13:06:08 +03:00
AidarKC
ab44cc5282 16 12 25
Промежуточная версия и ТУДУ на чём остановился
2025-12-16 17:56:36 +03:00
AidarKC
19c4fd6cd1 11 12 25
Вроде как то заработали
2025-12-11 17:52:13 +03:00
AidarKC
096246542d 11 12 25
Добавил Вывсти список активных сессий!!
2025-12-11 17:19:17 +03:00
AidarKC
a6be7b75aa 11 12 25
Добавил Закрытие сессии
2025-12-11 16:56:53 +03:00
AidarKC
80ffba545a 11 12 25
Добавил поле authNonce(Вместо SessionPWD) при запросе авторизации
2025-12-11 16:22:12 +03:00
AidarKC
dbf1f22bac 11 12 25
Сделал закрытие сесси сервером если пароль не верный
2025-12-11 16:03:46 +03:00
AidarKC
7f91c60d26 10 12 25
Переименовал классы в один стиль
2025-12-11 15:29:10 +03:00
AidarKC
7072882b0b 10 12 25
Вроде всё доделал. Авторификацию. (осталось закрытие сессии и получение списка активных сессии)
2025-12-10 16:18:32 +03:00
AidarKC
00fc9e3926 10 12 25
промежуточный не рабочий комит
2025-12-10 16:15:36 +03:00
AidarKC
95ec6ba037 10 12 25
доработал получение инфы о клиенте из соединения
2025-12-10 15:28:29 +03:00
AidarKC
87da6efbfb 10 12 25
Добавил таблицы для геолокации
2025-12-10 13:54:15 +03:00
AidarKC
2ab1bbc02c 10 12 25
Всё работает. Плюс чть улучшил тест работы геолокации
2025-12-10 13:20:24 +03:00
AidarKC
47c53c1a14 10 12 25
Попереименовывал классы и запросы для авторификации.
Вроде хоршо и наверное всё работает :) пока не тестил
2025-12-10 12:58:16 +03:00
AidarKC
888bb1595f 09 12 25
Авторификация работает и тест авторификации проходит.

(создание пользователя, два этапа создания сессии и рефреш сессии)
2025-12-09 20:04:18 +03:00
AidarKC
2ed4f6d666 09 12 25
В черновую переделал авторификацию
2025-12-09 19:12:37 +03:00
AidarKC
2b5fa16824 09 12 25
Исправил что бы во входящих запросах тоже был  payload
2025-12-09 18:34:17 +03:00
AidarKC
199769cac0 04 12 25 Версия авторификации где сервер выдовал сессион Ид 2025-12-05 17:36:02 +03:00
AidarKC
c9bfa2d01a 04 12 25 Версия авторификации где сервер выдовал сессион Ид 2025-12-05 17:35:58 +03:00
AidarKC
fc748a744c 04 12 25 2025-12-05 10:56:34 +03:00
AidarKC
5d8dd86c96 обновляю сетевые хэндлеры 2025-12-04 13:34:04 +03:00
302 changed files with 54487 additions and 2456 deletions

5
.gitignore vendored
View File

@ -1,3 +1,8 @@
## папки с данными создавайемыми при работе сервера
data/
logs/
logs
.gradle .gradle
build/ build/
!gradle/wrapper/gradle-wrapper.jar !gradle/wrapper/gradle-wrapper.jar

1
.idea/.name generated Normal file
View File

@ -0,0 +1 @@
shine-server-server

2
.idea/gradle.xml generated
View File

@ -13,6 +13,8 @@
<option value="$PROJECT_DIR$/shine-server-config" /> <option value="$PROJECT_DIR$/shine-server-config" />
<option value="$PROJECT_DIR$/shine-server-crypto" /> <option value="$PROJECT_DIR$/shine-server-crypto" />
<option value="$PROJECT_DIR$/shine-server-db" /> <option value="$PROJECT_DIR$/shine-server-db" />
<option value="$PROJECT_DIR$/shine-server-geo" />
<option value="$PROJECT_DIR$/shine-server-log" />
<option value="$PROJECT_DIR$/shine-server-net-protocol" /> <option value="$PROJECT_DIR$/shine-server-net-protocol" />
<option value="$PROJECT_DIR$/shine-server-net-server" /> <option value="$PROJECT_DIR$/shine-server-net-server" />
</set> </set>

2
.idea/vcs.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>

View File

@ -0,0 +1,62 @@
0. ПЕРЕДЕЛАТЬ ВСЁ НА НОВЫЙ ФОРМАТ!!
ВЫНЕСТИ ЭТИ ТРИ ВЕЩИ В ОБЩИЙ ПАРСЕР
* [2] type - тип соощения
* [2] Sиbtype - субтип сообщения
* [2] version - версия формата соощения
А ОСТАЛЬНОЕ В РЕАЛИЗАЦИЮ
ПЕРЕДЕЛАЕМ БД
1. СДЕЛАЕМ ЛИНИЮ ТОЛЬКО ДЛЯ ТЕХ ТИПОВ КОМУ НАДО (ЛАЙКАМ И ОТВЕТАМ НЕ НАДО)
(НОМЕР СООБЩЕНИЯ В ЛИНИИ ХРАНИТЬ В БЛОКАХ ВРОДЕ И НЕ НАДО ТЕМ БОЛЕЕ ЕГО ПОТОМ ПЕРЕПРОВЕРЯТЬ ВСЁ РАВНО)
А МОЖЕТ И НАДО ТК КАК ПО ОДНОМУ БЛОКУ ( ИЛИ ЧАСТИ БЛОКОВ ПОНЯТЬ КАКАЯ ЭТО ЧАСТЬ ПЕРЕПИСКИ - ВЕДЬ ГЛОБАЛ НОМЕР ВООБЩЕ НЕ ПОКАЗАТЕЛЬ)
В БД ПОМЕЧАТЬ ЧТО БЛОК ИЗ ЭТОЙ ЛИНИИ (ДЛЯ БЫСТРОГО ПОИСКА)
А УНИКАЛЬНЫЙ НОМЕР ЛИНИИ ЭТО ПО СУТИ НОМЕР СООБЩЕНИЯ СОЗДАВШЕГО ЛИНИЮ КАНАЛ (НУ И ФОРМАТ СООБЩЕНИЯ НАЧАЛА ЛИНИИ - КАНАЛА)
3. СООТВЕТСТВЕННО удалить НАПИСАТЬ/ПЕРОВЕРИТЬ НОРМАЛЬНЫЙ SubscriptionsDAO - ТК СТАРЫЙ РАБОТАЛ НО НА ДРУГОМ ФОРМАТЕ И ТИПО КРИВО
и дальше:
ЗДЕЛАТЬ ТРИ ЗАПРОСА:
СПИСОК КАНАЛОВ НА КОГО ПОДПИСАН И ПО СКОЛЬКО СООБЩЕНИЙ И ПОСЛДНИЙ ТЕКСТ
ДОДЕЛАТЬ И СВЯЗ ПОДПИСАН УЖЕ НЕ ТОЛЬКО НА ЧЕЛА НО И НА КАНАЛ. (И ПОЛУЧАЕТСЯ ЕСТЬ ОБЩИЙ КАНАЛЛ ПОСТОВ (НО НЕКОТОРЫЕ ПОСТЫ В НИКУДА-
А НЕКОТОРЫЕ ПОСТЫ ОБЪЯВЛЕНИЕ КАНАЛА
СПИСОК СООБЩЕНИЙ В КАНАЛЕ
ОПСИСАНИЕ ОДНОГО СОООБЩЕНИЯ (С ИСТОРИЕЙ ДО НАЧАЛА ВЕТКИ И СО ВСЕМИ ОТВЕТАМИ НА НЕГО)
(НУ И В БУДУЩЕМ четвёртый ИСТОРИЮ сообщения ПО ЕДИТУ)
И ПОМЯТКА
ВСЕГДА СЧИТАЕМ ПО ПОСЛЕДНЕМУ БЛОКЧЕЙНУ ДОСТУПНОМУ ПОЛЬЗОВАТЕЛЮ
ХОТЯ ССЫЛКА ПО НОМЕРУ БЛОКЧЕЙНА КУДА ДОБАВИЛИ
ЛАЙКИ И ОТВЕТЫ ПИШЕМ НА НОМЕР СООБЩЕНИЯ ЕДИТА
(СЧИТАЕМ ТРИГЕРОМ И НА ОРИГИНАЛЬНЫЙ СУМАРНОЕ И ОТДЕЛЬНО НА НЕГО, И НА КАЖДЫЙ ЕДИТ ОТДЕЛЬНО)
ОТВЕТЫ ПОКАЗЫВАЕМ ВСЕ ВРАЗ

View File

@ -0,0 +1,10 @@
Сделать возможность убрать свой лайк. (пока не надо а сложность что надо больше проверок) - хотя можно и без проверки, просто за двойной лайк или за снятие двойное лайка. Будет двойное проникновение :)) тому кто изменил код клиента и убрал проверку на клиенте - и блокчейн заблокируется и всё.
поэтому просто на каждую реакцию добавиться убрать эту ракцию .
- это просто
сделатьпотом что бы в солану_юзерс хранилось имя текущего блокчейна пользователя. Что бы потом можно было грузить именно актуальный ТО ЕСТЬ потом можно будет менять блокченый!
сделать сессион пасворд тоже ключём подписи устройства!!

22
DOC/doc_all_libs.md Normal file
View File

@ -0,0 +1,22 @@
Перечень библиотек и их краткое описание
shine-server-log
Статический “сиренный” метод для максимально заметного критического лога администратору
shine-server-config
Минимальный конфиг-лоадер, который один раз читает application.properties и даёт доступ к параметрам.
shine-server-geo
Утилиты, которые вытаскивают IP/язык/UA из Jetty WebSocket и (опционально) резолвят гео по IP с кэшем в БД.
shine-server-crypto
Базовые крипто-утилиты для SHA-256 и Ed25519 (BouncyCastle) + проверка подписи/хэша для .bch сущностей и маленький self-test.
shine-server-bd
Библиотека реалезующая всю работу с БД:
shine-server-blockchain
Библиотека, которая задаёт единый бинарный формат блоков (RAW+signature+hash), парсит/валидирует “тело” блока по type/version, и проверяет целостность/подпись цепочки через SHA-256 + Ed25519 с привязкой к login и предыдущим хэшам.
shine-server-protocol
Библиотека JSON-протокол поверх WebSocket для взаимодействия с клиентами.

View File

@ -0,0 +1,36 @@
краткая «памятка себе» по базовым классам и как они связаны.
server.logic.InboundMessageProcessor (устаревший путь)
Роль: маршрутизатор бинарного протокола: берёт входящие байты, читает первые 4 байта как op, находит MessageHandler и отдаёт ему сообщение.
Что возвращает: байтовый ответ хэндлера; при ошибках — 4 байта со статусом (BAD_REQUEST/INTERNAL_ERROR).
Важно: сейчас фактически не используется (карта HANDLERS пустая/закомментирована) — это «след» старого бинарного протокола, который вы заменили на JSON-WS.
server.ws.BlockchainTmpRecoveryOnStartup
Роль: «автослесарь» при старте: чинит последствия падения во время записи блокчейн-файла.
Логика: ищет *.tmp_bch в data/, сравнивает размеры tmp, main .bch и state.fileSizeBytes из БД.
Решения:
если stateSize == mainSize → tmp мусор, удаляем;
если stateSize == tmpSize → tmp актуален, атомарно заменяем main;
если не сходится / подозрительно (нет state, но есть main+tmp и т.п.) → CRITICAL + стоп сервера.
Итог: гарантирует, что на запуске не будет «тихо битого» блокчейна.
server.ws.BlockchainWsEndpoint
Роль: WS-эндпоинт Jetty, который принимает и бинарные, и текстовые сообщения.
Connect: сохраняет Session, кладёт её в ConnectionContext.
Binary: асинхронно вызывает InboundMessageProcessor.process(msg) и отправляет байтовый ответ. (Это тот самый устаревший путь.)
Text (JSON): асинхронно вызывает JsonInboundProcessor.processJson(message, connectionContext) и отправляет строку JSON.
Close: удаляет соединение из ActiveConnectionsRegistry, чистит ConnectionContext.
Смысл: один входной узел WS, где JSON — основной протокол, binary — “наследие”.
server.ws.WsServer
Роль: точка входа сервера.
Порядок запуска:
BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow() — если не смог починить/сопоставить → сервер не стартует;
читает порт из AppConfig (server.port), иначе 7070;
поднимает Jetty, конфигурирует WS-контейнер, маппит /ws → BlockchainWsEndpoint, ставит idleTimeout.
Итог: «бутстрап»: сначала безопасность файлов, потом сеть.

View File

@ -0,0 +1,17 @@
shine-server-bd — это библиотека реалезующая всю работу с БД:
хранит пользователей/сессии/параметры/кэш IP→гео и данные блокчейна (состояние + блоки), предоставляя единый SqliteDbController для соединений, набор DAO под каждую таблицу (Singleton, методы с Connection для транзакций и без Connection — сами открывают/закрывают), и простые entity-модели как контейнеры данных для маппинга ResultSet↔Java.
Логика структуры классов (в двух словах):
shine.db.SqliteDbController — один вход в БД: читает db.path, при отсутствии файла создаёт БД, выдаёт новые Connection и настраивает PRAGMA.
shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы).
shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getDeviceKeyByte()).
shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO:
UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit).
// Временное решение позволяющее регистрировать новых пользователей
// атомарно и добавляет запись и в solana_users и в BlockchainState

View File

@ -0,0 +1,85 @@
shine-server-blockchain — это библиотека, которая задаёт формат блока, правила парсинга/валидации тела, крипто-проверку (hash+Ed25519) и безопасную работу с файлами блокчейна (data/<name>.bch через временный .tmp_bch).
Как устроена структура и логика работы
1) “Блок” как центральный объект (ядро)
BchBlockEntry — единая модель блока “как лежит на диске/в сети”:
читает/собирает байты в формате RAW + signature64 + hash32
сразу парсит body через BodyRecordParser
сразу проверяет что lineIndex совпадает с тем, что ожидает конкретный тип body (expectedLineIndex())
То есть: всё, что считается “блоком”, обязано быть самодостаточно валидным уже на этапе создания объекта.
2) “Body” как плагины по типам (расширяемая часть) ! <-- Новые типы записей добавлть сюда !
BodyRecord — интерфейс контракта для всех тел:
type/version — идентификаторы формата
expectedLineIndex() — жёсткое правило “в какой линии может жить”
check() — логическая валидация содержимого
toBytes() — сериализация обратно в бинарь
BodyRecordParser — диспетчер: читает первые 4 байта (type+ver) и выбирает нужный класс:
HeaderBody (lineIndex=0)
TextBody (lineIndex=1)
ReactionBody (lineIndex=2)
Добавление нового типа = добавить новый класс XxxBody + кейс в BodyRecordParser.
3) Криптография как отдельный слой проверки
BchCryptoVerifier отвечает за “как получить хэш и как проверить подпись”:
строит preimage = "SHiNE" + login + prevGlobalHash32 + prevLineHash32 + rawBytes
считает sha256(preimage) и сравнивает с hash32 внутри блока
проверяет Ed25519 подпись над hash32
Важно: BchBlockEntry не проверяет подпись — он проверяет структуру блока и правильность body/lineIndex, а криптопроверка вынесена отдельно.
4) Утилиты вокруг имени и файлов
BlockchainNameUtil — извлекает login из blockchainName (отрезает 3 символа суффикса).
FileStoreUtil — безопасное файловое хранилище:
5) Объяснение структуры работы
Типичный сценарий: пришёл блок → проверить → принять
Шаг 0. Контекст (что у нас уже есть снаружи)
Снаружи библиотеки (в сервере) у тебя уже известны:
userLogin — владелец блокчейна
publicKey32 — публичный ключ пользователя
prevGlobalHash32 — хэш предыдущего блока по глобальной цепи
prevLineHash32 — хэш предыдущего блока по текущей линии
Библиотека не хранит это сама, она ожидает, что сервер это передаст.
Шаг 1. Парсинг блока (структура + логика)
BchBlockEntry block = new BchBlockEntry(fullBytes);
Что происходит здесь автоматически:
проверяется длина блока
проверяется recordSize
парсится RAW-заголовок
парсится body через BodyRecordParser
проверяется, что lineIndex соответствует типу body
(HEADER → line 0, TEXT → line 1, REACTION → line 2 и т.д.)
На этом шаге никакой криптографии ещё нет — только структура и логика формата.
Если тут не упало исключение → блок структурно корректен.
Шаг 2. Подготовка данных для криптопроверки (они получаются просто из частей байтов полного блокас подписью)
byte[] rawBytes = block.getRawBytes();
byte[] signature64 = block.getSignature64();
byte[] hash32FromTail = block.getHash32();
Важно:
rawBytes — это ровно те байты, которые участвовали в хэшировании
hash32FromTail — это то, что автор блока положил внутрь блока
Шаг 3. Криптографическая проверка (ключевой вызов)
boolean ok = BchCryptoVerifier.verifyAll(
userLogin,
prevGlobalHash32,
prevLineHash32,
rawBytes,
signature64,
publicKey32,
hash32FromTail
);

View File

@ -0,0 +1,48 @@
Общая структура блока
Блок — это бинарная запись фиксированного формата:
[ RAW ][ signature64 ][ hash32 ]
RAW — данные блока (участвуют в хэшировании и подписи)
signature64 — Ed25519-подпись над hash32
hash32 — SHA-256 от preimage (привязан к цепочке и владельцу)
RAW-часть (BigEndian)
recordSize int32 — размер RAW (без signature+hash)
recordNumber int32 — глобальный номер блока
timestamp int64 — unix time (seconds)
lineIndex int16 — индекс линии
lineNumber int32 — номер блока внутри линии
bodyBytes bytes — тело блока (type+version+payload)
Общая структура блокчейна
Блокчейн — это:
линейная цепочка блоков (по recordNumber)
внутри неё — параллельные логические линии (lineIndex)
каждая линия имеет собственную нумерацию (lineNumber) и prevLineHash
вся цепочка связана ещё и prevGlobalHash
👉 Таким образом, каждый блок:
связан с предыдущим глобальным блоком
и с предыдущим блоком своей линии
Это даёт:
строгий порядок всей истории
и независимую валидацию логических потоков
Криптографический смысл блока
Хэш блока считается от:
"SHiNE" +
login +
prevGlobalHash32 +
prevLineHash32 +
RAW
Это означает:
блок жёстко привязан к владельцу (login)
блок невозможно перенести в другую цепочку
подмена предыдущего блока ломает всю цепь
Подпись Ed25519 делается над этим хэшем.

View File

@ -0,0 +1,36 @@
Формат и смысл существующих Body
1) HeaderBody (type=0, ver=1)
Линия: lineIndex = 0
Смысл:
Генезис-блок блокчейна, объявляет формат и владельца.
Содержит:
сигнатуру формата "SHiNE"
login владельца блокчейна
👉 Всегда первый блок, всегда в линии 0.
2) TextBody (type=1, ver=1)
Линия: lineIndex = 1
Смысл:
Основной контент — текстовые записи (посты, сообщения, дневник).
Содержит:
UTF-8 текст произвольной длины
👉 Это “основная история” блокчейна пользователя.
3) ReactionBody (type=2, ver=1)
Линия: lineIndex = 2
Смысл:
Связь с другим блокчейном или блоком (реакция, ответ, лайк, ссылка).
Содержит:
код реакции
имя целевого блокчейна
globalNumber целевого блока
hash32 целевого блока
👉 Это механизм межблокчейн-связей без изменения чужих цепочек.

View File

@ -0,0 +1,7 @@
shine-server-config
Минимальная библиотека конфигурации, предоставляющая потокобезопасный singleton-доступ к параметрам из application.properties.
Настройки:
server.port=7070 — порт запуска сервера
db.path=data/shine.sqlite — путь к SQLite базе данных

View File

@ -0,0 +1,8 @@
shine-server-crypto
О чём: базовые крипто-утилиты для SHA-256 и Ed25519 (BouncyCastle) + проверка подписи/хэша для .bch сущностей и маленький self-test.
Внешние методы, которые вызываются:
BchCryptoVerifier.verifyAll(), BchCryptoVerifier.buildPreimage(), Ed25519Util.generatePrivateKey(), Ed25519Util.generatePrivateKeyFromString(), Ed25519Util.derivePublicKey(), Ed25519Util.sign(), Ed25519Util.verify(), Ed25519Util.keyToBase64(), Ed25519Util.keyFromBase64(),
HashSHA256Util.sha256()
HashSHA256Util.loginToLoginId(), HashSHA256Util.loginIdFromLogin(),

View File

@ -0,0 +1,19 @@
shine-server-geo
Назначение: утилиты для получения “кто подключился” (IP/UA/язык) и геолокации по IP (с опциональным кэшем в БД).
Классы:
ClientInfoService — собирает строку UA/ch-ua/platform/mobile/remoteIP, вытаскивает реальный IP (приоритет: X-Forwarded-For → X-Real-IP → remoteAddress), парсит первый Accept-Language.
GeoLookupService — геолокация по IP через внешний API (ip-api.com), умеет вариант без кэша и с кэшем в таблице ip_geo_cache через IpGeoCacheDAO (пишет даже unknown), плюс метод получения внешнего IP через api.ipify.org.
GeoLookupTestMain — консольный тест: берёт IP из аргумента или определяет внешний, вызывает геолокацию и печатает результат + время.
Внешние (публично используемые) методы:
ClientInfoService.buildClientInfoString(Session) — формирует строку с User-Agent, client-hints и реальным IP клиента
ClientInfoService.extractClientIp(Session) — извлекает реальный IP (X-Forwarded-For / X-Real-IP / remoteAddress)
ClientInfoService.extractPreferredLanguageTag(Session) — возвращает основной язык клиента из Accept-Language
GeoLookupService.resolveCountryCityOrIp(String ip) — геолокация по IP без кэша (Country, City или unknown)
GeoLookupService.resolveCountryCityOrIpWithCache(String ip) — геолокация по IP с кэшированием в БД
GeoLookupService.fetchPublicIpOrDefault(String fallbackIp) — получение внешнего IP текущей машины

View File

@ -0,0 +1,10 @@
shine-log (BlockchainAdminNotifier)
Суть (1 предложение): единая точка для “красного” оповещения админа о критических проблемах консистентности, сейчас — через максимально заметный log.error, позже — через Telegram/email/webhook и т.п.
Структура (очень кратко):
BlockchainAdminNotifier (final utility)
BlockchainAdminNotifier.critical(String message)
BlockchainAdminNotifier.critical(String message, Throwable t)

View File

@ -0,0 +1,52 @@
shine-server-protocol
Библиотека JSON-протокол поверх WebSocket для взаимодействия с клиентами.
Всё общение — JSON поверх WebSocket
Формат всегда один:
request: op + requestId + payload
response: op + requestId + status + payload
Net_Event / Net_Request / Net_Response
Базовые классы протокола.
requestId связывает запрос и ответ, status = результат.
Хэндлер = логика операции
Каждый op обрабатывается своим JsonMessageHandler.
Entities (Request / Response)
DTO-классы для Jackson:
Net_Xxx_Request — что приходит от клиента
Net_Xxx_Response — что уходит клиенту
JsonHandlerRegistry
Связывает:
op → RequestClass
op → Handler
JsonInboundProcessor
Единая точка входа:
парсит JSON → маппит payload → вызывает handler → собирает ответ JSON.
Папки по темам
auth/ — авторизация и сессии
(AuthChallenge → CreateAuthSession → Refresh / List / Close)
blockchain/ — AddBlock
tempToTest/ — AddUser (временный, потом уйдёт в блокчейн-логику)
ConnectionContext
Состояние одного WebSocket-подключения (login, session, authStatus).
ActiveConnectionsRegistry
Глобальный реестр активных авторизованных соединений
(нужно для закрытия других сессий).

View File

@ -0,0 +1,209 @@
SHiNE — структура БД (актуальная версия)
Перечень таблиц и назначение
solana_users
Справочник пользователей: логин + ключ устройства + (опционально) Solana-ключ.
Базовая таблица, используется как FK почти везде.
active_sessions
Активные сессии авторизации/работы клиента: секреты, тайминги, WebPush-данные, IP и информация о клиенте.
users_params
Хранилище актуальных параметров пользователя.
Для каждой пары (login, param) хранится только самая новая версия по time_ms.
ip_geo_cache
Кеш геолокации по IP для снижения нагрузки на внешние сервисы.
blockchain_state
Агрегированное состояние блокчейна по blockchain_name:
лимиты, текущий размер, последний глобальный блок и состояние линий 0..7.
blocks
Журнал всех блоков и сообщений.
Содержит историю событий: тексты, реакции, ответы, связи.
PRIMARY KEY намеренно отсутствует.
connections_state ⭐
Актуальное состояние связей между пользователями
(друг / контакт / подписка).
Обновляется автоматически на основе событий из blocks.
message_stats ⭐
Агрегированные счётчики лайков и ответов на конкретные сообщения.
Поддерживается триггерами из blocks.
Таблицы подробно
solana_users
login — TEXT PK — уникальный логин пользователя
device_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64))
solana_key — TEXT NULL — публичный ключ Solana-аккаунта
active_sessions
session_id — TEXT PK — идентификатор сессии
login — TEXT NOT NULL, FK → solana_users(login)
session_pwd — TEXT NOT NULL — секрет сессии
storage_pwd — TEXT NOT NULL — секрет storage
session_created_at_ms — INTEGER NOT NULL
last_authirificated_at_ms — INTEGER NOT NULL
push_endpoint — TEXT NULL
push_p256dh_key — TEXT NULL
push_auth_key — TEXT NULL
client_ip — TEXT NULL
client_info_from_client — TEXT NULL
client_info_from_request — TEXT NULL
user_language — TEXT NULL
users_params
login — TEXT NOT NULL, FK → solana_users(login)
param — TEXT NOT NULL
time_ms — INTEGER NOT NULL
value — TEXT NOT NULL
device_key — TEXT NULL
signature — TEXT NULL
Ограничение:
UNIQUE(login, param)
Логика:
обновление принимается только если excluded.time_ms > users_params.time_ms
ip_geo_cache
ip — TEXT PK
geo — TEXT NULL
updated_at_ms — INTEGER NOT NULL
blockchain_state
blockchain_name — TEXT PK
login — TEXT NOT NULL, FK → solana_users(login)
blockchain_key — TEXT NOT NULL
size_limit — INTEGER NOT NULL
file_size_bytes — INTEGER NOT NULL
last_global_number — INTEGER NOT NULL (-1 = genesis)
last_global_hash — TEXT NOT NULL
updated_at_ms — INTEGER NOT NULL
Линии 0..7:
для каждой линии:
lineX_last_number
lineX_last_hash
blocks
login — TEXT NOT NULL
bch_name — TEXT NOT NULL
block_global_number — INTEGER NOT NULL
block_global_pre_hash — TEXT NOT NULL
block_line_index — INTEGER NOT NULL
block_line_number — INTEGER NOT NULL
block_line_pre_hash — TEXT NOT NULL
msg_type — INTEGER NOT NULL
msg_sub_type — INTEGER NOT NULL
block_bytes — BLOB NULL
Ссылка на другой блок (nullable):
to_login
to_bch_name
to_block_global_number
to_block_hash
connections_state ⭐
Текущее агрегированное состояние связей.
login — TEXT NOT NULL
rel_type — INTEGER NOT NULL
10 = FRIEND
20 = CONTACT
30 = FOLLOW
to_login — TEXT NOT NULL
to_bch_name — TEXT NOT NULL
to_block_global_number — INTEGER NULL
to_block_hash — TEXT NULL
Ограничение:
UNIQUE(login, rel_type, to_login)
message_stats ⭐
Счётчики активности по целевому сообщению.
to_login — TEXT NOT NULL
to_bch_name — TEXT NOT NULL
to_block_global_number — INTEGER NOT NULL
to_block_hash — TEXT NOT NULL
likes_count — INTEGER NOT NULL DEFAULT 0
replies_count — INTEGER NOT NULL DEFAULT 0
UNIQUE:
(to_login, to_bch_name, to_block_global_number, to_block_hash)
Триггеры БД (полная логика)
3.1 Связи пользователей
trg_blocks_connection_state_ai
AFTER INSERT ON blocks
Условие:
msg_type = 3 (connection)
Добавление / обновление связи
msg_sub_type IN (10,20,30)
выполняется UPSERT в connections_state
Удаление связи
msg_sub_type IN (11,21,31)
удаляется соответствующая связь:
11 → 10
21 → 20
31 → 30
Итог:
blocks — журнал событий
connections_state — всегда актуальное состояние
3.2 Подсчёт лайков ⭐
trg_blocks_message_stats_like_ai
AFTER INSERT ON blocks
Условие:
msg_type = 2 (reaction)
msg_sub_type = 1 (like)
Действие:
определяется цель по to_bch_name, to_block_global_number, to_block_hash
to_login вычисляется как
substr(to_bch_name, 1, length(to_bch_name) - 3)
выполняется UPSERT в message_stats
likes_count += 1
3.3 Подсчёт ответов ⭐
trg_blocks_message_stats_reply_ai
AFTER INSERT ON blocks
Условие:
msg_type = 1 (text)
msg_sub_type = 2 (reply)
Действие:
цель определяется аналогично лайкам
выполняется UPSERT в message_stats
replies_count += 1
Индексы (смысл)
idx_solana_users_login — поиск пользователя
idx_active_sessions_login — сессии пользователя
idx_users_params_login — параметры пользователя
idx_ip_geo_cache_updated_at — чистка кеша
idx_blockchain_state_login — блокчейны пользователя
idx_blockchain_state_updated_at — обслуживание
idx_blocks_chain_global — чтение цепочки
idx_blocks_to_target — реакции / ответы
idx_message_stats_target — быстрый доступ к счётчикам
Итоговая модель мышления
blocks — неизменяемый журнал событий
connections_state — проекция связей
message_stats — проекция активности
всё вычисляется детерминированно через триггеры

View File

@ -0,0 +1,286 @@
JSON WebSocket протокол сервера: список существующих запросов (op)
Общий формат любого запроса (client → server):
op - имя операции (строка)
requestId - идентификатор запроса для связывания с ответом (строка)
payload - объект с параметрами конкретной операции (object)
Общий формат любого ответа (server → client):
op - имя операции (строка, совпадает с запросом)
requestId - идентификатор запроса (строка, совпадает с запросом)
status - статус результата (200 = успех, другое = ошибка)
payload - объект с полями ответа (object; при ошибке содержит code/message)
Группа: Авторизация и сессии
Эта группа управляет входом пользователя, созданием/обновлением сессий и безопасным завершением активных подключений.
----- AuthChallenge
Одноразовый шаг 1: по логину выдаёт nonce (authNonce), который затем подписывается на шаге 2.
Запрос:
op - "AuthChallenge"
requestId - id запроса
login - логин пользователя, для которого начинается авторизация
Ответ (успех):
op - "AuthChallenge"
requestId - id запроса
status - 200 если успех
authNonce - одноразовый nonce, Base64Url(32 bytes) без padding
Ответ (ошибка):
op - "AuthChallenge"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- CreateAuthSession
Шаг 2: проверяет подпись владения ключом, создаёт новую active_session и возвращает sessionId/sessionPwd.
Запрос:
op - "CreateAuthSession"
requestId - id запроса
storagePwd - ключ/пароль хранилища клиента, base64(32 bytes)
timeMs - время на клиенте в мс (для защиты от повторов и проверки рассинхрона)
signatureB64 - подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64)
clientInfo - короткая строка о клиенте/устройстве (до 50 символов), опционально
Ответ (успех):
op - "CreateAuthSession"
requestId - id запроса
status - 200 если успех
sessionId - идентификатор сессии, Base64Url(32 bytes)
sessionPwd - секрет сессии, Base64Url(32 bytes)
Ответ (ошибка):
op - "CreateAuthSession"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- RefreshSession
Повторный вход без подписи: проверяет sessionId+sessionPwd, обновляет метаданные сессии и возвращает storagePwd.
Запрос:
op - "RefreshSession"
requestId - id запроса
sessionId - идентификатор ранее выданной сессии (base64/url-safe строка)
sessionPwd - секрет ранее выданной сессии (base64/url-safe строка)
clientInfo - короткая строка о клиенте/устройстве (до 50 символов), опционально
Ответ (успех):
op - "RefreshSession"
requestId - id запроса
status - 200 если успех
storagePwd - пароль хранилища, сохранённый в сессии (base64(32 bytes))
Ответ (ошибка):
op - "RefreshSession"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- ListSessions
Возвращает список всех активных сессий текущего пользователя (для управления устройствами/входами).
Запрос:
op - "ListSessions"
requestId - id запроса
timeMs - время на клиенте в мс (нужно только если статус AUTH_IN_PROGRESS)
signatureB64 - подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (нужно только если статус AUTH_IN_PROGRESS)
Ответ (успех):
op - "ListSessions"
requestId - id запроса
status - 200 если успех
sessions - массив активных сессий пользователя
Поля элемента sessions[i]:
sessionId - идентификатор сессии, Base64Url(32 bytes)
clientInfoFromClient - что прислал клиент в clientInfo
clientInfoFromRequest - что собрал сервер из окружения (UA/платформа и т.п.)
geo - строка "Country, City" или "unknown"
lastAuthirificatedAtMs - время последней успешной авторизации/refresh (мс)
Ответ (ошибка):
op - "ListSessions"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- CloseActiveSession
Закрывает одну активную сессию пользователя (указанную или текущую), при необходимости подтверждая владение ключом подписью.
Запрос:
op - "CloseActiveSession"
requestId - id запроса
sessionId - идентификатор сессии для закрытия (если пусто, закрывается текущая)
timeMs - время на клиенте в мс (нужно только если статус AUTH_IN_PROGRESS)
signatureB64 - подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (нужно только если статус AUTH_IN_PROGRESS)
Ответ (успех):
op - "CloseActiveSession"
requestId - id запроса
status - 200 если успех
Ответ (ошибка):
op - "CloseActiveSession"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
Группа: Блокчейн (загрузка блоков)
Эта группа отвечает за приём и валидацию блоков (цепочка, линии, подпись/хэш) и атомарную запись в БД+файл.
----- AddBlock
Добавляет следующий блок в конкретный blockchainName, строго проверяя номера, prev-хэши, линии, подпись и лимит размера.
Запрос:
op - "AddBlock"
requestId - id запроса
blockchainName - имя цепочки (по нему вычисляется login)
globalNumber - глобальный номер добавляемого блока (должен быть serverLastGlobalNumber+1)
prevGlobalHash - HEX(64) предыдущего глобального хэша (или "" только там, где это допускается правилами)
blockBytesB64 - полный блок (raw+signature+hash) в Base64
Ответ (успех):
op - "AddBlock"
requestId - id запроса
status - 200 если успех
reasonCode - null при успехе
serverLastGlobalNumber - последний глобальный номер, который сервер считает актуальным после обработки
serverLastGlobalHash - последний глобальный хэш (HEX(64)) после обработки
Ответ (ошибка):
op - "AddBlock"
requestId - id запроса
status - код ошибки (например 400/404/413/500)
reasonCode - строка причины (например bad_block_base64, bad_global_number, limit_exceeded и т.п.)
serverLastGlobalNumber - текущий серверный lastGlobalNumber
serverLastGlobalHash - текущий серверный lastGlobalHash (HEX(64), если известен)
Группа: Параметры пользователя (UserParams)
Эта группа хранит и отдаёт подписанные клиентом параметры (ключ-значение) для синхронизации и состояния.
----- UpsertUserParam
Добавляет или обновляет параметр пользователя, проверяя подпись Ed25519 и применяя запись только если time_ms новее.
Запрос:
op - "UpsertUserParam"
requestId - id запроса
login - логин пользователя
param - имя параметра
time_ms - метка времени значения в мс
value - значение параметра
device_key - публичный ключ устройства, base64(32 bytes)
signature - подпись Ed25519 от строки USER_PARAMETER_PREFIX + login + param + time_ms + value
Ответ (успех):
op - "UpsertUserParam"
requestId - id запроса
status - 200 если успех
Ответ (ошибка):
op - "UpsertUserParam"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- GetUserParam
Возвращает один сохранённый параметр пользователя.
Запрос:
op - "GetUserParam"
requestId - id запроса
login - логин пользователя
param - имя параметра
Ответ (успех):
op - "GetUserParam"
requestId - id запроса
status - 200
login
param
time_ms
value
device_key
signature
Ответ (не найдено):
op - "GetUserParam"
requestId
status - 404
Ответ (ошибка):
op
requestId
status
code
message
----- ListUserParams
Возвращает все сохранённые параметры пользователя.
Запрос:
op - "ListUserParams"
requestId
login
Ответ (успех):
op
requestId
status - 200
login
params - массив параметров
Поля params[i]:
login
param
time_ms
value
device_key
signature
Ответ (ошибка):
op
requestId
status
code
message
Группа: Тестовые/временные операции
Эта группа предназначена для отладки и первичного наполнения БД.
----- AddUser
Тестовая регистрация локального пользователя.
Запрос:
op - "AddUser"
requestId
login
blockchainName
solanaKey
deviceKey
bchLimit
Ответ (успех):
op
requestId
status - 200
Ответ (ошибка):
op
requestId
status
code
message

View File

@ -0,0 +1,165 @@
1) Общий формат записи блока (BchBlockEntry)
Блок хранится как FULL = RAW + TAIL, где RAW участвует в хэшировании/подписи, а TAIL хранит подпись и итоговый хэш.
RAW (BigEndian)
recordSize [4] int32 — размер RAW в байтах (включая поля RAW-заголовка и bodyBytes), без signature64 и hash32.
recordNumber [4] int32 — глобальный порядковый номер блока (сквозной по всему блокчейну).
timestamp [8] int64 — Unix seconds (время создания блока).
lineIndex [2] int16 — номер линии (канонические линии см. LineIndex).
lineNumber [4] int32 — порядковый номер внутри выбранной линии.
bodyBytes [N] bytes — тело блока, начинается с [type][version] (и дальше подформат конкретного body).
TAIL (не входит в recordSize)
signature64 [64] bytes — подпись Ed25519 над hash32.
hash32 [32] bytes — SHA-256 от preimage (см. ниже).
2) Как считается хэш и что подписываем (BchCryptoVerifier)
preimage =
"SHiNE" (ASCII)
loginLen[1] + loginBytes[loginLen] (UTF-8, 1..255)
prevGlobalHash32[32]
prevLineHash32[32]
rawBytes[recordSize]
hash32 = SHA-256(preimage)
Далее верификация:
hash32 должен совпасть с hash32, записанным в блоке.
signature64 проверяется как Ed25519 подпись над hash32 публичным ключом пользователя.
3) Типы body и разновидности (по 1 предложению на тип)
HeaderBody (type=0) — генезис/идентификация блокчейна: фиксирует владельца (login) и тег формата.
TextBody (type=1) — текстовое сообщение: либо новое, либо ответ (reply), либо репост (repost) со ссылкой на целевой блок.
ReactionBody (type=2) — реакция на конкретный блок (в MVP — лайк) по ссылке на блок.
ConnectionBody (type=3) — событие связи с другим пользователем (friend/contact/follow) или отмена этой связи.
UserParamBody (type=4) — изменение/заявление одного параметра профиля в формате key/value.
4) Общий формат bodyBytes (для всех body)
type [2] int16 — код типа тела (0..4).
version [2] int16 — версия формата конкретного типа (сейчас везде 1).
subType [2] uint16 — подтип внутри типа (для Header всегда 0; для Text — NEW/REPLY/REPOST; для Reaction — LIKE; для Connection — set/unset + вид; для UserParam — TEXT_TEXT).
payload [N] bytes — поля конкретного body (строго по формату; “мусор” в конце запрещён).
5) Формат каждого типа body (по 1 строке на поле)
5.1 HeaderBody (type=0, ver=1, lineIndex=0)
type [2] — 0.
version [2] — 1.
subType [2] — 0 (compat).
tag [5] — ASCII "SHiNE".
loginLen [1] — длина login в UTF-8 (1..255).
login [N] — login UTF-8 (^[A-Za-z0-9_]+$).
5.2 TextBody (type=1, ver=1, lineIndex=1)
type [2] — 1.
version [2] — 1.
subType [2] — 1=NEW, 2=REPLY, 3=REPOST.
textLenBytes [2] — длина текста в байтах UTF-8 (1..65535).
text [N] — текст UTF-8 (валидный, не blank).
toBlockchainNameLen [1] — (только для REPLY/REPOST) длина имени блокчейна цели (1..255).
toBlockchainName [N] — (только для REPLY/REPOST) UTF-8 имя блокчейна цели.
toBlockGlobalNumber [4] — (только для REPLY/REPOST) globalNumber целевого блока.
toBlockHash32 [32] — (только для REPLY/REPOST) raw-хэш целевого блока.
5.3 ReactionBody (type=2, ver=1, lineIndex=2)
type [2] — 2.
version [2] — 1.
subType [2] — 1=LIKE (зарезервировано под будущие реакции).
toBlockchainNameLen [1] — длина имени блокчейна цели (1..255).
toBlockchainName [N] — UTF-8 имя блокчейна цели.
toBlockGlobalNumber [4] — globalNumber целевого блока.
toBlockHash32 [32] — raw-хэш целевого блока.
5.4 ConnectionBody (type=3, ver=1, lineIndex=3)
type [2] — 3.
version [2] — 1.
subType [2] — 10/20/30 (FRIEND/CONTACT/FOLLOW) или 11/21/31 (UNFRIEND/UNCONTACT/UNFOLLOW).
toLoginLen [1] — длина login цели (1..255).
toLogin [N] — UTF-8 login цели (^[A-Za-z0-9_]+$).
toBlockchainNameLen [1] — длина имени блокчейна цели (1..255).
toBlockchainName [N] — UTF-8 имя блокчейна цели (снимок/якорь).
toBlockGlobalNumber [4] — lastKnown globalNumber у цели (снимок/якорь).
toBlockHash32 [32] — lastKnown hash у цели (снимок/якорь).
5.5 UserParamBody (type=4, ver=1, lineIndex=4)
type [2] — 4.
version [2] — 1.
subType [2] — 1=TEXT_TEXT.
keyLenBytes [2] — длина ключа в байтах UTF-8 (1..65535).
keyUtf8 [N] — ключ параметра UTF-8 (валидный, не blank).
valueLenBytes [2] — длина значения в байтах UTF-8 (1..65535).
valueUtf8 [M] — значение UTF-8 (валидное, не blank).
6) Канонические линии (LineIndex)
0 HEADER — генезис/идентификация.
1 TEXT — сообщения.
2 REACTION — реакции.
3 CONNECTION — связи.
4 USER_PARAM — параметры профиля.

View File

@ -0,0 +1,9 @@
Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.
---
Потом в сервак дописать синхронизацию серверов.

View File

@ -0,0 +1,130 @@
# API для разработчиков: Общий формат запросов и ответов
Этот файл описывает не конкретные операции, а общий wire-контракт всего API сервера.
Здесь зафиксировано:
- как выглядит любой запрос;
- как выглядит любой успешный ответ;
- как выглядит любой ответ с ошибкой;
- какие поля являются обязательными для всех операций;
- как клиент должен интерпретировать `status`, `ok` и `payload`.
Логика простая: сначала клиент и сервер договариваются о едином формате конверта, и только потом в остальных документах уже описываются конкретные методы и их поля.
## 1. Общий формат запроса
Все запросы по WebSocket используют один и тот же JSON-конверт:
```json
{
"op": "OperationName",
"requestId": "req-001",
"payload": {
}
}
```
### Поля
- `op` — имя операции.
- `requestId` — клиентский идентификатор запроса.
- `payload` — объект параметров операции.
---
## 2. Общий формат успешного ответа
```json
{
"op": "OperationName",
"requestId": "req-001",
"status": 200,
"ok": true,
"payload": {
}
}
```
---
## 3. Общий формат ответа с ошибкой
```json
{
"op": "OperationName",
"requestId": "req-001",
"status": 400,
"ok": false,
"error": "BAD_REQUEST",
"message": "Human readable description",
"payload": {
}
}
```
---
## 4. Обязательные правила
- Сервер возвращает `op` в каждом ответе.
- Сервер возвращает `requestId` в каждом ответе без изменений.
- Сервер возвращает `status` в каждом ответе.
- Сервер возвращает `ok` в каждом ответе.
- Сервер всегда возвращает `payload` как объект.
- Даже при отсутствии данных сервер возвращает `payload: {}`.
- `ok` находится на верхнем уровне ответа, а не внутри `payload`.
---
## 5. Правило интерпретации
Источник истины — `status`.
- если `status` в диапазоне `200..299`, то ответ успешный и `ok` должен быть `true`;
- если `status` вне диапазона `200..299`, то ответ ошибочный и `ok` должен быть `false`.
Запрещённые состояния:
- `status = 200` и `ok = false`;
- `status = 400` и `ok = true`.
---
## 6. Общие правила формата
- Все строки подписи и challenge собираются в UTF-8.
- Временные метки передаются как Unix time в миллисекундах.
- Бинарные поля передаются строками Base64.
- При ошибке `error` — это машинный код причины.
- При ошибке `message` — человекочитаемое описание причины.
---
## 7. Общие коды ошибок
Ниже перечислены коды ошибок, которые не привязаны к одной конкретной операции и могут встречаться в разных местах API.
- `400 / EMPTY_JSON` — клиент отправил пустое или полностью отсутствующее JSON-сообщение.
- `400 / NO_OP` — в корневом объекте не передано поле `op`.
- `400 / UNKNOWN_OP` — сервер не знает такую операцию.
- `400 / NO_PAYLOAD` — в корневом объекте отсутствует `payload`.
- `400 / BAD_PAYLOAD``payload` передан, но это не JSON-объект.
- `400 / BAD_REQUEST_FORMAT` — JSON-конверт формально валиден, но поля операции не удалось распарсить в ожидаемый формат.
- `500 / INTERNAL_HANDLER_ERROR` — в handler конкретной операции случилась непредвиденная серверная ошибка.
- `500 / INTERNAL_ERROR` — произошла внутренняя ошибка на уровне общего JSON-процессора или другого серверного слоя.
Общее правило для dev/test этапа:
- `message` в таких ошибках должен быть коротким, но полезным;
- по возможности сервер добавляет тип исключения и краткую деталь причины;
- это сделано для упрощения интеграционных тестов и отладки;
- позже для production этот уровень детализации может быть уменьшен.
---
## 8. Короткое резюме
- Запросы всегда идут как `op + requestId + payload`.
- Ответы всегда идут как `op + requestId + status + ok + payload`.
- Ошибки всегда возвращают `ok: false`, `error`, `message`, `payload: {}`.

View File

@ -0,0 +1,173 @@
# API для разработчиков: Регистрация пользователя
Этот файл описывает временный раздел API, связанный с заведением пользователя на сервере и проверкой, существует ли пользователь.
Сейчас здесь два метода:
- `AddUser` — временная серверная регистрация пользователя;
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных.
Их логика пока вспомогательная и dev-oriented: сервер сам хранит эти данные локально и сам отвечает на existence-check. В будущем оба сценария должны быть заменены на нормальную работу напрямую через Solana, но пока этот контракт нужен клиентам для разработки и интеграции.
## Статус документа
Это временная глава API.
Текущая регистрация пользователя и текущая проверка, существует пользователь или нет, пока реализованы как серверные dev/test операции. В будущем и регистрация, и проверка identity должны идти напрямую через Solana.
---
## 1. Операция `AddUser`
### Назначение
Временная регистрация локального пользователя на сервере.
Сервер:
- создаёт запись в `solana_users`;
- создаёт стартовое состояние в `blockchain_state`.
### Запрос
```json
{
"op": "AddUser",
"requestId": "reg-001",
"payload": {
"login": "anya",
"blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY",
"deviceKey": "BASE64_32_PUBLIC_KEY",
"bchLimit": 1000000
}
}
```
### Успешный ответ
```json
{
"op": "AddUser",
"requestId": "reg-001",
"status": 200,
"ok": true,
"payload": {
}
}
```
### Пример ошибки
```json
{
"op": "AddUser",
"requestId": "reg-001",
"status": 409,
"ok": false,
"error": "USER_ALREADY_EXISTS",
"message": "Пользователь с таким login уже существует",
"payload": {
}
}
```
### Специфические коды ошибок `AddUser`
- `400 / BAD_FIELDS` — не переданы обязательные поля регистрации.
- `400 / BAD_BLOCKCHAIN_NAME``blockchainName` не соответствует формату `<login>-NNN`.
- `400 / BAD_KEY_FORMAT` — один из ключей не является корректным `Base64(32 bytes)`.
- `409 / USER_ALREADY_EXISTS` — пользователь с таким `login` уже есть.
- `409 / BLOCKCHAIN_ALREADY_EXISTS` — такой `blockchainName` уже занят.
- `409 / BLOCKCHAIN_STATE_ALREADY_EXISTS` — стартовое состояние blockchain уже существует.
- `501 / DB_ERROR` — ошибка БД при создании пользователя.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 2. Операция `GetUser`
### Назначение
Временная серверная проверка, существует пользователь или нет.
Важно:
- это временное решение;
- позже клиент должен проверять existence/identity напрямую через Solana;
- на финальный production flow не стоит жёстко завязывать архитектуру клиента на `GetUser`.
### Запрос
```json
{
"op": "GetUser",
"requestId": "user-001",
"payload": {
"login": "anya"
}
}
```
### Успешный ответ: пользователь существует
```json
{
"op": "GetUser",
"requestId": "user-001",
"status": 200,
"ok": true,
"payload": {
"exists": true,
"login": "Anya",
"blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY",
"deviceKey": "BASE64_32_PUBLIC_KEY"
}
}
```
### Успешный ответ: пользователя нет
```json
{
"op": "GetUser",
"requestId": "user-001",
"status": 200,
"ok": true,
"payload": {
"exists": false
}
}
```
### Пример ошибки
```json
{
"op": "GetUser",
"requestId": "user-001",
"status": 400,
"ok": false,
"error": "BAD_FIELDS",
"message": "Некорректные поля: login",
"payload": {
}
}
```
### Специфические коды ошибок `GetUser`
- `400 / BAD_FIELDS` — не передан или пуст `login`.
- `501 / DB_ERROR` — ошибка БД при поиске пользователя.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 3. Короткое резюме
- `AddUser` — временная регистрация пользователя на сервере.
- `GetUser` — временная проверка существования пользователя на сервере.
- И регистрация, и existence-check позже должны быть переведены на Solana.

View File

@ -0,0 +1,279 @@
# API для разработчиков: Авторизация
Этот файл описывает именно этапы авторизации клиента, то есть как создать новую сессию и как войти в уже существующую.
Здесь четыре метода:
- `AuthChallenge`
- `CreateAuthSession`
- `SessionChallenge`
- `SessionLogin`
Логика раздела такая:
- сначала клиент либо начинает создание новой сессии через `deviceKey`;
- либо начинает вход в уже созданную сессию через `sessionKey`;
- сервер на первом шаге выдаёт challenge/nonce;
- на втором шаге клиент присылает подписанный ответ;
- сервер сверяет актуальные публичные ключи и только потом проверяет подпись.
Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов.
## 1. Поток авторизации
Поддерживаются два сценария:
1. Создание новой сессии:
`AuthChallenge` -> `CreateAuthSession`
2. Вход в существующую сессию:
`SessionChallenge` -> `SessionLogin`
`deviceKey` используется для создания новой сессии.
`sessionKey` используется для входа в уже созданную сессию.
`sessionKey` передаётся и хранится целиком одной строкой, например:
```text
ed25519/BASE64_PUBLIC_KEY
```
---
## 2. `AuthChallenge`
### Запрос
```json
{
"op": "AuthChallenge",
"requestId": "auth-001",
"payload": {
"login": "alice"
}
}
```
### Успешный ответ
```json
{
"op": "AuthChallenge",
"requestId": "auth-001",
"status": 200,
"ok": true,
"payload": {
"authNonce": "8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11"
}
}
```
### Специфические коды ошибок `AuthChallenge`
- `400 / EMPTY_LOGIN` — пустой `login`.
- `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация.
- `422 / UNKNOWN_USER` — пользователь с таким `login` не найден.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария.
---
## 3. `CreateAuthSession`
### Запрос
```json
{
"op": "CreateAuthSession",
"requestId": "create-001",
"payload": {
"login": "alice",
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET",
"timeMs": 1774600000123,
"authNonce": "nonce",
"deviceKey": "BASE64_DEVICE_PUBLIC_KEY",
"signatureB64": "BASE64_SIGNATURE",
"clientInfo": "Android 15; Pixel 9"
}
}
```
### Строка для подписи
```text
AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
```
### Дополнительная проверка ключа
Перед проверкой подписи сервер должен:
1. взять актуальный `solana_users.device_key`;
2. сравнить его с `payload.deviceKey`;
3. только потом проверять подпись.
Если ключ не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`.
На будущее:
- для ротации `device_key` желательно добавить перепроверку через Solana.
### Успешный ответ
```json
{
"op": "CreateAuthSession",
"requestId": "create-001",
"status": 200,
"ok": true,
"payload": {
"sessionId": "sess_7c5e5c4b"
}
}
```
### Специфические коды ошибок `CreateAuthSession`
- `400 / NO_STEP1_CONTEXT` — для данного соединения не был корректно выполнен `AuthChallenge`.
- `400 / EMPTY_LOGIN` — пустой `login`.
- `400 / LOGIN_MISMATCH``login` не совпадает с тем, для кого был выдан `authNonce`.
- `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при повторном чтении пользователя.
- `422 / USER_NOT_FOUND` — пользователь не найден.
- `501 / NO_LOGIN`у пользователя на сервере не заполнен `login`.
- `400 / EMPTY_STORAGE_PWD` — пустой `storagePwd`.
- `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`.
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `deviceKey` не поддерживается текущим сервером.
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `deviceKey` или `signatureB64`.
- `400 / EMPTY_SIGNATURE` — пустая подпись.
- `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна.
- `400 / NO_DEVICE_KEY`у пользователя в БД отсутствует `deviceKey`.
- `400 / EMPTY_AUTH_NONCE` — пустой `authNonce`.
- `400 / AUTH_NONCE_MISMATCH``authNonce` не соответствует значению из `AuthChallenge`.
- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`.
- `422 / DEVICE_KEY_NOT_ACTUAL``deviceKey` не совпадает с актуальной версией на сервере.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `501 / DB_ERROR_SESSION_CREATE` — ошибка БД при создании записи активной сессии.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 4. `SessionChallenge`
### Запрос
```json
{
"op": "SessionChallenge",
"requestId": "sch-001",
"payload": {
"sessionId": "sess_7c5e5c4b"
}
}
```
### Успешный ответ
```json
{
"op": "SessionChallenge",
"requestId": "sch-001",
"status": 200,
"ok": true,
"payload": {
"nonce": "0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66"
}
}
```
### Специфические коды ошибок `SessionChallenge`
- `400 / EMPTY_SESSION_ID` — пустой `sessionId`.
- `501 / DB_ERROR` — ошибка БД при чтении сессии.
- `422 / SESSION_NOT_FOUND` — сессия не найдена.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 5. `SessionLogin`
### Запрос
```json
{
"op": "SessionLogin",
"requestId": "slogin-001",
"payload": {
"sessionId": "sess_7c5e5c4b",
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
"timeMs": 1774600010456,
"signatureB64": "BASE64_SIGNATURE",
"clientInfo": "Android 15; Pixel 9"
}
}
```
### Строка для подписи
```text
SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
```
### Дополнительная проверка ключа
Перед проверкой подписи сервер должен:
1. взять `active_sessions.session_key`;
2. сравнить его с `payload.sessionKey`;
3. только потом проверять подпись.
Если ключ не совпадает, сервер возвращает ошибку `SESSION_KEY_NOT_ACTUAL`.
### Успешный ответ
```json
{
"op": "SessionLogin",
"requestId": "slogin-001",
"status": 200,
"ok": true,
"payload": {
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET"
}
}
```
### Специфические коды ошибок `SessionLogin`
- `400 / EMPTY_SESSION_ID` — пустой `sessionId`.
- `400 / NO_CHALLENGE` — перед `SessionLogin` не был успешно выполнен `SessionChallenge` либо nonce уже истёк.
- `400 / SESSION_ID_MISMATCH` — nonce был выдан для другого `sessionId`.
- `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна.
- `400 / EMPTY_SIGNATURE` — пустая подпись.
- `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`.
- `501 / DB_ERROR` — ошибка БД при чтении сессии.
- `422 / SESSION_NOT_FOUND` — сессия не найдена.
- `501 / NO_SESSION_KEY`у сессии отсутствует `session_key`.
- `422 / SESSION_KEY_NOT_ACTUAL` — переданный `sessionKey` не совпадает с актуальной версией на сервере.
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` не поддерживается текущим сервером.
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey` или `signatureB64`.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при чтении пользователя для этой сессии.
- `422 / USER_NOT_FOUND_FOR_SESSION` — пользователь, которому принадлежит сессия, не найден.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 6. Пример ошибки
```json
{
"op": "SessionLogin",
"requestId": "slogin-001",
"status": 403,
"ok": false,
"error": "SESSION_KEY_NOT_ACTUAL",
"message": "session_key не соответствует актуальной версии",
"payload": {
}
}
```

View File

@ -0,0 +1,116 @@
# API для разработчиков: Управление сессиями
Этот файл описывает методы, которые используются уже после успешной авторизации пользователя в сессию.
Здесь два метода:
- `ListSessions` — получить список активных сессий пользователя;
- `CloseActiveSession` — закрыть одну из активных сессий.
Логика раздела такая:
- сначала пользователь проходит `SessionLogin`;
- после этого сервер считает соединение авторизованным;
- уже в этом состоянии клиент может читать список сессий и управлять ими.
То есть это не этап создания или входа в сессию, а этап последующего контроля уже существующих активных сессий.
## 1. `ListSessions`
Доступно только после успешного `SessionLogin`.
### Запрос
```json
{
"op": "ListSessions",
"requestId": "list-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "ListSessions",
"requestId": "list-001",
"status": 200,
"ok": true,
"payload": {
"sessions": [
{
"sessionId": "sess_7c5e5c4b",
"clientInfoFromClient": "Android 15; Pixel 9",
"clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1",
"geo": "RU/Moscow",
"lastAuthenticatedAtMs": 1774600010500
}
]
}
}
```
### Специфические коды ошибок `ListSessions`
- `422 / NOT_AUTHENTICATED` — запрос доступен только после успешного `SessionLogin`.
- `501 / DB_ERROR_LIST_SESSIONS` — ошибка БД при чтении списка активных сессий.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 2. `CloseActiveSession`
Доступно только после успешного `SessionLogin`.
### Запрос
```json
{
"op": "CloseActiveSession",
"requestId": "close-001",
"payload": {
"sessionId": "sess_7c5e5c4b"
}
}
```
### Успешный ответ
```json
{
"op": "CloseActiveSession",
"requestId": "close-001",
"status": 200,
"ok": true,
"payload": {
}
}
```
### Специфические коды ошибок `CloseActiveSession`
- `422 / NOT_AUTHENTICATED` — запрос доступен только после успешного `SessionLogin`.
- `400 / NO_SESSION_TO_CLOSE` — сервер не смог определить, какую сессию нужно закрыть.
- `501 / DB_ERROR` — ошибка БД при поиске сессии или её удалении.
- `422 / SESSION_NOT_FOUND` — целевая сессия не найдена.
- `422 / SESSION_OF_ANOTHER_USER` — нельзя закрывать сессию другого пользователя.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
---
## 3. Пример ошибки
```json
{
"op": "CloseActiveSession",
"requestId": "close-001",
"status": 403,
"ok": false,
"error": "NOT_AUTHENTICATED",
"message": "Операция доступна только для авторизованных пользователей",
"payload": {
}
}
```

View File

@ -0,0 +1,135 @@
# API для разработчиков: 04 — Добавление блока в блокчейн (AddBlock)
Документ описывает **текущий рабочий формат** сетевого вызова `AddBlock`, который используется для записи **любого** блока в блокчейн пользователя.
> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`.
> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока).
## 1. Что делает `AddBlock`
`AddBlock`:
- принимает имя блокчейна и base64 бинарного блока;
- проверяет непрерывность цепочки (`blockNumber`, `prevHash`);
- проверяет формат и подпись Ed25519;
- валидирует `body` по правилам типа блока;
- сохраняет блок и обновляет состояние цепочки.
## 2. JSON формат запроса
`op = "AddBlock"`.
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"payload": {
"blockchainName": "alice-001",
"blockNumber": 12,
"prevBlockHash": "ab12...ff",
"blockBytesB64": "AAAB..."
}
}
```
Поля `payload`:
- `blockchainName` — обязательно, формат `login-NNN`.
- `blockNumber` — обязательно (временное legacy-поле для совместимости; должно совпасть с номером внутри бинарного блока).
- `prevBlockHash` — legacy-поле, сейчас сервер использует `prevHash` из бинарного блока и состояние цепочки.
- `blockBytesB64` — обязательно: **полный бинарный блок** (`preimage + sigMarker + signature`) в Base64.
## 3. Успешный ответ
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"status": 200,
"ok": true,
"payload": {
"reasonCode": null,
"serverLastGlobalNumber": 12,
"serverLastGlobalHash": "9f0e...a1"
}
}
```
## 4. Ошибка (единый формат)
При ошибках сервер отдаёт `Net_Exception_Response` со стандартными полями и дополнительно с состоянием сервера для ресинка:
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"status": 400,
"ok": false,
"error": "bad_prev_hash",
"message": "Некорректный prevHash (цепочка не совпадает)",
"payload": {
"serverLastGlobalNumber": 11,
"serverLastGlobalHash": "c3d4...98"
}
}
```
### Основные `reasonCode`
- `empty_blockchain_name`, `bad_blockchain_name`
- `blockchain_state_not_found`
- `bad_block_base64`, `bad_block_format`, `bad_block_body`
- `bad_block_number`, `req_global_mismatch`, `bad_prev_hash`
- `bad_signature`, `signature_verify_failed`
- `prev_line_block_not_found`, `bad_prev_line_hash`
- `limit_exceeded`
- `internal_error`
## 5. Какие блоки реально можно добавлять через `AddBlock`
Через `AddBlock` можно писать все поддержанные форматы:
1. **TECH (type=0)**
- `HEADER_COMPAT (subType=0)`
- `TECH_CREATE_CHANNEL (subType=1)`
2. **TEXT (type=1)**
- `TEXT_POST (10)`
- `TEXT_EDIT_POST (11)`
- `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)`
3. **REACTION (type=2)**
- `REACTION_LIKE (1)`
4. **CONNECTION (type=3)**
- `CONNECTION_FRIEND (10)`
- `CONNECTION_UNFRIEND (11)`
- `CONNECTION_CONTACT (20)`
- `CONNECTION_UNCONTACT (21)`
- `CONNECTION_FOLLOW (30)`
- `CONNECTION_UNFOLLOW (31)`
5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)`
## 6. Хватает ли функций сейчас
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
Что есть:
- единый надёжный write-путь `AddBlock`;
- есть `GetFriendsLists` и API по `UserParam`;
- есть унифицированные коды ошибок и поля для ресинхронизации.
Что пока ограничивает продукт:
- нет полноценного read API для каналов/постов/тредов;
- нет API списка подписок с серверными счётчиками непрочитанного;
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
## 7. Рекомендации по клиенту при записи блоков
1. Перед отправкой держать локальный `lastNumber/lastHash`.
2. При `bad_prev_hash` или `bad_block_number`:
- взять `serverLastGlobalNumber/serverLastGlobalHash` из ошибки,
- пересобрать следующий блок на актуальной вершине.
3. Для edit-блоков всегда ссылаться на **оригинальный** блок, а не на предыдущий edit.
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.

View File

@ -0,0 +1,137 @@
# API для разработчиков: Технические запросы
Этот файл описывает технические запросы, которые не требуют авторизации и нужны для служебной работы клиента с сервером.
Сейчас здесь два метода:
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети.
Логика раздела такая:
- `Ping` нужен для регулярной проверки, что соединение всё ещё живо;
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов.
## 1. `Ping`
### Назначение
Служебный keep-alive запрос.
Клиент может отправлять его периодически, чтобы:
- поддерживать активное WebSocket-соединение;
- понимать, что сервер отвечает;
- при необходимости получать текущее серверное время.
### Запрос
```json
{
"op": "Ping",
"requestId": "ping-001",
"payload": {
"ts": 1774700000123
}
}
```
Поле `ts` в запросе необязательно для логики сервера. Сервер его не валидирует и не использует для принятия решения.
### Успешный ответ
```json
{
"op": "Ping",
"requestId": "ping-001",
"status": 200,
"ok": true,
"payload": {
"ts": 1774700000456
}
}
```
### Специфические коды ошибок `Ping`
- У `Ping` нет специальных прикладных ошибок.
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 2. `GetServerInfo`
### Назначение
Запрос публичной информации о сервере.
Он нужен клиенту для выбора сервера в децентрализованной сети. По этому запросу клиент может:
- проверить, что сервер вообще доступен;
- показать URL и версию сервера;
- показать физический регион или адрес размещения;
- показать описание сервера;
- показать поле `origin` как комментарий о природе этого узла;
- показать дополнительную текстовую информацию.
Этот запрос доступен без авторизации.
### Источник данных
- `version` берётся из Gradle build и подставляется в `application.properties`;
- остальные поля читаются из настроек сервера;
- если значение в конфиге не задано, сервер возвращает пустую строку.
### Запрос
```json
{
"op": "GetServerInfo",
"requestId": "srv-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "GetServerInfo",
"requestId": "srv-001",
"status": 200,
"ok": true,
"payload": {
"url": "wss://node.example.org/ws",
"version": "1.0",
"physicalRegion": "Грузия, Тбилиси",
"description": "Public community SHiNE node",
"origin": "Community-operated node",
"extraInfo": "IPv4 + IPv6; test federation enabled"
}
}
```
### Поля ответа
- `url` — публичный URL сервера.
- `version` — версия сервера из Gradle build.
- `physicalRegion` — физический регион или адрес размещения сервера.
- `description` — человекочитаемое описание сервера.
- `origin` — комментарий о том, какой это сервер.
- `extraInfo` — любая дополнительная информация о сервере.
### Специфические коды ошибок `GetServerInfo`
- У `GetServerInfo` нет специальных прикладных ошибок при штатной работе.
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 3. Короткое резюме
- `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо.
- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле.
- Оба запроса доступны без авторизации.

View File

@ -0,0 +1,25 @@
# Форматы блокчейнов и блоков (индекс раздела)
Этот раздел разбит на несколько файлов, чтобы формат добавляемых блоков было проще читать и сопровождать.
## Структура раздела
- `01_Common_Block_Format.md` — общий бинарный формат блока (Frame v0), правила подписи и проверки.
- `02_Blockchain_Kinds_and_Lines.md` — виды блокчейнов и логические линии внутри цепочки.
- `10_TECH_Blocks.md` — системные блоки (`type=0`).
- `11_TEXT_Blocks.md` — текстовые блоки (`type=1`).
- `12_REACTION_Blocks.md` — реакции (`type=2`).
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`).
- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`).
## Быстрая карта типов
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY.
- `type=2` — REACTION: LIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW и обратные операции.
- `type=4` — USER_PARAM: key/value-параметры пользователя.
## Примечание
Если нужно добавить новый тип или подтип блока, сначала обновляйте профильный файл этого раздела, затем API-документацию в `Dev_Docs/API`.

View File

@ -0,0 +1,49 @@
# Общий формат добавляемого блока (Frame v0)
Этот файл описывает **единый бинарный формат** блока, который клиент отправляет через `AddBlock` в поле `blockBytesB64`.
## 1. Полная структура блока
Блок состоит из двух частей:
1. **PREIMAGE** (подписывается)
2. **TAIL** (маркер подписи + подпись)
### PREIMAGE
- `frameCode (uint16)`
- `prevHash32 (32 bytes)`
- `blockSize (int32)` — размер PREIMAGE
- `blockNumber (int32)`
- `timestamp (int64)`
- `type (uint16)`
- `subType (uint16)`
- `version (uint16)`
- `bodyBytes (N)`
### TAIL
- `sigMarker (uint16)`
- `signature64 (64 bytes, Ed25519)`
## 2. Что проверяет сервер при AddBlock
- `frameCode` должен быть `0x0000`.
- `sigMarker` должен быть `0x0100`.
- `blockNumber` должен идти строго по порядку (`last + 1`).
- `prevHash32` должен совпасть с вершиной цепочки на сервере.
- `body` должен пройти `check()` для конкретного типа.
- подпись должна валидироваться публичным ключом блокчейна.
## 3. Ограничения
- максимальный полный размер блока: до 4 MiB;
- timestamp не должен сильно уходить в будущее;
- `bodyBytes` парсится по `type/subType/version` из заголовка блока.
## 4. Почему это важно
Одинаковый общий формат позволяет:
- передавать разные виды записей через один RPC `AddBlock`;
- валидировать блоки единообразно;
- расширять типы `body`, не ломая каркас блока.

View File

@ -0,0 +1,33 @@
# Виды блокчейнов и логических линий
## 1. Именованный блокчейн
Базовый идентификатор цепочки пользователя:
- `blockchainName = <login>-<NNN>`
- пример: `alice-001`
Обычно это одна основная цепочка пользователя.
## 2. Логические линии внутри одной цепочки
Физически цепочка одна, но внутри есть независимые логические последовательности (линии), которые ведутся через поля:
- `lineCode`
- `prevLineNumber`
- `prevLineHash32`
- `thisLineNumber`
Линии используются для:
- TECH-событий;
- каналов с текстовыми постами;
- связей и подписок;
- пользовательских параметров.
## 3. Root-идея для каналов и подписок
Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки:
- `HEADER` для базовой сущности пользователя/канала `0`;
- `CREATE_CHANNEL` для пользовательских каналов.
Так ссылки остаются стабильными, даже когда в канале появляются новые сообщения.

View File

@ -0,0 +1,18 @@
# TECH блоки (`type=0`, `version=1`)
TECH-тип покрывает системные записи цепочки.
## Подтипы
1. `subType=0``HEADER_COMPAT`
- стартовый блок цепочки;
- payload: tag `SHiNE` + login владельца.
2. `subType=1``TECH_CREATE_CHANNEL`
- создание нового канала;
- хранит line-поля + `channelName`.
## Назначение
- инициализация блокчейна;
- управление набором каналов пользователя.

View File

@ -0,0 +1,25 @@
# TEXT блоки (`type=1`, `version=1`)
TEXT-тип хранит сообщения и редактирования.
## Подтипы
1. `subType=10``TEXT_POST`
- пост в линии канала;
- содержит line-поля + текст.
2. `subType=11``TEXT_EDIT_POST`
- редактирование поста;
- line-поля + target на оригинальный POST + новый текст.
3. `subType=20``TEXT_REPLY`
- ответ на сообщение;
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
4. `subType=21``TEXT_EDIT_REPLY`
- редактирование ответа;
- target на исходный REPLY + новый текст.
## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.

View File

@ -0,0 +1,11 @@
# REACTION блоки (`type=2`, `version=1`)
## Подтипы
1. `subType=1``REACTION_LIKE`
- лайк на целевой блок;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
## Назначение
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).

View File

@ -0,0 +1,24 @@
# CONNECTION блоки (`type=3`, `version=1`)
CONNECTION-тип описывает социальные связи и подписки.
## Подтипы
1. `subType=10``CONNECTION_FRIEND`
2. `subType=11``CONNECTION_UNFRIEND`
3. `subType=20``CONNECTION_CONTACT`
4. `subType=21``CONNECTION_UNCONTACT`
5. `subType=30``CONNECTION_FOLLOW`
6. `subType=31``CONNECTION_UNFOLLOW`
## Общий формат payload
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`)
## Правила target
- FRIEND/CONTACT обычно указывают на `HEADER` цели (`block 0`).
- FOLLOW указывает на root канала:
- `HEADER` для канала `0`;
- `CREATE_CHANNEL` для пользовательского канала.

View File

@ -0,0 +1,14 @@
# USER_PARAM блоки (`type=4`, `version=1`)
## Подтипы
1. `subType=1``USER_PARAM_TEXT_TEXT`
- хранит line-поля + `paramKey` + `paramValue`.
## Назначение
- сохранение пользовательского состояния (настройки клиента, синк-метки, курсоры чтения и т.д.).
## Практика
Для сложных структур удобно хранить JSON-строку в `paramValue` с версией схемы.

View File

@ -5,39 +5,80 @@ plugins {
} }
group = 'shine' group = 'shine'
version = '1.0' version = '1.1_codex'
tasks.jar { tasks.jar {
enabled = false // это что бы не создавала обычный джар, а будет только толстый джар enabled = false // это что бы не создавала обычный джар, а будет только толстый джар
} }
tasks.processResources {
filteringCharset = 'UTF-8'
filesMatching('application.properties') {
expand(projectVersion: project.version.toString())
}
}
repositories { repositories {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation 'org.eclipse.jetty:jetty-server:11.0.20' implementation 'org.eclipse.jetty:jetty-server:11.0.20' // WS сервер
implementation 'org.eclipse.jetty:jetty-servlet:11.0.20' implementation 'org.eclipse.jetty:jetty-servlet:11.0.20'
implementation 'org.eclipse.jetty.websocket:websocket-jetty-server:11.0.20' implementation 'org.eclipse.jetty.websocket:websocket-jetty-server:11.0.20'
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' // шифрование implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' // шифрование
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // json implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // json
implementation 'org.slf4j:slf4j-api:2.0.9' // implementation 'org.slf4j:slf4j-api:2.0.9'
implementation 'ch.qos.logback:logback-classic:1.5.6' implementation 'ch.qos.logback:logback-classic:1.5.6'
// Logback (реализация SLF4J + классический модуль)
runtimeOnly "ch.qos.logback:logback-classic:1.5.6"
testImplementation platform('org.junit:junit-bom:5.10.0') testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.junit.jupiter:junit-jupiter'
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
// runtimeOnly "org.apache.logging.log4j:log4j-core:2.24.3" // Реализация: Log4j2 пишет в файл/консоль
// runtimeOnly "org.apache.logging.log4j:log4j-slf4j2-impl:2.24.3" // Реализация: Log4j2 пишет в файл/консоль
implementation project(':shine-server-config') // модуль настроек из application.properties implementation project(':shine-server-config') // модуль настроек из application.properties
implementation project(":shine-server-log") // модуль логирования и уведомления админов
implementation project(':shine-server-crypto') // модуль сервера для работы с криптографией implementation project(':shine-server-crypto') // модуль сервера для работы с криптографией
implementation project(':shine-server-blockchain') // модуль для работы с блокчейном
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
implementation project(':shine-server-blockchain') // модуль для работы с блокчейном
implementation project(':shine-server-geo') // модуль для определения геолокации по IP
implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response
implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов
// -------------------- ТЕСТЫ (JUnit 5) --------------------
// Один BOM на всё семейство JUnit (Jupiter + Platform модули)
testImplementation platform("org.junit:junit-bom:5.10.2")
// JUnit Jupiter (Test, BeforeAll, Assertions)
testImplementation "org.junit.jupiter:junit-jupiter"
// JUnit Platform Suite (@Suite, @SelectClasses)
testImplementation "org.junit.platform:junit-platform-suite-api"
testRuntimeOnly "org.junit.platform:junit-platform-suite-engine"
// Нужно для компиляции RussianSummaryListener (org.junit.platform.launcher.*)
// и чтобы JUnit мог подхватить listener при запуске
testImplementation "org.junit.platform:junit-platform-launcher"
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}
test {
useJUnitPlatform()
} }
application { application {
@ -67,6 +108,44 @@ shadowJar {
} }
} }
test {
useJUnitPlatform() tasks.named('test') {
enabled = false
}
tasks.register('itCleanRun', JavaExec) {
group = "build"
description = "Clean data → kill 7070 → start WS → run all IT tests"
classpath = sourceSets.test.runtimeClasspath
mainClass = "test.it.IT_RunAllCleanStartWsMain"
// пробрасываем системные флаги (по желанию)
systemProperty "it.debug", System.getProperty("it.debug", "true")
systemProperty "it.login", System.getProperty("it.login", "anya24")
dependsOn testClasses
}
tasks.register('itDeployServer', JavaExec) {
group = "build"
description = "Build → upload to server → clean remote data → restart service → run IT against server"
classpath = sourceSets.test.runtimeClasspath
mainClass = "test.it.IT_DeployRestartAndRunRemoteMain"
// можно переопределить при запуске:
// ./gradlew itDeployServer -Dit.remoteHost=... -Dit.wsUri=...
dependsOn shadowJar
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "10.147.20.7")
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
systemProperty "it.remoteDataDir", System.getProperty("it.remoteDataDir", "/home/user/docker/shine-server/data")
systemProperty "it.service", System.getProperty("it.service", "shine-server")
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
systemProperty "it.wsUri", System.getProperty("it.wsUri", "wss://shineup.me/ws")
systemProperty "it.login", System.getProperty("it.login", "anya24")
dependsOn testClasses
} }

246
create_git.sh Normal file
View File

@ -0,0 +1,246 @@
#!/usr/bin/env bash
set -euo pipefail
GITHUB_USER="ai5590"
TOKEN_VAR_NAME="GIT_AI5590_CLASSIC_API_KEY"
print_line() {
echo "------------------------------------------------------------"
}
abort() {
echo
echo "Ошибка: $1" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || abort "Не найдена команда '$1'. Установи её и запусти скрипт снова."
}
get_token() {
if [[ -z "${GIT_AI5590_CLASSIC_API_KEY:-}" ]]; then
abort "Не задана переменная окружения ${TOKEN_VAR_NAME}.
Перед запуском выполни:
export ${TOKEN_VAR_NAME}=\"ТВОЙ_GITHUB_TOKEN\""
fi
}
show_intro() {
print_line
echo "Этот скрипт создаст новый репозиторий в GitHub в аккаунте '${GITHUB_USER}',"
echo "затем инициализирует git в текущей папке (если нужно),"
echo "добавит файлы, кроме самого этого скрипта, создаст первый commit и отправит проект в GitHub."
echo
echo "Скрипт работает с содержимым ТЕКУЩЕЙ папки:"
echo " $(pwd)"
echo
echo "Для авторизации используется переменная окружения:"
echo " ${TOKEN_VAR_NAME}"
print_line
echo
}
ask_repo_name() {
local repo_name
read -r -p "Введите имя нового репозитория в GitHub: " repo_name
repo_name="$(echo "$repo_name" | xargs)"
[[ -n "$repo_name" ]] || abort "Имя репозитория не может быть пустым."
if [[ ! "$repo_name" =~ ^[A-Za-z0-9._-]+$ ]]; then
abort "Имя репозитория содержит недопустимые символы.
Разрешены: буквы, цифры, точка, дефис, подчёркивание."
fi
REPO_NAME="$repo_name"
}
ask_visibility() {
local answer
echo
read -r -p "Сделать репозиторий публичным? [y/N]: " answer
answer="${answer:-N}"
case "$answer" in
y|Y|yes|YES|да|Да|ДА)
REPO_PRIVATE="false"
REPO_VISIBILITY_TEXT="public"
;;
*)
REPO_PRIVATE="true"
REPO_VISIBILITY_TEXT="private"
;;
esac
}
ask_confirmation() {
echo
print_line
echo "Будет выполнено:"
echo "1. Создание GitHub-репозитория '${GITHUB_USER}/${REPO_NAME}' (${REPO_VISIBILITY_TEXT})"
echo "2. Подготовка git в текущей папке"
echo "3. Commit файлов из текущей папки, кроме самого этого скрипта"
echo "4. Push в ветку main"
print_line
echo
read -r -p "Продолжить? [y/N]: " confirm
confirm="${confirm:-N}"
case "$confirm" in
y|Y|yes|YES|да|Да|ДА) ;;
*) echo "Отменено пользователем."; exit 0 ;;
esac
}
check_not_inside_wrong_git_repo() {
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
local top
top="$(git rev-parse --show-toplevel)"
if [[ "$top" != "$(pwd)" ]]; then
abort "Ты запустил скрипт внутри уже существующего git-репозитория, но не в его корне.
Корень репозитория:
$top
Либо перейди в корень этого репозитория, либо запусти скрипт в папке, которая не вложена в другой git-репозиторий."
fi
fi
}
create_github_repo() {
echo
echo "Создаю репозиторий в GitHub..."
local http_code
local response_body_file
response_body_file="$(mktemp)"
http_code="$(
curl -sS \
-o "$response_body_file" \
-w "%{http_code}" \
-X POST "https://api.github.com/user/repos" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GIT_AI5590_CLASSIC_API_KEY}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-d "$(cat <<JSON
{
"name": "${REPO_NAME}",
"private": ${REPO_PRIVATE},
"auto_init": false
}
JSON
)"
)"
if [[ "$http_code" != "201" ]]; then
echo
echo "GitHub API вернул ошибку. HTTP code: $http_code"
echo "Ответ сервера:"
cat "$response_body_file"
rm -f "$response_body_file"
abort "Не удалось создать репозиторий '${GITHUB_USER}/${REPO_NAME}'."
fi
rm -f "$response_body_file"
echo "Репозиторий успешно создан: https://github.com/${GITHUB_USER}/${REPO_NAME}"
}
get_script_paths() {
SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
PROJECT_PATH="$(pwd -P)"
SCRIPT_INSIDE_PROJECT="false"
SCRIPT_RELATIVE_PATH=""
case "$SCRIPT_PATH" in
"$PROJECT_PATH"/*)
SCRIPT_INSIDE_PROJECT="true"
SCRIPT_RELATIVE_PATH="${SCRIPT_PATH#$PROJECT_PATH/}"
;;
*)
SCRIPT_INSIDE_PROJECT="false"
;;
esac
}
prepare_git_repo() {
echo
echo "Подготавливаю git в текущей папке..."
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Git уже инициализирован."
else
git init
echo "Git инициализирован."
fi
get_script_paths
if [[ "$SCRIPT_INSIDE_PROJECT" == "true" ]]; then
echo "Скрипт находится внутри проекта и будет исключён из commit:"
echo " $SCRIPT_RELATIVE_PATH"
git add . ":!$SCRIPT_RELATIVE_PATH"
else
git add .
fi
if git diff --cached --quiet; then
echo "В staged нет изменений. Возможно, файлы уже были закоммичены ранее."
else
git commit -m "Initial commit"
echo "Создан commit: Initial commit"
fi
git branch -M main
local remote_url="https://${GITHUB_USER}:${GIT_AI5590_CLASSIC_API_KEY}@github.com/${GITHUB_USER}/${REPO_NAME}.git"
if git remote get-url origin >/dev/null 2>&1; then
echo "Remote 'origin' уже существует. Обновляю URL..."
git remote set-url origin "$remote_url"
else
git remote add origin "$remote_url"
fi
}
push_to_github() {
echo
echo "Отправляю проект в GitHub..."
git push -u origin main
echo
echo "Готово."
echo "Репозиторий: https://github.com/${GITHUB_USER}/${REPO_NAME}"
}
cleanup_remote_url() {
echo
echo "Убираю токен из remote URL, чтобы он не светился в git config..."
local safe_url="https://github.com/${GITHUB_USER}/${REPO_NAME}.git"
git remote set-url origin "$safe_url"
echo "Теперь origin = ${safe_url}"
}
main() {
require_command git
require_command curl
require_command realpath
get_token
check_not_inside_wrong_git_repo
show_intro
ask_repo_name
ask_visibility
ask_confirmation
create_github_repo
prepare_git_repo
push_to_github
cleanup_remote_url
}
main "$@"

27
deploy_shine-ui.sh Executable file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
SRC_DIR="shine-UI"
REMOTE_HOST="root@194.87.0.247"
REMOTE_DIR="/home/user/docker/caddyFile/sites/shine-UI"
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
export BUILD_VERSION
if [[ ! -d "$SRC_DIR" ]]; then
echo "ERROR: source directory not found: $SRC_DIR" >&2
exit 1
fi
echo "==> Applying build version: $BUILD_VERSION"
find "$SRC_DIR" -type f \( -name "*.js" -o -name "index.html" \) -print0 | xargs -0 perl -0pi -e 's/(\.js\?v=)([^"'"'"'\''\s>]*)/$1$ENV{BUILD_VERSION}/g; s/(\.css\?v=)([^"'"'"'\''\s>]*)/$1$ENV{BUILD_VERSION}/g'
echo "==> Checking SSH connectivity to $REMOTE_HOST"
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null
echo "==> Preparing remote directory: $REMOTE_DIR"
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
echo "==> Syncing files from $SRC_DIR to $REMOTE_DIR"
rsync -avz --delete "$SRC_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
echo "Всё хорошо"

View File

@ -1,6 +1,8 @@
rootProject.name = 'shine-server-server' rootProject.name = 'shine-server-server'
include 'shine-server-log'
include 'shine-server-config' include 'shine-server-config'
include 'shine-server-geo'
include 'shine-server-crypto' include 'shine-server-crypto'
include 'shine-server-blockchain' include 'shine-server-blockchain'
include 'shine-server-db' include 'shine-server-db'

File diff suppressed because it is too large Load Diff

40
shine-UI/AGENTS.md Normal file
View File

@ -0,0 +1,40 @@
# AGENTS
## Назначение проекта
Это демо-прототип мобильного веб-приложения в формате статического сайта.
## Технические ограничения
- Проект сделан без бэкенда, без базы данных и без реальных API.
- Все данные моковые и хранятся в `js/mock-data.js`.
- Навигация между экранами идет без полной перезагрузки страницы (SPA-подход на hash-router).
## Обязательные требования к каждому экрану
- У каждого экрана есть явный верхний заголовок на русском языке.
- У каждого экрана есть нижняя служебная подпись над toolbar в формате:
`[Русское название] ([english-page-id])`.
- `page-id` должен совпадать с именем JS-файла страницы или быть максимально близким к нему.
## Архитектурные правила
- Структура проекта должна оставаться понятной и модульной.
- Новые доработки нужно вносить аккуратно, не ломая существующую навигацию.
- Стиль проекта: темная тема, mobile-first, интерфейс на русском языке.
## Экраны и файлы
- Профиль: `js/pages/profile-view.js`
- Кошелёк: `js/pages/wallet-view.js`
- Настройки: `js/pages/settings-view.js`
- Личные сообщения: `js/pages/messages-list.js`
- Чат: `js/pages/chat-view.js`
- Каналы: `js/pages/channels-list.js`
- Канал: `js/pages/channel-view.js`
- Связи: `js/pages/network-view.js`
- Уведомления: `js/pages/notifications-view.js`
## Ключевые файлы приложения
- Точка входа: `index.html`
- Инициализация приложения: `js/app.js`
- Роутинг: `js/router.js`
- Состояние клиента: `js/state.js`
- Моки: `js/mock-data.js`
- Компоненты: `js/components/*`
- Стили: `styles/*`

View File

@ -0,0 +1,83 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" shape-rendering="crispEdges" role="img" aria-label="QR demo 64x64">
<rect width="64" height="64" fill="#ffffff"/>
<rect x="0" y="0" width="16" height="16" fill="#000000"/>
<rect x="2" y="2" width="12" height="12" fill="#ffffff"/>
<rect x="4" y="4" width="8" height="8" fill="#000000"/>
<rect x="48" y="0" width="16" height="16" fill="#000000"/>
<rect x="50" y="2" width="12" height="12" fill="#ffffff"/>
<rect x="52" y="4" width="8" height="8" fill="#000000"/>
<rect x="0" y="48" width="16" height="16" fill="#000000"/>
<rect x="2" y="50" width="12" height="12" fill="#ffffff"/>
<rect x="4" y="52" width="8" height="8" fill="#000000"/>
<rect x="20" y="4" width="2" height="2" fill="#000000"/>
<rect x="24" y="4" width="2" height="2" fill="#000000"/>
<rect x="28" y="4" width="2" height="2" fill="#000000"/>
<rect x="34" y="4" width="2" height="2" fill="#000000"/>
<rect x="38" y="4" width="2" height="2" fill="#000000"/>
<rect x="42" y="4" width="2" height="2" fill="#000000"/>
<rect x="18" y="10" width="2" height="2" fill="#000000"/>
<rect x="22" y="10" width="2" height="2" fill="#000000"/>
<rect x="26" y="10" width="2" height="2" fill="#000000"/>
<rect x="30" y="10" width="2" height="2" fill="#000000"/>
<rect x="34" y="10" width="2" height="2" fill="#000000"/>
<rect x="40" y="10" width="2" height="2" fill="#000000"/>
<rect x="44" y="10" width="2" height="2" fill="#000000"/>
<rect x="18" y="18" width="2" height="2" fill="#000000"/>
<rect x="22" y="18" width="2" height="2" fill="#000000"/>
<rect x="30" y="18" width="2" height="2" fill="#000000"/>
<rect x="34" y="18" width="2" height="2" fill="#000000"/>
<rect x="38" y="18" width="2" height="2" fill="#000000"/>
<rect x="44" y="18" width="2" height="2" fill="#000000"/>
<rect x="20" y="24" width="2" height="2" fill="#000000"/>
<rect x="24" y="24" width="2" height="2" fill="#000000"/>
<rect x="28" y="24" width="2" height="2" fill="#000000"/>
<rect x="32" y="24" width="2" height="2" fill="#000000"/>
<rect x="40" y="24" width="2" height="2" fill="#000000"/>
<rect x="44" y="24" width="2" height="2" fill="#000000"/>
<rect x="18" y="30" width="2" height="2" fill="#000000"/>
<rect x="22" y="30" width="2" height="2" fill="#000000"/>
<rect x="26" y="30" width="2" height="2" fill="#000000"/>
<rect x="34" y="30" width="2" height="2" fill="#000000"/>
<rect x="38" y="30" width="2" height="2" fill="#000000"/>
<rect x="44" y="30" width="2" height="2" fill="#000000"/>
<rect x="18" y="36" width="2" height="2" fill="#000000"/>
<rect x="22" y="36" width="2" height="2" fill="#000000"/>
<rect x="30" y="36" width="2" height="2" fill="#000000"/>
<rect x="34" y="36" width="2" height="2" fill="#000000"/>
<rect x="40" y="36" width="2" height="2" fill="#000000"/>
<rect x="44" y="36" width="2" height="2" fill="#000000"/>
<rect x="20" y="42" width="2" height="2" fill="#000000"/>
<rect x="24" y="42" width="2" height="2" fill="#000000"/>
<rect x="28" y="42" width="2" height="2" fill="#000000"/>
<rect x="32" y="42" width="2" height="2" fill="#000000"/>
<rect x="36" y="42" width="2" height="2" fill="#000000"/>
<rect x="42" y="42" width="2" height="2" fill="#000000"/>
<rect x="50" y="20" width="2" height="2" fill="#000000"/>
<rect x="54" y="20" width="2" height="2" fill="#000000"/>
<rect x="58" y="20" width="2" height="2" fill="#000000"/>
<rect x="50" y="24" width="2" height="2" fill="#000000"/>
<rect x="56" y="24" width="2" height="2" fill="#000000"/>
<rect x="60" y="24" width="2" height="2" fill="#000000"/>
<rect x="50" y="28" width="2" height="2" fill="#000000"/>
<rect x="54" y="28" width="2" height="2" fill="#000000"/>
<rect x="58" y="28" width="2" height="2" fill="#000000"/>
<rect x="20" y="50" width="2" height="2" fill="#000000"/>
<rect x="24" y="50" width="2" height="2" fill="#000000"/>
<rect x="30" y="50" width="2" height="2" fill="#000000"/>
<rect x="36" y="50" width="2" height="2" fill="#000000"/>
<rect x="42" y="50" width="2" height="2" fill="#000000"/>
<rect x="20" y="54" width="2" height="2" fill="#000000"/>
<rect x="28" y="54" width="2" height="2" fill="#000000"/>
<rect x="34" y="54" width="2" height="2" fill="#000000"/>
<rect x="40" y="54" width="2" height="2" fill="#000000"/>
<rect x="44" y="54" width="2" height="2" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
shine-UI/img/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

20
shine-UI/index.html Normal file
View File

@ -0,0 +1,20 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Shine UI Demo</title>
<link rel="stylesheet" href="./styles/main.css?v=20260330001044" />
<link rel="stylesheet" href="./styles/layout.css?v=20260330001044" />
<link rel="stylesheet" href="./styles/components.css?v=20260330001044" />
</head>
<body>
<div class="app-shell">
<main id="app-screen" class="screen-content"></main>
<div id="page-label-slot" class="page-label-slot"></div>
<div id="toolbar-slot" class="toolbar-slot"></div>
</div>
<div id="modal-root"></div>
<script type="module" src="./js/app.js?v=20260330001044"></script>
</body>
</html>

156
shine-UI/js/app.js Normal file
View File

@ -0,0 +1,156 @@
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260330001044';
import { renderToolbar } from './components/toolbar.js?v=20260330001044';
import { renderPageLabel } from './components/page-label.js?v=20260330001044';
import {
authService,
authorizeSession,
isSessionInvalidError,
refreshSessions,
setSessionResetHandler,
state,
terminateCurrentSession,
togglePageLabel,
} from './state.js?v=20260330001044';
import * as startView from './pages/start-view.js?v=20260330001044';
import * as entrySettingsView from './pages/entry-settings-view.js?v=20260330001044';
import * as registerView from './pages/register-view.js?v=20260330001044';
import * as registrationPaymentView from './pages/registration-payment-view.js?v=20260330001044';
import * as registrationKeysView from './pages/registration-keys-view.js?v=20260330001044';
import * as topupView from './pages/topup-view.js?v=20260330001044';
import * as loginView from './pages/login-view.js?v=20260330001044';
import * as loginCameraView from './pages/login-camera-view.js?v=20260330001044';
import * as loginPasswordView from './pages/login-password-view.js?v=20260330001044';
import * as keyStorageView from './pages/key-storage-view.js?v=20260330001044';
import * as profileView from './pages/profile-view.js?v=20260330001044';
import * as walletView from './pages/wallet-view.js?v=20260330001044';
import * as settingsView from './pages/settings-view.js?v=20260330001044';
import * as serverSettingsView from './pages/server-settings-view.js?v=20260330001044';
import * as deviceView from './pages/device-view.js?v=20260330001044';
import * as connectDeviceView from './pages/connect-device-view.js?v=20260330001044';
import * as deviceQrView from './pages/device-qr-view.js?v=20260330001044';
import * as deviceCameraView from './pages/device-camera-view.js?v=20260330001044';
import * as showKeysView from './pages/show-keys-view.js?v=20260330001044';
import * as deviceSessionView from './pages/device-session-view.js?v=20260330001044';
import * as languageView from './pages/language-view.js?v=20260330001044';
import * as messagesList from './pages/messages-list.js?v=20260330001044';
import * as contactSearchView from './pages/contact-search-view.js?v=20260330001044';
import * as chatView from './pages/chat-view.js?v=20260330001044';
import * as channelsList from './pages/channels-list.js?v=20260330001044';
import * as channelView from './pages/channel-view.js?v=20260330001044';
import * as networkView from './pages/network-view.js?v=20260330001044';
import * as notificationsView from './pages/notifications-view.js?v=20260330001044';
const routes = {
'start-view': startView,
'entry-settings-view': entrySettingsView,
'register-view': registerView,
'registration-payment-view': registrationPaymentView,
'registration-keys-view': registrationKeysView,
'topup-view': topupView,
'login-view': loginView,
'login-camera-view': loginCameraView,
'login-password-view': loginPasswordView,
'key-storage-view': keyStorageView,
'profile-view': profileView,
'wallet-view': walletView,
'settings-view': settingsView,
'server-settings-view': serverSettingsView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
'device-qr-view': deviceQrView,
'device-camera-view': deviceCameraView,
'show-keys-view': showKeysView,
'device-session-view': deviceSessionView,
'language-view': languageView,
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,
'channels-list': channelsList,
'channel-view': channelView,
'network-view': networkView,
'notifications-view': notificationsView,
};
const screenEl = document.getElementById('app-screen');
const labelEl = document.getElementById('page-label-slot');
const toolbarEl = document.getElementById('toolbar-slot');
let currentCleanup = null;
function renderApp() {
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view');
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) {
navigate('start-view');
return;
}
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
navigate('profile-view');
return;
}
const page = routes[pageId] || routes['start-view'];
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
screenEl.innerHTML = '';
const screen = page.render({ route, navigate });
screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
labelEl.innerHTML = '';
toolbarEl.innerHTML = '';
if (showAppChrome) {
labelEl.append(
renderPageLabel(page.pageMeta.title, page.pageMeta.id, state.pageLabelCollapsed, () => {
togglePageLabel();
renderApp();
}),
);
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
}
async function tryAutoLogin() {
if (!state.session.login || !state.session.sessionId) return;
try {
await authService.reconnect(state.entrySettings.shineServer);
const resumed = await authService.resumeSession(state.session.login, state.session.sessionId);
authorizeSession(resumed);
await refreshSessions();
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
}
}
}
async function init() {
setSessionResetHandler(() => {
navigate('start-view');
});
await tryAutoLogin();
if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
} else {
renderApp();
}
window.addEventListener('hashchange', renderApp);
}
init();

View File

@ -0,0 +1,31 @@
export function renderHeader({ title, leftAction, rightActions = [] }) {
const wrap = document.createElement('header');
wrap.className = 'page-header';
const left = document.createElement('div');
left.className = 'header-left';
if (leftAction) {
const btn = document.createElement('button');
btn.className = 'icon-btn';
btn.textContent = leftAction.label;
btn.addEventListener('click', leftAction.onClick);
left.append(btn);
}
const h1 = document.createElement('h1');
h1.className = 'page-title';
h1.textContent = title;
const right = document.createElement('div');
right.className = 'header-actions';
rightActions.forEach((action) => {
const btn = document.createElement('button');
btn.className = 'icon-btn';
btn.textContent = action.label;
btn.addEventListener('click', action.onClick);
right.append(btn);
});
wrap.append(left, h1, right);
return wrap;
}

View File

@ -0,0 +1,36 @@
export function renderPageLabel(titleRu, pageId, collapsed, onToggle) {
const label = document.createElement('div');
label.className = `page-label${collapsed ? ' is-collapsed' : ''}`;
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'page-label-toggle';
toggle.title = collapsed ? 'Показать подпись' : 'Скрыть подпись';
toggle.setAttribute(
'aria-label',
collapsed ? 'Показать подпись страницы для разработки' : 'Скрыть подпись страницы для разработки',
);
toggle.addEventListener('click', onToggle);
if (!collapsed) {
label.append(toggle);
const content = document.createElement('div');
content.className = 'page-label-content';
const hint = document.createElement('div');
hint.className = 'page-label-hint';
hint.textContent = 'Для разработки';
const caption = document.createElement('div');
caption.className = 'page-label-caption';
caption.textContent = `${titleRu} (${pageId})`;
content.append(hint, caption);
label.append(content);
} else {
label.append(toggle);
}
return label;
}

View File

@ -0,0 +1,25 @@
import { resolveToolbarActive } from '../router.js?v=20260330001044';
const ITEMS = [
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
{ pageId: 'channels-list', label: 'Каналы', icon: '📢' },
{ pageId: 'network-view', label: 'Связи', icon: '🕸' },
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
];
export function renderToolbar(currentPageId, navigate) {
const root = document.createElement('nav');
root.className = 'toolbar';
const active = resolveToolbarActive(currentPageId);
ITEMS.forEach((item) => {
const btn = document.createElement('button');
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}`;
btn.innerHTML = `<span>${item.icon}</span><span>${item.label}</span>`;
btn.addEventListener('click', () => navigate(item.pageId));
root.append(btn);
});
return root;
}

237
shine-UI/js/mock-data.js Normal file
View File

@ -0,0 +1,237 @@
export const profile = {
login: '@shine.alex',
name: 'Алексей сияющий',
avatarInitials: 'АС',
phone: '+7 (916) 221-45-88',
address: 'Москва, Пресненская наб., 12',
email: 'alex.shine@demo.local',
socials: '@alexshine / t.me/alexshine',
badges: ['Официальный аккаунт', 'Сияющий'],
};
export const wallet = {
balanceSOL: '182.4571',
publicAddress: '9sVAXJ2CqP3BrtC6AFeQHhcuWjN1kUyhY7L8pkQJxMZe',
updatedAt: 'сегодня, 14:42',
};
export const deviceSessions = [
{
sessionId: 'sess_7c5e5c4b',
clientInfoFromClient: 'Android 15; Pixel 9',
clientInfoFromRequest: 'UA=Java-http-client/17.0.18; remote=127.0.0.1',
geo: 'RU/Moscow',
lastAuthenticatedAtMs: 1774600010500,
},
{
sessionId: 'sess_90ab11de',
clientInfoFromClient: 'iOS 19; iPhone 17',
clientInfoFromRequest: 'UA=ShineMobile/2.4; remote=10.0.2.12',
geo: 'RU/Moscow',
lastAuthenticatedAtMs: 1774553310000,
},
{
sessionId: 'sess_3ea4f11c',
clientInfoFromClient: 'Windows 11; Chrome 124',
clientInfoFromRequest: 'UA=Mozilla/5.0; remote=192.168.1.21',
geo: 'RU/Kazan',
lastAuthenticatedAtMs: 1774499010000,
},
];
export const directMessages = [
{
id: 'u1',
name: 'Марина К.',
initials: 'МК',
lastMessage: 'Вечером скину обновления по макетам.',
time: '15:08',
unread: 2,
},
{
id: 'u2',
name: 'Илья П.',
initials: 'ИП',
lastMessage: 'Спасибо, уже проверяю!',
time: '14:31',
unread: 0,
},
{
id: 'u3',
name: 'Елена Д.',
initials: 'ЕД',
lastMessage: 'Тестовый стенд снова доступен.',
time: '13:02',
unread: 5,
},
{
id: 'u4',
name: 'Никита О.',
initials: 'НО',
lastMessage: 'Отлично, давай так и сделаем.',
time: 'вчера',
unread: 0,
},
];
export const contactDirectory = [
{
id: 'u5',
name: 'Марк С.',
initials: 'МС',
about: 'Продуктовый аналитик, любит короткие созвоны и длинные отчёты.',
},
{
id: 'u6',
name: 'Мария Л.',
initials: 'МЛ',
about: 'UI-дизайнер, собирает референсы и следит за визуальным стилем.',
},
{
id: 'u7',
name: 'Марина Р.',
initials: 'МР',
about: 'Контент-менеджер, ведёт каналы и готовит анонсы.',
},
{
id: 'u8',
name: 'Максим В.',
initials: 'МВ',
about: 'Frontend-разработчик, отвечает за анимации и адаптивность.',
},
{
id: 'u9',
name: 'Мадина А.',
initials: 'МА',
about: 'Комьюнити-менеджер, быстро находит нужных людей.',
},
{
id: 'u10',
name: 'Ирина П.',
initials: 'ИП',
about: 'Редактор новостей, помогает с текстами и публикациями.',
},
{
id: 'u11',
name: 'Николай Д.',
initials: 'НД',
about: 'Технический писатель, структурирует знания по продукту.',
},
{
id: 'u12',
name: 'Егор Т.',
initials: 'ЕТ',
about: 'QA-инженер, любит проверять сложные сценарии вручную.',
},
];
export const chatMessages = {
u1: [
{ from: 'in', text: 'Привет! Видел новые карточки?' },
{ from: 'out', text: 'Да, смотрятся сильно. Нужен финальный текст.' },
{ from: 'in', text: 'Вечером скину обновления по макетам.' },
],
u2: [
{ from: 'out', text: 'Скинул доступы в чат команды.' },
{ from: 'in', text: 'Спасибо, уже проверяю!' },
],
u3: [
{ from: 'in', text: 'Тестовый стенд снова доступен.' },
{ from: 'out', text: 'Отлично, запускаю прогон сценариев.' },
],
u4: [
{ from: 'in', text: 'Подтверждаю план на завтра.' },
{ from: 'out', text: 'Отлично, давай так и сделаем.' },
],
};
export const channels = [
{
id: 'ch1',
name: 'Новости продукта',
initials: 'НП',
description: 'Официальный канал команды Shine с релизами и обновлениями.',
lastMessage: 'Опубликовали обзор нового демо-прототипа мобильного интерфейса.',
time: '16:05',
unread: 14,
},
{
id: 'ch2',
name: 'Анекдоты дня',
initials: 'АД',
description: 'Лёгкий развлекательный канал с короткими шутками и мемами.',
lastMessage: 'Новый пост: как дизайнер, разработчик и дедлайн зашли в бар.',
time: '15:20',
unread: 3,
},
{
id: 'ch3',
name: 'Новости рынка',
initials: 'НР',
description: 'Короткие ежедневные сводки по рынку, технологиям и сообществам.',
lastMessage: 'В ленте свежая подборка новостей и главных событий дня.',
time: 'вчера',
unread: 0,
},
];
export const channelPosts = {
ch1: [
{
id: 'p1',
title: 'Новый экран профиля',
body: 'Добавлены бейджи статуса, переработан верхний блок и улучшены быстрые переходы.',
},
{
id: 'p2',
title: 'Навигация без перезагрузки',
body: 'Переходы между экранами теперь стабильнее работают в SPA-режиме через hash-router.',
},
],
ch2: [
{
id: 'p3',
title: 'Анекдот утра',
body: 'Разработчик говорит: "Я починил один баг". Баги в ответ: "Нас было трое".',
},
{
id: 'p4',
title: 'Анекдот про дедлайн',
body: 'Дедлайн был настолько близко, что команда начала здороваться с ним по имени.',
},
],
ch3: [
{
id: 'p5',
title: 'Утренний дайджест',
body: 'Собрали ключевые новости дня: обновления продуктов, движения рынка и заметные релизы.',
},
{
id: 'p6',
title: 'Что обсуждают сегодня',
body: 'В фокусе дня: рост интереса к мобильным dApp-интерфейсам и новые анонсы сообществ.',
},
],
};
export const notifications = {
replies: [
{ id: 'r1', title: 'Марина К. ответила на ваш комментарий', text: 'Согласна, такую структуру и оставим.', time: '12 минут назад' },
{ id: 'r2', title: 'Илья П. ответил в обсуждении', text: 'Добавил примеры экранов для onboarding.', time: '48 минут назад' },
],
events: [
{ id: 'e1', title: 'Елена Д. добавила вас в друзья', text: 'Теперь вы в связях первого уровня.', time: 'сегодня' },
{ id: 'e2', title: 'Никита О. удалил из друзей', text: 'Связь перенесена в архив событий.', time: 'вчера' },
{ id: 'e3', title: 'Марина К. поставила лайк', text: 'Оценен ваш пост о прототипе.', time: '2 дня назад' },
],
};
export const networkGraph = {
center: { id: 'me', name: 'Вы', initials: 'ВЫ', x: 50, y: 50 },
peers: [
{ id: 'p1', name: 'Марина', initials: 'МК', x: 20, y: 24 },
{ id: 'p2', name: 'Илья', initials: 'ИП', x: 80, y: 22 },
{ id: 'p3', name: 'Елена', initials: 'ЕД', x: 18, y: 78 },
{ id: 'p4', name: 'Никита', initials: 'НО', x: 82, y: 76 },
],
};

View File

@ -0,0 +1,41 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { channelPosts, channels } from '../mock-data.js?v=20260330001044';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
export function render({ navigate, route }) {
const channelId = route.params.channelId || 'ch1';
const channel = channels.find((c) => c.id === channelId) || channels[0];
const posts = channelPosts[channelId] || [];
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: `Канал: ${channel.name}`,
leftAction: { label: '←', onClick: () => navigate('channels-list') },
})
);
const head = document.createElement('div');
head.className = 'card';
head.innerHTML = `
<strong># ${channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
<p class="meta-muted" style="margin-top:8px;">Публичный канал, режим только чтение</p>
`;
const feed = document.createElement('div');
feed.className = 'stack';
posts.forEach((post) => {
const card = document.createElement('article');
card.className = 'card stack';
card.innerHTML = `<strong>${post.title}</strong><p class="meta-muted">${post.body}</p>`;
feed.append(card);
});
screen.append(head, feed);
return screen;
}

View File

@ -0,0 +1,42 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { channels } from '../mock-data.js?v=20260330001044';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(renderHeader({ title: 'Каналы' }));
const search = document.createElement('div');
search.className = 'card';
search.textContent = 'Найти канал';
search.style.color = 'var(--text-muted)';
const list = document.createElement('div');
list.className = 'stack';
channels.forEach((channel) => {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
<div class="avatar">${channel.initials}</div>
<div>
<strong># ${channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
<p class="meta-muted" style="margin-top:6px; color:#d8e3ff;">${channel.lastMessage}</p>
</div>
<div style="display:grid; justify-items:end; gap:6px;">
<span class="badge alt" style="padding:4px 8px; font-size:10px;">Канал</span>
<span class="meta-muted">${channel.time}</span>
${channel.unread ? `<span class="unread">${channel.unread}</span>` : '<span></span>'}
</div>
`;
row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
list.append(row);
});
screen.append(search, list);
return screen;
}

View File

@ -0,0 +1,58 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { directMessages } from '../mock-data.js?v=20260330001044';
import { addChatMessage, getChatMessages } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
function renderLog(list, chatId) {
list.innerHTML = '';
const messages = getChatMessages(chatId);
messages.forEach((msg) => {
const bubble = document.createElement('div');
bubble.className = `bubble ${msg.from}`;
bubble.textContent = msg.text;
list.append(bubble);
});
list.scrollTop = list.scrollHeight;
}
export function render({ navigate, route }) {
const chatId = route.params.chatId || 'u1';
const contact = directMessages.find((d) => d.id === chatId) || directMessages[0];
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: `Чат: ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
})
);
const wrap = document.createElement('div');
wrap.className = 'chat-wrap';
const log = document.createElement('div');
log.className = 'messages-log';
const form = document.createElement('form');
form.className = 'chat-input';
form.innerHTML = `
<input class="input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
<button class="primary-btn" type="submit">Отправить</button>
`;
form.addEventListener('submit', (event) => {
event.preventDefault();
const input = form.elements.message;
addChatMessage(chatId, input.value);
input.value = '';
renderLog(log, chatId);
});
renderLog(log, chatId);
wrap.append(log, form);
screen.append(wrap);
return screen;
}

View File

@ -0,0 +1,103 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Подключить устройство',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<p>Выберите, какие ключи передать на подключаемое устройство</p>
<label class="checkbox-row"><input type="checkbox" id="connect-root" ${state.deviceConnect.root ? 'checked' : ''} /> root key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-blockchain" ${state.deviceConnect.blockchain ? 'checked' : ''} /> blockchain key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> device key</label>
<div class="row">
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
</div>
<div class="stack">
<button class="primary-btn" type="button" id="open-qr">Показать QR-код для подключения</button>
<button class="text-btn" type="button" id="open-camera">Подключить через камеру</button>
</div>
`;
const rootToggle = card.querySelector('#connect-root');
const blockchainToggle = card.querySelector('#connect-blockchain');
const deviceToggle = card.querySelector('#connect-device');
deviceToggle.checked = true;
rootToggle.addEventListener('change', () => {
state.deviceConnect.root = rootToggle.checked;
});
blockchainToggle.addEventListener('change', () => {
state.deviceConnect.blockchain = blockchainToggle.checked;
});
deviceToggle.addEventListener('change', () => {
state.deviceConnect.device = true;
deviceToggle.checked = true;
});
const helpModal = document.createElement('div');
helpModal.className = 'modal-shell';
helpModal.hidden = true;
helpModal.innerHTML = `
<div class="modal-backdrop" data-close="true"></div>
<div class="modal-dialog card" role="dialog" aria-modal="true" tabindex="-1">
<div class="row" style="align-items:flex-start;">
<h3 style="font-size:18px;">Техсправка</h3>
<button class="icon-btn" type="button" data-close="true" aria-label="Закрыть"></button>
</div>
<div class="stack" style="gap:6px;">
<p class="meta-muted">пользователь выбирает ключи для передачи</p>
<p class="meta-muted">передать можно только существующие ключи</p>
<p class="meta-muted">если ключа нет он недоступен</p>
<p class="meta-muted">blockchain key можно передать или нет</p>
<p class="meta-muted">root key только если существует</p>
<p class="meta-muted">device key передаётся всегда</p>
<p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p>
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
<p class="meta-muted">обратный сценарий пока не реализован</p>
</div>
<button class="primary-btn" type="button" data-close="true">OK</button>
</div>
`;
const openHelp = () => {
helpModal.hidden = false;
helpModal.querySelector('.modal-dialog').focus();
};
const closeHelp = () => {
helpModal.hidden = true;
};
card.querySelector('#tech-help').addEventListener('click', openHelp);
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
helpModal.addEventListener('click', (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.dataset.close === 'true') {
closeHelp();
}
});
helpModal.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeHelp();
}
});
screen.append(card, helpModal);
return screen;
}

View File

@ -0,0 +1,129 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { contactDirectory, directMessages } from '../mock-data.js?v=20260330001044';
import { ensureChat } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
function getMatches(query) {
const normalized = query.trim().toLowerCase();
if (!normalized) return [];
return contactDirectory
.filter((contact) => contact.name.toLowerCase().startsWith(normalized))
.slice(0, 5);
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const input = document.createElement('input');
input.className = 'input';
input.type = 'text';
input.name = 'contact';
input.placeholder = 'Введите имя контакта';
input.autocomplete = 'off';
input.maxLength = 80;
const resultsCard = document.createElement('section');
resultsCard.className = 'card stack';
resultsCard.hidden = true;
const status = document.createElement('p');
status.className = 'meta-muted';
const resultsList = document.createElement('div');
resultsList.className = 'stack';
let latestMatches = [];
const renderResults = (matches, query) => {
latestMatches = matches;
resultsList.innerHTML = '';
resultsCard.hidden = false;
if (!query.trim()) {
status.textContent = 'Введите первые буквы имени, чтобы найти контакт.';
return;
}
if (!matches.length) {
status.textContent = 'Совпадений не найдено.';
return;
}
status.textContent = `Найдено пользователей: ${matches.length}`;
matches.forEach((contact) => {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
<div class="avatar">${contact.initials}</div>
<div>
<strong>${contact.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${contact.about}</p>
</div>
<div class="meta-muted">Контакт</div>
`;
resultsList.append(row);
});
};
const searchButton = document.createElement('button');
searchButton.className = 'primary-btn';
searchButton.type = 'button';
searchButton.textContent = 'Найти';
searchButton.addEventListener('click', () => {
renderResults(getMatches(input.value), input.value);
});
const addButton = document.createElement('button');
addButton.className = 'ghost-btn';
addButton.type = 'button';
addButton.textContent = 'Добавить';
addButton.addEventListener('click', () => {
if (!latestMatches.length) {
status.textContent = 'Сначала выполните поиск, чтобы добавить контакт.';
resultsCard.hidden = false;
return;
}
const contact = latestMatches[0];
const exists = directMessages.some((item) => item.id === contact.id);
if (!exists) {
directMessages.unshift({
id: contact.id,
name: contact.name,
initials: contact.initials,
lastMessage: 'Новый контакт добавлен. Можно начинать диалог.',
time: 'сейчас',
unread: 0,
});
}
ensureChat(contact.id);
navigate(`chat-view/${contact.id}`);
});
const controls = document.createElement('div');
controls.className = 'contact-search-actions';
controls.append(searchButton, addButton);
const formCard = document.createElement('section');
formCard.className = 'card stack';
formCard.append(input, controls);
resultsCard.append(status, resultsList);
screen.append(
renderHeader({
title: 'Поиск контактов',
leftAction: { label: '←', onClick: () => navigate('messages-list') },
}),
formCard,
resultsCard,
);
return screen;
}

View File

@ -0,0 +1,26 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
export const pageMeta = { id: 'device-camera-view', title: 'Подключить через камеру' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Подключить через камеру',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
const frame = document.createElement('div');
frame.className = 'camera-shell';
frame.innerHTML = `
<div class="camera-placeholder">Область камеры (демо-заглушка)</div>
<div class="camera-frame"></div>
<div class="camera-hint">Логика сканирования пока не реализована</div>
`;
screen.append(frame);
return screen;
}

View File

@ -0,0 +1,36 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { profile } from '../mock-data.js?v=20260330001044';
import { state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const selectedKeys = [];
if (state.deviceConnect.root) selectedKeys.push('root key');
if (state.deviceConnect.blockchain) selectedKeys.push('blockchain key');
if (state.deviceConnect.device) selectedKeys.push('device key');
screen.append(
renderHeader({
title: 'Показать QR-код',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack qr-card';
card.innerHTML = `
<img class="qr-image" src="img/device-qr-64.svg" width="64" height="64" alt="QR-код для подключения" />
<p class="meta-muted">Логин пользователя: ${profile.login}</p>
<p class="meta-muted">Передаваемые ключи: ${selectedKeys.join(', ')}</p>
<button class="primary-btn" type="button" id="qr-ok">OK</button>
`;
card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
screen.append(card);
return screen;
}

View File

@ -0,0 +1,101 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import {
authService,
isSessionInvalidError,
refreshSessions,
setAuthError,
state,
terminateCurrentSession,
} from '../state.js?v=20260330001044';
export const pageMeta = { id: 'device-session-view', title: 'Сеанс устройства' };
function formatSessionTime(ms) {
return new Date(ms).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function render({ navigate, route }) {
const screen = document.createElement('section');
screen.className = 'stack';
const sessionId = route?.params?.sessionId || '';
const session = (state.sessions || []).find((item) => item.sessionId === sessionId) || state.sessions[0];
screen.append(
renderHeader({
title: 'Сеанс устройства',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
if (!session) {
const empty = document.createElement('div');
empty.className = 'card';
empty.textContent = 'Сеанс не найден.';
screen.append(empty);
return screen;
}
const details = document.createElement('div');
details.className = 'card stack';
details.innerHTML = `
<div><p class="meta-muted">sessionId</p><p>${session.sessionId}</p></div>
<div><p class="meta-muted">clientInfoFromClient</p><p>${session.clientInfoFromClient || '-'}</p></div>
<div><p class="meta-muted">clientInfoFromRequest</p><p>${session.clientInfoFromRequest || '-'}</p></div>
<div><p class="meta-muted">geo</p><p>${session.geo || 'unknown'}</p></div>
<div><p class="meta-muted">дата/время</p><p>${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</p></div>
`;
const actionBtn = document.createElement('button');
actionBtn.className = 'text-btn';
actionBtn.type = 'button';
actionBtn.textContent = 'Завершить сеанс';
actionBtn.addEventListener('click', async () => {
const isCurrentSession = session.sessionId === state.session.sessionId;
const confirmed = window.confirm(
isCurrentSession ? 'Хотите завершить текущую сессию?' : 'Хотите завершить этот сеанс?',
);
if (!confirmed) return;
try {
await authService.closeSession(session.sessionId);
} catch (error) {
if (!isSessionInvalidError(error)) {
setAuthError(error.message);
window.alert(error.message);
return;
}
}
if (isCurrentSession) {
await terminateCurrentSession({
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
});
return;
}
try {
await refreshSessions();
navigate('device-view');
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
return;
}
setAuthError(error.message);
window.alert(error.message);
}
});
screen.append(details, actionBtn);
return screen;
}

View File

@ -0,0 +1,146 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import {
authService,
isSessionInvalidError,
refreshSessions,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js?v=20260330001044';
export const pageMeta = { id: 'device-view', title: 'Устройства' };
function formatSessionTime(ms) {
return new Date(ms).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Устройства',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const actions = document.createElement('div');
actions.className = 'card stack';
actions.innerHTML = `
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
`;
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
const sessionsBlock = document.createElement('div');
sessionsBlock.className = 'card stack';
const buildList = () => {
sessionsBlock.innerHTML = '';
const sessions = state.sessions || [];
const current = sessions.find((s) => s.sessionId === state.session.sessionId) || sessions[0];
const others = sessions.filter((s) => s.sessionId !== current?.sessionId);
const createSessionItem = (session, isCurrent) => {
const item = document.createElement('button');
item.className = 'session-item';
item.type = 'button';
item.innerHTML = `
<div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px; text-align:left;">
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
<span class="meta-muted">${session.geo || 'unknown'}</span>
</div>
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
</div>
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
`;
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
return item;
};
if (!current) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = 'Активные сессии не найдены.';
sessionsBlock.append(empty);
return;
}
const currentMenu = document.createElement('div');
currentMenu.className = 'stack';
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
currentMenu.append(createSessionItem(current, true));
const endCurrentSessionBtn = document.createElement('button');
endCurrentSessionBtn.className = 'text-btn';
endCurrentSessionBtn.type = 'button';
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
endCurrentSessionBtn.addEventListener('click', async () => {
const confirmed = window.confirm('Хотите завершить текущую сессию?');
if (!confirmed) return;
try {
await authService.closeSession(state.session.sessionId);
} catch (error) {
if (!isSessionInvalidError(error)) {
setAuthError(error.message);
window.alert(error.message);
return;
}
}
await terminateCurrentSession({
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
});
});
currentMenu.append(endCurrentSessionBtn);
const othersMenu = document.createElement('div');
othersMenu.className = 'stack';
othersMenu.innerHTML = '<p class="meta-muted">Остальные активные сеансы</p>';
if (others.length === 0) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = 'Других активных сеансов нет.';
othersMenu.append(empty);
} else {
others.forEach((session) => {
othersMenu.append(createSessionItem(session, false));
});
}
sessionsBlock.append(currentMenu, othersMenu);
};
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
try {
await refreshSessions();
buildList();
setAuthInfo('Список сессий обновлён.');
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
return;
}
setAuthError(error.message);
window.alert(error.message);
}
});
buildList();
screen.append(actions, sessionsBlock);
return screen;
}

View File

@ -0,0 +1,161 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { checkServerAvailability, saveEntrySettings, state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false };
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServer', label: 'Адрес сервера Сияние' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
];
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const draft = {
language: state.entrySettings.language,
solanaServer: state.entrySettings.solanaServer,
shineServer: state.entrySettings.shineServer,
arweaveServer: state.entrySettings.arweaveServer,
statuses: { ...state.entrySettings.statuses },
};
const timers = new Map();
const body = document.createElement('div');
body.className = 'card stack';
const languageLabel = document.createElement('label');
languageLabel.className = 'stack';
languageLabel.innerHTML = `<span class="field-label">Язык</span>`;
const languageSelect = document.createElement('select');
languageSelect.className = 'select';
languageSelect.innerHTML = `
<option value="ru">Русский</option>
<option value="en">English</option>
`;
languageSelect.value = draft.language;
languageSelect.addEventListener('change', () => {
draft.language = languageSelect.value;
});
languageLabel.append(languageSelect);
body.append(languageLabel);
SERVER_FIELDS.forEach((field) => {
const block = document.createElement('div');
block.className = 'stack';
const title = document.createElement('label');
title.className = 'field-label';
title.textContent = field.label;
const input = document.createElement('input');
input.className = 'input';
input.type = 'text';
input.value = draft[field.key];
const controls = document.createElement('div');
controls.className = 'row wrap-row';
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn server-check-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить';
const status = document.createElement('span');
status.className = 'status-line';
const applyStatus = (value) => {
draft.statuses[field.key] = value;
checkButton.classList.remove('is-available', 'is-unavailable');
status.classList.remove('is-available', 'is-unavailable');
if (value === 'available') {
status.textContent = 'Доступен';
checkButton.classList.add('is-available');
status.classList.add('is-available');
} else if (value === 'unavailable') {
status.textContent = 'Недоступен';
checkButton.classList.add('is-unavailable');
status.classList.add('is-unavailable');
} else {
status.textContent = 'Статус не проверен';
}
};
const runCheck = () => {
draft[field.key] = input.value.trim();
applyStatus(checkServerAvailability(input.value));
};
applyStatus(draft.statuses[field.key]);
checkButton.addEventListener('click', runCheck);
input.addEventListener('input', () => {
draft[field.key] = input.value;
applyStatus('idle');
window.clearTimeout(timers.get(field.key));
timers.set(field.key, window.setTimeout(runCheck, 3000));
});
input.addEventListener('blur', runCheck);
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
runCheck();
}
});
controls.append(checkButton, status);
block.append(title, input, controls);
body.append(block);
});
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
const saveButton = document.createElement('button');
saveButton.className = 'primary-btn';
saveButton.type = 'button';
saveButton.textContent = 'Сохранить';
saveButton.addEventListener('click', () => {
saveEntrySettings(draft);
navigate('start-view');
});
actions.append(cancelButton, saveButton);
const help = document.createElement('button');
help.className = 'help-fab';
help.type = 'button';
help.textContent = '?';
help.addEventListener('click', () => {
window.alert(
'Текст для разработчиков: после ввода адреса любого сервера автопроверка запускается по кнопке "Проверить", после перехода в другое поле или если подождать больше 3 секунд. Зелёный статус означает доступность, красный — недоступность.',
);
});
screen.append(
renderHeader({
title: 'Настройки входа',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
body,
actions,
help,
);
screen.cleanup = () => {
timers.forEach((timerId) => window.clearTimeout(timerId));
};
return screen;
}

View File

@ -0,0 +1,99 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { authorizeSession, state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack';
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
rootToggle.addEventListener('change', () => {
state.keyStorage.saveRoot = rootToggle.checked;
if (rootToggle.checked) {
window.alert('Мы советуем не сохранять главный ключ на устройстве, он используется только для смены паролей и основных настроек.');
}
});
const blockchainToggle = document.createElement('input');
blockchainToggle.type = 'checkbox';
blockchainToggle.checked = state.keyStorage.saveBlockchain;
blockchainToggle.addEventListener('change', () => {
state.keyStorage.saveBlockchain = blockchainToggle.checked;
});
const deviceToggle = document.createElement('input');
deviceToggle.type = 'checkbox';
deviceToggle.checked = true;
deviceToggle.disabled = true;
card.innerHTML = `
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Root Key</span></label>
<input class="input" type="text" value="${state.keyStorage.rootKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
<input class="input" type="text" value="${state.keyStorage.deviceKey}" />
</div>
`;
card.children[0].querySelector('label').prepend(rootToggle);
card.children[1].querySelector('label').prepend(blockchainToggle);
card.children[2].querySelector('label').prepend(deviceToggle);
const rootInput = card.children[0].querySelector('.input');
rootInput.addEventListener('input', () => {
state.keyStorage.rootKey = rootInput.value;
});
const blockchainInput = card.children[1].querySelector('.input');
blockchainInput.addEventListener('input', () => {
state.keyStorage.blockchainKey = blockchainInput.value;
});
const deviceInput = card.children[2].querySelector('.input');
deviceInput.addEventListener('input', () => {
state.keyStorage.deviceKey = deviceInput.value;
});
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('login-password-view'));
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
okButton.type = 'button';
okButton.textContent = 'OK';
okButton.addEventListener('click', () => {
authorizeSession();
navigate('profile-view');
});
actions.append(cancelButton, okButton);
screen.append(
renderHeader({
title: 'Какие ключи сохранить',
leftAction: { label: '←', onClick: () => navigate('login-password-view') },
}),
card,
actions,
);
return screen;
}

View File

@ -0,0 +1,43 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'language-view', title: 'Язык' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Язык',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<label class="checkbox-row"><input type="radio" name="language" value="ru" ${state.entrySettings.language === 'ru' ? 'checked' : ''} /> Русский</label>
<label class="checkbox-row"><input type="radio" name="language" value="en" ${state.entrySettings.language === 'en' ? 'checked' : ''} /> English</label>
`;
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.innerHTML = `
<button class="primary-btn" type="button" id="language-ok">ОК</button>
<button class="ghost-btn" type="button" id="language-cancel">Отмена</button>
`;
actions.querySelector('#language-ok').addEventListener('click', () => {
const selected = card.querySelector('input[name="language"]:checked');
if (selected) {
state.entrySettings.language = selected.value;
}
navigate('settings-view');
});
actions.querySelector('#language-cancel').addEventListener('click', () => navigate('settings-view'));
screen.append(card, actions);
return screen;
}

View File

@ -0,0 +1,67 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
export const pageMeta = { id: 'login-camera-view', title: 'Войти по камере', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const frame = document.createElement('div');
frame.className = 'camera-shell';
frame.innerHTML = `
<video class="camera-video" autoplay playsinline muted></video>
<div class="camera-frame"></div>
<div class="camera-hint">Наведите QR-код в рамку</div>
`;
const video = frame.querySelector('video');
let stream = null;
const stopCamera = () => {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
};
if (navigator.mediaDevices?.getUserMedia) {
navigator.mediaDevices
.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
.then((nextStream) => {
stream = nextStream;
video.srcObject = nextStream;
})
.catch(() => {
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Не удалось открыть камеру. Проверьте разрешения браузера.</div>');
});
} else {
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Камера не поддерживается в этом браузере.</div>');
}
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => {
stopCamera();
navigate('login-view');
});
screen.append(
renderHeader({
title: 'Войти по камере',
leftAction: {
label: '←',
onClick: () => {
stopCamera();
navigate('login-view');
},
},
}),
frame,
backButton,
);
screen.cleanup = stopCamera;
return screen;
}

View File

@ -0,0 +1,105 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import {
authService,
clearAuthMessages,
setAuthBusy,
setAuthError,
state,
} from '../state.js?v=20260330001044';
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
clearAuthMessages();
const form = document.createElement('div');
form.className = 'card stack';
const loginInput = document.createElement('input');
loginInput.className = 'input';
loginInput.type = 'text';
loginInput.value = state.loginDraft.login;
loginInput.placeholder = 'Введите логин';
const passwordInput = document.createElement('input');
passwordInput.className = 'input';
passwordInput.type = 'password';
passwordInput.value = state.loginDraft.password;
passwordInput.placeholder = 'Введите пароль';
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent = 'Root/dev/bch ключи вычисляются из пароля через SHA-256, storagePwd каждый вход приходит с сервера.';
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label>
`;
form.children[0].append(loginInput);
form.children[1].append(passwordInput);
form.append(hint);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('login-view'));
const enterButton = document.createElement('button');
enterButton.className = 'primary-btn';
enterButton.type = 'button';
enterButton.textContent = 'Войти';
enterButton.addEventListener('click', async () => {
state.loginDraft.login = loginInput.value.trim();
state.loginDraft.password = passwordInput.value;
if (!state.loginDraft.login || !state.loginDraft.password) {
window.alert('Введите логин и пароль');
return;
}
setAuthBusy(true);
setAuthError('');
enterButton.disabled = true;
enterButton.textContent = 'Входим...';
try {
await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.createSessionForExistingUser(state.loginDraft.login, state.loginDraft.password);
state.registrationDraft.flowType = 'login';
state.registrationDraft.login = result.login;
state.registrationDraft.password = state.loginDraft.password;
state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = result.keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
navigate('registration-keys-view');
} catch (error) {
setAuthError(error.message);
window.alert(error.message);
} finally {
setAuthBusy(false);
enterButton.disabled = false;
enterButton.textContent = 'Войти';
}
});
actions.append(backButton, enterButton);
screen.append(
renderHeader({
title: 'Войти по логину',
leftAction: { label: '←', onClick: () => navigate('login-view') },
}),
form,
actions,
);
return screen;
}

View File

@ -0,0 +1,72 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome: false };
function createQrCode() {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 100 100');
svg.classList.add('qr-code');
const cells = [
[6, 6, 22, 22], [72, 6, 22, 22], [6, 72, 22, 22], [14, 14, 6, 6], [80, 14, 6, 6], [14, 80, 6, 6],
[38, 12, 8, 8], [52, 12, 8, 8], [38, 26, 8, 8], [52, 26, 8, 8], [32, 40, 10, 10], [48, 40, 10, 10],
[64, 40, 10, 10], [40, 56, 8, 8], [56, 56, 8, 8], [72, 56, 8, 8], [32, 72, 8, 8], [48, 72, 8, 8],
[64, 72, 8, 8], [48, 86, 8, 8],
];
cells.forEach(([x, y, width, height]) => {
const rect = document.createElementNS(svgNS, 'rect');
rect.setAttribute('x', x);
rect.setAttribute('y', y);
rect.setAttribute('width', width);
rect.setAttribute('height', height);
rect.setAttribute('rx', '2');
svg.append(rect);
});
return svg;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const qrCard = document.createElement('div');
qrCard.className = 'card stack qr-card';
qrCard.append(createQrCode());
const cameraButton = document.createElement('button');
cameraButton.className = 'primary-btn';
cameraButton.type = 'button';
cameraButton.textContent = 'Войти по камере';
cameraButton.addEventListener('click', () => navigate('login-camera-view'));
const loginButton = document.createElement('button');
loginButton.className = 'ghost-btn';
loginButton.type = 'button';
loginButton.textContent = 'Войти по логину';
loginButton.addEventListener('click', () => navigate('login-password-view'));
const actions = document.createElement('div');
actions.className = 'auth-actions';
actions.append(cameraButton, loginButton);
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
screen.append(
renderHeader({
title: 'Войти',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
qrCard,
actions,
backButton,
);
return screen;
}

View File

@ -0,0 +1,42 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { directMessages } from '../mock-data.js?v=20260330001044';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Личные сообщения',
rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }],
}),
);
const list = document.createElement('div');
list.className = 'stack';
directMessages.forEach((item) => {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
<div class="avatar">${item.initials}</div>
<div>
<div class="row" style="justify-content:flex-start; gap:8px;">
<strong>${item.name}</strong>
</div>
<p class="meta-muted" style="margin-top:4px;">${item.lastMessage}</p>
</div>
<div style="display:grid; justify-items:end; gap:6px;">
<span class="meta-muted">${item.time}</span>
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
</div>
`;
row.addEventListener('click', () => navigate(`chat-view/${item.id}`));
list.append(row);
});
screen.append(list);
return screen;
}

View File

@ -0,0 +1,77 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { networkGraph } from '../mock-data.js?v=20260330001044';
export const pageMeta = { id: 'network-view', title: 'Связи' };
function toPoint(v) {
return `${v.x}%`;
}
function showHelpModal() {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="network-help-modal">
<div class="modal-card stack">
<h3 style="font-size:18px;">Справка по схеме связей</h3>
<p class="meta-muted">В центре находишься ты.</p>
<p class="meta-muted">Рядом показаны друзья первого уровня.</p>
<p class="meta-muted">Далее могут существовать друзья второго уровня.</p>
<p class="meta-muted">При одном нажатии на узел можно показать его связи.</p>
<p class="meta-muted">При двойном нажатии узел может переместиться в центр.</p>
<p class="meta-muted">При долгом удержании может открываться меню действий.</p>
<p class="meta-muted">Логика схемы строится на одном запросе связей пользователя, дальше дерево достраивается на его основе.</p>
<button class="primary-btn" id="close-network-help">Понятно</button>
</div>
</div>
`;
root.querySelector('#close-network-help').addEventListener('click', () => {
root.innerHTML = '';
});
}
export function render() {
const screen = document.createElement('section');
screen.className = 'stack';
const header = renderHeader({
title: 'Связи',
rightActions: [{ label: 'Справка', onClick: showHelpModal }],
});
const board = document.createElement('div');
board.className = 'network-board';
const lines = networkGraph.peers
.map(
(peer) =>
`<line x1="${toPoint(networkGraph.center)}" y1="${networkGraph.center.y}%" x2="${peer.x}%" y2="${peer.y}%" stroke="rgba(125,170,255,0.55)" stroke-width="1.5"/>`
)
.join('');
board.innerHTML = `<svg class="network-svg" viewBox="0 0 100 100" preserveAspectRatio="none">${lines}</svg>`;
const centerNode = document.createElement('div');
centerNode.className = 'node center';
centerNode.style.left = `${networkGraph.center.x}%`;
centerNode.style.top = `${networkGraph.center.y}%`;
centerNode.innerHTML = `<div class="node-dot">${networkGraph.center.initials}</div><div class="node-label">${networkGraph.center.name}</div>`;
board.append(centerNode);
networkGraph.peers.forEach((peer) => {
const node = document.createElement('div');
node.className = 'node';
node.style.left = `${peer.x}%`;
node.style.top = `${peer.y}%`;
node.innerHTML = `<div class="node-dot">${peer.initials}</div><div class="node-label">${peer.name}</div>`;
board.append(node);
});
const note = document.createElement('p');
note.className = 'meta-muted';
note.textContent = 'Схема статичная для демо, архитектура подготовлена под дальнейшую интерактивность.';
screen.append(header, board, note);
return screen;
}

View File

@ -0,0 +1,48 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { notifications } from '../mock-data.js?v=20260330001044';
import { state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
function renderList(container) {
const active = state.notificationsTab;
const items = notifications[active] || [];
container.innerHTML = '';
items.forEach((item) => {
const card = document.createElement('article');
card.className = 'card stack';
card.innerHTML = `<strong>${item.title}</strong><p class="meta-muted">${item.text}</p><p class="meta-muted">${item.time}</p>`;
container.append(card);
});
}
export function render() {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(renderHeader({ title: 'Уведомления' }));
const tabs = document.createElement('div');
tabs.className = 'tabs';
tabs.innerHTML = `
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
`;
const list = document.createElement('div');
list.className = 'stack';
renderList(list);
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
state.notificationsTab = btn.dataset.tab;
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
btn.classList.add('active');
renderList(list);
});
});
screen.append(tabs, list);
return screen;
}

View File

@ -0,0 +1,107 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { profile } from '../mock-data.js?v=20260330001044';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
export function render({ navigate }) {
const badgeHelp = {
official: {
title: 'Официальный аккаунт',
text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
},
shine: {
title: 'Сияющий',
text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
},
};
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Профиль',
rightActions: [
{ label: 'Кошелёк', onClick: () => navigate('wallet-view') },
{ label: 'Настройки', onClick: () => navigate('settings-view') },
],
})
);
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<div class="row">
<div class="avatar large">${profile.avatarInitials}</div>
<div class="stack" style="justify-items:end; text-align:right;">
<button class="badge profile-badge-trigger" type="button" data-badge="official"> ${profile.badges[0]}</button>
<button class="badge alt profile-badge-trigger" type="button" data-badge="shine"> ${profile.badges[1]}</button>
</div>
</div>
<div>
<h2 style="font-size:22px; margin-bottom:2px;">${profile.name}</h2>
<p class="meta-muted">${profile.login}</p>
</div>
<div class="stack" style="gap:8px;">
<div class="card" style="padding:10px;"><span class="meta-muted">Телефон:</span> ${profile.phone}</div>
<div class="card" style="padding:10px;"><span class="meta-muted">Адрес:</span> ${profile.address}</div>
<div class="card" style="padding:10px;"><span class="meta-muted">Email:</span> ${profile.email}</div>
<div class="card" style="padding:10px;"><span class="meta-muted">Соцсети:</span> ${profile.socials}</div>
</div>
`;
const modal = document.createElement('div');
modal.className = 'profile-help-modal';
modal.hidden = true;
modal.innerHTML = `
<div class="profile-help-backdrop" data-close="true"></div>
<div class="profile-help-dialog card" role="dialog" aria-modal="true" aria-labelledby="profile-help-title" tabindex="-1">
<div class="row" style="align-items:flex-start;">
<div>
<div class="meta-muted" style="margin-bottom:4px;">Управление функцией</div>
<h3 id="profile-help-title" style="font-size:18px;"></h3>
</div>
<button class="icon-btn profile-help-close" type="button" aria-label="Закрыть"></button>
</div>
<p class="profile-help-text"></p>
</div>
`;
const titleEl = modal.querySelector('#profile-help-title');
const textEl = modal.querySelector('.profile-help-text');
const dialogEl = modal.querySelector('.profile-help-dialog');
function closeModal() {
modal.hidden = true;
}
function openModal(type) {
const content = badgeHelp[type];
if (!content) return;
titleEl.textContent = content.title;
textEl.textContent = content.text;
modal.hidden = false;
dialogEl.focus();
}
card.querySelectorAll('.profile-badge-trigger').forEach((button) => {
button.addEventListener('click', () => openModal(button.dataset.badge));
});
modal.addEventListener('click', (event) => {
const target = event.target;
if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) {
closeModal();
}
});
modal.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeModal();
}
});
screen.append(card, modal);
return screen;
}

View File

@ -0,0 +1,114 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { authService, clearAuthMessages, state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
clearAuthMessages();
const form = document.createElement('div');
form.className = 'card stack';
const loginInput = document.createElement('input');
loginInput.className = 'input';
loginInput.type = 'text';
loginInput.value = state.registrationDraft.login;
loginInput.placeholder = 'Введите логин';
const passwordInput = document.createElement('input');
passwordInput.className = 'input';
passwordInput.type = 'password';
passwordInput.value = state.registrationDraft.password;
passwordInput.placeholder = 'Введите пароль';
const statusText = document.createElement('p');
statusText.className = 'meta-muted';
statusText.textContent = 'Проверка логина: не выполнена';
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить логин';
async function runAvailabilityCheck() {
const login = loginInput.value.trim();
if (!login) {
statusText.textContent = 'Введите логин';
return false;
}
checkButton.disabled = true;
checkButton.textContent = 'Проверка...';
try {
await authService.reconnect(state.entrySettings.shineServer);
const isFree = await authService.ensureLoginFree(login);
statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌';
statusText.className = isFree ? 'is-available' : 'is-unavailable';
return isFree;
} catch (error) {
statusText.textContent = error.message;
statusText.className = 'is-unavailable';
return false;
} finally {
checkButton.disabled = false;
checkButton.textContent = 'Проверить логин';
}
}
checkButton.addEventListener('click', runAvailabilityCheck);
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label>
`;
form.children[0].append(loginInput);
form.children[1].append(passwordInput);
form.append(checkButton, statusText);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
const nextButton = document.createElement('button');
nextButton.className = 'primary-btn';
nextButton.type = 'button';
nextButton.textContent = 'Далее';
nextButton.addEventListener('click', async () => {
const isFree = await runAvailabilityCheck();
if (!isFree) {
window.alert('Выберите свободный логин');
return;
}
state.registrationDraft.login = loginInput.value.trim();
state.registrationDraft.password = passwordInput.value;
if (!state.registrationDraft.password) {
window.alert('Введите пароль');
return;
}
navigate('registration-payment-view');
});
actions.append(backButton, nextButton);
screen.append(
renderHeader({
title: 'Зарегистрироваться',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
form,
actions,
);
return screen;
}

View File

@ -0,0 +1,140 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import {
authService,
authorizeSession,
refreshSessions,
setAuthError,
setAuthInfo,
state,
} from '../state.js?v=20260330001044';
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const isLoginFlow = state.registrationDraft.flowType === 'login';
const normalizedLogin = (state.registrationDraft.login || '').trim();
const displayLogin = normalizedLogin || '@new.user';
const card = document.createElement('div');
card.className = 'card stack';
const title = document.createElement('p');
title.className = 'auth-copy';
title.textContent = isLoginFlow
? `Вход выполнен для логина ${displayLogin}.`
: `Отлично, логин ${displayLogin} зарегистрирован.`;
const question = document.createElement('p');
question.className = 'auth-copy';
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
const blockchainToggle = document.createElement('input');
blockchainToggle.type = 'checkbox';
blockchainToggle.checked = state.keyStorage.saveBlockchain;
const deviceToggle = document.createElement('input');
deviceToggle.type = 'checkbox';
deviceToggle.checked = true;
deviceToggle.disabled = true;
const rootRow = document.createElement('label');
rootRow.className = 'checkbox-row';
rootRow.append(rootToggle, document.createTextNode('root key'));
const blockchainRow = document.createElement('label');
blockchainRow.className = 'checkbox-row';
blockchainRow.append(blockchainToggle, document.createTextNode('blockchain.key'));
const deviceRow = document.createElement('label');
deviceRow.className = 'checkbox-row';
deviceRow.append(deviceToggle, document.createTextNode('device key (всегда)'));
card.append(title, question, rootRow, blockchainRow, deviceRow);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
okButton.type = 'button';
okButton.textContent = 'OK';
okButton.addEventListener('click', async () => {
try {
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
}
state.keyStorage.saveRoot = rootToggle.checked;
state.keyStorage.saveBlockchain = blockchainToggle.checked;
await authService.persistSelectedKeys(
state.registrationDraft.login,
state.registrationDraft.storagePwd,
state.registrationDraft.pendingKeyBundle,
{
saveRoot: state.keyStorage.saveRoot,
saveBlockchain: state.keyStorage.saveBlockchain,
},
);
await authService.persistSessionMaterial(
state.registrationDraft.login,
state.registrationDraft.pendingSessionMaterial,
);
if (!state.keyStorage.saveRoot && state.registrationDraft.pendingKeyBundle) {
state.registrationDraft.pendingKeyBundle.rootPair = null;
}
if (!state.keyStorage.saveBlockchain && state.registrationDraft.pendingKeyBundle) {
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
}
authorizeSession({
login: state.registrationDraft.login,
sessionId: state.registrationDraft.sessionId,
storagePwd: state.registrationDraft.storagePwd,
});
state.loginDraft.login = state.registrationDraft.login;
state.loginDraft.password = '';
state.registrationDraft.flowType = '';
state.registrationDraft.password = '';
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
state.registrationDraft.pendingSessionMaterial = null;
await refreshSessions();
setAuthInfo(isLoginFlow ? 'Ключи сохранены, вход завершён.' : 'Ключи сохранены, регистрация завершена.');
navigate('profile-view');
} catch (error) {
setAuthError(error.message);
window.alert(error.message);
}
});
actions.append(cancelButton, okButton);
screen.append(
renderHeader({
title: 'Сохранение ключей',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
card,
actions,
);
return screen;
}

View File

@ -0,0 +1,120 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import {
authService,
refreshRegistrationBalance,
setAuthError,
setAuthInfo,
state,
} from '../state.js?v=20260330001044';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack';
const walletValue = document.createElement('input');
walletValue.className = 'input';
walletValue.type = 'text';
walletValue.value = state.registrationPayment.walletAddress;
walletValue.addEventListener('input', () => {
state.registrationPayment.walletAddress = walletValue.value;
});
const walletRow = document.createElement('div');
walletRow.className = 'inline-input-row';
const copyButton = document.createElement('button');
copyButton.className = 'ghost-btn';
copyButton.type = 'button';
copyButton.textContent = 'Скопировать номер';
copyButton.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(walletValue.value);
copyButton.textContent = 'Скопировано';
window.setTimeout(() => {
copyButton.textContent = 'Скопировать номер';
}, 1500);
} catch {
window.alert('Не удалось скопировать номер кошелька.');
}
});
walletRow.append(walletValue, copyButton);
const balanceRow = document.createElement('div');
balanceRow.className = 'row wrap-row';
const balanceValue = document.createElement('strong');
balanceValue.textContent = `${state.registrationPayment.balanceSOL} SOL`;
const refreshButton = document.createElement('button');
refreshButton.className = 'square-btn';
refreshButton.type = 'button';
refreshButton.textContent = '↻';
refreshButton.title = 'Обновить';
refreshButton.addEventListener('click', () => {
balanceValue.textContent = `${refreshRegistrationBalance()} SOL`;
});
balanceRow.append(balanceValue, refreshButton);
const topupButton = document.createElement('button');
topupButton.className = 'ghost-btn';
topupButton.type = 'button';
topupButton.textContent = 'Пополнить счет';
topupButton.addEventListener('click', () => navigate('topup-view'));
const submitButton = document.createElement('button');
submitButton.className = 'primary-btn';
submitButton.type = 'button';
submitButton.textContent = 'Зарегистрироваться';
submitButton.addEventListener('click', async () => {
try {
submitButton.disabled = true;
submitButton.textContent = 'Регистрация...';
await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password);
state.registrationDraft.flowType = 'registration';
state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = result.keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`);
window.alert('Отлично, вы зарегистрировались');
navigate('registration-keys-view');
} catch (error) {
setAuthError(error.message);
window.alert(error.message);
} finally {
submitButton.disabled = false;
submitButton.textContent = 'Зарегистрироваться';
}
});
card.innerHTML = `
<p class="auth-copy">Для регистрации в Solana нужно заплатить 0,01 SOL (примерно 1 доллар).</p>
<label class="stack"><span class="field-label">Номер кошелька</span></label>
<div class="stack">
<span class="field-label">Баланс</span>
</div>
`;
card.children[1].append(walletRow);
card.children[2].append(balanceRow);
card.append(topupButton, submitButton);
screen.append(
renderHeader({
title: 'Оплата регистрации',
leftAction: { label: '←', onClick: () => navigate('register-view') },
}),
card,
);
return screen;
}

View File

@ -0,0 +1,143 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { checkServerAvailability, saveEntrySettings, state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'server-settings-view', title: 'Настройки серверов' };
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServer', label: 'Адрес сервера Сияние' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
];
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const draft = {
language: state.entrySettings.language,
solanaServer: state.entrySettings.solanaServer,
shineServer: state.entrySettings.shineServer,
arweaveServer: state.entrySettings.arweaveServer,
statuses: { ...state.entrySettings.statuses },
};
const timers = new Map();
const body = document.createElement('div');
body.className = 'card stack';
SERVER_FIELDS.forEach((field) => {
const block = document.createElement('div');
block.className = 'stack';
const title = document.createElement('label');
title.className = 'field-label';
title.textContent = field.label;
const input = document.createElement('input');
input.className = 'input';
input.type = 'text';
input.value = draft[field.key];
const controls = document.createElement('div');
controls.className = 'row wrap-row';
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn server-check-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить';
const status = document.createElement('span');
status.className = 'status-line';
const applyStatus = (value) => {
draft.statuses[field.key] = value;
checkButton.classList.remove('is-available', 'is-unavailable');
status.classList.remove('is-available', 'is-unavailable');
if (value === 'available') {
status.textContent = 'Доступен';
checkButton.classList.add('is-available');
status.classList.add('is-available');
} else if (value === 'unavailable') {
status.textContent = 'Недоступен';
checkButton.classList.add('is-unavailable');
status.classList.add('is-unavailable');
} else {
status.textContent = 'Статус не проверен';
}
};
const runCheck = () => {
draft[field.key] = input.value.trim();
applyStatus(checkServerAvailability(input.value));
};
applyStatus(draft.statuses[field.key]);
checkButton.addEventListener('click', runCheck);
input.addEventListener('input', () => {
draft[field.key] = input.value;
applyStatus('idle');
window.clearTimeout(timers.get(field.key));
timers.set(field.key, window.setTimeout(runCheck, 3000));
});
input.addEventListener('blur', runCheck);
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
runCheck();
}
});
controls.append(checkButton, status);
block.append(title, input, controls);
body.append(block);
});
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('settings-view'));
const saveButton = document.createElement('button');
saveButton.className = 'primary-btn';
saveButton.type = 'button';
saveButton.textContent = 'Сохранить';
saveButton.addEventListener('click', () => {
saveEntrySettings(draft);
navigate('settings-view');
});
actions.append(cancelButton, saveButton);
const help = document.createElement('button');
help.className = 'help-fab';
help.type = 'button';
help.textContent = '?';
help.addEventListener('click', () => {
window.alert(
'Текст для разработчиков: после ввода адреса любого сервера автопроверка запускается по кнопке "Проверить", после перехода в другое поле или если подождать больше 3 секунд. Зелёный статус означает доступность, красный — недоступность.',
);
});
screen.append(
renderHeader({
title: 'Настройки серверов',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
body,
actions,
help,
);
screen.cleanup = () => {
timers.forEach((timerId) => window.clearTimeout(timerId));
};
return screen;
}

View File

@ -0,0 +1,30 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Настройки',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
screen.append(card);
return screen;
}

View File

@ -0,0 +1,127 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { state } from '../state.js?v=20260330001044';
import { loadEncryptedUserSecrets } from '../services/key-vault.js?v=20260330001044';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const visible = {
root: false,
blockchain: false,
device: false,
};
const keys = {
root: '',
blockchain: '',
device: '',
};
screen.append(
renderHeader({
title: 'Показать ключи',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack';
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Загружаем сохранённые ключи...';
card.append(status);
const renderField = (id, label) => {
const row = document.createElement('div');
row.className = 'key-card stack';
row.innerHTML = `
<div class="row">
<span class="field-label">${label}</span>
<button class="icon-btn small-btn" type="button" data-toggle="${id}">Показать</button>
</div>
<div class="key-value" data-value="${id}">*****</div>
`;
return row;
};
card.append(
renderField('root', 'root key'),
renderField('blockchain', 'blockchain.key'),
renderField('device', 'device key'),
);
const setMissingState = (id) => {
const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
valueEl.textContent = 'нет данных';
btnEl.disabled = true;
btnEl.textContent = 'Нет';
};
const updateField = (id) => {
const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
if (!keys[id]) {
setMissingState(id);
return;
}
valueEl.textContent = visible[id] ? keys[id] : '*****';
btnEl.disabled = false;
btnEl.textContent = visible[id] ? 'Скрыть' : 'Показать';
};
card.querySelectorAll('[data-toggle]').forEach((button) => {
button.addEventListener('click', () => {
const { toggle } = button.dataset;
if (!keys[toggle]) return;
visible[toggle] = !visible[toggle];
updateField(toggle);
});
});
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const closeButton = document.createElement('button');
closeButton.className = 'ghost-btn';
closeButton.type = 'button';
closeButton.textContent = 'Назад';
closeButton.addEventListener('click', () => navigate('device-view'));
actions.append(closeButton);
(async () => {
try {
if (!state.session.login || !state.session.storagePwdInMemory) {
throw new Error('Нет активной сессии для чтения ключей');
}
const savedKeys = await loadEncryptedUserSecrets(
state.session.login,
state.session.storagePwdInMemory,
);
keys.root = savedKeys.rootKey || '';
keys.blockchain = savedKeys.blockchainKey || '';
keys.device = savedKeys.deviceKey || '';
if (keys.root || keys.blockchain || keys.device) {
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';
} else {
status.textContent = 'На этом устройстве нет сохранённых ключей.';
}
} catch (error) {
status.textContent = 'На этом устройстве нет сохранённых ключей.';
}
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
})();
screen.append(card, actions);
return screen;
}

View File

@ -0,0 +1,53 @@
import { clearStartHint, state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'auth-screen stack';
const logo = document.createElement('img');
logo.className = 'auth-logo';
logo.src = './img/logo.jpg';
logo.alt = 'Логотип Сияние';
const title = document.createElement('h1');
title.className = 'auth-brand';
title.textContent = 'Сияние';
const actions = document.createElement('div');
actions.className = 'auth-actions';
const loginButton = document.createElement('button');
loginButton.className = 'primary-btn';
loginButton.type = 'button';
loginButton.textContent = 'Войти';
loginButton.addEventListener('click', () => navigate('login-view'));
const registerButton = document.createElement('button');
registerButton.className = 'ghost-btn';
registerButton.type = 'button';
registerButton.textContent = 'Зарегистрироваться';
registerButton.addEventListener('click', () => navigate('register-view'));
const settingsButton = document.createElement('button');
settingsButton.className = 'ghost-btn';
settingsButton.type = 'button';
settingsButton.textContent = 'Настройки';
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, settingsButton);
screen.append(logo, title);
if (state.startHint) {
const notice = document.createElement('div');
notice.className = 'card auth-status-card';
notice.textContent = state.startHint;
screen.append(notice);
clearStartHint();
}
screen.append(actions);
return screen;
}

View File

@ -0,0 +1,84 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
const BUY_LINK = 'https://www.moonpay.com/buy/sol';
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const walletValue = document.createElement('input');
walletValue.className = 'input';
walletValue.type = 'text';
walletValue.value = state.registrationPayment.walletAddress;
walletValue.readOnly = true;
walletValue.style.fontSize = '13px';
const copyButton = document.createElement('button');
copyButton.className = 'ghost-btn';
copyButton.type = 'button';
copyButton.textContent = 'Скопировать';
copyButton.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(walletValue.value);
copyButton.textContent = 'Скопировано';
window.setTimeout(() => {
copyButton.textContent = 'Скопировать';
}, 1500);
} catch {
window.alert('Не удалось скопировать номер кошелька.');
}
});
const walletRow = document.createElement('div');
walletRow.className = 'inline-input-row';
walletRow.append(walletValue, copyButton);
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<p class="auth-copy">Для пополнения счета скопируйте номер кошелька.</p>
<div class="stack" style="gap:6px;">
<p class="meta-muted">1. Пополните через любое свое приложение, используя этот кошелек в сети Solana.</p>
<p class="meta-muted">2. Либо откройте страницу для покупки SOL.</p>
<p class="meta-muted">3. Либо используйте кнопку «Тестовое пополнение» (работает в тестовой Solana).</p>
</div>
<a class="link-card" href="${BUY_LINK}" target="_blank" rel="noreferrer">Открыть страницу покупки SOL</a>
<div class="card stack" style="padding:12px; max-width:320px;">
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения</div>
</div>
`;
card.children[3].append(walletRow);
const testButton = document.createElement('button');
testButton.className = 'ghost-btn';
testButton.type = 'button';
testButton.textContent = 'Тестовое пополнение';
testButton.addEventListener('click', () => {
state.registrationPayment.balanceSOL = '0.0250';
window.alert('Тестовое пополнение выполнено. Баланс обновлён.');
});
const backButton = document.createElement('button');
backButton.className = 'primary-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('registration-payment-view'));
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.append(testButton, backButton);
screen.append(
renderHeader({
title: 'Пополнение счета',
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
}),
card,
actions,
);
return screen;
}

View File

@ -0,0 +1,78 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { wallet } from '../mock-data.js?v=20260330001044';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
let statusText = 'Данные демонстрационные';
const status = document.createElement('p');
status.className = 'meta-muted';
const updateStatus = (text) => {
statusText = text;
status.textContent = statusText;
};
screen.append(
renderHeader({
title: 'Кошелёк',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
})
);
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<div>
<p class="meta-muted">Баланс</p>
<h2 style="font-size:30px;">${wallet.balanceSOL} SOL</h2>
<p class="meta-muted">Обновлено: ${wallet.updatedAt}</p>
</div>
<div class="card" style="padding:10px;">
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;">${wallet.publicAddress}</p>
</div>
`;
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<div class="row">
<button class="text-btn" id="copy-address">Копировать адрес</button>
<button class="ghost-btn" id="refresh-balance">Обновить баланс</button>
</div>
<div class="row">
<button class="primary-btn" id="send-sol" style="width:100%;">Перевести</button>
<button class="primary-btn" id="topup-sol" style="width:100%;">Пополнить</button>
</div>
`;
actions.querySelector('#copy-address').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(wallet.publicAddress);
updateStatus('Адрес скопирован в буфер обмена');
} catch {
updateStatus('Не удалось скопировать в этом браузере');
}
});
actions.querySelector('#refresh-balance').addEventListener('click', () => {
updateStatus(`Баланс обновлен: ${wallet.balanceSOL} SOL`);
});
actions.querySelector('#send-sol').addEventListener('click', () => {
updateStatus('Демо-функция: перевод будет добавлен позже');
});
actions.querySelector('#topup-sol').addEventListener('click', () => {
updateStatus('Демо-функция: пополнение будет добавлено позже');
});
updateStatus(statusText);
screen.append(card, actions, status);
return screen;
}

62
shine-UI/js/router.js Normal file
View File

@ -0,0 +1,62 @@
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
export const PRE_AUTH_PAGES = [
'start-view',
'entry-settings-view',
'register-view',
'registration-payment-view',
'registration-keys-view',
'topup-view',
'login-view',
'login-camera-view',
'login-password-view',
'key-storage-view',
];
export function getRoute() {
const raw = window.location.hash.replace(/^#\/?/, '');
if (!raw) {
return { pageId: '', params: {} };
}
const [pageId, dynamicId] = raw.split('/');
if (pageId === 'chat-view') {
return { pageId, params: { chatId: dynamicId || '' } };
}
if (pageId === 'channel-view') {
return { pageId, params: { channelId: dynamicId || '' } };
}
if (pageId === 'device-session-view') {
return { pageId, params: { sessionId: dynamicId || '' } };
}
return { pageId, params: {} };
}
export function navigate(path) {
window.location.hash = `#/${path}`;
}
export function resolveToolbarActive(pageId) {
if (ROOT_PAGES.includes(pageId)) return pageId;
if (
pageId === 'wallet-view' ||
pageId === 'settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'device-view' ||
pageId === 'connect-device-view' ||
pageId === 'device-qr-view' ||
pageId === 'device-camera-view' ||
pageId === 'show-keys-view' ||
pageId === 'device-session-view' ||
pageId === 'language-view'
) {
return 'profile-view';
}
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view') return 'channels-list';
return 'profile-view';
}

View File

@ -0,0 +1,223 @@
import { WsJsonClient } from './ws-client.js?v=20260330001044';
import {
deriveEd25519FromPassword,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
importPkcs8Ed25519,
randomBase64,
signBase64,
} from './crypto-utils.js?v=20260330001044';
import { loadSessionMaterial, saveEncryptedUserSecrets, saveSessionMaterial } from './key-vault.js?v=20260330001044';
const BCH_SUFFIX = '001';
function normalizeServerUrl(url) {
const value = (url || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('https://') || value.startsWith('http://')) {
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
}
return value;
}
function opError(op, response) {
const message = response?.payload?.message || response?.message || 'Неизвестная ошибка сервера';
const code = response?.payload?.code || response?.code || 'UNKNOWN';
const error = new Error(`${op}: ${message} (${code})`);
error.op = op;
error.code = code;
error.status = response?.status || 0;
return error;
}
function makeClientInfo() {
const ua = navigator.userAgent || 'unknown';
return ua.slice(0, 50);
}
export class AuthService {
constructor(serverUrl) {
this.serverUrl = normalizeServerUrl(serverUrl);
this.ws = new WsJsonClient(this.serverUrl);
}
async reconnect(serverUrl) {
const normalized = normalizeServerUrl(serverUrl);
if (normalized === this.serverUrl) return;
this.ws.close();
this.serverUrl = normalized;
this.ws = new WsJsonClient(this.serverUrl);
}
async getUser(login) {
const response = await this.ws.request('GetUser', { login });
if (response.status !== 200) throw opError('GetUser', response);
return response.payload || {};
}
async ensureLoginFree(login) {
const payload = await this.getUser(login);
return payload.exists !== true;
}
async derivePasswordKeyBundle(password) {
if (!password) throw new Error('Введите пароль');
const rootPair = await deriveEd25519FromPassword(password, 'root.key');
const blockchainPair = await deriveEd25519FromPassword(password, 'bch.key');
const devicePair = await deriveEd25519FromPassword(password, 'dev.key');
return { rootPair, blockchainPair, devicePair };
}
async createAuthSession(login, keyBundle) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
const sessionPair = await generateEd25519Pair();
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
const sessionKey = `ed25519/${sessionKeyPub}`;
const storagePwd = randomBase64(32);
const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin });
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
const authNonce = challengeResp?.payload?.authNonce;
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
const timeMs = Date.now();
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
const signatureB64 = await signBase64(keyBundle.devicePair.privateKey, preimage);
const createResp = await this.ws.request('CreateAuthSession', {
login: cleanLogin,
storagePwd,
sessionKey,
timeMs,
authNonce,
deviceKey: keyBundle.devicePair.publicKeyB64,
signatureB64,
clientInfo: makeClientInfo(),
});
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
return {
login: cleanLogin,
sessionId,
storagePwd,
sessionMaterial: {
sessionId,
sessionKey,
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
},
};
}
async registerUser(login, password) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
if (!password) throw new Error('Введите пароль');
const isFree = await this.ensureLoginFree(cleanLogin);
if (!isFree) throw new Error('Этот логин уже занят');
const keyBundle = await this.derivePasswordKeyBundle(password);
const addResp = await this.ws.request('AddUser', {
login: cleanLogin,
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
solanaKey: keyBundle.rootPair.publicKeyB64,
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
deviceKey: keyBundle.devicePair.publicKeyB64,
bchLimit: 1000000,
});
if (addResp.status !== 200) throw opError('AddUser', addResp);
const session = await this.createAuthSession(cleanLogin, keyBundle);
return { ...session, keyBundle };
}
async createSessionForExistingUser(login, password) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
if (!password) throw new Error('Введите пароль');
const user = await this.getUser(cleanLogin);
if (!user.exists) throw new Error('Пользователь не найден');
const keyBundle = await this.derivePasswordKeyBundle(password);
const session = await this.createAuthSession(cleanLogin, keyBundle);
return { ...session, keyBundle };
}
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
const secrets = { deviceKey: keyBundle.devicePair.privatePkcs8B64 };
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
await saveEncryptedUserSecrets(login, storagePwd, secrets);
}
async persistSessionMaterial(login, sessionMaterial) {
await saveSessionMaterial(login, sessionMaterial);
}
async resumeSession(login, preferredSessionId = '') {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Нет login для авто-входа');
const sessionMaterial = await loadSessionMaterial(cleanLogin);
if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) {
throw new Error('На устройстве нет сохраненного ключа сессии');
}
const targetSessionId = preferredSessionId || sessionMaterial.sessionId;
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
const challengeResp = await this.ws.request('SessionChallenge', { sessionId: targetSessionId });
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
const nonce = challengeResp?.payload?.nonce;
if (!nonce) throw new Error('SessionChallenge: не вернулся nonce');
const timeMs = Date.now();
const preimage = `SESSION_LOGIN:${targetSessionId}:${timeMs}:${nonce}`;
const signatureB64 = await signBase64(privateKey, preimage);
const loginResp = await this.ws.request('SessionLogin', {
sessionId: targetSessionId,
sessionKey: sessionMaterial.sessionKey,
timeMs,
signatureB64,
clientInfo: makeClientInfo(),
});
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
const storagePwd = loginResp?.payload?.storagePwd;
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
return {
login: cleanLogin,
sessionId: targetSessionId,
storagePwd,
};
}
async listSessions() {
const response = await this.ws.request('ListSessions', {});
if (response.status !== 200) throw opError('ListSessions', response);
return response?.payload?.sessions || [];
}
async closeSession(sessionId) {
const response = await this.ws.request('CloseActiveSession', { sessionId });
if (response.status !== 200) throw opError('CloseActiveSession', response);
}
close() {
this.ws.close();
}
}

View File

@ -0,0 +1,150 @@
const encoder = new TextEncoder();
function base64UrlToBase64(value) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padLen = (4 - (normalized.length % 4)) % 4;
return normalized + '='.repeat(padLen);
}
export function randomBase64(byteLen = 32) {
const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
return bytesToBase64(bytes);
}
export function bytesToBase64(bytes) {
let binary = '';
bytes.forEach((b) => {
binary += String.fromCharCode(b);
});
return btoa(binary);
}
export function base64ToBytes(base64) {
const normalized = (base64 || '').trim();
const binary = atob(normalized);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export function utf8Bytes(value) {
return encoder.encode(value);
}
export async function sha256Bytes(bytes) {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return new Uint8Array(digest);
}
export async function sha256Text(text) {
return sha256Bytes(utf8Bytes(text));
}
export async function derivePasswordSeed(password, suffix) {
const base = await sha256Text(password || '');
const concat = `${bytesToBase64(base)}${suffix}`;
return sha256Text(concat);
}
function ed25519Pkcs8FromSeed(seed32) {
if (seed32.length !== 32) {
throw new Error('Для Ed25519 нужен seed длиной 32 байта');
}
const prefix = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
const out = new Uint8Array(prefix.length + seed32.length);
out.set(prefix, 0);
out.set(seed32, prefix.length);
return out;
}
export async function deriveEd25519FromPassword(password, suffix) {
const seed = await derivePasswordSeed(password, suffix);
const pkcs8 = ed25519Pkcs8FromSeed(seed);
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
return {
privateKey,
publicKeyB64: bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))),
privatePkcs8B64: bytesToBase64(pkcs8),
};
}
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
const baseKey = await crypto.subtle.importKey(
'raw',
utf8Bytes(storagePwd),
{ name: 'PBKDF2' },
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: 210000,
hash: 'SHA-256',
},
baseKey,
{
name: 'AES-GCM',
length: 256,
},
false,
['encrypt', 'decrypt'],
);
}
export async function encryptJsonWithStoragePwd(value, storagePwd) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
const plainBytes = utf8Bytes(JSON.stringify(value));
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
return {
saltB64: bytesToBase64(salt),
ivB64: bytesToBase64(iv),
cipherB64: bytesToBase64(new Uint8Array(cipher)),
};
}
export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
const salt = base64ToBytes(envelope.saltB64);
const iv = base64ToBytes(envelope.ivB64);
const cipher = base64ToBytes(envelope.cipherB64);
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);
const text = new TextDecoder().decode(plain);
return JSON.parse(text);
}
export async function generateEd25519Pair() {
return crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
}
export async function exportEd25519PublicKeyB64(publicKey) {
const raw = await crypto.subtle.exportKey('raw', publicKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function exportPkcs8B64(privateKey) {
const raw = await crypto.subtle.exportKey('pkcs8', privateKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function importPkcs8Ed25519(pkcs8B64) {
return crypto.subtle.importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
}
export async function signBase64(privateKey, text) {
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
return bytesToBase64(new Uint8Array(signature));
}

View File

@ -0,0 +1,87 @@
import {
decryptJsonWithStoragePwd,
encryptJsonWithStoragePwd,
} from './crypto-utils.js?v=20260330001044';
const DB_NAME = 'shine-ui-auth';
const DB_VERSION = 1;
const STORE_SECRETS = 'encrypted-secrets';
const STORE_SESSIONS = 'session-keys';
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_SECRETS)) {
db.createObjectStore(STORE_SECRETS, { keyPath: 'login' });
}
if (!db.objectStoreNames.contains(STORE_SESSIONS)) {
db.createObjectStore(STORE_SESSIONS, { keyPath: 'login' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('IndexedDB недоступен'));
});
}
async function put(storeName, value) {
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).put(value);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error('Ошибка записи в IndexedDB'));
});
db.close();
}
async function get(storeName, key) {
const db = await openDb();
const result = await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const req = tx.objectStore(storeName).get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error || new Error('Ошибка чтения из IndexedDB'));
});
db.close();
return result;
}
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
await put(STORE_SECRETS, {
login,
encrypted,
updatedAtMs: Date.now(),
});
}
export async function loadEncryptedUserSecrets(login, storagePwd) {
const row = await get(STORE_SECRETS, login);
if (!row?.encrypted) {
throw new Error('На устройстве нет сохранённых ключей для этого логина');
}
return decryptJsonWithStoragePwd(row.encrypted, storagePwd);
}
export async function saveSessionMaterial(login, material) {
await put(STORE_SESSIONS, {
login,
...material,
updatedAtMs: Date.now(),
});
}
export async function loadSessionMaterial(login) {
return get(STORE_SESSIONS, login);
}
export async function clearClientAuthData() {
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('Не удалось очистить IndexedDB'));
request.onblocked = () => reject(new Error('Очистка IndexedDB заблокирована открытыми соединениями'));
});
}

View File

@ -0,0 +1,112 @@
const DEFAULT_TIMEOUT_MS = 12000;
function buildWsUrl(raw) {
const value = (raw || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('http://')) return `ws://${value.slice('http://'.length)}`;
if (value.startsWith('https://')) return `wss://${value.slice('https://'.length)}`;
return value;
}
function createRequestId(op) {
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export class WsJsonClient {
constructor(url) {
this.url = buildWsUrl(url);
this.ws = null;
this.pending = new Map();
this.openPromise = null;
}
async open() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
if (this.openPromise) return this.openPromise;
this.openPromise = new Promise((resolve, reject) => {
const ws = new WebSocket(this.url);
this.ws = ws;
ws.addEventListener('open', () => {
resolve();
}, { once: true });
ws.addEventListener('error', () => {
reject(new Error(`Не удалось подключиться к ${this.url}`));
}, { once: true });
ws.addEventListener('close', () => {
this.failPending('Соединение WebSocket закрыто');
});
ws.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}).finally(() => {
this.openPromise = null;
});
return this.openPromise;
}
async request(op, payload = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
await this.open();
const requestId = createRequestId(op);
const body = { op, requestId, payload };
const responsePromise = new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
this.pending.delete(requestId);
reject(new Error(`Таймаут ответа для операции ${op}`));
}, timeoutMs);
this.pending.set(requestId, {
resolve: (value) => {
window.clearTimeout(timer);
resolve(value);
},
reject: (error) => {
window.clearTimeout(timer);
reject(error);
},
});
});
this.ws.send(JSON.stringify(body));
return responsePromise;
}
close() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
handleMessage(raw) {
let data;
try {
data = JSON.parse(raw);
} catch {
return;
}
const requestId = data?.requestId;
if (!requestId) return;
const slot = this.pending.get(requestId);
if (!slot) return;
this.pending.delete(requestId);
slot.resolve(data);
}
failPending(message) {
const error = new Error(message);
for (const [, slot] of this.pending.entries()) {
slot.reject(error);
}
this.pending.clear();
}
}

234
shine-UI/js/state.js Normal file
View File

@ -0,0 +1,234 @@
import { chatMessages, wallet } from './mock-data.js?v=20260330001044';
import { AuthService } from './services/auth-service.js?v=20260330001044';
import { clearClientAuthData } from './services/key-vault.js?v=20260330001044';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
const INVALID_SESSION_CODES = new Set([
'NOT_AUTHENTICATED',
'SESSION_NOT_FOUND',
'SESSION_KEY_NOT_ACTUAL',
'SESSION_OF_ANOTHER_USER',
]);
function loadStoredSession() {
try {
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function persistSession(session) {
try {
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
} catch {
// ignore quota/storage errors for prototype
}
}
function clearStoredSession() {
try {
localStorage.removeItem(SESSION_STORAGE_KEY);
} catch {
// ignore
}
}
function createInitialState({ withStoredSession = true } = {}) {
const storedSession = withStoredSession ? loadStoredSession() : null;
return {
chats: clone(chatMessages),
notificationsTab: 'replies',
pageLabelCollapsed: false,
session: {
isAuthorized: false,
login: storedSession?.login || '',
sessionId: storedSession?.sessionId || '',
storagePwdInMemory: '',
},
startHint: '',
entrySettings: {
language: 'ru',
solanaServer: 'https://api.mainnet-beta.solana.com',
shineServer: 'wss://shineup.me/ws',
arweaveServer: 'https://arweave.net',
statuses: {
solanaServer: 'idle',
shineServer: 'idle',
arweaveServer: 'idle',
},
},
registrationDraft: {
flowType: '',
login: '',
password: '',
sessionId: '',
storagePwd: '',
pendingKeyBundle: null,
pendingSessionMaterial: null,
},
loginDraft: {
login: storedSession?.login || '',
password: '',
},
registrationPayment: {
walletAddress: wallet.publicAddress,
balanceSOL: '0.0068',
},
keyStorage: {
rootKey: 'Ключ root хранится в зашифрованном виде',
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
deviceKey: 'Ключ device хранится в зашифрованном виде',
saveRoot: false,
saveBlockchain: true,
saveDevice: true,
},
deviceConnect: {
root: true,
blockchain: true,
device: true,
},
authUi: {
busy: false,
error: '',
info: '',
},
sessions: [],
};
}
export const state = createInitialState();
export const authService = new AuthService(state.entrySettings.shineServer);
let onSessionReset = null;
export function getChatMessages(chatId) {
if (!state.chats[chatId]) {
state.chats[chatId] = [];
}
return state.chats[chatId];
}
export function addChatMessage(chatId, text) {
const message = text.trim();
if (!message) return;
getChatMessages(chatId).push({ from: 'out', text: message });
}
export function togglePageLabel() {
state.pageLabelCollapsed = !state.pageLabelCollapsed;
}
export function ensureChat(chatId) {
return getChatMessages(chatId);
}
export function checkServerAvailability(address) {
const normalized = address.trim().toLowerCase();
if (!normalized) return 'unavailable';
const looksLikeUrl = /^(https?:\/\/|wss?:\/\/)[a-z0-9.-]+/i.test(normalized);
const blockedWord = /(offline|down|fail|bad|broken|invalid)/i.test(normalized);
return looksLikeUrl && !blockedWord ? 'available' : 'unavailable';
}
export async function saveEntrySettings(nextSettings) {
state.entrySettings = {
...state.entrySettings,
...nextSettings,
statuses: {
...state.entrySettings.statuses,
...(nextSettings.statuses || {}),
},
};
await authService.reconnect(state.entrySettings.shineServer);
state.startHint = 'Настройки входа сохранены, адреса серверов обновлены.';
}
export function clearStartHint() {
state.startHint = '';
}
export function setAuthBusy(flag) {
state.authUi.busy = flag;
}
export function setAuthError(message) {
state.authUi.error = message || '';
}
export function setAuthInfo(message) {
state.authUi.info = message || '';
}
export function clearAuthMessages() {
state.authUi.error = '';
state.authUi.info = '';
}
export function authorizeSession({ login, sessionId, storagePwd }) {
state.session.isAuthorized = true;
state.session.login = login;
state.session.sessionId = sessionId;
state.session.storagePwdInMemory = storagePwd;
persistSession({
isAuthorized: true,
login,
sessionId,
});
state.startHint = '';
}
export function setSessionResetHandler(handler) {
onSessionReset = typeof handler === 'function' ? handler : null;
}
export function isSessionInvalidError(error) {
return INVALID_SESSION_CODES.has(error?.code);
}
export async function refreshSessions() {
state.sessions = await authService.listSessions();
return state.sessions;
}
function resetStateForSignedOut() {
const next = createInitialState({ withStoredSession: false });
state.chats = next.chats;
state.notificationsTab = next.notificationsTab;
state.session = next.session;
state.startHint = next.startHint;
state.registrationDraft = next.registrationDraft;
state.loginDraft = next.loginDraft;
state.registrationPayment = next.registrationPayment;
state.keyStorage = next.keyStorage;
state.deviceConnect = next.deviceConnect;
state.authUi = next.authUi;
state.sessions = next.sessions;
}
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
clearStoredSession();
resetStateForSignedOut();
authService.close();
try {
await clearClientAuthData();
} catch {
// ignore cleanup errors in prototype mode
}
if (infoMessage) {
state.startHint = infoMessage;
}
if (onSessionReset) {
onSessionReset();
}
}
export function refreshRegistrationBalance() {
const next = (0.005 + Math.random() * 0.03).toFixed(4);
state.registrationPayment.balanceSOL = next;
return next;
}

View File

@ -0,0 +1,730 @@
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 8px;
}
.page-title {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.2px;
}
.header-actions,
.header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 74px;
}
.header-left {
justify-content: flex-start;
}
.header-actions {
justify-content: flex-end;
}
.icon-btn,
.text-btn,
.primary-btn,
.ghost-btn {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--card-soft);
padding: 8px 10px;
cursor: pointer;
transition: 0.2s ease;
}
.icon-btn:hover,
.text-btn:hover,
.primary-btn:hover,
.ghost-btn:hover {
border-color: var(--accent);
}
.primary-btn {
background: linear-gradient(120deg, var(--accent-soft), rgba(82, 120, 240, 0.22));
}
.ghost-btn {
background: rgba(255, 255, 255, 0.03);
}
.card {
background: linear-gradient(180deg, rgba(31, 44, 67, 0.62), rgba(21, 30, 48, 0.9));
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--radius-lg);
padding: 14px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.stack {
display: grid;
gap: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(132, 244, 161, 0.35);
color: #d7ffe3;
background: rgba(132, 244, 161, 0.09);
font-size: 12px;
font-weight: 600;
}
.badge.profile-badge-trigger {
cursor: pointer;
}
.badge.alt {
border-color: rgba(83, 216, 251, 0.35);
color: #dff8ff;
background: rgba(83, 216, 251, 0.11);
}
.profile-help-modal[hidden] {
display: none;
}
.profile-help-modal {
position: fixed;
inset: 0;
z-index: 20;
}
.profile-help-backdrop {
position: absolute;
inset: 0;
background: rgba(5, 9, 16, 0.72);
backdrop-filter: blur(4px);
}
.profile-help-dialog {
position: absolute;
left: 16px;
right: 16px;
bottom: 24px;
display: grid;
gap: 12px;
padding: 16px;
box-shadow: var(--shadow);
}
.profile-help-text {
color: #d8e3ff;
line-height: 1.45;
font-size: 14px;
}
.auth-screen {
min-height: calc(100dvh - 48px - env(safe-area-inset-bottom));
display: grid;
align-content: center;
justify-items: center;
gap: 18px;
text-align: center;
}
.auth-logo {
width: 126px;
height: 126px;
border-radius: 28px;
object-fit: cover;
box-shadow: var(--shadow);
}
.auth-brand {
font-size: 32px;
letter-spacing: 0.04em;
}
.auth-actions,
.auth-footer-actions {
display: grid;
gap: 10px;
}
.auth-actions {
width: min(100%, 320px);
}
.auth-footer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.auth-copy {
line-height: 1.45;
color: #d8e3ff;
}
.auth-status-card {
width: min(100%, 320px);
color: #d8e3ff;
}
.field-label {
color: #b2c2e6;
font-size: 13px;
}
.select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
min-height: 44px;
padding: 0 12px;
color: var(--text);
}
.select:focus {
outline: none;
border-color: rgba(83, 216, 251, 0.55);
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
}
.wrap-row {
flex-wrap: wrap;
}
.status-line {
font-size: 13px;
color: var(--text-muted);
}
.status-line.is-available {
color: #8ef0a8;
}
.status-line.is-unavailable {
color: #ff8d97;
}
.server-check-btn.is-available {
border-color: rgba(132, 244, 161, 0.42);
background: rgba(132, 244, 161, 0.12);
color: #d7ffe3;
}
.server-check-btn.is-unavailable {
border-color: rgba(255, 113, 143, 0.42);
background: rgba(255, 113, 143, 0.12);
color: #ffd7df;
}
.help-fab,
.square-btn {
width: 42px;
height: 42px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--card-soft);
color: var(--text);
cursor: pointer;
}
.help-fab {
position: fixed;
right: max(20px, calc((100vw - min(100vw, 430px)) / 2 + 20px));
bottom: calc(20px + env(safe-area-inset-bottom));
z-index: 12;
}
.inline-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.link-card {
display: block;
padding: 14px;
border-radius: var(--radius-md);
background: rgba(83, 216, 251, 0.08);
border: 1px solid rgba(83, 216, 251, 0.22);
color: #d9f8ff;
}
.qr-card {
justify-items: center;
}
.qr-code {
width: min(220px, 100%);
aspect-ratio: 1;
fill: #eff5ff;
background: #111723;
border-radius: 22px;
padding: 18px;
}
.camera-shell {
position: relative;
min-height: 380px;
border-radius: var(--radius-lg);
overflow: hidden;
background: #09101a;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.camera-video {
width: 100%;
height: 100%;
min-height: 380px;
object-fit: cover;
display: block;
}
.camera-frame {
position: absolute;
inset: 70px 40px 110px;
border: 3px solid rgba(83, 216, 251, 0.85);
border-radius: 24px;
box-shadow: 0 0 0 999px rgba(5, 9, 16, 0.38);
}
.camera-hint,
.camera-error {
position: absolute;
left: 16px;
right: 16px;
text-align: center;
padding: 10px 12px;
border-radius: 12px;
background: rgba(10, 14, 23, 0.78);
}
.camera-hint {
bottom: 18px;
}
.camera-error {
top: 18px;
color: #ffd7df;
}
.camera-placeholder {
width: 100%;
min-height: 380px;
display: grid;
place-items: center;
color: #c8d6f9;
background:
radial-gradient(circle at 20% 10%, rgba(83, 216, 251, 0.16), transparent 48%),
linear-gradient(180deg, #0a1220, #070d17);
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
}
.key-card {
padding: 12px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.list-item {
display: grid;
grid-template-columns: 44px 1fr auto;
gap: 10px;
align-items: center;
padding: 11px;
border-radius: var(--radius-md);
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
}
.avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(130deg, #3c4f73, #243352);
display: grid;
place-items: center;
font-weight: 700;
color: #e5ebff;
}
.avatar.large {
width: 82px;
height: 82px;
font-size: 26px;
}
.meta-muted {
color: var(--text-muted);
font-size: 13px;
}
.unread {
min-width: 20px;
height: 20px;
border-radius: 999px;
display: grid;
place-items: center;
background: var(--accent);
color: #08212a;
font-size: 12px;
font-weight: 700;
padding: 0 6px;
}
.toolbar {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
padding: 8px;
background: rgba(20, 28, 44, 0.95);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 16px;
backdrop-filter: blur(8px);
}
.toolbar-btn {
border: 0;
border-radius: 12px;
background: transparent;
color: var(--text-muted);
padding: 8px 4px;
cursor: pointer;
display: grid;
justify-items: center;
gap: 4px;
font-size: 11px;
}
.toolbar-btn.active {
background: rgba(83, 216, 251, 0.14);
color: var(--text);
}
.page-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-radius: 10px;
padding: 8px 10px;
color: #c5d2f4;
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.page-label.is-collapsed {
width: fit-content;
padding: 6px;
}
.page-label-content {
min-width: 0;
}
.page-label-hint {
margin-bottom: 3px;
color: #8ea2cd;
font-size: 10px;
line-height: 1;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-label-caption {
font-size: 12px;
line-height: 1.2;
}
.page-label-toggle {
width: 16px;
height: 16px;
flex: 0 0 16px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
}
.page-label-toggle:hover {
border-color: rgba(83, 216, 251, 0.5);
background: rgba(83, 216, 251, 0.16);
}
.chat-wrap {
display: grid;
grid-template-rows: 1fr auto;
gap: 10px;
min-height: calc(100dvh - 210px);
}
.messages-log {
display: grid;
gap: 8px;
align-content: start;
}
.bubble {
max-width: 76%;
padding: 10px 12px;
border-radius: 14px;
font-size: 14px;
line-height: 1.35;
}
.bubble.in {
justify-self: start;
background: #1f2c46;
border-top-left-radius: 6px;
}
.bubble.out {
justify-self: end;
background: #273f63;
border-top-right-radius: 6px;
}
.chat-input {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.contact-search-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.input {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
min-height: 40px;
padding: 0 12px;
width: 100%;
outline: none;
}
.input:focus {
border-color: rgba(83, 216, 251, 0.55);
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
}
.small-btn {
padding: 6px 10px;
font-size: 13px;
}
.session-item {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.03);
color: inherit;
padding: 10px;
cursor: pointer;
}
.session-item:hover {
border-color: rgba(83, 216, 251, 0.4);
}
.session-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.session-tab {
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
min-height: 36px;
padding: 6px 8px;
cursor: pointer;
}
.session-tab.is-active {
color: var(--text);
border-color: rgba(83, 216, 251, 0.45);
background: rgba(83, 216, 251, 0.15);
}
.session-current-badge {
display: inline-flex;
margin-top: 8px;
padding: 4px 9px;
border-radius: 999px;
font-size: 12px;
color: #d7ffe3;
border: 1px solid rgba(132, 244, 161, 0.36);
background: rgba(132, 244, 161, 0.1);
}
.key-value {
font-family: "IBM Plex Mono", "Fira Code", monospace;
font-size: 13px;
line-height: 1.35;
word-break: break-all;
color: #dce7ff;
}
.qr-demo {
width: 64px;
height: 64px;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.85);
background:
linear-gradient(
90deg,
#f6fbff 0 8px,
#0f1524 8px 16px,
#f6fbff 16px 24px,
#0f1524 24px 32px,
#f6fbff 32px 40px,
#0f1524 40px 48px,
#f6fbff 48px 56px,
#0f1524 56px 64px
);
}
.qr-image {
width: 64px;
height: 64px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: #fff;
}
.modal-shell[hidden] {
display: none;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 24;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(5, 9, 16, 0.74);
backdrop-filter: blur(4px);
}
.modal-dialog {
position: absolute;
left: 16px;
right: 16px;
bottom: 24px;
display: grid;
gap: 12px;
box-shadow: var(--shadow);
}
.network-board {
position: relative;
height: 290px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
background: radial-gradient(circle at center, rgba(83, 216, 251, 0.08), rgba(255, 255, 255, 0.01));
overflow: hidden;
}
.network-svg {
position: absolute;
inset: 0;
}
.node {
position: absolute;
transform: translate(-50%, -50%);
width: 74px;
text-align: center;
}
.node-dot {
width: 52px;
height: 52px;
margin: 0 auto 4px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
background: #2f4265;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.node.center .node-dot {
width: 60px;
height: 60px;
background: linear-gradient(130deg, #3a5f8e, #3dc4df);
color: #061119;
}
.node-label {
font-size: 11px;
color: #d6e2ff;
}
.tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 4px;
background: rgba(255, 255, 255, 0.02);
}
.tab-btn {
border: 0;
background: transparent;
border-radius: 9px;
padding: 8px;
color: var(--text-muted);
cursor: pointer;
}
.tab-btn.active {
background: rgba(83, 216, 251, 0.16);
color: var(--text);
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
display: grid;
place-items: center;
padding: 20px;
z-index: 20;
}
.modal-card {
width: min(100%, 390px);
background: #172238;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 14px;
}

View File

@ -0,0 +1,55 @@
body {
display: flex;
justify-content: center;
}
.app-shell {
width: min(100vw, 430px);
height: 100dvh;
position: relative;
background: linear-gradient(165deg, rgba(16, 22, 36, 0.96), rgba(11, 16, 27, 0.99));
border-left: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: var(--shadow);
overflow: hidden;
}
.screen-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 118px;
overflow-y: auto;
padding: 14px 14px 24px;
}
.screen-content.no-app-chrome {
bottom: 0;
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
.page-label-slot {
position: absolute;
left: 0;
right: 0;
bottom: 99px;
padding: 0 14px;
}
.toolbar-slot {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(10, 14, 23, 0) 0%, rgba(10, 14, 23, 0.95) 42%);
}
@media (min-width: 900px) {
.app-shell {
margin: 16px 0;
height: calc(100dvh - 32px);
border-radius: 24px;
}
}

51
shine-UI/styles/main.css Normal file
View File

@ -0,0 +1,51 @@
:root {
--bg-0: #080b12;
--bg-1: #101624;
--bg-2: #171f32;
--card: #1a2436;
--card-soft: #202d45;
--line: #2a3854;
--text: #ebf1ff;
--text-muted: #99a8cb;
--accent: #53d8fb;
--accent-soft: rgba(83, 216, 251, 0.17);
--danger: #ff718f;
--ok: #84f4a1;
--radius-lg: 18px;
--radius-md: 12px;
--radius-sm: 9px;
--shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
--font-main: "Manrope", "IBM Plex Sans", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
background: radial-gradient(circle at 22% -10%, #1f355e 0%, var(--bg-0) 45%) fixed;
color: var(--text);
font-family: var(--font-main);
}
button,
input {
font: inherit;
color: inherit;
}
h1,
h2,
h3,
p {
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}

File diff suppressed because it is too large Load Diff

View File

@ -22,10 +22,14 @@ dependencies {
// JSON (BchInfoManager) // JSON (BchInfoManager)
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.1' implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.1'
// логгер implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
implementation 'org.slf4j:slf4j-api:2.0.16'
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
implementation project(':shine-server-config') // модуль с настройками
implementation project(":shine-server-log") // модуль логирования и уведомления админов
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
} }
test { test {

View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -0,0 +1,372 @@
package blockchain;
import blockchain.body.BodyRecord;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Instant;
import java.util.Arrays;
import java.util.Objects;
/**
* BchBlockEntry универсальный блок формата SHiNE (Frame v0).
*
* =========================================================================
* FRAME v0 ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
* =========================================================================
*
* Все числа BigEndian.
*
* PREIMAGE (входит в blockSize, подписывается):
* [2] frameCode (uint16) код/версия рамки:
* - 0x0000 = Frame v0 (текущий)
* [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка)
* [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode,
* НО БЕЗ sigMarker и БЕЗ signature64
* [4] blockNumber (int32) глобальный номер блока (>=0)
* [8] timestamp (int64) unix seconds
* [2] type (uint16) тип сообщения
* [2] subType (uint16) подтип сообщения
* [2] version (uint16) версия формата сообщения
* [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version)
*
* TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0):
* [2] sigMarker (uint16) маркер подписи:
* - 0x0100 (256) = далее подпись Ed25519 64 байта
* [64] signature64 (bytes) Ed25519 signature над hash32
*
* hash32 НЕ хранится в блоке.
* hash32 вычисляется при парсинге:
* preimage = первые blockSize байт
* hash32 = SHA-256(preimage)
*
* Правила MVP-парсера (Frame v0):
* - frameCode должен быть строго 0x0000, иначе REJECT.
* - sigMarker должен быть строго 0x0100, иначе REJECT.
* - подпись обязана присутствовать всегда (sigMarker+signature64).
* - НИКАКИХ fallback-веток если маркер другой, то подписи нет/другой хвост.
*
* Важно по безопасности:
* - sigMarker в v0 не входит в подписываемые байты его можно подменить,
* поэтому единственная безопасная логика: "если не 0x0100 — reject".
* =========================================================================
*/
public final class BchBlockEntry {
public static final int SIGNATURE_LEN = 64;
public static final int HASH_LEN = 32;
public static final int FRAME_CODE_LEN = 2;
public static final int SIG_MARKER_LEN = 2;
/** Frame v0 */
public static final int FRAME_CODE_V0 = 0x0000;
/** sigMarker: 256 = 0x0100 */
public static final int SIG_MARKER_ED25519 = 0x0100;
/**
* Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature),
* чтобы не уложить сервер по памяти/диску.
*/
public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024;
/**
* Насколько блок может обгонять текущее время (защита от кривых часов/вбросов).
* Если timestamp больше now + 60 сек блок считаем неверным.
*/
public static final long MAX_FUTURE_SECONDS = 60;
/**
* Размер фиксированной части PREIMAGE (без bodyBytes).
*
* PREIMAGE header:
* frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8)
* + type(2) + subType(2) + version(2)
*/
public static final int PREIMAGE_HEADER_SIZE =
2 // frameCode
+ 32 // prevHash32
+ 4 // blockSize
+ 4 // blockNumber
+ 8 // timestamp
+ 2 // type
+ 2 // subType
+ 2; // version
/** Минимальный полный размер блока (без bodyBytes). */
public static final int MIN_FULL_BYTES =
PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN;
// --- HEADER (PREIMAGE) ---
public final int frameCode; // uint16 (v0=0)
public final byte[] prevHash32; // 32
public final int blockSize; // preimage size (включая frameCode)
public final int blockNumber; // >=0
public final long timestamp;
public final short type;
public final short subType;
public final short version;
// --- BODY (PREIMAGE) ---
public final byte[] bodyBytes;
/** Распарсенное тело (создаётся сразу при парсинге блока). */
public final BodyRecord body;
// --- TAIL ---
public final int sigMarker; // uint16 (v0: 0x0100)
private final byte[] signature64; // 64
// --- derived ---
private final byte[] hash32; // 32, computed
private final byte[] preimage; // blockSize bytes
private final byte[] fullBytes; // preimage + sigMarker + signature
/* ===================================================================== */
/* ====================== Конструктор из байт ========================== */
/* ===================================================================== */
public BchBlockEntry(byte[] fullBytes) {
Objects.requireNonNull(fullBytes, "fullBytes == null");
if (fullBytes.length < MIN_FULL_BYTES) {
throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES);
}
if (fullBytes.length > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES);
}
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
// [2] frameCode
this.frameCode = Short.toUnsignedInt(bb.getShort());
if (this.frameCode != FRAME_CODE_V0) {
throw new IllegalArgumentException(String.format(
"Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0
));
}
// [32] prevHash32
this.prevHash32 = new byte[32];
bb.get(this.prevHash32);
// [4] blockSize
this.blockSize = bb.getInt();
if (blockSize < PREIMAGE_HEADER_SIZE) {
throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
}
// fullLen must match exactly: blockSize + sigMarker(2) + signature(64)
int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
if (expectedFullLen != fullBytes.length) {
throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize
+ " expectedFullLen=" + expectedFullLen
+ " fullLen=" + fullBytes.length);
}
if (expectedFullLen > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES);
}
// [4] blockNumber
this.blockNumber = bb.getInt();
if (this.blockNumber < 0) {
throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber);
}
// [8] timestamp
this.timestamp = bb.getLong();
// запрет в будущее больше чем на 1 минуту
long now = Instant.now().getEpochSecond();
if (this.timestamp > now + MAX_FUTURE_SECONDS) {
throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
}
// [2][2][2] type/subType/version
this.type = bb.getShort();
this.subType = bb.getShort();
this.version = bb.getShort();
// [N] bodyBytes
int bodyLen = blockSize - PREIMAGE_HEADER_SIZE;
if (bodyLen < 0) {
throw new IllegalArgumentException("Invalid body length: " + bodyLen);
}
this.bodyBytes = new byte[bodyLen];
bb.get(this.bodyBytes);
// TAIL: [2] sigMarker
this.sigMarker = Short.toUnsignedInt(bb.getShort());
if (this.sigMarker != SIG_MARKER_ED25519) {
throw new IllegalArgumentException(String.format(
"Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519
));
}
// TAIL: [64] signature64
this.signature64 = new byte[SIGNATURE_LEN];
bb.get(this.signature64);
// preimage = первые blockSize байт (включая frameCode)
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
// hash32 = sha256(preimage)
this.hash32 = BchCryptoVerifier.sha256(preimage);
// parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check()
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
}
/* ===================================================================== */
/* ====================== Конструктор сборки ============================ */
/* ===================================================================== */
public BchBlockEntry(byte[] prevHash32,
int blockNumber,
long timestamp,
short type,
short subType,
short version,
byte[] bodyBytes,
byte[] signature64) {
Objects.requireNonNull(prevHash32, "prevHash32 == null");
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
Objects.requireNonNull(signature64, "signature64 == null");
if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
if (blockNumber < 0) {
throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
}
// запрет в будущее больше чем на 1 минуту
long now = Instant.now().getEpochSecond();
if (timestamp > now + MAX_FUTURE_SECONDS) {
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
}
this.frameCode = FRAME_CODE_V0;
this.prevHash32 = Arrays.copyOf(prevHash32, 32);
this.blockNumber = blockNumber;
this.timestamp = timestamp;
this.type = type;
this.subType = subType;
this.version = version;
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
// blockSize = размер preimage (включая frameCode)
this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length;
int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
if (fullLen > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES);
}
// parse body по header + ОБЯЗАТЕЛЬНЫЙ check()
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
// tail marker фиксирован
this.sigMarker = SIG_MARKER_ED25519;
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
// build preimage
ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF));
pre.put(this.prevHash32);
pre.putInt(this.blockSize);
pre.putInt(this.blockNumber);
pre.putLong(this.timestamp);
pre.putShort(this.type);
pre.putShort(this.subType);
pre.putShort(this.version);
pre.put(this.bodyBytes);
this.preimage = pre.array();
this.hash32 = BchCryptoVerifier.sha256(preimage);
// build fullBytes: preimage + sigMarker + signature64
ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
full.put(this.preimage);
full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF));
full.put(this.signature64);
this.fullBytes = full.array();
}
/* ===================================================================== */
/* ============================ Getters ================================= */
/* ===================================================================== */
public byte[] getPreimageBytes() {
return Arrays.copyOf(preimage, preimage.length);
}
/** Возвращает подпись Ed25519 (64 байта). */
public byte[] getSignature64() {
return Arrays.copyOf(signature64, SIGNATURE_LEN);
}
/** Возвращает hash32 = SHA-256(preimage). */
public byte[] getHash32() {
return Arrays.copyOf(hash32, HASH_LEN);
}
/** Возвращает полный блок: preimage + sigMarker + signature. */
public byte[] toBytes() {
return Arrays.copyOf(fullBytes, fullBytes.length);
}
@Override
public String toString() {
String timeIso;
try {
timeIso = Instant.ofEpochSecond(timestamp).toString();
} catch (Exception e) {
timeIso = "некорректныйTimestamp";
}
return "BchBlockEntry{"
+ "FRAME{frameCode=0x" + hex4(frameCode)
+ "}, HDR{"
+ "blockSize=" + blockSize
+ ", blockNumber=" + blockNumber
+ ", timestamp=" + timestamp + " (" + timeIso + ")"
+ ", type=" + (type & 0xFFFF)
+ ", subType=" + (subType & 0xFFFF)
+ ", version=" + (version & 0xFFFF)
+ ", prevHash32(hex)=" + toHex(prevHash32)
+ "}"
+ ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
+ ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}"
+ ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
+ "}";
}
private static String hex4(int v) {
String s = Integer.toHexString(v & 0xFFFF);
while (s.length() < 4) s = "0" + s;
return s;
}
private static String toHex(byte[] bytes) {
if (bytes == null) return "null";
char[] HEX = "0123456789abcdef".toCharArray();
char[] out = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int vv = bytes[i] & 0xFF;
out[i * 2] = HEX[vv >>> 4];
out[i * 2 + 1] = HEX[vv & 0x0F];
}
return new String(out);
}
}

View File

@ -0,0 +1,42 @@
package blockchain;
import utils.crypto.Ed25519Util;
import java.security.MessageDigest;
import java.util.Objects;
/**
* Верификатор SHiNE (Frame v0):
*
* preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000),
* = всё до TAIL (sigMarker+signature).
*
* hash32 = SHA-256(preimage)
* verify = Ed25519.verify(hash32, signature64, pubKey32)
*/
public final class BchCryptoVerifier {
private BchCryptoVerifier() {}
public static byte[] sha256(byte[] data) {
Objects.requireNonNull(data, "data == null");
try {
MessageDigest d = MessageDigest.getInstance("SHA-256");
return d.digest(data);
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
Objects.requireNonNull(block, "block == null");
Objects.requireNonNull(publicKey32, "publicKey32 == null");
if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");
byte[] hash32 = block.getHash32();
byte[] sig64 = block.getSignature64();
return Ed25519Util.verify(hash32, sig64, publicKey32);
}
}

View File

@ -0,0 +1,62 @@
package blockchain;
import blockchain.body.*;
/**
* Парсер body выбирает класс по header: type/subType/version,
* потому что bodyBytes больше НЕ содержат type/subType/version.
*/
public final class BodyRecordParser {
private BodyRecordParser() {}
public static BodyRecord parse(short type, short subType, short version, byte[] bodyBytes) {
if (bodyBytes == null) throw new IllegalArgumentException("bodyBytes == null");
int t = type & 0xFFFF;
int v = version & 0xFFFF;
int key = (t << 16) | v;
BodyRecord r = switch (key) {
case HeaderBody.KEY -> {
int st = subType & 0xFFFF;
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) {
yield new HeaderBody(subType, version, bodyBytes);
}
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) {
yield new CreateChannelBody(subType, version, bodyBytes);
}
throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st);
}
// TEXT type=1 ver=1: выбираем класс по subType
case TextBody.KEY -> {
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes);
}
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
yield new TextReplyBody(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);
default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version from header: type=%d ver=%d subType=%d",
t, v, (subType & 0xFFFF)
));
};
return r.check();
}
}

View File

@ -0,0 +1,17 @@
//package blockchain;
//
///**
// * LineIndex канонические номера линий блокчейна.
// *
// * Линия = независимая последовательность блоков внутри одного блокчейна.
// */
//public final class LineIndex {
//
// private LineIndex() {}
//
// public static final short HEADER = 0; // genesis / идентификация
// public static final short TEXT = 1; // сообщения да надо
// public static final short REACTION = 2; // реакции не надо
// public static final short CONNECTION = 3; // связи (friend/contact/follow) да надо
// public static final short USER_PARAM = 4; // параметры профиля да надо
//}

View File

@ -0,0 +1,93 @@
package blockchain;
/**
* MsgSubType единое место для ВСЕХ subType сообщений (msg_sub_type).
*
* Правило:
* - НИКАКИХ "магических чисел" subType по проекту.
* - В тестах, в body-классах и в SQL-триггерах используем только эти константы.
*
* Важно:
* - Значения менять после релиза нельзя (иначе сломается совместимость).
*
* =========================================================================
* Про EDIT-типы (важные правила, чтобы не было двойных правок):
*
* 1) EDIT разрешён ТОЛЬКО автору (в своём блокчейне).
* Никаких я отредачу чужое нельзя.
*
* 2) EDIT всегда ссылается ТОЛЬКО на ОРИГИНАЛ:
* - EDIT_POST -> на исходный POST
* - EDIT_REPLY -> на исходный REPLY
* НЕЛЬЗЯ ссылаться на предыдущий EDIT (цепочка edit-ов запрещена).
*
* 3) REPLY может ссылаться на блоки в чужих линиях / чужих каналах,
* и существование цели на уровне check() не проверяется
* (check() БД не видит). Если цели нет никто не увидит и ок.
* =========================================================================
*/
public final class MsgSubType {
private MsgSubType() {}
/* ===================== HEADER (msg_type=0) ===================== */
/** HeaderBody: subType всегда 0 (compat). */
public static final short HEADER_COMPAT = 0;
public static final short TECH_CREATE_CHANNEL = 1;
/* ===================== TEXT (msg_type=1) ===================== */
/**
* POST обычный пост в канале (в линии канала).
* Имеет hasLine (prevLineNumber/prevLineHash32/thisLineNumber).
*/
public static final short TEXT_POST = 10;
/**
* EDIT_POST редактирование ПОСТА.
* Имеет hasLine (принадлежит линии канала)
* И имеет target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName).
*/
public static final short TEXT_EDIT_POST = 11;
/**
* REPLY ответ на сообщение.
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
*/
public static final short TEXT_REPLY = 20;
/**
* EDIT_REPLY редактирование ОТВЕТА.
* НЕ в линии. Имеет target на ОРИГИНАЛЬНЫЙ REPLY (без toBlockchainName).
*/
public static final short TEXT_EDIT_REPLY = 21;
/* ===================== REACTION (msg_type=2) ===================== */
/** Лайк (LIKE). */
public static final short REACTION_LIKE = 1;
/* ===================== CONNECTION (msg_type=3) ===================== */
/** Добавить в друзья. */
public static final short CONNECTION_FRIEND = 10;
/** Удалить из друзей. */
public static final short CONNECTION_UNFRIEND = 11;
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
/** Удалить из контактов. */
public static final short CONNECTION_UNCONTACT = 21;
/** Подписаться (follow). */
public static final short CONNECTION_FOLLOW = 30;
/** Отписаться (unfollow). */
public static final short CONNECTION_UNFOLLOW = 31;
/* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
}

View File

@ -0,0 +1,29 @@
package blockchain.body;
/**
* BodyHasLine для типов, которые имеют линейные поля в body.
*
* Line-prefix (BigEndian) в НАЧАЛЕ bodyBytes:
* [4] lineCode код линии (root-идентификатор):
* - 0 для дефолтной линии/канала "0" (root = HEADER, blockNumber=0)
* - для канала "X": blockNumber root-блока канала (CREATE_CHANNEL)
*
* [4] prevLineBlockGlobalNumber глобальный номер предыдущего блока в этой линии
* [32] prevLineBlockHash32 hash32 предыдущего блока в этой линии
*
* [4] lineSeq порядковый номер сообщения внутри линии (1..N)
*
* Важно:
* - Проверка связности линии (prevLineBlockGlobalNumber prevLineBlockHash32) и корректности lineSeq
* выполняется на сервере/в БД при вставке (а не в body.check()).
*/
public interface BodyHasLine {
int lineCode();
int prevLineBlockGlobalNumber();
byte[] prevLineBlockHash32();
int lineSeq();
}

View File

@ -0,0 +1,31 @@
package blockchain.body;
import utils.blockchain.BlockchainNameUtil;
/**
* BodyHasTarget дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
*
* Новое правило:
* - toLogin НЕ храним в байтах блока.
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
*
* Все методы могут возвращать null.
*/
public interface BodyHasTarget {
/** login цели (nullable). Вычисляется из toBchName(). */
default String toLogin() {
String bch = toBchName();
if (bch == null) return null;
return BlockchainNameUtil.loginFromBlockchainName(bch);
}
/** blockchainName цели (nullable). */
String toBchName();
/** globalNumber цели (nullable). */
Integer toBlockGlobalNumber();
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
byte[] toBlockHashBytes();
}

View File

@ -0,0 +1,26 @@
package blockchain.body;
/**
* BodyRecord общий контракт для всех типов body (тела блока).
*
* ВАЖНО (новый формат):
* - type/subType/version НЕ лежат в bodyBytes.
* - type/subType/version читаются из заголовка блока (BchBlockEntry).
*
* Поэтому из интерфейса УБРАНЫ:
* - type()
* - subType()
* - version()
* - expectedLineIndex()
*/
public interface BodyRecord {
/** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */
BodyRecord check();
/**
* Сериализовать тело записи в байты (ровно то, что кладётся в block.bodyBytes).
* Важно: НЕ включает type/subType/version.
*/
byte[] toBytes();
}

View File

@ -0,0 +1,259 @@
package blockchain.body;
import blockchain.MsgSubType;
import utils.blockchain.BlockchainNameUtil;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* ConnectionBody type=3, ver=1 (в заголовке блока).
*
* subType (в заголовке блока) как MsgSubType:
* FRIEND=10, UNFRIEND=11
* CONTACT=20, UNCONTACT=21
* FOLLOW=30, UNFOLLOW=31
*
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
*
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* toLogin вычисляется автоматически из toBlockchainName:
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
*/
/**
* =========================================================================
* ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
* =========================================================================
*
* Термины:
* - ROOT линии/канала = блок, который "начинает" линию:
* * для канала "0" root = HEADER (blockNumber=0)
* * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
*
* 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
* FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя:
* toBlockNumber = 0
* toBlockHash32 = hash32(HEADER цели)
*
* 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
* FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
* toBlockNumber = 0
* toBlockHash32 = hash32(HEADER цели)
*
* FOLLOW/подписка на конкретный канал пользователя ->
* цель = ROOT этого канала:
* - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
* - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
* toBlockHash32=hash32(CREATE_CHANNEL)
*
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
*
* Зачем так:
* - связи и подписки всегда стабильны и не ломаются при новых постах,
* - один понятный инвариант: "подписка всегда указывает на root линии".
* =========================================================================
*/
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
public static final short TYPE = 3;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // из header
// line
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32;
public final int thisLineNumber;
// payload
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF));
}
if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
}
// минимум:
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
throw new IllegalArgumentException("ConnectionBody too short");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
int bchLen = Byte.toUnsignedInt(bb.get());
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
byte[] bchBytes = new byte[bchLen];
bb.get(bchBytes);
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
public ConnectionBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
short subType,
String toBlockchainName,
int toBlockGlobalNumber,
byte[] toBlockHash32) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
// Железное правило формата: bchName -> login + "-NNN"
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
this.thisLineNumber = thisLineNumber;
this.subType = subType;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
}
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
}
@Override
public ConnectionBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
// line rule (как было)
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else {
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("toBlockchainName is blank");
// гарантируем вычислимый toLogin (иначе target битый по стандарту)
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
return this;
}
@Override
public byte[] toBytes() {
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (bchBytes.length == 0 || bchBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
int cap = 4 + (4 + 32 + 4)
+ 1 + bchBytes.length
+ 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) bchBytes.length);
bb.put(bchBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
private static boolean isAllZero32(byte[] b) {
if (b == null || b.length != 32) return true;
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
return true;
}
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
}

View File

@ -0,0 +1,170 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* CreateChannelBody TECH сообщение создания канала.
*
* type=0, ver=1 (в заголовке блока)
* subType=MsgSubType.TECH_CREATE_CHANNEL (=1)
*
* Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine):
* - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL)
* - thisLineNumber: 1,2,3... (тех-нумерация)
*
* bodyBytes (BigEndian), новый формат line-prefix:
* [4] lineCode (для TECH линии обычно 0)
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] channelNameLen (uint8)
* [N] channelName UTF-8 (^[A-Za-z0-9_]+$)
*
* Важно:
* - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя.
*/
public final class CreateChannelBody implements BodyRecord, BodyHasLine {
public static final short TYPE = 0;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
private static final byte[] ZERO32 = new byte[32];
public final short subType; // из header
public final short version; // из header
// line
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32; // 32
public final int thisLineNumber;
// payload
public final String channelName;
public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF));
}
if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
}
// минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1)
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
throw new IllegalArgumentException("CreateChannelBody too short");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
if (bb.remaining() != nameLen) {
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
}
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.channelName = new String(nameBytes, StandardCharsets.UTF_8);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
public CreateChannelBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
String channelName) {
Objects.requireNonNull(channelName, "channelName == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
this.subType = SUBTYPE;
this.version = VER;
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
this.thisLineNumber = thisLineNumber;
this.channelName = channelName;
}
@Override
public CreateChannelBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF))
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
if (channelName == null || channelName.isBlank())
throw new IllegalArgumentException("channelName is blank");
if (!channelName.matches("^[A-Za-z0-9_]+$"))
throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$");
if ("0".equals(channelName))
throw new IllegalArgumentException("channelName \"0\" is reserved");
// tech-line: prev обязателен (минимум HEADER=0)
if (prevLineNumber < 0)
throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
if (prevLineHash32 == null || prevLineHash32.length != 32)
throw new IllegalArgumentException("prevLineHash32 invalid");
if (thisLineNumber <= 0)
throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
return this;
}
@Override
public byte[] toBytes() {
byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255)
throw new IllegalArgumentException("channelName utf8 len must be 1..255");
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
return bb.array();
}
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; }
}

View File

@ -0,0 +1,127 @@
package blockchain.body;
import utils.config.ShineSignatureConstants;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* HeaderBody type=0, version=1.
*
* В новом формате type/subType/version живут в HEADER блока,
* поэтому bodyBytes для HeaderBody содержат только payload:
*
* bodyBytes (BigEndian):
* [TAG_LEN] tag ASCII "SHiNE"
* [1] loginLength=N (uint8)
* [N] login UTF-8
*/
public final class HeaderBody implements BodyRecord {
public static final short TYPE = 0;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
/** Для header subType всегда 0 (служебная совместимость). */
public static final short SUBTYPE_COMPAT = 0;
/** TAG формата (ASCII). */
public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG;
private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII);
private static final int TAG_LEN = TAG_ASCII.length;
public final short subType; // всегда 0 (из заголовка блока)
public final short version; // из заголовка блока
public final String tag; // "SHiNE"
public final String login;
/** Десериализация из payload bodyBytes (без type/subType/version). */
public HeaderBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) {
throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF));
}
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF));
}
// минимум: tag[TAG_LEN] + loginLen[1]
if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody too short");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
byte[] tagBytes = new byte[TAG_LEN];
bb.get(tagBytes);
String t = new String(tagBytes, StandardCharsets.US_ASCII);
if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t);
this.tag = t;
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0 || bb.remaining() < loginLen)
throw new IllegalArgumentException("Bad login length");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.login = new String(loginBytes, StandardCharsets.UTF_8);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
/** Создание “вручную”. */
public HeaderBody(String login) {
Objects.requireNonNull(login, "login == null");
this.subType = SUBTYPE_COMPAT;
this.version = VER;
this.tag = TAG;
this.login = login;
}
@Override
public HeaderBody check() {
if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF))
throw new IllegalArgumentException("HeaderBody subType must be 0");
if (login == null || login.isBlank())
throw new IllegalArgumentException("Login is blank");
if (!login.matches("^[A-Za-z0-9_]+$"))
throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");
return this;
}
@Override
public byte[] toBytes() {
byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("Login utf8 len must be 1..255");
int cap = TAG_LEN + 1 + loginUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.put(TAG_ASCII);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
return bb.array();
}
@Override
public String toString() {
return """
HeaderBody {
тип записи : HEADER (type=0, ver=1) [в заголовке блока]
subType : 0 (compat)
тег формата : "%s"
login владельца : "%s"
}
""".formatted(tag, login);
}
}

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