Compare commits
185 Commits
master
...
codex/add-
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
f3e4651bd5 | ||
|
|
525627c972 | ||
|
|
cf5460c5c7 | ||
|
|
c25393e3b6 | ||
|
|
8a83ac85d9 | ||
|
|
78e62997d1 | ||
|
|
c0fba4af94 | ||
|
|
9723696b2c | ||
|
|
eb5593c7be | ||
|
|
089146a137 | ||
|
|
d4c8201a88 | ||
|
|
538ec8ec73 | ||
|
|
52fa631733 | ||
|
|
284e962910 | ||
|
|
6ba7a54921 | ||
|
|
ecd059ced2 | ||
|
|
4f825e2a86 | ||
|
|
1bf1c768dd | ||
|
|
b33fa4aeaa | ||
|
|
889ce0d921 | ||
|
|
1c9841b4a6 | ||
|
|
99cf000f24 | ||
|
|
3d780a2605 | ||
|
|
1aabcf4d80 | ||
|
|
51de9779e3 | ||
|
|
2f9cf2bff1 | ||
|
|
6d3719ba71 | ||
|
|
dabda362e6 | ||
|
|
b23ecdfdf2 | ||
|
|
18bf5d65d7 | ||
|
|
37c36ffdba | ||
|
|
c7440e2b5c | ||
|
|
6949fd8a2f | ||
|
|
ef72719502 | ||
|
|
a647091a3f | ||
|
|
b5706d3ed5 | ||
|
|
0b2bee0a3d | ||
|
|
4cee326a25 | ||
|
|
bf4cecde05 | ||
|
|
84bef3365e | ||
|
|
922c18db4b | ||
|
|
22fb35d1d4 | ||
|
|
ebf7c9f18e | ||
|
|
43b0efb4d3 | ||
|
|
4430615117 | ||
|
|
e84c63c3d1 | ||
|
|
580695b486 | ||
|
|
9f1ca37977 | ||
|
|
c1964adb58 | ||
|
|
ad5525d88b | ||
|
|
98d478531b | ||
|
|
a2495afa44 | ||
|
|
3f5f94a53f | ||
|
|
97840a45d6 | ||
|
|
69cd33479b | ||
|
|
376d42cd79 | ||
|
|
b69075cbac | ||
|
|
bbca821dcd | ||
|
|
d9fe1f02b8 | ||
|
|
5fe41c7656 | ||
|
|
9cf6fabe64 | ||
|
|
cd0352f904 | ||
|
|
fa30bd2a49 | ||
|
|
e9e05c1192 | ||
|
|
b7025dde59 | ||
|
|
973a632b85 | ||
|
|
9d0da4b39f | ||
|
|
aba86fc687 | ||
|
|
4c87207129 | ||
|
|
7a167b470a | ||
|
|
1ea5390771 | ||
|
|
a218f6586d | ||
|
|
4753b83831 | ||
|
|
a2626dfdd0 | ||
|
|
8e19486cf5 | ||
|
|
e2b89da2fa | ||
|
|
f1af2bd4d4 | ||
|
|
1c94bb25a6 | ||
|
|
06c77b1c1f | ||
|
|
8bcaa192c5 | ||
|
|
8fd7f4676b | ||
|
|
93c007b2b9 | ||
|
|
eb922d918b | ||
|
|
7ba333bf6c | ||
|
|
94777c58c6 | ||
|
|
a6a5089379 | ||
|
|
eb122456ab | ||
|
|
55d34e2a87 | ||
|
|
bfffe44c4a | ||
|
|
dd49c4de00 | ||
|
|
eef760d776 | ||
|
|
fa019bcb4f | ||
|
|
432b574592 | ||
|
|
c3d20ba338 | ||
|
|
be7a3ab7a6 | ||
|
|
272d7ca1be | ||
|
|
05a4714fb1 | ||
|
|
59e5df0dd3 | ||
|
|
771758c831 | ||
|
|
ca55bfca93 | ||
|
|
71f1a6179c | ||
|
|
c13940216b | ||
|
|
f17d077f25 | ||
|
|
62ea49d1fc | ||
|
|
f653689112 | ||
|
|
df03f3f4ba | ||
|
|
34e8640e78 | ||
|
|
b6b50557a7 | ||
|
|
08d90b6e8e | ||
|
|
43a26007d6 | ||
|
|
7fdc890a85 | ||
|
|
ae3838ccf2 | ||
|
|
783b5b08e3 | ||
|
|
015caec01c | ||
|
|
526e2d9cc4 | ||
|
|
3f374f48e1 | ||
|
|
795341dd8d | ||
|
|
c523816cdf | ||
|
|
b26e09904a | ||
|
|
1526392ca5 | ||
|
|
809d897da6 | ||
|
|
eeb8ee9069 | ||
|
|
25aa57dc5e | ||
|
|
6c2449f623 | ||
|
|
d8057807a3 | ||
|
|
e532401a75 | ||
|
|
f8cc12560e | ||
|
|
c8ee9925a1 | ||
|
|
d460ea2952 | ||
|
|
e1b2c62231 | ||
|
|
bead78b372 | ||
|
|
4e14f300f9 | ||
|
|
4759521176 | ||
|
|
a309b6f3ef | ||
|
|
834cf98ef9 | ||
|
|
80ea016687 | ||
|
|
5ecaf67bcb | ||
|
|
33635886e0 | ||
|
|
bba4b7fb41 | ||
|
|
26afcb892a | ||
|
|
9633e3528d | ||
|
|
62e4338e88 | ||
|
|
d949895fec | ||
|
|
b5fa05a660 | ||
|
|
c515d5287e | ||
|
|
ae63a653c8 | ||
|
|
03b6ff3c32 | ||
|
|
935ffecbb0 | ||
|
|
c140e3aae4 | ||
|
|
7f92dc5f51 | ||
|
|
627321d4ae | ||
|
|
0c49cae055 | ||
|
|
3cafd29ee5 | ||
|
|
6c4d8cd51b | ||
|
|
d6d2bfeb73 | ||
|
|
1b1da19d3d | ||
|
|
4fb6b10a97 | ||
|
|
e9c11d6b75 | ||
|
|
2037ebaa8b | ||
|
|
45a862b11f | ||
|
|
29c6e5a0f6 | ||
|
|
aa2caf1f10 | ||
|
|
8188b91f86 | ||
|
|
eb37b43de4 | ||
|
|
eaf1affb27 | ||
|
|
ab44cc5282 | ||
|
|
19c4fd6cd1 | ||
|
|
096246542d | ||
|
|
a6be7b75aa | ||
|
|
80ffba545a | ||
|
|
dbf1f22bac | ||
|
|
7f91c60d26 | ||
|
|
7072882b0b | ||
|
|
00fc9e3926 | ||
|
|
95ec6ba037 | ||
|
|
87da6efbfb | ||
|
|
2ab1bbc02c | ||
|
|
47c53c1a14 | ||
|
|
888bb1595f | ||
|
|
2ed4f6d666 | ||
|
|
2b5fa16824 | ||
|
|
199769cac0 | ||
|
|
c9bfa2d01a | ||
|
|
fc748a744c | ||
|
|
5d8dd86c96 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
1
.idea/.name
generated
Normal file
@ -0,0 +1 @@
|
|||||||
|
shine-server-server
|
||||||
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@ -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
2
.idea/vcs.xml
generated
@ -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>
|
||||||
62
DOC/!!! TODO что бы не забыть - для после выходных
Normal file
62
DOC/!!! TODO что бы не забыть - для после выходных
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
0. ПЕРЕДЕЛАТЬ ВСЁ НА НОВЫЙ ФОРМАТ!!
|
||||||
|
|
||||||
|
ВЫНЕСТИ ЭТИ ТРИ ВЕЩИ В ОБЩИЙ ПАРСЕР
|
||||||
|
* [2] type - тип соощения
|
||||||
|
* [2] Sиbtype - субтип сообщения
|
||||||
|
* [2] version - версия формата соощения
|
||||||
|
|
||||||
|
А ОСТАЛЬНОЕ В РЕАЛИЗАЦИЮ
|
||||||
|
|
||||||
|
|
||||||
|
ПЕРЕДЕЛАЕМ БД
|
||||||
|
|
||||||
|
1. СДЕЛАЕМ ЛИНИЮ ТОЛЬКО ДЛЯ ТЕХ ТИПОВ КОМУ НАДО (ЛАЙКАМ И ОТВЕТАМ НЕ НАДО)
|
||||||
|
(НОМЕР СООБЩЕНИЯ В ЛИНИИ ХРАНИТЬ В БЛОКАХ ВРОДЕ И НЕ НАДО ТЕМ БОЛЕЕ ЕГО ПОТОМ ПЕРЕПРОВЕРЯТЬ ВСЁ РАВНО)
|
||||||
|
А МОЖЕТ И НАДО ТК КАК ПО ОДНОМУ БЛОКУ ( ИЛИ ЧАСТИ БЛОКОВ ПОНЯТЬ КАКАЯ ЭТО ЧАСТЬ ПЕРЕПИСКИ - ВЕДЬ ГЛОБАЛ НОМЕР ВООБЩЕ НЕ ПОКАЗАТЕЛЬ)
|
||||||
|
|
||||||
|
В БД ПОМЕЧАТЬ ЧТО БЛОК ИЗ ЭТОЙ ЛИНИИ (ДЛЯ БЫСТРОГО ПОИСКА)
|
||||||
|
|
||||||
|
А УНИКАЛЬНЫЙ НОМЕР ЛИНИИ ЭТО ПО СУТИ НОМЕР СООБЩЕНИЯ СОЗДАВШЕГО ЛИНИЮ КАНАЛ (НУ И ФОРМАТ СООБЩЕНИЯ НАЧАЛА ЛИНИИ - КАНАЛА)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
3. СООТВЕТСТВЕННО удалить НАПИСАТЬ/ПЕРОВЕРИТЬ НОРМАЛЬНЫЙ SubscriptionsDAO - ТК СТАРЫЙ РАБОТАЛ НО НА ДРУГОМ ФОРМАТЕ И ТИПО КРИВО
|
||||||
|
|
||||||
|
и дальше:
|
||||||
|
ЗДЕЛАТЬ ТРИ ЗАПРОСА:
|
||||||
|
СПИСОК КАНАЛОВ НА КОГО ПОДПИСАН И ПО СКОЛЬКО СООБЩЕНИЙ И ПОСЛДНИЙ ТЕКСТ
|
||||||
|
ДОДЕЛАТЬ И СВЯЗ ПОДПИСАН УЖЕ НЕ ТОЛЬКО НА ЧЕЛА НО И НА КАНАЛ. (И ПОЛУЧАЕТСЯ ЕСТЬ ОБЩИЙ КАНАЛЛ ПОСТОВ (НО НЕКОТОРЫЕ ПОСТЫ В НИКУДА-
|
||||||
|
А НЕКОТОРЫЕ ПОСТЫ ОБЪЯВЛЕНИЕ КАНАЛА
|
||||||
|
|
||||||
|
СПИСОК СООБЩЕНИЙ В КАНАЛЕ
|
||||||
|
|
||||||
|
ОПСИСАНИЕ ОДНОГО СОООБЩЕНИЯ (С ИСТОРИЕЙ ДО НАЧАЛА ВЕТКИ И СО ВСЕМИ ОТВЕТАМИ НА НЕГО)
|
||||||
|
|
||||||
|
(НУ И В БУДУЩЕМ четвёртый ИСТОРИЮ сообщения ПО ЕДИТУ)
|
||||||
|
|
||||||
|
|
||||||
|
И ПОМЯТКА
|
||||||
|
ВСЕГДА СЧИТАЕМ ПО ПОСЛЕДНЕМУ БЛОКЧЕЙНУ ДОСТУПНОМУ ПОЛЬЗОВАТЕЛЮ
|
||||||
|
ХОТЯ ССЫЛКА ПО НОМЕРУ БЛОКЧЕЙНА КУДА ДОБАВИЛИ
|
||||||
|
|
||||||
|
ЛАЙКИ И ОТВЕТЫ ПИШЕМ НА НОМЕР СООБЩЕНИЯ ЕДИТА
|
||||||
|
(СЧИТАЕМ ТРИГЕРОМ И НА ОРИГИНАЛЬНЫЙ СУМАРНОЕ И ОТДЕЛЬНО НА НЕГО, И НА КАЖДЫЙ ЕДИТ ОТДЕЛЬНО)
|
||||||
|
|
||||||
|
ОТВЕТЫ ПОКАЗЫВАЕМ ВСЕ ВРАЗ
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
10
DOC/TODO то что пока отложенно на будущее.md
Normal file
10
DOC/TODO то что пока отложенно на будущее.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Сделать возможность убрать свой лайк. (пока не надо а сложность что надо больше проверок) - хотя можно и без проверки, просто за двойной лайк или за снятие двойное лайка. Будет двойное проникновение :)) тому кто изменил код клиента и убрал проверку на клиенте - и блокчейн заблокируется и всё.
|
||||||
|
поэтому просто на каждую реакцию добавиться убрать эту ракцию .
|
||||||
|
- это просто
|
||||||
|
|
||||||
|
сделатьпотом что бы в солану_юзерс хранилось имя текущего блокчейна пользователя. Что бы потом можно было грузить именно актуальный ТО ЕСТЬ потом можно будет менять блокченый!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
сделать сессион пасворд тоже ключём подписи устройства!!
|
||||||
22
DOC/doc_all_libs.md
Normal file
22
DOC/doc_all_libs.md
Normal 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 для взаимодействия с клиентами.
|
||||||
36
DOC/libs/shine-main Описание базовых классов.md
Normal file
36
DOC/libs/shine-main Описание базовых классов.md
Normal 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.
|
||||||
|
Итог: «бутстрап»: сначала безопасность файлов, потом сеть.
|
||||||
17
DOC/libs/shine-server-bd/DOC.md
Normal file
17
DOC/libs/shine-server-bd/DOC.md
Normal 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
|
||||||
|
|
||||||
85
DOC/libs/shine-server-blockchain/Doc.md
Normal file
85
DOC/libs/shine-server-blockchain/Doc.md
Normal 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
|
||||||
|
);
|
||||||
48
DOC/libs/shine-server-blockchain/Общая структура блока.md
Normal file
48
DOC/libs/shine-server-blockchain/Общая структура блока.md
Normal 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 делается над этим хэшем.
|
||||||
36
DOC/libs/shine-server-blockchain/Формат существующих Body.md
Normal file
36
DOC/libs/shine-server-blockchain/Формат существующих Body.md
Normal 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 целевого блока
|
||||||
|
👉 Это механизм межблокчейн-связей без изменения чужих цепочек.
|
||||||
|
|
||||||
7
DOC/libs/shine-server-config/doc.md
Normal file
7
DOC/libs/shine-server-config/doc.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
shine-server-config
|
||||||
|
|
||||||
|
Минимальная библиотека конфигурации, предоставляющая потокобезопасный singleton-доступ к параметрам из application.properties.
|
||||||
|
|
||||||
|
Настройки:
|
||||||
|
server.port=7070 — порт запуска сервера
|
||||||
|
db.path=data/shine.sqlite — путь к SQLite базе данных
|
||||||
8
DOC/libs/shine-server-crypto/DOC.md
Normal file
8
DOC/libs/shine-server-crypto/DOC.md
Normal 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(),
|
||||||
19
DOC/libs/shine-server-geo/DOC.md
Normal file
19
DOC/libs/shine-server-geo/DOC.md
Normal 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 текущей машины
|
||||||
10
DOC/libs/shine-server-log/DOC.md
Normal file
10
DOC/libs/shine-server-log/DOC.md
Normal 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)
|
||||||
52
DOC/libs/shine-server-protocol/doc.md
Normal file
52
DOC/libs/shine-server-protocol/doc.md
Normal 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
|
||||||
|
Глобальный реестр активных авторизованных соединений
|
||||||
|
(нужно для закрытия других сессий).
|
||||||
209
DOC/Описание БД.md
Normal file
209
DOC/Описание БД.md
Normal 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 — проекция активности
|
||||||
|
всё вычисляется детерминированно через триггеры
|
||||||
286
DOC/Описание протокола.md
Normal file
286
DOC/Описание протокола.md
Normal 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
|
||||||
165
DOC/Формат Блокцейнов.md
Normal file
165
DOC/Формат Блокцейнов.md
Normal 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 — параметры профиля.
|
||||||
9
DOC/Что недоделано. И потом надо доделать
Normal file
9
DOC/Что недоделано. И потом надо доделать
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
|
||||||
|
Дальше делать:
|
||||||
|
Описание форматов.
|
||||||
|
Запросы клиент-сервер.
|
||||||
|
Промт на клиента.
|
||||||
|
|
||||||
|
---
|
||||||
|
Потом в сервак дописать синхронизацию серверов.
|
||||||
130
Dev_Docs/API/00_Common_API_Format.md
Normal file
130
Dev_Docs/API/00_Common_API_Format.md
Normal 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: {}`.
|
||||||
173
Dev_Docs/API/01_User_Registration_API.md
Normal file
173
Dev_Docs/API/01_User_Registration_API.md
Normal 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.
|
||||||
279
Dev_Docs/API/02_Authentication_API.md
Normal file
279
Dev_Docs/API/02_Authentication_API.md
Normal 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": {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
136
Dev_Docs/API/03_Session_Management_API.md
Normal file
136
Dev_Docs/API/03_Session_Management_API.md
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
# 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": {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 4. Формат `sessionId`
|
||||||
|
|
||||||
|
Текущее серверное значение `sessionId` генерируется как:
|
||||||
|
|
||||||
|
- случайные **32 байта** (`SecureRandom`),
|
||||||
|
- кодирование в **стандартный Base64 RFC 4648** (алфавит `A-Z a-z 0-9 + /`),
|
||||||
|
- **без padding** `=`.
|
||||||
|
|
||||||
|
Практически это строка длиной около **43 символов** (для 32 байт без `=`).
|
||||||
|
|
||||||
|
Пример реального формата:
|
||||||
|
|
||||||
|
```
|
||||||
|
K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
||||||
|
```
|
||||||
|
|
||||||
|
Важно: это **не человеко-читаемое имя**, а непрозрачный идентификатор.
|
||||||
|
Нужно передавать его как есть, без нормализации регистра и без URL-экранирования внутри JSON.
|
||||||
160
Dev_Docs/API/04_Add_Block_to_Blockchain_API.md
Normal file
160
Dev_Docs/API/04_Add_Block_to_Blockchain_API.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# 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), а не на произвольный пост.
|
||||||
|
|
||||||
|
|
||||||
|
## 8. USER_PARAM для «личных данных»
|
||||||
|
|
||||||
|
Да, на текущем API это можно добавить **без изменения серверного кода**:
|
||||||
|
|
||||||
|
- в `UserParam` поле `param` сейчас не ограничено фиксированным справочником;
|
||||||
|
- сервер хранит пары `param -> value` как строки (при наличии корректной подписи и `time_ms`);
|
||||||
|
- чтение уже есть через `GetUserParam` и `ListUserParams`.
|
||||||
|
|
||||||
|
Рекомендуемый стартовый набор ключей для профиля (MVP):
|
||||||
|
|
||||||
|
- `name`
|
||||||
|
- `last_name`
|
||||||
|
- `address_physical`
|
||||||
|
- `address_web`
|
||||||
|
- `phone`
|
||||||
|
|
||||||
|
Практическая рекомендация: заранее зафиксировать единый словарь ключей в клиенте/документации, чтобы избежать дублей вида `lastname` vs `last_name`, `site` vs `address_web` и т.д.
|
||||||
|
|
||||||
|
Ограничения, которые важно учесть:
|
||||||
|
|
||||||
|
- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`);
|
||||||
|
- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента);
|
||||||
|
- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`.
|
||||||
157
Dev_Docs/API/05_Technical_Requests_API.md
Normal file
157
Dev_Docs/API/05_Technical_Requests_API.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# 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` нужен для выбора сервера в сети и показа публичной информации об узле.
|
||||||
|
- Оба запроса доступны без авторизации.
|
||||||
|
|
||||||
|
|
||||||
|
## 4. Прямое техническое сообщение в конкретную сессию
|
||||||
|
|
||||||
|
На текущий момент в публичном JSON API этого документа **нет отдельного RPC** для отправки произвольного технического сообщения в конкретную сессию пользователя (по `sessionId`).
|
||||||
|
|
||||||
|
Что уже есть в системе:
|
||||||
|
|
||||||
|
- сервер хранит `sessionId` активной сессии;
|
||||||
|
- есть `ListSessions`, чтобы клиент получил список sessionId своего пользователя;
|
||||||
|
- у сервера есть внутренний реестр активных WS-подключений по `sessionId`.
|
||||||
|
|
||||||
|
Чего не хватает для полноценной фичи «direct tech message by sessionId»:
|
||||||
|
|
||||||
|
1. отдельная API-операция (например, `SendSessionTechMessage`);
|
||||||
|
2. правило авторизации (кто имеет право писать в чужую/свою сессию);
|
||||||
|
3. унифицированный формат payload и события доставки;
|
||||||
|
4. коды ошибок (`SESSION_OFFLINE`, `SESSION_NOT_FOUND`, `FORBIDDEN` и т.п.).
|
||||||
|
|
||||||
|
Итог: как инфраструктурная база это почти готово, но нужен отдельный RPC-слой и политика доступа.
|
||||||
208
Dev_Docs/API/06_Channels_Read_API.md
Normal file
208
Dev_Docs/API/06_Channels_Read_API.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# 06. Channels Read API
|
||||||
|
|
||||||
|
## Человеко-читаемое объяснение
|
||||||
|
Эти 3 функции — это **чтение данных каналов** для UI:
|
||||||
|
|
||||||
|
1. `ListSubscriptionsFeed` — отдает данные для экрана списка каналов:
|
||||||
|
- ваши каналы (личный + созданные вами),
|
||||||
|
- каналы пользователей, на кого вы подписаны,
|
||||||
|
- отдельные каналы, на которые вы подписаны напрямую.
|
||||||
|
|
||||||
|
2. `GetChannelMessages` — отдает полную ленту одного канала (пока без курсоров, загружается сразу целиком),
|
||||||
|
включая версии сообщений, лайки и ответы.
|
||||||
|
|
||||||
|
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
|
||||||
|
предки, фокус-сообщение, потомки.
|
||||||
|
|
||||||
|
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) ListSubscriptionsFeed
|
||||||
|
|
||||||
|
### Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "ListSubscriptionsFeed",
|
||||||
|
"requestId": "req-1",
|
||||||
|
"payload": {
|
||||||
|
"login": "Alice",
|
||||||
|
"limit": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (success)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "ListSubscriptionsFeed",
|
||||||
|
"requestId": "req-1",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"login": "Alice",
|
||||||
|
"ownedChannels": [
|
||||||
|
{
|
||||||
|
"channel": {
|
||||||
|
"ownerLogin": "Alice",
|
||||||
|
"ownerBlockchainName": "alice-001",
|
||||||
|
"channelName": "0",
|
||||||
|
"personal": true,
|
||||||
|
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
||||||
|
},
|
||||||
|
"messagesCount": 120,
|
||||||
|
"lastMessage": {
|
||||||
|
"messageRef": { "blockNumber": 921, "blockHash": "..." },
|
||||||
|
"text": "последняя версия текста",
|
||||||
|
"createdAtMs": 1760000000000,
|
||||||
|
"authorLogin": "Alice",
|
||||||
|
"authorBlockchainName": "alice-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"followedUsersChannels": [
|
||||||
|
{
|
||||||
|
"channel": {
|
||||||
|
"ownerLogin": "Bob",
|
||||||
|
"ownerBlockchainName": "bob-001",
|
||||||
|
"channelName": "0",
|
||||||
|
"personal": true,
|
||||||
|
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
||||||
|
},
|
||||||
|
"messagesCount": 540,
|
||||||
|
"lastMessage": {
|
||||||
|
"messageRef": { "blockNumber": 922, "blockHash": "..." },
|
||||||
|
"text": "последняя версия текста",
|
||||||
|
"createdAtMs": 1760000100000,
|
||||||
|
"authorLogin": "Bob",
|
||||||
|
"authorBlockchainName": "bob-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"followedChannels": [
|
||||||
|
{
|
||||||
|
"channel": {
|
||||||
|
"ownerLogin": "Carl",
|
||||||
|
"ownerBlockchainName": "carl-001",
|
||||||
|
"channelName": "market",
|
||||||
|
"personal": false,
|
||||||
|
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||||||
|
},
|
||||||
|
"messagesCount": 90,
|
||||||
|
"lastMessage": {
|
||||||
|
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
||||||
|
"text": "актуальный текст",
|
||||||
|
"createdAtMs": 1760001000000,
|
||||||
|
"authorLogin": "Carl",
|
||||||
|
"authorBlockchainName": "carl-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) GetChannelMessages
|
||||||
|
|
||||||
|
### Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetChannelMessages",
|
||||||
|
"requestId": "req-2",
|
||||||
|
"payload": {
|
||||||
|
"channel": {
|
||||||
|
"ownerBlockchainName": "bob-001",
|
||||||
|
"channelRootBlockNumber": 123,
|
||||||
|
"channelRootBlockHash": "..."
|
||||||
|
},
|
||||||
|
"limit": 200,
|
||||||
|
"sort": "asc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (success)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetChannelMessages",
|
||||||
|
"requestId": "req-2",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"channel": {
|
||||||
|
"ownerLogin": "Bob",
|
||||||
|
"ownerBlockchainName": "bob-001",
|
||||||
|
"channelName": "news",
|
||||||
|
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"messageRef": { "blockNumber": 140, "blockHash": "..." },
|
||||||
|
"authorLogin": "Bob",
|
||||||
|
"authorBlockchainName": "bob-001",
|
||||||
|
"createdAtMs": 1760000000000,
|
||||||
|
"text": "текущая версия",
|
||||||
|
"likesCount": 12,
|
||||||
|
"repliesCount": 3,
|
||||||
|
"versionsTotal": 4,
|
||||||
|
"versions": [
|
||||||
|
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
|
||||||
|
{ "versionIndex": 2, "blockNumber": 155, "blockHash": "...", "text": "v2", "createdAtMs": 1760001000000 },
|
||||||
|
{ "versionIndex": 3, "blockNumber": 170, "blockHash": "...", "text": "v3", "createdAtMs": 1760002000000 },
|
||||||
|
{ "versionIndex": 4, "blockNumber": 199, "blockHash": "...", "text": "v4", "createdAtMs": 1760003000000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) GetMessageThread
|
||||||
|
|
||||||
|
### Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetMessageThread",
|
||||||
|
"requestId": "req-3",
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"blockchainName": "bob-001",
|
||||||
|
"blockNumber": 333,
|
||||||
|
"blockHash": "..."
|
||||||
|
},
|
||||||
|
"depthUp": 20,
|
||||||
|
"depthDown": 2,
|
||||||
|
"limitChildrenPerNode": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (success)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetMessageThread",
|
||||||
|
"requestId": "req-3",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"ancestors": [MessageNode],
|
||||||
|
"focus": MessageNode,
|
||||||
|
"descendants": [MessageNodeTree]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reason codes
|
||||||
|
- `bad_fields`
|
||||||
|
- `user_not_found`
|
||||||
|
- `channel_not_found`
|
||||||
|
- `message_not_found`
|
||||||
|
- `limit_too_large`
|
||||||
|
- `channel_name_already_exists`
|
||||||
|
- `internal_error`
|
||||||
140
Dev_Docs/API/07_Channels_Feature_Runbook.md
Normal file
140
Dev_Docs/API/07_Channels_Feature_Runbook.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# 07. Channels Feature Runbook (человеческое описание + диагностика)
|
||||||
|
|
||||||
|
## 1) Что уже сделано простыми словами
|
||||||
|
|
||||||
|
Сейчас реализован полный минимальный контур для каналов:
|
||||||
|
|
||||||
|
1. **Серверные read API**:
|
||||||
|
- `ListSubscriptionsFeed` — экран списка каналов.
|
||||||
|
- `GetChannelMessages` — сообщения конкретного канала.
|
||||||
|
- `GetMessageThread` — дерево обсуждения для сообщения.
|
||||||
|
|
||||||
|
2. **UI вкладки Каналы**:
|
||||||
|
- при открытии пытается загрузить реальный feed с сервера;
|
||||||
|
- если сервер недоступен — fallback на мок-данные;
|
||||||
|
- группы каналов выводятся в нужном порядке;
|
||||||
|
- есть кнопка «Добавить канал», модалки подписки, переход в канал.
|
||||||
|
|
||||||
|
3. **Проверка уникальности имени канала на сервере**
|
||||||
|
- в `AddBlock` при `CreateChannelBody` добавлена проверка;
|
||||||
|
- при дубле возвращается `409 channel_name_already_exists`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Что тестировать в первую очередь (быстрый чеклист)
|
||||||
|
|
||||||
|
### Базовый smoke
|
||||||
|
1. Авторизоваться в UI.
|
||||||
|
2. Открыть вкладку «Каналы».
|
||||||
|
3. Убедиться, что данные загрузились с сервера (или виден fallback-баннер).
|
||||||
|
4. Нажать любой канал — должен открыться экран канала с сообщениями.
|
||||||
|
|
||||||
|
### API smoke
|
||||||
|
1. Вызвать `ListSubscriptionsFeed`.
|
||||||
|
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
|
||||||
|
3. Для первого `messages[0]` вызвать `GetMessageThread`.
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`.
|
||||||
|
2. `GetChannelMessages` с битым channel payload -> `bad_fields`.
|
||||||
|
3. `GetMessageThread` с несуществующим block -> `message_not_found`.
|
||||||
|
4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Готовые JSON-запросы для ручной диагностики
|
||||||
|
|
||||||
|
## 3.1 ListSubscriptionsFeed
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "ListSubscriptionsFeed",
|
||||||
|
"requestId": "debug-feed-1",
|
||||||
|
"payload": {
|
||||||
|
"login": "TestUser1",
|
||||||
|
"limit": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.2 GetChannelMessages
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetChannelMessages",
|
||||||
|
"requestId": "debug-ch-1",
|
||||||
|
"payload": {
|
||||||
|
"channel": {
|
||||||
|
"ownerBlockchainName": "TestUser1-001",
|
||||||
|
"channelRootBlockNumber": 0,
|
||||||
|
"channelRootBlockHash": ""
|
||||||
|
},
|
||||||
|
"limit": 200,
|
||||||
|
"sort": "asc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.3 GetMessageThread
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetMessageThread",
|
||||||
|
"requestId": "debug-thread-1",
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"blockchainName": "TestUser1-001",
|
||||||
|
"blockNumber": 123,
|
||||||
|
"blockHash": "<hash-from-GetChannelMessages>"
|
||||||
|
},
|
||||||
|
"depthUp": 20,
|
||||||
|
"depthDown": 2,
|
||||||
|
"limitChildrenPerNode": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Что смотреть в ответах
|
||||||
|
|
||||||
|
### ListSubscriptionsFeed
|
||||||
|
- `payload.login` — канонический login.
|
||||||
|
- `ownedChannels / followedUsersChannels / followedChannels` — массивы.
|
||||||
|
- у каждой записи есть:
|
||||||
|
- `channel.channelRoot.blockNumber`,
|
||||||
|
- `messagesCount`,
|
||||||
|
- `lastMessage` (может быть null, если сообщений нет).
|
||||||
|
|
||||||
|
### GetChannelMessages
|
||||||
|
- `payload.channel` заполнен;
|
||||||
|
- `payload.messages[]` содержит:
|
||||||
|
- `likesCount`, `repliesCount`,
|
||||||
|
- `versionsTotal`, `versions[]`,
|
||||||
|
- `text` должен быть текущей (последней) версией.
|
||||||
|
|
||||||
|
### GetMessageThread
|
||||||
|
- `payload.ancestors[]`, `payload.focus`, `payload.descendants[]`.
|
||||||
|
- у узлов должны быть версии и счетчики.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Частые проблемы и как быстро локализовать
|
||||||
|
|
||||||
|
1. **`status != 200`, code=bad_fields**
|
||||||
|
- проверить вложенность payload и обязательные поля.
|
||||||
|
|
||||||
|
2. **`message_not_found` в GetMessageThread**
|
||||||
|
- обычно передали blockNumber/hash не из `messageRef`.
|
||||||
|
|
||||||
|
3. **Пустой список сообщений в GetChannelMessages**
|
||||||
|
- проверить `ownerBlockchainName` и `channelRootBlockNumber`.
|
||||||
|
|
||||||
|
4. **`channel_name_already_exists` при AddBlock**
|
||||||
|
- это ожидаемо: в этой цепочке уже есть канал с таким именем.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Для будущей доработки
|
||||||
|
|
||||||
|
1. Добавить курсоры (пагинацию) для больших каналов.
|
||||||
|
2. Перевести «Подписаться»/«Добавить канал» в UI с демо-заглушек на реальные write RPC.
|
||||||
|
3. Добавить batch-агрегации для thread/versions (оптимизация).
|
||||||
|
4. Добавить полноценные интеграционные тесты на негативные кейсы и нагрузку.
|
||||||
25
Dev_Docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md
Normal file
25
Dev_Docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md
Normal 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`.
|
||||||
49
Dev_Docs/Blockchain/01_Common_Block_Format.md
Normal file
49
Dev_Docs/Blockchain/01_Common_Block_Format.md
Normal 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`, не ломая каркас блока.
|
||||||
33
Dev_Docs/Blockchain/02_Blockchain_Kinds_and_Lines.md
Normal file
33
Dev_Docs/Blockchain/02_Blockchain_Kinds_and_Lines.md
Normal 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` для пользовательских каналов.
|
||||||
|
|
||||||
|
Так ссылки остаются стабильными, даже когда в канале появляются новые сообщения.
|
||||||
18
Dev_Docs/Blockchain/10_TECH_Blocks.md
Normal file
18
Dev_Docs/Blockchain/10_TECH_Blocks.md
Normal 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`.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
- инициализация блокчейна;
|
||||||
|
- управление набором каналов пользователя.
|
||||||
25
Dev_Docs/Blockchain/11_TEXT_Blocks.md
Normal file
25
Dev_Docs/Blockchain/11_TEXT_Blocks.md
Normal 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.
|
||||||
11
Dev_Docs/Blockchain/12_REACTION_Blocks.md
Normal file
11
Dev_Docs/Blockchain/12_REACTION_Blocks.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# REACTION блоки (`type=2`, `version=1`)
|
||||||
|
|
||||||
|
## Подтипы
|
||||||
|
|
||||||
|
1. `subType=1` — `REACTION_LIKE`
|
||||||
|
- лайк на целевой блок;
|
||||||
|
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
|
||||||
24
Dev_Docs/Blockchain/13_CONNECTION_Blocks.md
Normal file
24
Dev_Docs/Blockchain/13_CONNECTION_Blocks.md
Normal 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` для пользовательского канала.
|
||||||
14
Dev_Docs/Blockchain/14_USER_PARAM_Blocks.md
Normal file
14
Dev_Docs/Blockchain/14_USER_PARAM_Blocks.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# USER_PARAM блоки (`type=4`, `version=1`)
|
||||||
|
|
||||||
|
## Подтипы
|
||||||
|
|
||||||
|
1. `subType=1` — `USER_PARAM_TEXT_TEXT`
|
||||||
|
- хранит line-поля + `paramKey` + `paramValue`.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
- сохранение пользовательского состояния (настройки клиента, синк-метки, курсоры чтения и т.д.).
|
||||||
|
|
||||||
|
## Практика
|
||||||
|
|
||||||
|
Для сложных структур удобно хранить JSON-строку в `paramValue` с версией схемы.
|
||||||
131
build.gradle
131
build.gradle
@ -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,84 @@ shadowJar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test {
|
|
||||||
useJUnitPlatform()
|
tasks.named('test') {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('cleanServerLogs') {
|
||||||
|
group = "!!test"
|
||||||
|
description = "Clear server logs/app.log and remove rolled log files"
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
File logsDir = file('logs')
|
||||||
|
if (!logsDir.exists()) {
|
||||||
|
logsDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
File appLog = new File(logsDir, 'app.log')
|
||||||
|
if (!appLog.exists()) {
|
||||||
|
appLog.createNewFile()
|
||||||
|
}
|
||||||
|
appLog.text = ''
|
||||||
|
|
||||||
|
fileTree(logsDir) {
|
||||||
|
include 'app.*.log'
|
||||||
|
}.files.each { File f ->
|
||||||
|
if (!f.delete()) {
|
||||||
|
throw new GradleException("Failed to delete log file: ${f.absolutePath}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println "Server logs cleared: ${logsDir.absolutePath}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('integrationTest', JavaExec) {
|
||||||
|
group = "!!test"
|
||||||
|
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.named('build') {
|
||||||
|
finalizedBy tasks.named('integrationTest')
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('deployServer', JavaExec) {
|
||||||
|
group = "!!deployment"
|
||||||
|
description = "Build → upload to server → clean remote data → restart service → run IT against server"
|
||||||
|
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = "test.it.IT_DeployRestartAndRunRemoteMain"
|
||||||
|
|
||||||
|
// можно переопределить при запуске:
|
||||||
|
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
||||||
|
dependsOn shadowJar
|
||||||
|
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('deployPWA', Exec) {
|
||||||
|
group = "!!deployment"
|
||||||
|
description = "Deploy PWA via deploy_shine-PWA.sh"
|
||||||
|
|
||||||
|
workingDir = rootDir
|
||||||
|
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath
|
||||||
}
|
}
|
||||||
|
|||||||
246
create_git.sh
Normal file
246
create_git.sh
Normal 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-PWA.sh
Executable file
27
deploy_shine-PWA.sh
Executable 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 "Всё хорошо"
|
||||||
@ -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'
|
||||||
|
|||||||
1509
shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log
Normal file
1509
shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log
Normal file
File diff suppressed because it is too large
Load Diff
40
shine-UI/AGENTS.md
Normal file
40
shine-UI/AGENTS.md
Normal 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/*`
|
||||||
83
shine-UI/img/device-qr-64.svg
Normal file
83
shine-UI/img/device-qr-64.svg
Normal 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
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
20
shine-UI/index.html
Normal 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=20260403081123" />
|
||||||
|
<link rel="stylesheet" href="./styles/layout.css?v=20260403081123" />
|
||||||
|
<link rel="stylesheet" href="./styles/components.css?v=20260403081123" />
|
||||||
|
</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=20260403081123"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
188
shine-UI/js/app.js
Normal file
188
shine-UI/js/app.js
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260403081123';
|
||||||
|
import { renderToolbar } from './components/toolbar.js?v=20260403081123';
|
||||||
|
import { renderPageLabel } from './components/page-label.js?v=20260403081123';
|
||||||
|
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js?v=20260403081123';
|
||||||
|
import {
|
||||||
|
authService,
|
||||||
|
authorizeSession,
|
||||||
|
isSessionInvalidError,
|
||||||
|
refreshSessions,
|
||||||
|
setSessionResetHandler,
|
||||||
|
state,
|
||||||
|
terminateCurrentSession,
|
||||||
|
togglePageLabel,
|
||||||
|
} from './state.js?v=20260403081123';
|
||||||
|
|
||||||
|
import * as startView from './pages/start-view.js?v=20260403081123';
|
||||||
|
import * as entrySettingsView from './pages/entry-settings-view.js?v=20260403081123';
|
||||||
|
import * as registerView from './pages/register-view.js?v=20260403081123';
|
||||||
|
import * as registrationPaymentView from './pages/registration-payment-view.js?v=20260403081123';
|
||||||
|
import * as registrationKeysView from './pages/registration-keys-view.js?v=20260403081123';
|
||||||
|
import * as topupView from './pages/topup-view.js?v=20260403081123';
|
||||||
|
import * as loginView from './pages/login-view.js?v=20260403081123';
|
||||||
|
import * as loginCameraView from './pages/login-camera-view.js?v=20260403081123';
|
||||||
|
import * as loginPasswordView from './pages/login-password-view.js?v=20260403081123';
|
||||||
|
import * as keyStorageView from './pages/key-storage-view.js?v=20260403081123';
|
||||||
|
|
||||||
|
import * as profileView from './pages/profile-view.js?v=20260403081123';
|
||||||
|
import * as walletView from './pages/wallet-view.js?v=20260403081123';
|
||||||
|
import * as settingsView from './pages/settings-view.js?v=20260403081123';
|
||||||
|
import * as serverSettingsView from './pages/server-settings-view.js?v=20260403081123';
|
||||||
|
import * as deviceView from './pages/device-view.js?v=20260403081123';
|
||||||
|
import * as connectDeviceView from './pages/connect-device-view.js?v=20260403081123';
|
||||||
|
import * as deviceQrView from './pages/device-qr-view.js?v=20260403081123';
|
||||||
|
import * as deviceCameraView from './pages/device-camera-view.js?v=20260403081123';
|
||||||
|
import * as showKeysView from './pages/show-keys-view.js?v=20260403081123';
|
||||||
|
import * as deviceSessionView from './pages/device-session-view.js?v=20260403081123';
|
||||||
|
import * as languageView from './pages/language-view.js?v=20260403081123';
|
||||||
|
import * as messagesList from './pages/messages-list.js?v=20260403081123';
|
||||||
|
import * as contactSearchView from './pages/contact-search-view.js?v=20260403081123';
|
||||||
|
import * as chatView from './pages/chat-view.js?v=20260403081123';
|
||||||
|
import * as channelsList from './pages/channels-list.js?v=20260403081123';
|
||||||
|
import * as channelView from './pages/channel-view.js?v=20260403081123';
|
||||||
|
import * as addChannelView from './pages/add-channel-view.js?v=20260403081123';
|
||||||
|
import * as networkView from './pages/network-view.js?v=20260403081123';
|
||||||
|
import * as notificationsView from './pages/notifications-view.js?v=20260403081123';
|
||||||
|
|
||||||
|
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,
|
||||||
|
'add-channel-view': addChannelView,
|
||||||
|
'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;
|
||||||
|
|
||||||
|
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
captureClientError({
|
||||||
|
kind: 'global_error',
|
||||||
|
message: event.message || 'Global JS error',
|
||||||
|
stack: event.error?.stack || '',
|
||||||
|
sourceUrl: event.filename || '',
|
||||||
|
lineNumber: event.lineno,
|
||||||
|
columnNumber: event.colno,
|
||||||
|
context: {
|
||||||
|
pageId: getRoute().pageId || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const reason = event.reason;
|
||||||
|
captureClientError({
|
||||||
|
kind: 'unhandled_rejection',
|
||||||
|
message: reason?.message || String(reason || 'Unhandled promise rejection'),
|
||||||
|
stack: reason?.stack || '',
|
||||||
|
context: {
|
||||||
|
pageId: getRoute().pageId || '',
|
||||||
|
reasonType: reason?.constructor?.name || typeof reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
31
shine-UI/js/components/header.js
Normal file
31
shine-UI/js/components/header.js
Normal 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;
|
||||||
|
}
|
||||||
36
shine-UI/js/components/page-label.js
Normal file
36
shine-UI/js/components/page-label.js
Normal 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;
|
||||||
|
}
|
||||||
25
shine-UI/js/components/toolbar.js
Normal file
25
shine-UI/js/components/toolbar.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { resolveToolbarActive } from '../router.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
282
shine-UI/js/mock-data.js
Normal file
282
shine-UI/js/mock-data.js
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
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: 'ch0',
|
||||||
|
name: 'Личный канал',
|
||||||
|
initials: 'ЛК',
|
||||||
|
ownerLogin: '@shine.alex',
|
||||||
|
ownerName: 'Вы',
|
||||||
|
description: 'Ваш основной канал (нулевой).',
|
||||||
|
lastMessage: 'Добро пожаловать в личный канал.',
|
||||||
|
time: '16:05',
|
||||||
|
messagesCount: 14,
|
||||||
|
kind: 'own-personal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ch1',
|
||||||
|
name: 'Команда продукта',
|
||||||
|
initials: 'КП',
|
||||||
|
ownerLogin: '@shine.alex',
|
||||||
|
ownerName: 'Вы',
|
||||||
|
description: 'Канал команды, который вы создали.',
|
||||||
|
lastMessage: 'Обновили roadmap на апрель.',
|
||||||
|
time: '15:42',
|
||||||
|
messagesCount: 8,
|
||||||
|
kind: 'own',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ch2',
|
||||||
|
name: 'Новости Bob',
|
||||||
|
initials: 'NB',
|
||||||
|
ownerLogin: '@bob',
|
||||||
|
ownerName: 'Bob',
|
||||||
|
description: 'Основной канал пользователя Bob.',
|
||||||
|
lastMessage: 'Вышел новый дайджест разработчика.',
|
||||||
|
time: '15:20',
|
||||||
|
messagesCount: 5,
|
||||||
|
kind: 'followed-user-channel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ch3',
|
||||||
|
name: 'Стендап команды Bob',
|
||||||
|
initials: 'SB',
|
||||||
|
ownerLogin: '@bob',
|
||||||
|
ownerName: 'Bob',
|
||||||
|
description: 'Второй канал пользователя Bob.',
|
||||||
|
lastMessage: 'Перенесли созвон на 19:30.',
|
||||||
|
time: 'вчера',
|
||||||
|
messagesCount: 11,
|
||||||
|
kind: 'followed-user-channel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ch4',
|
||||||
|
name: 'Анекдоты дня',
|
||||||
|
initials: 'АД',
|
||||||
|
ownerLogin: '@fun.club',
|
||||||
|
ownerName: 'Fun Club',
|
||||||
|
description: 'Публичный развлекательный канал по подписке.',
|
||||||
|
lastMessage: 'Сегодня в выпуске 5 новых шуток.',
|
||||||
|
time: 'вчера',
|
||||||
|
messagesCount: 33,
|
||||||
|
kind: 'subscribed',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const channelPosts = {
|
||||||
|
ch0: [
|
||||||
|
{
|
||||||
|
id: 'p0-1',
|
||||||
|
title: 'Первый личный пост',
|
||||||
|
body: 'Этот канал всегда ваш и стоит в списке первым.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p0-2',
|
||||||
|
title: 'Планы',
|
||||||
|
body: 'Сюда удобно сохранять личные заметки и объявления.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
38
shine-UI/js/pages/add-channel-view.js
Normal file
38
shine-UI/js/pages/add-channel-view.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'add-channel-view', title: 'Добавить канал' };
|
||||||
|
|
||||||
|
export function render({ navigate }) {
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack';
|
||||||
|
|
||||||
|
screen.append(
|
||||||
|
renderHeader({
|
||||||
|
title: 'Добавить канал',
|
||||||
|
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.className = 'card stack';
|
||||||
|
form.innerHTML = `
|
||||||
|
<label for="channel-name">Имя канала</label>
|
||||||
|
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Новости команды" required />
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||||
|
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
|
||||||
|
<button type="submit" class="primary-btn">Создать</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
navigate('channels-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
form.querySelector('#cancel-create-channel').addEventListener('click', () => {
|
||||||
|
navigate('channels-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.append(form);
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
184
shine-UI/js/pages/channel-view.js
Normal file
184
shine-UI/js/pages/channel-view.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { channelPosts, channels } from '../mock-data.js?v=20260403081123';
|
||||||
|
import { addLocalChannelPost, authService, getLocalChannelPosts, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||||
|
|
||||||
|
function findMockChannel(channelId) {
|
||||||
|
const channel = channels.find((c) => c.id === channelId) || channels[0];
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
posts: [
|
||||||
|
...(channelPosts[channel.id] || []).map((post) => ({ title: post.title, body: post.body })),
|
||||||
|
...getLocalChannelPosts(channelId),
|
||||||
|
],
|
||||||
|
isOwnChannel: channel.ownerLogin === '@shine.alex',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiMessageToPost(message) {
|
||||||
|
return {
|
||||||
|
title: `${message.authorLogin || 'author'} • #${message.messageRef?.blockNumber ?? '?'}`,
|
||||||
|
body: message.text || '(пусто)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPostCard(post) {
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'card stack';
|
||||||
|
card.innerHTML = `<strong>${post.title}</strong><p class="meta-muted">${post.body}</p>`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddMessageModal({ channelId, channelName, onSubmit }) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="channel-message-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 style="font-size:18px;">Новое сообщение в канал</h3>
|
||||||
|
<p class="meta-muted"># ${channelName}</p>
|
||||||
|
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Введите текст сообщения"></textarea>
|
||||||
|
<div class="meta-muted" id="channel-message-error" style="min-height:18px;"></div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||||
|
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||||
|
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textEl = root.querySelector('#channel-message-text');
|
||||||
|
const errorEl = root.querySelector('#channel-message-error');
|
||||||
|
const close = () => {
|
||||||
|
root.innerHTML = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
root.querySelector('#channel-message-cancel').addEventListener('click', close);
|
||||||
|
root.querySelector('#channel-message-submit').addEventListener('click', () => {
|
||||||
|
const body = textEl.value.trim();
|
||||||
|
if (!body) {
|
||||||
|
errorEl.textContent = 'Введите текст сообщения.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
title: `${state.session.login || 'Вы'} • сейчас`,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (textEl) textEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(screen, navigate, channelId, channelData) {
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'card';
|
||||||
|
head.innerHTML = `
|
||||||
|
<strong># ${channelData.channel.name}</strong>
|
||||||
|
<p class="meta-muted" style="margin-top:4px;">${channelData.channel.description}</p>
|
||||||
|
<p class="meta-muted" style="margin-top:8px;">Владелец: ${channelData.channel.ownerName}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const actionButton = document.createElement('button');
|
||||||
|
actionButton.className = channelData.isOwnChannel ? 'primary-btn' : 'secondary-btn';
|
||||||
|
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
|
||||||
|
|
||||||
|
const feed = document.createElement('div');
|
||||||
|
feed.className = 'stack';
|
||||||
|
|
||||||
|
channelData.posts.forEach((post) => {
|
||||||
|
feed.append(renderPostCard(post));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (channelData.isOwnChannel) {
|
||||||
|
actionButton.addEventListener('click', () => {
|
||||||
|
openAddMessageModal({
|
||||||
|
channelId,
|
||||||
|
channelName: channelData.channel.name,
|
||||||
|
onSubmit: (post) => {
|
||||||
|
addLocalChannelPost(channelId, post);
|
||||||
|
channelData.posts.push(post);
|
||||||
|
feed.append(renderPostCard(post));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const backButton = document.createElement('button');
|
||||||
|
backButton.className = 'secondary-btn';
|
||||||
|
backButton.textContent = 'Назад к списку';
|
||||||
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
|
screen.append(head, actionButton, feed, backButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFromApi(channelId) {
|
||||||
|
const summary = state.channelsIndex[channelId];
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
const selector = {
|
||||||
|
ownerBlockchainName: summary.channel?.ownerBlockchainName,
|
||||||
|
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
|
||||||
|
channelRootBlockHash: summary.channel?.channelRoot?.blockHash,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selector.ownerBlockchainName || selector.channelRootBlockNumber == null) return null;
|
||||||
|
|
||||||
|
const payload = await authService.getChannelMessages(selector, 200, 'asc');
|
||||||
|
const posts = [
|
||||||
|
...(payload.messages || []).map(mapApiMessageToPost),
|
||||||
|
...getLocalChannelPosts(channelId),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel: {
|
||||||
|
name: payload.channel?.channelName || summary.channel?.channelName || 'unknown',
|
||||||
|
description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`,
|
||||||
|
ownerName: payload.channel?.ownerLogin || summary.channel?.ownerLogin || 'unknown',
|
||||||
|
},
|
||||||
|
posts,
|
||||||
|
isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate, route }) {
|
||||||
|
const channelId = route.params.channelId || 'ch1';
|
||||||
|
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack';
|
||||||
|
|
||||||
|
const headerTitle = state.channelsIndex[channelId]?.channel?.channelName
|
||||||
|
? `Канал: ${state.channelsIndex[channelId].channel.channelName}`
|
||||||
|
: `Канал: ${(channels.find((c) => c.id === channelId) || channels[0]).name}`;
|
||||||
|
|
||||||
|
screen.append(
|
||||||
|
renderHeader({
|
||||||
|
title: headerTitle,
|
||||||
|
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const loading = document.createElement('div');
|
||||||
|
loading.className = 'card meta-muted';
|
||||||
|
loading.textContent = 'Загрузка канала...';
|
||||||
|
screen.append(loading);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const apiData = await loadFromApi(channelId);
|
||||||
|
loading.remove();
|
||||||
|
if (apiData) {
|
||||||
|
renderBody(screen, navigate, channelId, apiData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback to mock below
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.remove();
|
||||||
|
renderBody(screen, navigate, channelId, findMockChannel(channelId));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
184
shine-UI/js/pages/channels-list.js
Normal file
184
shine-UI/js/pages/channels-list.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { channels as mockChannels } from '../mock-data.js?v=20260403081123';
|
||||||
|
import { authService, setChannelsFeed, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
|
|
||||||
|
function openSimpleSubscribeModal(kindLabel) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="channels-subscribe-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 style="font-size:18px;">${kindLabel}</h3>
|
||||||
|
<label class="meta-muted" for="subscribe-input">Введите идентификатор</label>
|
||||||
|
<input id="subscribe-input" class="input" placeholder="@login или #канал" />
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||||
|
<button class="secondary-btn" id="sub-cancel">Отмена</button>
|
||||||
|
<button class="primary-btn" id="sub-submit">Подписаться</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
root.innerHTML = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
root.querySelector('#sub-cancel').addEventListener('click', close);
|
||||||
|
root.querySelector('#sub-submit').addEventListener('click', close);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialsFromName(name = '') {
|
||||||
|
const parts = name.split(/\s+/).filter(Boolean);
|
||||||
|
return (parts[0]?.[0] || '#') + (parts[1]?.[0] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMockGroups() {
|
||||||
|
const ownChannels = mockChannels.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own');
|
||||||
|
const followedUserChannels = mockChannels.filter((channel) => channel.kind === 'followed-user-channel');
|
||||||
|
const subscribedChannels = mockChannels.filter((channel) => channel.kind === 'subscribed');
|
||||||
|
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiChannelRow(summary, bucketKey, idx, index) {
|
||||||
|
const rowId = `${bucketKey}-${idx}`;
|
||||||
|
index[rowId] = summary;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rowId,
|
||||||
|
source: 'api',
|
||||||
|
ownerName: summary.channel?.ownerLogin || 'unknown',
|
||||||
|
initials: initialsFromName(summary.channel?.channelName || summary.channel?.ownerLogin || '?'),
|
||||||
|
name: summary.channel?.channelName || '(без имени)',
|
||||||
|
description: `owner=${summary.channel?.ownerLogin || '-'} / bch=${summary.channel?.ownerBlockchainName || '-'}`,
|
||||||
|
lastMessage: summary.lastMessage?.text || 'Сообщений пока нет',
|
||||||
|
time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '—',
|
||||||
|
messagesCount: summary.messagesCount || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiFeed(feed) {
|
||||||
|
const index = {};
|
||||||
|
|
||||||
|
const ownChannels = (feed?.ownedChannels || []).map((it, idx) => mapApiChannelRow(it, 'own', idx, index));
|
||||||
|
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index));
|
||||||
|
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index));
|
||||||
|
|
||||||
|
ownChannels.sort((a, b) => {
|
||||||
|
const ap = index[a.id]?.channel?.personal === true;
|
||||||
|
const bp = index[b.id]?.channel?.personal === true;
|
||||||
|
if (ap && !bp) return -1;
|
||||||
|
if (!ap && bp) return 1;
|
||||||
|
return a.name.localeCompare(b.name, 'ru');
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ownChannels, followedUserChannels, subscribedChannels, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChannelRow(channel, navigate) {
|
||||||
|
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>
|
||||||
|
<p class="meta-muted" style="margin-top:6px;">Владелец: ${channel.ownerName}</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>
|
||||||
|
<span class="unread">${channel.messagesCount}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection(title, items, navigate) {
|
||||||
|
const wrap = document.createElement('section');
|
||||||
|
wrap.className = 'stack';
|
||||||
|
|
||||||
|
const header = document.createElement('h3');
|
||||||
|
header.className = 'section-title';
|
||||||
|
header.textContent = title;
|
||||||
|
|
||||||
|
wrap.append(header);
|
||||||
|
items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate)));
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupedList(screen, navigate, groups) {
|
||||||
|
const listWrap = document.createElement('div');
|
||||||
|
listWrap.className = 'channels-scroll-wrap';
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'stack channels-groups';
|
||||||
|
|
||||||
|
list.append(renderSection('Мои каналы', groups.ownChannels, navigate));
|
||||||
|
|
||||||
|
const dividerOne = document.createElement('hr');
|
||||||
|
dividerOne.className = 'channels-divider';
|
||||||
|
list.append(dividerOne);
|
||||||
|
|
||||||
|
list.append(renderSection('Каналы пользователей, на кого вы подписаны', groups.followedUserChannels, navigate));
|
||||||
|
|
||||||
|
const dividerTwo = document.createElement('hr');
|
||||||
|
dividerTwo.className = 'channels-divider';
|
||||||
|
list.append(dividerTwo);
|
||||||
|
|
||||||
|
list.append(renderSection('Каналы, на которые вы подписаны', groups.subscribedChannels, navigate));
|
||||||
|
|
||||||
|
const addChannelButton = document.createElement('button');
|
||||||
|
addChannelButton.className = 'primary-btn';
|
||||||
|
addChannelButton.textContent = 'Добавить канал';
|
||||||
|
addChannelButton.addEventListener('click', () => navigate('add-channel-view'));
|
||||||
|
|
||||||
|
list.append(addChannelButton);
|
||||||
|
|
||||||
|
const scrollHint = document.createElement('div');
|
||||||
|
scrollHint.className = 'channels-scroll-hint';
|
||||||
|
|
||||||
|
listWrap.append(list, scrollHint);
|
||||||
|
screen.append(listWrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFeedAndRender(screen, navigate) {
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'card meta-muted';
|
||||||
|
status.textContent = 'Загрузка каналов с сервера...';
|
||||||
|
screen.append(status);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!state.session.login) throw new Error('not_authorized');
|
||||||
|
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||||
|
const groups = mapApiFeed(feed);
|
||||||
|
setChannelsFeed(feed, groups.index);
|
||||||
|
status.remove();
|
||||||
|
renderGroupedList(screen, navigate, groups);
|
||||||
|
} catch {
|
||||||
|
setChannelsFeed(null, {});
|
||||||
|
status.textContent = 'Сервер недоступен или нет данных. Показаны демо-каналы.';
|
||||||
|
renderGroupedList(screen, navigate, mapMockGroups());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate }) {
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack';
|
||||||
|
|
||||||
|
screen.append(
|
||||||
|
renderHeader({
|
||||||
|
title: 'Каналы',
|
||||||
|
rightActions: [
|
||||||
|
{ label: 'Подписаться на человека', onClick: () => openSimpleSubscribeModal('Подписка на человека') },
|
||||||
|
{ label: 'Подписаться на канал', onClick: () => openSimpleSubscribeModal('Подписка на канал') },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
loadFeedAndRender(screen, navigate);
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
58
shine-UI/js/pages/chat-view.js
Normal file
58
shine-UI/js/pages/chat-view.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { directMessages } from '../mock-data.js?v=20260403081123';
|
||||||
|
import { addChatMessage, getChatMessages } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
103
shine-UI/js/pages/connect-device-view.js
Normal file
103
shine-UI/js/pages/connect-device-view.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
129
shine-UI/js/pages/contact-search-view.js
Normal file
129
shine-UI/js/pages/contact-search-view.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { contactDirectory, directMessages } from '../mock-data.js?v=20260403081123';
|
||||||
|
import { ensureChat } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
26
shine-UI/js/pages/device-camera-view.js
Normal file
26
shine-UI/js/pages/device-camera-view.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
36
shine-UI/js/pages/device-qr-view.js
Normal file
36
shine-UI/js/pages/device-qr-view.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { profile } from '../mock-data.js?v=20260403081123';
|
||||||
|
import { state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
101
shine-UI/js/pages/device-session-view.js
Normal file
101
shine-UI/js/pages/device-session-view.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import {
|
||||||
|
authService,
|
||||||
|
isSessionInvalidError,
|
||||||
|
refreshSessions,
|
||||||
|
setAuthError,
|
||||||
|
state,
|
||||||
|
terminateCurrentSession,
|
||||||
|
} from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
146
shine-UI/js/pages/device-view.js
Normal file
146
shine-UI/js/pages/device-view.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import {
|
||||||
|
authService,
|
||||||
|
isSessionInvalidError,
|
||||||
|
refreshSessions,
|
||||||
|
setAuthError,
|
||||||
|
setAuthInfo,
|
||||||
|
state,
|
||||||
|
terminateCurrentSession,
|
||||||
|
} from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
161
shine-UI/js/pages/entry-settings-view.js
Normal file
161
shine-UI/js/pages/entry-settings-view.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { checkServerAvailability, saveEntrySettings, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
99
shine-UI/js/pages/key-storage-view.js
Normal file
99
shine-UI/js/pages/key-storage-view.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { authorizeSession, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
43
shine-UI/js/pages/language-view.js
Normal file
43
shine-UI/js/pages/language-view.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
67
shine-UI/js/pages/login-camera-view.js
Normal file
67
shine-UI/js/pages/login-camera-view.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
105
shine-UI/js/pages/login-password-view.js
Normal file
105
shine-UI/js/pages/login-password-view.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import {
|
||||||
|
authService,
|
||||||
|
clearAuthMessages,
|
||||||
|
setAuthBusy,
|
||||||
|
setAuthError,
|
||||||
|
state,
|
||||||
|
} from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
72
shine-UI/js/pages/login-view.js
Normal file
72
shine-UI/js/pages/login-view.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
42
shine-UI/js/pages/messages-list.js
Normal file
42
shine-UI/js/pages/messages-list.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { directMessages } from '../mock-data.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
77
shine-UI/js/pages/network-view.js
Normal file
77
shine-UI/js/pages/network-view.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { networkGraph } from '../mock-data.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
48
shine-UI/js/pages/notifications-view.js
Normal file
48
shine-UI/js/pages/notifications-view.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { notifications } from '../mock-data.js?v=20260403081123';
|
||||||
|
import { state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
261
shine-UI/js/pages/profile-view.js
Normal file
261
shine-UI/js/pages/profile-view.js
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { profile } from '../mock-data.js?v=20260403081123';
|
||||||
|
import { state } from '../state.js?v=20260403081123';
|
||||||
|
import {
|
||||||
|
loadProfileSnapshot,
|
||||||
|
profileFieldDefs,
|
||||||
|
saveProfileParams,
|
||||||
|
saveProfileToggle,
|
||||||
|
} from '../services/user-profile-params.js?v=20260403081123';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
|
function formatDateTime(timeMs) {
|
||||||
|
if (!timeMs) return 'ещё не заполнено';
|
||||||
|
return new Date(timeMs).toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(fieldMap) {
|
||||||
|
const firstName = fieldMap.get('first_name')?.value?.trim() || '';
|
||||||
|
const lastName = fieldMap.get('last_name')?.value?.trim() || '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
return fullName || profile.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleText(enabled) {
|
||||||
|
return enabled ? 'yes' : 'no';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate }) {
|
||||||
|
const login = state.session.login || profile.login;
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
const topRow = document.createElement('div');
|
||||||
|
topRow.className = 'row';
|
||||||
|
topRow.innerHTML = `
|
||||||
|
<div class="row" style="gap:12px; align-items:center;">
|
||||||
|
<div class="avatar large">${profile.avatarInitials}</div>
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:22px; margin-bottom:2px;" data-profile-name="true">${profile.name}</h2>
|
||||||
|
<p class="meta-muted">${login}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="primary-btn" type="button" data-open-edit="true">Обновить</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const badgesRow = document.createElement('div');
|
||||||
|
badgesRow.className = 'row';
|
||||||
|
badgesRow.innerHTML = `
|
||||||
|
<button class="badge profile-toggle-btn" type="button" data-toggle="official">✔ Официальный: no</button>
|
||||||
|
<button class="badge alt profile-toggle-btn" type="button" data-toggle="shine">✨ Сияющий: no</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className = 'card profile-data-help';
|
||||||
|
hint.innerHTML = `
|
||||||
|
<div class="meta-muted">Личные данные пользователя</div>
|
||||||
|
<p>Поля ниже читаются из реальных пользовательских параметров сервера (ListUserParams). Любое изменение отправляется как блокчейн-запись параметра и требует подпись ключом пользователя.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = 'Загрузка параметров...';
|
||||||
|
|
||||||
|
const listWrap = document.createElement('div');
|
||||||
|
listWrap.className = 'stack profile-param-list';
|
||||||
|
|
||||||
|
const editModal = document.createElement('div');
|
||||||
|
editModal.className = 'profile-help-modal';
|
||||||
|
editModal.hidden = true;
|
||||||
|
editModal.innerHTML = `
|
||||||
|
<div class="profile-help-backdrop" data-close="true"></div>
|
||||||
|
<div class="profile-help-dialog card" role="dialog" aria-modal="true" aria-labelledby="profile-edit-title" tabindex="-1">
|
||||||
|
<div class="row" style="align-items:flex-start;">
|
||||||
|
<div>
|
||||||
|
<div class="meta-muted" style="margin-bottom:4px;">Обновление личных данных</div>
|
||||||
|
<h3 id="profile-edit-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>
|
||||||
|
<form class="stack" data-profile-form="true"></form>
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost-btn" type="button" data-cancel-edit="true">Отмена</button>
|
||||||
|
<button class="primary-btn" type="button" data-save-profile="true">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const profileNameEl = topRow.querySelector('[data-profile-name="true"]');
|
||||||
|
const openEditBtn = topRow.querySelector('[data-open-edit="true"]');
|
||||||
|
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
||||||
|
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
||||||
|
const formEl = editModal.querySelector('[data-profile-form="true"]');
|
||||||
|
const dialogEl = editModal.querySelector('.profile-help-dialog');
|
||||||
|
const saveBtn = editModal.querySelector('[data-save-profile="true"]');
|
||||||
|
|
||||||
|
let currentFields = profileFieldDefs.map((field) => ({ ...field, value: '', timeMs: 0 }));
|
||||||
|
let currentToggles = [
|
||||||
|
{ key: 'official', enabled: false, timeMs: 0 },
|
||||||
|
{ key: 'shine', enabled: false, timeMs: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function updateTogglesUi() {
|
||||||
|
const official = currentToggles.find((item) => item.key === 'official') || { enabled: false };
|
||||||
|
const shine = currentToggles.find((item) => item.key === 'shine') || { enabled: false };
|
||||||
|
|
||||||
|
officialBtn.textContent = `✔ Официальный: ${toggleText(official.enabled)}`;
|
||||||
|
shineBtn.textContent = `✨ Сияющий: ${toggleText(shine.enabled)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFields(fields) {
|
||||||
|
const fieldMap = new Map(fields.map((field) => [field.key, field]));
|
||||||
|
profileNameEl.textContent = getDisplayName(fieldMap);
|
||||||
|
|
||||||
|
listWrap.innerHTML = '';
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'card profile-param-item';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="profile-param-head">
|
||||||
|
<span class="meta-muted">${field.label}</span>
|
||||||
|
<span class="meta-muted">${field.key}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-param-value">${field.value || '—'}</div>
|
||||||
|
<div class="meta-muted profile-param-time">Обновлено: ${formatDateTime(field.timeMs)}</div>
|
||||||
|
`;
|
||||||
|
listWrap.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshProfileSnapshot() {
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = 'Загрузка параметров...';
|
||||||
|
openEditBtn.disabled = true;
|
||||||
|
officialBtn.disabled = true;
|
||||||
|
shineBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await loadProfileSnapshot(login);
|
||||||
|
currentFields = snapshot.fields;
|
||||||
|
currentToggles = snapshot.toggles;
|
||||||
|
renderFields(snapshot.fields);
|
||||||
|
updateTogglesUi();
|
||||||
|
status.className = 'status-line is-available';
|
||||||
|
status.textContent = 'Актуальные параметры загружены с сервера.';
|
||||||
|
} catch (error) {
|
||||||
|
renderFields(currentFields);
|
||||||
|
updateTogglesUi();
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось загрузить параметры: ${error.message || 'ошибка сети'}`;
|
||||||
|
} finally {
|
||||||
|
openEditBtn.disabled = false;
|
||||||
|
officialBtn.disabled = false;
|
||||||
|
shineBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
editModal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal() {
|
||||||
|
formEl.innerHTML = '';
|
||||||
|
|
||||||
|
currentFields.forEach((field) => {
|
||||||
|
const fieldWrap = document.createElement('label');
|
||||||
|
fieldWrap.className = 'stack';
|
||||||
|
fieldWrap.innerHTML = `
|
||||||
|
<span class="field-label">${field.label}</span>
|
||||||
|
<input class="input" type="text" name="${field.key}" placeholder="${field.placeholder || ''}" value="${field.value || ''}" />
|
||||||
|
`;
|
||||||
|
formEl.append(fieldWrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
editModal.hidden = false;
|
||||||
|
dialogEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChanges() {
|
||||||
|
const valuesByKey = {};
|
||||||
|
currentFields.forEach((field) => {
|
||||||
|
const input = formEl.querySelector(`input[name="${field.key}"]`);
|
||||||
|
valuesByKey[field.key] = input instanceof HTMLInputElement ? input.value : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await saveProfileParams(login, valuesByKey);
|
||||||
|
closeEditModal();
|
||||||
|
await refreshProfileSnapshot();
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось сохранить: ${error.message || 'ошибка сети'}`;
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleClick(toggleKey) {
|
||||||
|
const toggle = currentToggles.find((item) => item.key === toggleKey) || { enabled: false };
|
||||||
|
const nextEnabled = !toggle.enabled;
|
||||||
|
const title = toggleKey === 'official' ? 'Официальный аккаунт' : 'Сияющий аккаунт';
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Изменить параметр «${title}» на ${toggleText(nextEnabled)}?\n\n` +
|
||||||
|
'Внимание: изменение будет записано как блокчейн-параметр пользователя и требует подписи ключом блокчейна/пользователя на устройстве.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = 'Отправка изменения в блокчейн...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveProfileToggle(login, toggleKey, nextEnabled);
|
||||||
|
await refreshProfileSnapshot();
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось изменить ${toggleKey}: ${error.message || 'ошибка сети'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openEditBtn.addEventListener('click', openEditModal);
|
||||||
|
saveBtn.addEventListener('click', saveChanges);
|
||||||
|
officialBtn.addEventListener('click', () => onToggleClick('official'));
|
||||||
|
shineBtn.addEventListener('click', () => onToggleClick('shine'));
|
||||||
|
editModal.querySelector('[data-cancel-edit="true"]').addEventListener('click', closeEditModal);
|
||||||
|
|
||||||
|
editModal.addEventListener('click', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) {
|
||||||
|
closeEditModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editModal.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') closeEditModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
card.append(topRow, badgesRow, hint, status, listWrap);
|
||||||
|
screen.append(card, editModal);
|
||||||
|
|
||||||
|
refreshProfileSnapshot();
|
||||||
|
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
114
shine-UI/js/pages/register-view.js
Normal file
114
shine-UI/js/pages/register-view.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { authService, clearAuthMessages, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
140
shine-UI/js/pages/registration-keys-view.js
Normal file
140
shine-UI/js/pages/registration-keys-view.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import {
|
||||||
|
authService,
|
||||||
|
authorizeSession,
|
||||||
|
refreshSessions,
|
||||||
|
setAuthError,
|
||||||
|
setAuthInfo,
|
||||||
|
state,
|
||||||
|
} from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
120
shine-UI/js/pages/registration-payment-view.js
Normal file
120
shine-UI/js/pages/registration-payment-view.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import {
|
||||||
|
authService,
|
||||||
|
refreshRegistrationBalance,
|
||||||
|
setAuthError,
|
||||||
|
setAuthInfo,
|
||||||
|
state,
|
||||||
|
} from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
143
shine-UI/js/pages/server-settings-view.js
Normal file
143
shine-UI/js/pages/server-settings-view.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { checkServerAvailability, saveEntrySettings, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
30
shine-UI/js/pages/settings-view.js
Normal file
30
shine-UI/js/pages/settings-view.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
127
shine-UI/js/pages/show-keys-view.js
Normal file
127
shine-UI/js/pages/show-keys-view.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { state } from '../state.js?v=20260403081123';
|
||||||
|
import { loadEncryptedUserSecrets } from '../services/key-vault.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
53
shine-UI/js/pages/start-view.js
Normal file
53
shine-UI/js/pages/start-view.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { clearStartHint, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
84
shine-UI/js/pages/topup-view.js
Normal file
84
shine-UI/js/pages/topup-view.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
78
shine-UI/js/pages/wallet-view.js
Normal file
78
shine-UI/js/pages/wallet-view.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
|
import { wallet } from '../mock-data.js?v=20260403081123';
|
||||||
|
|
||||||
|
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
62
shine-UI/js/router.js
Normal 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' || pageId === 'add-channel-view') return 'channels-list';
|
||||||
|
return 'profile-view';
|
||||||
|
}
|
||||||
296
shine-UI/js/services/auth-service.js
Normal file
296
shine-UI/js/services/auth-service.js
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import { WsJsonClient } from './ws-client.js?v=20260403081123';
|
||||||
|
import {
|
||||||
|
deriveEd25519FromPassword,
|
||||||
|
exportEd25519PublicKeyB64,
|
||||||
|
exportPkcs8B64,
|
||||||
|
generateEd25519Pair,
|
||||||
|
importPkcs8Ed25519,
|
||||||
|
randomBase64,
|
||||||
|
signBase64,
|
||||||
|
} from './crypto-utils.js?v=20260403081123';
|
||||||
|
import { loadEncryptedUserSecrets, loadSessionMaterial, saveEncryptedUserSecrets, saveSessionMaterial } from './key-vault.js?v=20260403081123';
|
||||||
|
|
||||||
|
const BCH_SUFFIX = '001';
|
||||||
|
const USER_PARAMETER_PREFIX = 'SHiNe/UserParameter:';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSubscriptionsFeed(login, limit = 200) {
|
||||||
|
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
|
||||||
|
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChannelMessages(channel, limit = 200, sort = 'asc') {
|
||||||
|
const response = await this.ws.request('GetChannelMessages', { channel, limit, sort });
|
||||||
|
if (response.status !== 200) throw opError('GetChannelMessages', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) {
|
||||||
|
const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode });
|
||||||
|
if (response.status !== 200) throw opError('GetMessageThread', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async listUserParams(login) {
|
||||||
|
const cleanLogin = (login || '').trim();
|
||||||
|
if (!cleanLogin) throw new Error('Не передан login');
|
||||||
|
const response = await this.ws.request('ListUserParams', { login: cleanLogin });
|
||||||
|
if (response.status !== 200) throw opError('ListUserParams', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertUserParam({ login, param, value, timeMs = Date.now(), storagePwd }) {
|
||||||
|
const cleanLogin = (login || '').trim();
|
||||||
|
const cleanParam = (param || '').trim();
|
||||||
|
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param');
|
||||||
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи UserParam');
|
||||||
|
|
||||||
|
const user = await this.getUser(cleanLogin);
|
||||||
|
const deviceKey = String(user?.deviceKey || '').trim();
|
||||||
|
if (!deviceKey) {
|
||||||
|
throw new Error('GetUser не вернул deviceKey для подписи UserParam');
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||||
|
const devicePrivatePkcs8 = savedKeys?.deviceKey;
|
||||||
|
if (!devicePrivatePkcs8) {
|
||||||
|
throw new Error('На устройстве нет сохраненного приватного deviceKey');
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKey = await importPkcs8Ed25519(devicePrivatePkcs8);
|
||||||
|
const cleanValue = String(value ?? '');
|
||||||
|
const signText = `${USER_PARAMETER_PREFIX}${cleanLogin}${cleanParam}${timeMs}${cleanValue}`;
|
||||||
|
const signature = await signBase64(privateKey, signText);
|
||||||
|
|
||||||
|
const response = await this.ws.request('UpsertUserParam', {
|
||||||
|
login: cleanLogin,
|
||||||
|
param: cleanParam,
|
||||||
|
time_ms: Number(timeMs),
|
||||||
|
value: cleanValue,
|
||||||
|
device_key: deviceKey,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) throw opError('UpsertUserParam', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportClientError(details) {
|
||||||
|
try {
|
||||||
|
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
|
||||||
|
return response?.status === 200;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
105
shine-UI/js/services/client-error-reporter.js
Normal file
105
shine-UI/js/services/client-error-reporter.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
const MAX_CONTEXT_LEN = 2000;
|
||||||
|
const RECENT_WINDOW_MS = 5000;
|
||||||
|
|
||||||
|
let transport = null;
|
||||||
|
let transportDepth = 0;
|
||||||
|
const recentFingerprints = new Map();
|
||||||
|
|
||||||
|
function nowTs() {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanString(value, maxLen = 1000) {
|
||||||
|
if (value == null) return '';
|
||||||
|
const normalized = String(value).replace(/\s+/g, ' ').trim();
|
||||||
|
if (normalized.length <= maxLen) return normalized;
|
||||||
|
return `${normalized.slice(0, Math.max(0, maxLen - 3))}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyContext(context) {
|
||||||
|
if (context == null) return '';
|
||||||
|
try {
|
||||||
|
const raw = JSON.stringify(context);
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw.length <= MAX_CONTEXT_LEN) return raw;
|
||||||
|
return `${raw.slice(0, MAX_CONTEXT_LEN - 3)}...`;
|
||||||
|
} catch (error) {
|
||||||
|
return cleanString(`context_json_error:${error?.message || error}`, MAX_CONTEXT_LEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFingerprint(payload) {
|
||||||
|
return [
|
||||||
|
payload.kind,
|
||||||
|
payload.message,
|
||||||
|
payload.sourceUrl,
|
||||||
|
payload.lineNumber,
|
||||||
|
payload.columnNumber,
|
||||||
|
payload.requestOp,
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicate(fingerprint) {
|
||||||
|
const ts = nowTs();
|
||||||
|
const prev = recentFingerprints.get(fingerprint);
|
||||||
|
recentFingerprints.set(fingerprint, ts);
|
||||||
|
|
||||||
|
for (const [key, time] of recentFingerprints.entries()) {
|
||||||
|
if (ts - time > RECENT_WINDOW_MS) {
|
||||||
|
recentFingerprints.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev != null && ts - prev < RECENT_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(details = {}) {
|
||||||
|
return {
|
||||||
|
kind: cleanString(details.kind || 'client_error', 64),
|
||||||
|
message: cleanString(details.message || details.reason || 'Unknown client error', 500),
|
||||||
|
stack: cleanString(details.stack || details.error?.stack || '', 8000),
|
||||||
|
sourceUrl: cleanString(details.sourceUrl || details.fileName || '', 240),
|
||||||
|
lineNumber: Number.isFinite(details.lineNumber) ? details.lineNumber : null,
|
||||||
|
columnNumber: Number.isFinite(details.columnNumber) ? details.columnNumber : null,
|
||||||
|
route: cleanString(details.route || window.location?.hash || '', 200),
|
||||||
|
href: cleanString(details.href || window.location?.href || '', 240),
|
||||||
|
userAgent: cleanString(details.userAgent || navigator.userAgent || '', 240),
|
||||||
|
clientTs: Number.isFinite(details.clientTs) ? details.clientTs : nowTs(),
|
||||||
|
requestOp: cleanString(details.requestOp || '', 64),
|
||||||
|
requestIdRef: cleanString(details.requestIdRef || '', 128),
|
||||||
|
contextJson: stringifyContext({
|
||||||
|
title: document.title || '',
|
||||||
|
pageVisibility: document.visibilityState || '',
|
||||||
|
...details.context,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setClientErrorTransport(fn) {
|
||||||
|
transport = typeof fn === 'function' ? fn : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureClientError(details = {}) {
|
||||||
|
const payload = buildPayload(details);
|
||||||
|
if (!payload.message) return false;
|
||||||
|
|
||||||
|
const fingerprint = details.dedupeKey || makeFingerprint(payload);
|
||||||
|
if (isDuplicate(fingerprint)) return false;
|
||||||
|
|
||||||
|
console.error('[client-error]', payload.kind, payload.message, details.error || '');
|
||||||
|
|
||||||
|
if (!transport || details.skipTransport === true || transportDepth > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
transportDepth += 1;
|
||||||
|
await transport(payload);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('client error transport failed', error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
transportDepth = Math.max(0, transportDepth - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
shine-UI/js/services/crypto-utils.js
Normal file
150
shine-UI/js/services/crypto-utils.js
Normal 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));
|
||||||
|
}
|
||||||
87
shine-UI/js/services/key-vault.js
Normal file
87
shine-UI/js/services/key-vault.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
decryptJsonWithStoragePwd,
|
||||||
|
encryptJsonWithStoragePwd,
|
||||||
|
} from './crypto-utils.js?v=20260403081123';
|
||||||
|
|
||||||
|
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 заблокирована открытыми соединениями'));
|
||||||
|
});
|
||||||
|
}
|
||||||
106
shine-UI/js/services/user-profile-params.js
Normal file
106
shine-UI/js/services/user-profile-params.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { authService, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
|
export const profileFieldDefs = [
|
||||||
|
{ key: 'first_name', readKeys: ['first_name', 'name'], label: 'First name', placeholder: 'Введите имя' },
|
||||||
|
{ key: 'last_name', readKeys: ['last_name'], label: 'Last name', placeholder: 'Введите фамилию' },
|
||||||
|
{ key: 'address_physical', readKeys: ['address_physical'], label: 'Address physical', placeholder: 'Город, улица, дом' },
|
||||||
|
{ key: 'address_web', readKeys: ['address_web'], label: 'Address web', placeholder: 'Сайт или профиль' },
|
||||||
|
{ key: 'phone', readKeys: ['phone'], label: 'Phone', placeholder: '+7 ...' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const profileToggleDefs = [
|
||||||
|
{ key: 'official', label: 'Официальный' },
|
||||||
|
{ key: 'shine', label: 'Сияющий' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeItems(responsePayload) {
|
||||||
|
const params = responsePayload?.params;
|
||||||
|
if (!Array.isArray(params)) return [];
|
||||||
|
return params
|
||||||
|
.map((item) => ({
|
||||||
|
param: String(item?.param || '').trim(),
|
||||||
|
value: String(item?.value || ''),
|
||||||
|
timeMs: Number(item?.time_ms || 0),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.param);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestByAliases(items, aliases) {
|
||||||
|
let latest = null;
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (!aliases.includes(item.param)) return;
|
||||||
|
if (!latest || item.timeMs >= latest.timeMs) {
|
||||||
|
latest = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToggleValue(value) {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
return normalized === 'true' || normalized === 'yes' || normalized === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStoragePwd() {
|
||||||
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
|
if (!storagePwd) {
|
||||||
|
throw new Error('Нет storagePwd в памяти сессии. Выполните вход заново.');
|
||||||
|
}
|
||||||
|
return storagePwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProfileSnapshot(login) {
|
||||||
|
const payload = await authService.listUserParams(login);
|
||||||
|
const items = normalizeItems(payload);
|
||||||
|
|
||||||
|
const fields = profileFieldDefs.map((field) => {
|
||||||
|
const latest = getLatestByAliases(items, field.readKeys);
|
||||||
|
return {
|
||||||
|
key: field.key,
|
||||||
|
label: field.label,
|
||||||
|
placeholder: field.placeholder,
|
||||||
|
value: latest?.value || '',
|
||||||
|
timeMs: latest?.timeMs || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggles = profileToggleDefs.map((toggle) => {
|
||||||
|
const latest = getLatestByAliases(items, [toggle.key]);
|
||||||
|
return {
|
||||||
|
key: toggle.key,
|
||||||
|
label: toggle.label,
|
||||||
|
enabled: latest ? parseToggleValue(latest.value) : false,
|
||||||
|
rawValue: latest?.value || 'no',
|
||||||
|
timeMs: latest?.timeMs || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fields, toggles };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProfileParams(login, valuesByKey) {
|
||||||
|
const storagePwd = await getStoragePwd();
|
||||||
|
const baseTime = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < profileFieldDefs.length; i += 1) {
|
||||||
|
const field = profileFieldDefs[i];
|
||||||
|
await authService.upsertUserParam({
|
||||||
|
login,
|
||||||
|
param: field.key,
|
||||||
|
value: String(valuesByKey[field.key] || '').trim(),
|
||||||
|
timeMs: baseTime + i,
|
||||||
|
storagePwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProfileToggle(login, key, enabled) {
|
||||||
|
const storagePwd = await getStoragePwd();
|
||||||
|
await authService.upsertUserParam({
|
||||||
|
login,
|
||||||
|
param: key,
|
||||||
|
value: enabled ? 'yes' : 'no',
|
||||||
|
timeMs: Date.now(),
|
||||||
|
storagePwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
145
shine-UI/js/services/ws-client.js
Normal file
145
shine-UI/js/services/ws-client.js
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { captureClientError } from './client-error-reporter.js?v=20260403081123';
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
captureClientError({
|
||||||
|
kind: 'ws_open_error',
|
||||||
|
message: `Failed to connect WebSocket ${this.url}`,
|
||||||
|
context: { url: this.url },
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
if (op !== 'ClientErrorLog') {
|
||||||
|
captureClientError({
|
||||||
|
kind: 'ws_timeout',
|
||||||
|
message: `Timeout waiting for ${op}`,
|
||||||
|
requestOp: op,
|
||||||
|
requestIdRef: requestId,
|
||||||
|
context: { url: this.url, timeoutMs },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reject(new Error(`Таймаут ответа для операции ${op}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.pending.set(requestId, {
|
||||||
|
op,
|
||||||
|
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 {
|
||||||
|
captureClientError({
|
||||||
|
kind: 'ws_bad_json',
|
||||||
|
message: 'Received non-JSON message from server',
|
||||||
|
context: { raw: String(raw).slice(0, 1000) },
|
||||||
|
});
|
||||||
|
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 pendingOps = [...this.pending.values()]
|
||||||
|
.map((slot) => slot.op)
|
||||||
|
.filter((op) => op && op !== 'ClientErrorLog');
|
||||||
|
if (pendingOps.length > 0) {
|
||||||
|
captureClientError({
|
||||||
|
kind: 'ws_closed',
|
||||||
|
message,
|
||||||
|
context: { url: this.url, pendingOps },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(message);
|
||||||
|
for (const [, slot] of this.pending.entries()) {
|
||||||
|
slot.reject(error);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
261
shine-UI/js/state.js
Normal file
261
shine-UI/js/state.js
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import { chatMessages, wallet } from './mock-data.js?v=20260403081123';
|
||||||
|
import { AuthService } from './services/auth-service.js?v=20260403081123';
|
||||||
|
import { clearClientAuthData } from './services/key-vault.js?v=20260403081123';
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
channelsFeed: null,
|
||||||
|
channelsIndex: {},
|
||||||
|
localChannelPosts: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setChannelsFeed(feed, index) {
|
||||||
|
state.channelsFeed = feed || null;
|
||||||
|
state.channelsIndex = index || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalChannelPosts(channelId) {
|
||||||
|
if (!channelId) return [];
|
||||||
|
if (!state.localChannelPosts[channelId]) {
|
||||||
|
state.localChannelPosts[channelId] = [];
|
||||||
|
}
|
||||||
|
return state.localChannelPosts[channelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLocalChannelPost(channelId, post) {
|
||||||
|
if (!channelId) return;
|
||||||
|
const text = post?.body?.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
getLocalChannelPosts(channelId).push({
|
||||||
|
title: post.title || `${state.session.login || 'Вы'} • сейчас`,
|
||||||
|
body: text,
|
||||||
|
});
|
||||||
|
}
|
||||||
797
shine-UI/styles/components.css
Normal file
797
shine-UI/styles/components.css
Normal file
@ -0,0 +1,797 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.profile-data-help p {
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #d8e3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-param-list {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-param-item {
|
||||||
|
padding: 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-param-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-param-value {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #eef3ff;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-param-time {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #dbe7ff;
|
||||||
|
margin: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-scroll-wrap {
|
||||||
|
position: relative;
|
||||||
|
max-height: 58vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-groups {
|
||||||
|
min-height: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-divider {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
margin: 6px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-scroll-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: calc(100% - 8px);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, rgba(83, 216, 251, 0.55), rgba(83, 216, 251, 0.15));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
55
shine-UI/styles/layout.css
Normal file
55
shine-UI/styles/layout.css
Normal 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
51
shine-UI/styles/main.css
Normal 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;
|
||||||
|
}
|
||||||
2884
shine-server-blockchain/all_files.txt
Normal file
2884
shine-server-blockchain/all_files.txt
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||||
|
|||||||
20
shine-server-blockchain/concat_to_file.sh
Executable file
20
shine-server-blockchain/concat_to_file.sh
Executable 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)"
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; // параметры профиля да надо
|
||||||
|
//}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user