Compare commits
27 Commits
1b0e1cf1d4
...
89d06d317b
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
89d06d317b | ||
|
|
c5ec32f87a | ||
|
|
ce5c348023 | ||
|
|
832eea5889 | ||
|
|
60049442f1 | ||
|
|
624557ebfd | ||
|
|
6b0379bfdc | ||
|
|
a9510a6d36 | ||
|
|
59e4156bb9 | ||
|
|
de9606519a | ||
|
|
eeb115584d | ||
|
|
ee3721dfa4 | ||
|
|
239cc231ea | ||
|
|
4bd4df7b09 | ||
|
|
d12371b84f | ||
|
|
c97b3e3ec3 | ||
|
|
2c2aad1355 | ||
|
|
9949935bcc | ||
|
|
35fc6ebf62 | ||
|
|
d2205648e6 | ||
|
|
68ed93dd24 | ||
|
|
a06b76b800 | ||
|
|
67f882b9bc | ||
|
|
17dc4981c6 | ||
|
|
0179b25d12 | ||
|
|
e3c1cbf1c0 | ||
|
|
5899bd2f77 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -89,3 +89,7 @@ ESP32/**/*.uf2
|
|||||||
ESP32/**/*.o
|
ESP32/**/*.o
|
||||||
ESP32/**/*.d
|
ESP32/**/*.d
|
||||||
ESP32/**/*.a
|
ESP32/**/*.a
|
||||||
|
|
||||||
|
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||||
|
server-backup/archive/**
|
||||||
|
!server-backup/archive/.gitkeep
|
||||||
|
|||||||
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@ -3,7 +3,6 @@
|
|||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/shine-solana" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/tools/understand-anything-lab/upstream" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$/tools/understand-anything-lab/upstream" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
11
AGENTS.md
11
AGENTS.md
@ -8,6 +8,15 @@
|
|||||||
## Примечание
|
## Примечание
|
||||||
- Если внешний инструмент/интеграция требует английский формат, допускается английский, но рядом желательно дать краткое пояснение на русском.
|
- Если внешний инструмент/интеграция требует английский формат, допускается английский, но рядом желательно дать краткое пояснение на русском.
|
||||||
|
|
||||||
|
## Структура проекта (кратко)
|
||||||
|
- Серверный код SHiNE находится в папке `SHiNE-server/`.
|
||||||
|
- Код клиентского UI SHiNE находится в папке `shine-UI/`.
|
||||||
|
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
||||||
|
- точка входа `shine-UI/server-ui.html`;
|
||||||
|
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
||||||
|
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
|
||||||
|
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
||||||
|
|
||||||
## Сервис агента-кодера
|
## Сервис агента-кодера
|
||||||
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
||||||
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
||||||
@ -27,7 +36,7 @@
|
|||||||
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
|
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
|
||||||
- `Dev_Docs/Solana_Architecture/README.md`
|
- `Dev_Docs/Solana_Architecture/README.md`
|
||||||
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
||||||
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md`
|
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
||||||
|
|
||||||
## Документация блокчейна
|
## Документация блокчейна
|
||||||
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
|
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
|
||||||
|
|||||||
@ -1,2 +1,11 @@
|
|||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
@AGENT_DEBUG_RUNBOOK.md
|
@AGENT_DEBUG_RUNBOOK.md
|
||||||
|
|
||||||
|
## Обязательно читать при работе с UI
|
||||||
|
@shine-UI/AGENTS.md
|
||||||
|
|
||||||
|
## Справка по подпроектам
|
||||||
|
- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`.
|
||||||
|
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
|
||||||
|
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
|
||||||
|
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
||||||
|
|||||||
255
DAO_запуск/README.md
Normal file
255
DAO_запуск/README.md
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
# DAO_запуск
|
||||||
|
|
||||||
|
Рабочий документ по тому, что ещё нужно сделать для первого запуска DAO-сценария SHiNE.
|
||||||
|
|
||||||
|
Логика документа:
|
||||||
|
|
||||||
|
- `этап1` — то, без чего нельзя считать сценарий первого запуска собранным даже в тестовом виде;
|
||||||
|
- `этап2` — то, что полезно и, вероятно, потребуется дальше, но это можно делать после старта `этап1` или параллельно без блокировки первого результата.
|
||||||
|
|
||||||
|
Базовая среда первого прохода:
|
||||||
|
|
||||||
|
- сеть `Solana devnet`;
|
||||||
|
- модель синхронизации: `server-to-server`;
|
||||||
|
- `Solana + Arweave` используются как якорь и архив;
|
||||||
|
- DAO понимается как стандартный governance/smart-contract контур, который управляет отдельными программами SHiNE, приносящими деньги.
|
||||||
|
|
||||||
|
## Краткий вывод
|
||||||
|
|
||||||
|
Для первого запуска DAO в тестовом виде текущего списка в целом хватает, но только если понимать запуск как:
|
||||||
|
|
||||||
|
- можно развернуть и проверить базовый DAO-контур;
|
||||||
|
- можно зарегистрировать пользователей и ключевые сущности;
|
||||||
|
- можно провести тестовую покупку билета через smart contract;
|
||||||
|
- можно завести тестовый денежный поток в программы, управляемые DAO;
|
||||||
|
- можно проверить опорную межсерверную синхронизацию и фиксацию состояния в архивный слой.
|
||||||
|
|
||||||
|
Если же под "запуском" понимать уже полностью устойчивую production-схему с ротацией ключей, восстановлением любого сервера из архива, железными устройствами подписи и полным циклом администрирования, то текущий список нужно будет ещё расширять.
|
||||||
|
|
||||||
|
## Этап1
|
||||||
|
|
||||||
|
Цель этапа: собрать минимально жизнеспособный DAO-сценарий в `devnet`, который можно пройти руками от регистрации до базовой экономики и проверки архитектуры.
|
||||||
|
|
||||||
|
### 1. Переписать и стабилизировать регистрацию пользователей без Anchor
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- довести `shine_users` в чистом Rust/Solana SDK до рабочего и проверенного состояния;
|
||||||
|
- убедиться, что `shine_login_guard` и связанный сценарий регистрации совместимы с новым ABI;
|
||||||
|
- проверить создание и чтение `user_pda`;
|
||||||
|
- проверить update пользовательской записи и связанные экономические параметры;
|
||||||
|
- синхронизировать сервер, UI и lazy-import с новым форматом и seed'ами.
|
||||||
|
|
||||||
|
Почему это в `этап1`:
|
||||||
|
|
||||||
|
- без стабильной пользовательской регистрации дальше нельзя строить ни DAO-сценарий, ни привязку устройств, ни платёжные сценарии.
|
||||||
|
|
||||||
|
### 2. Проверить полный сценарий регистрации и базовой Solana-интеграции
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- руками прогнать регистрацию нового пользователя;
|
||||||
|
- руками прогнать создание и update server PDA там, где это требуется текущему сценарию;
|
||||||
|
- убедиться, что сервер читает новые PDA без anchor-зависимостей и без старых discriminator'ов;
|
||||||
|
- зафиксировать, какие именно части сценария уже подтверждены руками, а какие ещё нет.
|
||||||
|
|
||||||
|
Почему это в `этап1`:
|
||||||
|
|
||||||
|
- сейчас в проекте уже есть признаки перехода на pure Rust, но без ручной проверки это нельзя считать завершённым.
|
||||||
|
|
||||||
|
### 3. Создать стандартный DAO smart contract / governance-контур
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- определить и реализовать стандартный DAO-контур, который будет управлять программами SHiNE;
|
||||||
|
- зафиксировать, какие права сразу передаются DAO, а какие временно остаются на отдельных ключах;
|
||||||
|
- подготовить тестовую DAO-структуру в `devnet`.
|
||||||
|
|
||||||
|
Минимум для первого запуска:
|
||||||
|
|
||||||
|
- DAO существует как управляемая сущность;
|
||||||
|
- DAO может владеть или контролировать ключевые права управления денежными программами;
|
||||||
|
- есть понятный путь, как DAO влияет на доходные программы SHiNE.
|
||||||
|
|
||||||
|
Почему это в `этап1`:
|
||||||
|
|
||||||
|
- без этого "DAO-запуск" будет только запуском отдельных Solana-программ, но не запуском управляемой DAO-системы.
|
||||||
|
|
||||||
|
### 4. Доработать смарт-контракт выплат с третьей очередью
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- добавить в `shine_payments` третью очередь, о которой уже принято решение;
|
||||||
|
- проверить совместимость с текущей моделью тикетов, выплат и DAO-управления;
|
||||||
|
- убедиться, что логика очередей соответствует ожидаемой экономике проекта.
|
||||||
|
|
||||||
|
Почему это в `этап1`:
|
||||||
|
|
||||||
|
- по текущей постановке это нужно именно для сценария регистрации DAO и дальнейшей экономики.
|
||||||
|
|
||||||
|
### 5. Сделать UI для покупки билетов и просмотра очереди
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- добавить UI-сценарий покупки билетов через smart contract;
|
||||||
|
- показать пользователю, сколько перед ним человек в очереди;
|
||||||
|
- убедиться, что UI отражает актуальное состояние контрактной логики, а не локальные предположения.
|
||||||
|
|
||||||
|
Почему это в `этап1`:
|
||||||
|
|
||||||
|
- покупка билетов у тебя обозначена как часть DAO-сценария, а не как побочная функция;
|
||||||
|
- без UI можно тестировать контракт вручную, но нельзя считать сценарий запуска достаточно собранным для нормальной проверки.
|
||||||
|
|
||||||
|
### 6. Реализовать базовую синхронизацию серверов
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- сделать обмен состоянием между серверами по модели `server-to-server`;
|
||||||
|
- определить минимальный набор данных, который обязан синхронизироваться;
|
||||||
|
- предусмотреть фиксацию синхронизированного состояния в `Arweave`, а `Solana` использовать как якорь и ссылочный слой;
|
||||||
|
- описать, какой сервер считается источником истины в спорных случаях или как решается конфликт.
|
||||||
|
|
||||||
|
Почему это в `этап1`:
|
||||||
|
|
||||||
|
- без межсерверной синхронизации трудно обосновать архитектуру сети как воспроизводимую и переносимую;
|
||||||
|
- это напрямую связано с идеей, что любой сможет поднять свой сервер.
|
||||||
|
|
||||||
|
### 7. Подготовить базовый сценарий архивирования и восстановления
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- описать и частично реализовать схему: серверы синхронизируются между собой, архив состояния уходит в `Arweave`, ссылка/якорь фиксируется через `Solana`;
|
||||||
|
- определить минимальный сценарий восстановления блоков или состояния из архивного слоя;
|
||||||
|
- подтвердить, что новый сервер может получить достаточно данных для старта.
|
||||||
|
|
||||||
|
Почему это в `этап1`:
|
||||||
|
|
||||||
|
- это один из ключевых признаков независимой и воспроизводимой DAO-инфраструктуры.
|
||||||
|
|
||||||
|
## Этап2
|
||||||
|
|
||||||
|
Цель этапа: усилить безопасность, автономность и удобство системы после того, как минимальный DAO-сценарий уже запустился и проверен в `devnet`.
|
||||||
|
|
||||||
|
### 1. Смена ключей цифровой подписи
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- продумать и реализовать смену `root key`, `device key`, `blockchain key`;
|
||||||
|
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
|
||||||
|
- продумать, как не потерять доступ и как обновлять доверие к новым ключам.
|
||||||
|
|
||||||
|
Почему это в `этап2`:
|
||||||
|
|
||||||
|
- для production это очень важно;
|
||||||
|
- для первого тестового запуска можно временно использовать фиксированный набор ключей.
|
||||||
|
|
||||||
|
### 2. Полная повторная перепроверка всех сценариев
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- повторно прогнать регистрацию, DAO, выплаты, билеты, синхронизацию и архивирование после стабилизации `этап1`;
|
||||||
|
- оформить итоговый чек-лист ручной проверки;
|
||||||
|
- отдельно проверить пограничные сценарии и восстановление после ошибок.
|
||||||
|
|
||||||
|
Почему это в `этап2`:
|
||||||
|
|
||||||
|
- это обязательный шаг перед переходом от "собрали" к "доверяем".
|
||||||
|
|
||||||
|
### 3. Устройство на ESP32 как сабсервер с ключами
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- дописать прошивку, чтобы устройство могло выступать сабсервером с ключами;
|
||||||
|
- дать ему возможность регистрироваться и подключаться к серверу;
|
||||||
|
- определить, какие операции устройство подписывает и где хранит ключевой материал.
|
||||||
|
|
||||||
|
Почему это в `этап2`:
|
||||||
|
|
||||||
|
- это очень сильное развитие архитектуры, но оно не должно блокировать первый DAO-запуск.
|
||||||
|
|
||||||
|
### 4. Логин и подпись через коробочки / устройства
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- реализовать сценарий входа через устройство или хотя бы сценарий подписи сообщений и ключей через устройство;
|
||||||
|
- определить, как это встраивается в регистрацию DAO и подтверждение действий;
|
||||||
|
- проверить, можно ли через это безопасно регистрировать DAO или подписывать критичные команды.
|
||||||
|
|
||||||
|
Почему это в `этап2`:
|
||||||
|
|
||||||
|
- это следующий уровень безопасности и UX, но не минимальный блокер первого старта.
|
||||||
|
|
||||||
|
### 5. Создание тестового DAO с использованием устройств подписи
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- после готовности устройств собрать тестовый DAO-сценарий уже с аппаратным участием;
|
||||||
|
- проверить, где устройство достаточно, а где всё ещё нужен обычный кошелёк или управляющий ключ.
|
||||||
|
|
||||||
|
Почему это в `этап2`:
|
||||||
|
|
||||||
|
- это проверка усиленной модели, а не базового старта.
|
||||||
|
|
||||||
|
### 6. Расписание синхронизации серверов
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- определить периодичность и правила фоновой синхронизации;
|
||||||
|
- продумать ручной и автоматический режим;
|
||||||
|
- решить, как часто публиковать архивные снимки и якоря.
|
||||||
|
|
||||||
|
Почему это в `этап2`:
|
||||||
|
|
||||||
|
- сначала важнее добиться самой работающей синхронизации, а потом уже делать её регулярной и автономной.
|
||||||
|
|
||||||
|
### 7. Полное восстановление блоков из Solana/Arweave
|
||||||
|
|
||||||
|
Что сделать:
|
||||||
|
|
||||||
|
- довести процедуру восстановления до сценария "любой может поднять свой сервер";
|
||||||
|
- определить минимальный bootstrap-набор;
|
||||||
|
- проверить восстановление на чистом окружении.
|
||||||
|
|
||||||
|
Почему это в `этап2`:
|
||||||
|
|
||||||
|
- для концепции сети это критично, но как полноценная задача обычно идёт после появления базового архива и первичной синхронизации.
|
||||||
|
|
||||||
|
## Что блокирует первый запуск сильнее всего
|
||||||
|
|
||||||
|
Если расставить приоритет внутри `этап1`, то самый жёсткий порядок сейчас выглядит так:
|
||||||
|
|
||||||
|
1. pure Rust регистрация пользователей и ручная проверка сценария;
|
||||||
|
2. DAO/gov-контур и его права управления;
|
||||||
|
3. доработка выплат с третьей очередью;
|
||||||
|
4. покупка билетов через smart contract и UI-проверка очереди;
|
||||||
|
5. межсерверная синхронизация;
|
||||||
|
6. архивирование в `Arweave` с якорем в `Solana`;
|
||||||
|
7. минимальное восстановление состояния новым сервером.
|
||||||
|
|
||||||
|
## Что уже частично похоже на готовое
|
||||||
|
|
||||||
|
По текущим документам и следам в проекте уже видно, что:
|
||||||
|
|
||||||
|
- переход `shine_users` и `shine_login_guard` на pure Rust уже начат и в значительной степени сделан;
|
||||||
|
- архитектура DAO, `shine_users` и `shine_payments` уже описана;
|
||||||
|
- часть Solana-структуры и PDA-форматов уже формализована;
|
||||||
|
- тема ESP32 уже отдельно присутствует в проекте как направление.
|
||||||
|
|
||||||
|
Это хорошо, потому что документ получается не "с нуля", а как сборка того, что уже назрело в коде и планах.
|
||||||
|
|
||||||
|
## Вопросы, которые всё ещё стоит уточнить
|
||||||
|
|
||||||
|
1. Какой именно стандарт DAO планируется использовать в первом проходе: готовый governance-стек Solana или собственная минимальная обвязка вокруг управляющих кошельков?
|
||||||
|
2. Третья очередь в `shine_payments` уже точно определена по смыслу, или пока есть только решение "она нужна", но без финальной экономики?
|
||||||
|
3. Что именно считается единицей синхронизации между серверами: блоки SHiNE, агрегированные снапшоты, PDA-состояния, или смесь этих вариантов?
|
||||||
|
4. Нужен ли для `этап1` уже полноценный автоматический recovery нового сервера, или достаточно доказать это в полу-ручном сценарии?
|
||||||
|
5. Покупка билетов должна в первом проходе работать только через web/UI, или также нужен отдельный сценарий из серверного UI или скриптов?
|
||||||
|
|
||||||
|
## Рекомендуемый следующий практический шаг
|
||||||
|
|
||||||
|
Если идти без распыления, то следующим рабочим фокусом стоит считать:
|
||||||
|
|
||||||
|
1. закрыть ручную проверку pure Rust регистрации;
|
||||||
|
2. после этого формализовать минимальный DAO-контур;
|
||||||
|
3. затем переходить к третьей очереди выплат и к UI покупки билетов;
|
||||||
|
4. после этого делать синхронизацию, архив и восстановление.
|
||||||
100
Dev_Docs/Blockchain/sync-between-servers.md
Normal file
100
Dev_Docs/Blockchain/sync-between-servers.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# Синхронизация блоков и DM между серверами SHiNE
|
||||||
|
|
||||||
|
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
|
||||||
|
|
||||||
|
## 1. Зачем нужна синхронизация
|
||||||
|
|
||||||
|
Пользователи SHiNE могут быть «приписаны» к разным серверам.
|
||||||
|
Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y):
|
||||||
|
|
||||||
|
1. Сервер X принимает сообщение;
|
||||||
|
2. Сервер X должен переслать DM-блок серверу Y;
|
||||||
|
3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B.
|
||||||
|
|
||||||
|
Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться,
|
||||||
|
чтобы любой партнёрский сервер мог отдать полную историю пользователя.
|
||||||
|
|
||||||
|
## 2. Список серверов синхронизации (`sync_servers`)
|
||||||
|
|
||||||
|
Каждый сервер регистрирует в своей Solana PDA список `sync_servers` —
|
||||||
|
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
||||||
|
|
||||||
|
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
||||||
|
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
||||||
|
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
|
||||||
|
|
||||||
|
## 3. Что синхронизируется
|
||||||
|
|
||||||
|
### 3.1 Личные сообщения (DM)
|
||||||
|
|
||||||
|
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
|
||||||
|
- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя.
|
||||||
|
- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии.
|
||||||
|
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
|
||||||
|
|
||||||
|
### 3.2 Блоки пользовательского блокчейна
|
||||||
|
|
||||||
|
- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него.
|
||||||
|
- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`.
|
||||||
|
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
||||||
|
- Дедупликация по глобальному номеру блока и хэшу.
|
||||||
|
|
||||||
|
## 4. Протокол синхронизации (целевой, не реализован)
|
||||||
|
|
||||||
|
### 4.1 Межсерверное соединение
|
||||||
|
|
||||||
|
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
|
||||||
|
- Адрес партнёра определяется по `server_address` из его Solana PDA.
|
||||||
|
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
|
||||||
|
- При разрыве — переподключение с экспоненциальным backoff.
|
||||||
|
|
||||||
|
### 4.2 Доставка новых данных (push)
|
||||||
|
|
||||||
|
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
|
||||||
|
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
|
||||||
|
|
||||||
|
### 4.3 Начальная синхронизация (backfill)
|
||||||
|
|
||||||
|
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
|
||||||
|
последний глобальный номер блока, последний известный DM-ключ.
|
||||||
|
- Сервер с более полной историей досылает недостающее партнёру.
|
||||||
|
|
||||||
|
### 4.4 Разрешение конфликтов
|
||||||
|
|
||||||
|
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
|
||||||
|
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`).
|
||||||
|
- DM: конфликтов нет, `message_key` уникален.
|
||||||
|
|
||||||
|
## 5. Маршрутизация DM между серверами
|
||||||
|
|
||||||
|
При отправке DM от пользователя A к пользователю B:
|
||||||
|
|
||||||
|
1. Клиент A отправляет пару блоков на свой сервер X.
|
||||||
|
2. Сервер X определяет, на каком сервере зарегистрирован пользователь B.
|
||||||
|
- Сначала проверяет локально (если B зарегистрирован на X).
|
||||||
|
- Иначе читает PDA пользователя B из Solana и смотрит `access_servers`.
|
||||||
|
- Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM.
|
||||||
|
3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки.
|
||||||
|
|
||||||
|
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
|
||||||
|
|
||||||
|
## 6. Безопасность
|
||||||
|
|
||||||
|
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
|
||||||
|
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
|
||||||
|
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
|
||||||
|
|
||||||
|
## 7. Статус реализации
|
||||||
|
|
||||||
|
| Компонент | Статус |
|
||||||
|
|-----------|--------|
|
||||||
|
| Регистрация серверной PDA в Solana | ✅ Реализовано |
|
||||||
|
| Чтение `sync_servers` из PDA | Нужна реализация |
|
||||||
|
| Межсерверный WebSocket-канал | Нужна реализация |
|
||||||
|
| Push новых DM партнёрам | Нужна реализация |
|
||||||
|
| Push блоков блокчейна партнёрам | Нужна реализация |
|
||||||
|
| Backfill при первом подключении | Нужна реализация |
|
||||||
|
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
|
||||||
|
|
||||||
|
Текущая версия сервера работает без межсерверной синхронизации.
|
||||||
|
Синхронизация — задача следующего этапа разработки.
|
||||||
@ -36,6 +36,12 @@
|
|||||||
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
|
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
|
||||||
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
|
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
|
||||||
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
||||||
|
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
||||||
|
- `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи.
|
||||||
|
|
||||||
|
### DAO-запуск
|
||||||
|
|
||||||
|
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
|
||||||
|
|
||||||
### Дальнее будущее
|
### Дальнее будущее
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
# ESP32 как аппаратный кошелёк (device-сессия)
|
||||||
|
|
||||||
|
## Суть фичи
|
||||||
|
|
||||||
|
ESP32 становится аппаратным HSM (hardware security module): хранит ключи, постоянно подключён к SHiNE-серверу как device-сессия, подтверждает операции нажатием на экране. Другие устройства (браузер, телефон) взаимодействуют с ESP32 через сервер — без прямого соединения.
|
||||||
|
|
||||||
|
## Два ключевых сценария
|
||||||
|
|
||||||
|
### Сценарий 1 — Создание делегированной сессии
|
||||||
|
1. Браузер/телефон → сервер: «хочу делегированную сессию от имени пользователя X»
|
||||||
|
2. Сервер → ESP32 (device-сессия): «запрос на одобрение»
|
||||||
|
3. Пользователь нажимает «Да» на сенсорном экране ESP32
|
||||||
|
4. ESP32 → сервер: одобрено → сервер создаёт делегированную сессию для браузера
|
||||||
|
|
||||||
|
### Сценарий 2 — Подпись транзакции / блока
|
||||||
|
1. Браузер (через делегированную сессию) → сервер → ESP32: «подпиши вот это»
|
||||||
|
2. ESP32 показывает запрос на экране, пользователь подтверждает
|
||||||
|
3. ESP32 подписывает нужным ключом → ответ через сервер → браузер
|
||||||
|
|
||||||
|
## Что нужно сделать
|
||||||
|
|
||||||
|
### ESP32 (основная работа)
|
||||||
|
- [ ] Инициализация WiFi (SSID/пароль в NVS)
|
||||||
|
- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером
|
||||||
|
- [ ] Авторизация на сервере: `AuthChallenge` → `CreateAuthSession` через `deviceKey` (уже есть в NVS), сохранить `sessionId` в NVS
|
||||||
|
- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу
|
||||||
|
- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет
|
||||||
|
- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх)
|
||||||
|
- [ ] Переподключение при разрыве (reconnect loop)
|
||||||
|
|
||||||
|
### Сервер (минимальные изменения)
|
||||||
|
- [ ] Добавить поле `sessionType` (`USER` / `DEVICE`) в таблицу `active_sessions`
|
||||||
|
- [ ] Новая операция `DeviceApprovalRequest` — браузер запрашивает одобрение у device-сессии
|
||||||
|
- [ ] Новая операция `DeviceApprovalResponse` — ESP32 отвечает (одобрено/отклонено)
|
||||||
|
- [ ] Новые операции `SignRequest` / `SignResponse` — запрос подписи и ответ
|
||||||
|
- [ ] Роутинг: при получении запроса найти device-сессию через `ActiveConnectionsRegistry.getByLogin(login)` + фильтр по `sessionType=DEVICE`, переслать туда
|
||||||
|
|
||||||
|
### Клиент (отдельный этап)
|
||||||
|
- [ ] Браузерное расширение или UI: создание делегированной сессии, отправка `SignRequest`
|
||||||
|
|
||||||
|
## Что уже готово (переиспользуем)
|
||||||
|
|
||||||
|
- **Роутинг сообщений** — `SendDirectMessage` с `TARGET_ONE_SESSION` и `CallSignalToSession` уже умеют точечно доставлять в конкретный `sessionId`. Механизм готов, нужно добавить только новые op-коды поверх него.
|
||||||
|
- **Ed25519 на ESP32** — библиотека `<Ed25519.h>` уже используется в скетче. Подписи работают.
|
||||||
|
- **NVS** — уже хранит логин, мастер-секрет, 3 пары ключей. Расширяется легко.
|
||||||
|
- **`ActiveConnectionsRegistry`** — поиск по `login` и `sessionId` уже есть на сервере.
|
||||||
|
- **Аутентификация** — схема `AuthChallenge` → `CreateAuthSession` через Ed25519 уже полностью реализована.
|
||||||
|
|
||||||
|
## Оценка сложности
|
||||||
|
|
||||||
|
| Компонент | Сложность |
|
||||||
|
|---|---|
|
||||||
|
| ESP32: WiFi + WebSocket-клиент + авторизация | Средняя |
|
||||||
|
| ESP32: обработчик входящих + UI подтверждений | Средняя |
|
||||||
|
| Сервер: флаг sessionType + 4 новых op-а + роутинг | Низкая–средняя |
|
||||||
|
| Браузерное расширение | Высокая (отдельный этап) |
|
||||||
|
|
||||||
|
**Итого фазы ESP32 + сервер: ~1–1.5 недели.**
|
||||||
|
|
||||||
|
## С чего начинать
|
||||||
|
|
||||||
|
1. Серверная часть проще и быстрее — начать с добавления `sessionType` и `DeviceApprovalRequest/Response`.
|
||||||
|
2. Затем ESP32: WiFi → WebSocket → авторизация → обработчик входящих → UI.
|
||||||
|
3. Браузерное расширение — отдельная итерация после того как ESP32 + сервер работают.
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
# Сессионные саб-серверы в PDA пользователя
|
||||||
|
|
||||||
|
- Статус:
|
||||||
|
`future`
|
||||||
|
|
||||||
|
- Горизонт:
|
||||||
|
`medium`
|
||||||
|
|
||||||
|
- Ориентир:
|
||||||
|
после завершения первого этапа по пользовательским сессиям
|
||||||
|
|
||||||
|
- Основание:
|
||||||
|
Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних саб-серверов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли.
|
||||||
|
|
||||||
|
## Зачем нужна фича
|
||||||
|
|
||||||
|
У одного пользователя может быть несколько доверенных внутренних саб-серверов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели.
|
||||||
|
|
||||||
|
Это нужно, чтобы:
|
||||||
|
|
||||||
|
- хранить несколько саб-серверов у одного пользователя одновременно;
|
||||||
|
- различать обычные клиентские сессии и серверные сессии по явному типу;
|
||||||
|
- дать расширяемый формат записи с версией;
|
||||||
|
- использовать единый подход для DM, звонков и внутренних команд между сессиями.
|
||||||
|
|
||||||
|
## Целевая идея
|
||||||
|
|
||||||
|
В пользовательском PDA должен появиться список записей сессий, где каждая запись содержит как минимум:
|
||||||
|
|
||||||
|
- `sessionType` (`u8`);
|
||||||
|
- `sessionVersion` (`u8`);
|
||||||
|
- `sessionName`;
|
||||||
|
- `sessionPubKey`.
|
||||||
|
|
||||||
|
Предварительные значения:
|
||||||
|
|
||||||
|
- тип `1` - обычная пользовательская сессия;
|
||||||
|
- тип `100` - саб-сервер пользователя;
|
||||||
|
- версия `1` - первая рабочая версия формата записи сессии.
|
||||||
|
|
||||||
|
На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`.
|
||||||
|
|
||||||
|
Важно: саб-серверов у одного пользователя может быть несколько.
|
||||||
|
|
||||||
|
## Архитектурный принцип
|
||||||
|
|
||||||
|
Внутренний протокол взаимодействия должен оставаться транспортным.
|
||||||
|
|
||||||
|
То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки саб-сервера, а должен:
|
||||||
|
|
||||||
|
- доставлять сообщения между сессиями;
|
||||||
|
- доставлять сигналы звонков между сессиями;
|
||||||
|
- хранить и маршрутизировать адресацию;
|
||||||
|
- не принимать на себя бизнес-логику содержимого внутренних команд.
|
||||||
|
|
||||||
|
## Что уже подтверждается текущим кодом
|
||||||
|
|
||||||
|
- Личные сообщения уже доставляются по всем сессиям целевого пользователя с отдельным учётом доставки на каждую сессию.
|
||||||
|
- Подтверждение доставки DM уже идёт отдельно по каждой сессии.
|
||||||
|
- Вызов звонка уже рассылается по нескольким активным сессиям пользователя.
|
||||||
|
- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя.
|
||||||
|
|
||||||
|
Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол саб-сервера".
|
||||||
|
|
||||||
|
## Что нужно сделать при возврате к задаче
|
||||||
|
|
||||||
|
1. Согласовать финальный бинарный формат записи сессии в PDA пользователя.
|
||||||
|
2. Проверить, не меняет ли это уже опубликованный формат пользовательской PDA-записи.
|
||||||
|
3. Если формат PDA меняется, заранее предупредить пользователя и получить отдельное подтверждение.
|
||||||
|
4. Решить, где именно хранится массив сессий:
|
||||||
|
- в основной записи пользователя;
|
||||||
|
- в отдельной PDA-структуре расширения;
|
||||||
|
- или в смешанной схеме с базовой записью и внешними индексами.
|
||||||
|
5. Зафиксировать ограничения:
|
||||||
|
- максимальное число сессий;
|
||||||
|
- максимальную длину `sessionName`;
|
||||||
|
- правила удаления и обновления записи;
|
||||||
|
- правила ротации `sessionPubKey`.
|
||||||
|
6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`.
|
||||||
|
7. Определить, какие внутренние сообщения саб-сервера останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации.
|
||||||
|
8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов.
|
||||||
|
9. После реализации обязательно обновить документацию.
|
||||||
|
|
||||||
|
## Что нужно обновить при реализации
|
||||||
|
|
||||||
|
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
||||||
|
- `Dev_Docs/Solana_Architecture/README.md`
|
||||||
|
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
|
||||||
|
- `Dev_Docs/Keys/README.md`
|
||||||
|
- `Dev_Docs/Personal_Messages/README.md`, если изменится адресация DM по типам сессий
|
||||||
|
- `Dev_Docs/API/`, если появятся новые серверные операции или изменятся ответы
|
||||||
|
|
||||||
|
## Что пока не делать
|
||||||
|
|
||||||
|
- Не включать это автоматически в основной deploy сервера.
|
||||||
|
- Не менять сейчас Solana PDA-формат без отдельного подтверждения.
|
||||||
|
- Не добавлять временные поля в публичный API "на всякий случай".
|
||||||
|
|
||||||
|
## С какого места продолжать
|
||||||
|
|
||||||
|
Продолжать после завершения первой части:
|
||||||
|
|
||||||
|
1. описать минимальный формат записи пользовательской сессии;
|
||||||
|
2. отдельно решить, живут ли саб-серверы в том же списке, что и обычные сессии;
|
||||||
|
3. затем уже проектировать операции регистрации, обновления и отключения таких сессий.
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
# Подключение других устройств через QR
|
||||||
|
|
||||||
|
- Горизонт:
|
||||||
|
`medium`
|
||||||
|
- Ориентир:
|
||||||
|
позже, не сейчас
|
||||||
|
- Статус:
|
||||||
|
`future`
|
||||||
|
|
||||||
|
## Зачем нужна фича
|
||||||
|
|
||||||
|
Нужно нормально довести подключение другого устройства через QR-код. Сейчас есть полуготовая заготовка, но сценарий работает нестабильно и требует отдельной доработки.
|
||||||
|
|
||||||
|
## Что уже есть
|
||||||
|
|
||||||
|
- В UI уже есть экраны:
|
||||||
|
- `shine-UI/js/pages/connect-device-view.js`
|
||||||
|
- `shine-UI/js/pages/device-qr-view.js`
|
||||||
|
- Есть сервис переноса ключей через QR:
|
||||||
|
- `shine-UI/js/services/qr-key-transfer-service.js`
|
||||||
|
- Логика частично собрана, но её нельзя считать завершённой или надёжной.
|
||||||
|
|
||||||
|
## Что нужно будет сделать потом
|
||||||
|
|
||||||
|
1. Проверить и довести формат QR-передачи.
|
||||||
|
2. Проверить сканирование и ручной ввод QR-текста.
|
||||||
|
3. Проверить перенос `device`, `blockchain`, `root` ключей только по реальному наличию на исходном устройстве.
|
||||||
|
4. Проверить, что после переноса очищается старая история нужного логина и не ломается вход.
|
||||||
|
5. Отдельно проверить сценарий без `BarcodeDetector`.
|
||||||
|
6. Довести экран подтверждения на втором устройстве.
|
||||||
|
|
||||||
|
## Что сейчас важно
|
||||||
|
|
||||||
|
- Не считать эту часть готовой.
|
||||||
|
- Не возвращать её в активную разработку без отдельной команды пользователя.
|
||||||
|
- Если вернёмся к задаче, сначала нужно понять, что именно уже работает, а что нет, и потом починить целиком.
|
||||||
|
|
||||||
|
## Что обновить при возврате
|
||||||
|
|
||||||
|
- `Dev_Docs/Pending_Features/README.md`
|
||||||
|
- `shine-UI/js/pages/connect-device-view.js`
|
||||||
|
- `shine-UI/js/pages/device-qr-view.js`
|
||||||
|
- `shine-UI/js/services/qr-key-transfer-service.js`
|
||||||
|
- документацию по ключам, если формат переноса меняется
|
||||||
|
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# Центр задач Telegram-агента
|
||||||
|
|
||||||
|
## Краткое описание
|
||||||
|
|
||||||
|
Добавлена первая версия центра задач и предложений внутри `SHiNE-agent-bot-coder`.
|
||||||
|
|
||||||
|
Бот хранит задачи и предложения в JSON-файле данных сервиса, умеет показывать список через `/tasks`, создавать задачи для игроков по фразе Айдара, принимать предложения игроков по префиксу `предложение:`, менять статусы и добавлять короткие напоминания после ответов.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
|
||||||
|
- Айдар пишет `/tasks` и видит текущий список задач и предложений без уже закрытого предложения от Димы.
|
||||||
|
- Айдар пишет `поставь задачу Милане: проверить описание SHiNE` и задача появляется в списке Миланы.
|
||||||
|
- Милана пишет `/tasks` и видит назначенную задачу.
|
||||||
|
- Игрок пишет `предложение: ...`, после чего предложение появляется у Айдара.
|
||||||
|
- Айдар меняет статус фразами вида `одобрить TC-XXXX`, `доработать TC-XXXX`, `закрыть TC-XXXX`, где `TC-XXXX` - ID существующей задачи или предложения.
|
||||||
|
- После обычного ответа бота Айдару или игроку появляется короткое напоминание, если у пользователя есть активные задачи.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
Задачи и предложения сохраняются между перезапусками сервиса, статусы меняются корректно, напоминания не мешают основному ответу Codex.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
pending
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
# Рестарты и voice-настройки Telegram-агента
|
||||||
|
|
||||||
|
## Краткое описание
|
||||||
|
|
||||||
|
Добавлена первая версия безопасного рестарта Telegram-агента:
|
||||||
|
|
||||||
|
- `/restart` и `/restart_service` ставят отложенный рестарт после текущей задачи и до взятия следующей;
|
||||||
|
- `/restart_hard`, `/restart_now`, `/restart_force` выполняют жёсткий рестарт сразу;
|
||||||
|
- команды рестарта доступны только Айдару;
|
||||||
|
- voice-ответы включены по умолчанию для новых пользователей;
|
||||||
|
- адаптация текста перед озвучкой стала ближе к исходному ответу и не должна менять смысл;
|
||||||
|
- скрыты отдельные команды статуса voice-функций из справки, состояние показывается через `/status`.
|
||||||
|
|
||||||
|
## Что проверить
|
||||||
|
|
||||||
|
1. Отправить `/restart` во время активной задачи игрока или Айдара.
|
||||||
|
2. Убедиться, что активная задача завершается, после чего сервис перезапускается до следующей задачи.
|
||||||
|
3. Отправить `/restart_hard` и убедиться, что сервис перезапускается сразу.
|
||||||
|
4. Проверить, что игрок не может выполнить команды рестарта.
|
||||||
|
5. Проверить `/status`: он показывает очередь и состояния голосовых функций.
|
||||||
|
6. Проверить нового пользователя: voice-ответы должны быть включены по умолчанию.
|
||||||
|
7. Проверить текстовый запрос пользователя с включённым voice: после текстового ответа должен прийти voice-файл.
|
||||||
|
8. Проверить, что адаптированная озвучка не превращается в другой ответ, а только убирает длинные технические строки.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
Сервис можно обновлять без потери текущей задачи через отложенный рестарт. Жёсткий рестарт остаётся аварийной командой Айдара. Voice-ответы работают для текстовых и голосовых запросов, а голосовая версия остаётся близкой к текстовой.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
pending
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
# Кнопки вкладки «Каналы»
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
Доработана верхняя панель вкладки «Каналы»:
|
||||||
|
- при открытии нижней кнопкой «Каналы» показывается режим «Все каналы»;
|
||||||
|
- в режиме «Все каналы» справа доступны кнопка «Мои каналы» и иконка поиска канала;
|
||||||
|
- в режиме «Мои каналы» доступен переход обратно во «Все каналы», а справа показывается плюсик создания канала.
|
||||||
|
|
||||||
|
## Что проверить
|
||||||
|
|
||||||
|
1. Открыть вкладку «Каналы» через нижнюю навигацию.
|
||||||
|
2. Убедиться, что открыт режим «Все каналы», а плюсик создания канала не отображается.
|
||||||
|
3. Нажать иконку поиска в режиме «Все каналы».
|
||||||
|
4. Убедиться, что открывается текущий сценарий поиска каналов.
|
||||||
|
5. Нажать «Мои каналы».
|
||||||
|
6. Убедиться, что справа появился плюсик создания канала.
|
||||||
|
7. Нажать «Все каналы» или стрелку назад и проверить возврат к режиму «Все каналы».
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
Кнопки верхней панели соответствуют активному режиму: поиск в «Все каналы», создание только в «Мои каналы».
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
pending
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
# Длинные voice/audio в Telegram-боте агента
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
Бот теперь умеет обрабатывать длинные voice/audio аккуратнее: учитывает лимит Telegram Bot API на скачивание слишком больших файлов, поддерживает альтернативный `TELEGRAM_API_BASE_URL` для локального `telegram-bot-api`, локально пережимает длинное аудио через `ffmpeg`, режет на куски и отправляет их в OpenAI transcription последовательно.
|
||||||
|
- что именно проверять:
|
||||||
|
1. Короткий `voice` по-прежнему распознаётся без заметной задержки.
|
||||||
|
2. Длинный `audio/voice`, который помещается в скачивание Telegram, успешно пережимается, режется на части и даёт цельную расшифровку.
|
||||||
|
3. Очень большой файл через обычный `https://api.telegram.org` даёт понятное сообщение про лимит Telegram.
|
||||||
|
4. После переключения на локальный `telegram-bot-api` такой же большой файл начинает скачиваться и распознаваться.
|
||||||
|
- ожидаемый результат:
|
||||||
|
Бот не падает на длинных аудио, даёт либо расшифровку, либо понятное объяснение, какой именно лимит мешает и что нужно включить.
|
||||||
|
- статус:
|
||||||
|
pending
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
# Диагностика больших voice/audio в Telegram-боте
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- Бот при большом voice/audio больше не отказывается заранее по метаданным Telegram. Теперь он сначала сообщает, что пробует скачать файл, затем отдельно сообщает об успешном скачивании и только после этого переходит к подготовке аудио и распознаванию через OpenAI.
|
||||||
|
- что именно проверять:
|
||||||
|
- Отправить в бота большой `voice` или `audio`, который раньше попадал под ранний отказ.
|
||||||
|
- Проверить, что сначала приходит сообщение о попытке скачать большой файл.
|
||||||
|
- Проверить два сценария:
|
||||||
|
- скачивание удалось: бот пишет об успешной загрузке и продолжает распознавание;
|
||||||
|
- скачивание не удалось: бот пишет именно о неудачном скачивании из Telegram, без ложной привязки к ошибке OpenAI.
|
||||||
|
- ожидаемый результат:
|
||||||
|
- Пользователь видит понятную поэтапную диагностику: попытка скачивания, результат скачивания и только потом следующий этап обработки.
|
||||||
|
- статус:
|
||||||
|
- pending
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# Перенос server UI в shine-UI
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
Веб-панель управления серверной Solana PDA перенесена в `shine-UI/` как отдельные страницы.
|
||||||
|
Новая точка входа: `shine-UI/server-ui.html`.
|
||||||
|
Общая логика работы с PDA вынесена в единый модуль `shine-UI/js/services/shine-user-pda-service.js`.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
1. Открытие `shine-UI/server-ui.html` и переходы на страницы создания и обновления PDA.
|
||||||
|
2. Генерацию ключей из логина и пароля на странице создания.
|
||||||
|
3. Ручной ввод base58-ключей и регистрацию серверного PDA.
|
||||||
|
4. Загрузку существующей серверной PDA на странице обновления.
|
||||||
|
5. Обновление `server_address` и `sync_servers` только по `root + device` без blockchain-ключа.
|
||||||
|
6. Корректное чтение нового формата `ServerProfileBlock` через общий PDA-модуль.
|
||||||
|
7. То, что актуальной точкой входа остаётся `shine-UI/server-ui.html`.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
1. Новые страницы открываются без JS-ошибок.
|
||||||
|
2. Создание серверной PDA проходит через общий модуль и пишет актуальный формат.
|
||||||
|
3. Обновление серверной PDA переиспользует существующую подпись LastBlockState и не требует blockchain-ключ.
|
||||||
|
4. Клиентский UI не ломается после перевода общего PDA-слоя на новый формат.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
pending
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
# Кнопка настройки сервера и DEVNET topup
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
На экране `entry-settings-view` добавлена кнопка `Настроить свой сервер`, открывающая `server-ui.html` в новой вкладке.
|
||||||
|
На страницах серверного UI добавлена кнопка открытия `devnet-topup-view` в новой вкладке с автоматической передачей `wallet` из device-адреса.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
1. На странице настроек входа есть кнопка `Настроить свой сервер`.
|
||||||
|
2. Кнопка открывает `shine-UI/server-ui.html` в новой вкладке.
|
||||||
|
3. На страницах `create-server-pda.html` и `update-server-pda.html` есть кнопка `Открыть пополнение DEVNET`.
|
||||||
|
4. Если device public key заполнен, новая вкладка открывает `devnet-topup-view?wallet=...` с правильным адресом.
|
||||||
|
5. Если device-адрес не введён, серверный UI показывает понятную ошибку и не открывает пустую ссылку.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
1. Переход в серверный UI с клиентской страницы настроек работает.
|
||||||
|
2. Пополнение devnet из серверного UI открывается сразу на нужный адрес.
|
||||||
|
3. Основной клиентский UI и серверные страницы не получают JS-ошибок при загрузке.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
pending
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
# Фикс DEVNET topup и автоподстановки пароля
|
||||||
|
|
||||||
|
- статус: pending
|
||||||
|
- кратко: исправлена ширина экрана `devnet-topup-view` после успешного пополнения и отключена нежелательная автоподстановка пароля в server UI и на экранах входа/регистрации.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
- Открыть страницу пополнения DEVNET, выполнить пополнение и убедиться, что после появления `Signature` экран не расширяется по ширине.
|
||||||
|
- Проверить, что кнопки на странице пополнения остаются аккуратными и не разъезжаются.
|
||||||
|
- Открыть `server-ui/update-server-pda.html`, загрузить PDA и убедиться, что поле пароля остаётся пустым.
|
||||||
|
- Проверить обычные экраны входа и регистрации: поле пароля не должно самопроизвольно заполняться длинной строкой.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
- Длинная transaction signature переносится по строкам внутри прежней ширины экрана.
|
||||||
|
- Кнопки сохраняют компактный mobile-first layout.
|
||||||
|
- Поля пароля пустые, пока пользователь сам ничего не вводил.
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
# Диагностика ключей server PDA и баланс device
|
||||||
|
|
||||||
|
- статус: pending
|
||||||
|
- кратко: на странице обновления server PDA добавлена сверка ожидаемых ключей с уже загруженной PDA, предупреждение о неверном пароле, кнопка показа баланса device-аккаунта и уточнение, что create/update оплачиваются с deviceKey.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
- На `update-server-pda.html` загрузить существующую PDA и убедиться, что видны ожидаемые `root/blockchain/device` public key.
|
||||||
|
- Ввести правильный пароль и нажать `Сгенерировать`: должно появиться сообщение, что ключи совпадают.
|
||||||
|
- Ввести неверный пароль и нажать `Сгенерировать`: должно появиться сообщение, что ключи не совпали и пароль, вероятно, неверный.
|
||||||
|
- На `create-server-pda.html` и `update-server-pda.html` нажать `Показать / обновить баланс device` и убедиться, что баланс читается по текущему `devPub`.
|
||||||
|
- Повторить `update_user_pda` после увеличения `heap frame` и проверить, ушла ли ошибка `memory allocation failed`.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
- Пользователь видит, какие именно public key должны получиться для загруженной PDA.
|
||||||
|
- Ошибка неправильного пароля выявляется до отправки транзакции.
|
||||||
|
- Баланс device-кошелька читается прямо со страницы.
|
||||||
|
- Если проблема `OOM` была только в размере heap frame/compute budget клиента, `update_user_pda` начинает проходить.
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
# Lazy-import Solana PDA: актуальный формат
|
||||||
|
|
||||||
|
- Краткое описание:
|
||||||
|
Серверный Java lazy-import пользователя из `shine_users` обновлён под актуальный формат `user_pda`. Убран RPC-фильтр по размеру PDA, добавлен разбор нового `ServerProfileBlock` (`block_type = 30`) без сохранения server-only полей в `solana_users`.
|
||||||
|
- Что проверять:
|
||||||
|
1. Взять логин пользователя, который существует в Solana PDA, но отсутствует в локальной таблице `solana_users`.
|
||||||
|
2. Выполнить вход этим логином через сервер.
|
||||||
|
3. Убедиться, что lazy-import подтянул пользователя из Solana.
|
||||||
|
4. Убедиться, что запись в `solana_users` создана с полями `login`, `blockchain_name`, `solana_key`, `blockchain_key`, `device_key`.
|
||||||
|
5. Убедиться, что отсутствие/наличие server-полей в PDA не ломает импорт.
|
||||||
|
- Ожидаемый результат:
|
||||||
|
1. Пользователь успешно находится и импортируется из Solana PDA независимо от фактического размера PDA.
|
||||||
|
2. Новый `ServerProfileBlock` не ломает парсер.
|
||||||
|
3. В БД не появляются лишние server-only поля.
|
||||||
|
- Статус: `pending`
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
# Pure Rust `shine_users` и `shine_login_guard`
|
||||||
|
|
||||||
|
Статус: `pending`
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
- `shine_login_guard` переписан без Anchor на чистый Rust/Solana SDK.
|
||||||
|
- `shine_users` переписан без Anchor на чистый Rust/Solana SDK.
|
||||||
|
- Для `shine_users` введён новый instruction ABI без Anchor discriminator'ов.
|
||||||
|
- Для `shine_users` используются новые seed'ы:
|
||||||
|
- `user_login=` для `user_pda`
|
||||||
|
- `shine_users_economy_config` для economy PDA
|
||||||
|
- Формат блоков PDA синхронизирован:
|
||||||
|
- `SessionsBlock = 50`
|
||||||
|
- `TrustedStateBlock = 70`
|
||||||
|
- UI JS-модуль и Java lazy-import обновлены под новые seeds/ABI/коды блоков.
|
||||||
|
|
||||||
|
## Что проверить руками
|
||||||
|
|
||||||
|
1. В обычном UI выполнить регистрацию нового пользователя в Solana.
|
||||||
|
2. Проверить, что после регистрации читается новая `user_pda`.
|
||||||
|
3. В server UI выполнить создание server PDA.
|
||||||
|
4. В server UI выполнить update server PDA.
|
||||||
|
5. Проверить, что после update растёт `record_number`.
|
||||||
|
6. Проверить, что lazy-import на сервере читает новый формат PDA без ошибок.
|
||||||
|
7. Проверить, что старые Anchor discriminator'ы больше нигде не требуются.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- Регистрация и update работают на новых чисто-rust программах.
|
||||||
|
- UI не использует старый Anchor ABI.
|
||||||
|
- Серверный Java parser читает новый формат PDA.
|
||||||
|
- Ошибок `out of memory` и anchor-specific падений больше нет.
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# ESP32 Argon2/UI совместимость и экран результата
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
выравнивание derivation на `ESP32` с текущим `UI` по нормализации логина, совместимости `master secret`/`root.key`/`bch.key`/`dev.key`, а также правки экрана результата и progress bar.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
1. На `UI` и `ESP32` ввести один и тот же логин в разном регистре, например `Anya24`, и один и тот же непустой пароль.
|
||||||
|
2. Убедиться, что после нормализации логина на `ESP32` и `UI` получаются одинаковые:
|
||||||
|
`master secret`, `root`, `blockchain`, `device` в `Base58`.
|
||||||
|
3. Проверить режим пустого пароля:
|
||||||
|
`UI` и `ESP32` должны выдать одинаковые ключи в legacy-режиме.
|
||||||
|
4. Проверить, что пустой логин на `ESP32` не запускает расчёт и показывает сообщение об ошибке.
|
||||||
|
5. Проверить progress bar:
|
||||||
|
при непустом пароле полоса должна быть видна и двигаться.
|
||||||
|
6. Проверить экран результата:
|
||||||
|
сначала `Login`, затем `Password`, затем `Master secret` и ключи;
|
||||||
|
свайп вверх/вниз должен прокручивать длинный результат без артефактов.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
`ESP32` и `UI` считают одинаковый `master secret` и одинаковые ключи для одинаковых входных данных;
|
||||||
|
progress bar виден;
|
||||||
|
экран результата читаемый и корректно прокручивается.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
pending
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
## Краткое описание
|
||||||
|
В `SHiNE-agent-bot-coder` для личных чатов добавлен режим одного редактируемого статусного сообщения. Бот принимает запрос, обновляет это сообщение по этапам обработки и в конце превращает его в финальный текстовый ответ. При длинном ответе допускается ещё одно дополнительное текстовое сообщение с продолжением. Голосовой ответ остаётся отдельным сообщением.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
1. Отправить в личный чат короткий текстовый запрос и убедиться, что бот не шлёт цепочку промежуточных сообщений, а редактирует одно сообщение до финального ответа.
|
||||||
|
2. Отправить в личный чат `voice` или `audio` и убедиться, что в том же сообщении последовательно видны этапы распознавания и выполнения.
|
||||||
|
3. Проверить длинный ответ, который не помещается в один Telegram message: должно получиться не больше двух текстовых сообщений.
|
||||||
|
4. Проверить, что `voice`-ответ приходит отдельным новым сообщением после текстового.
|
||||||
|
5. Проверить, что в `@shine_writing` по-прежнему логируются только итоговые `вопрос -> ответ`, без промежуточных статусов.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
- В личке основная переписка стала чище: промежуточные этапы живут в одном редактируемом сообщении.
|
||||||
|
- При длинном ответе бот не разбрасывает ответ на много сообщений.
|
||||||
|
- Канал `@shine_writing` работает по старой схеме без лишнего шума.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
`pending`
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
## Краткое описание
|
||||||
|
В локальный Telegram-бот `SHiNE-agent-bot-coder` добавлена команда `/settings`, которая сразу показывает текущие персональные настройки пользователя и список доступных команд для их изменения. В `/help` оставлена только ссылка на `/settings` без перечисления самих команд настроек. Также добавлен переключатель режима ответа в личке: один редактируемый статус или отдельные сообщения по этапам.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
1. Отправить `/help` и убедиться, что в справке есть `/settings`, но нет списка команд `/voice_*` и `/single_message_*`.
|
||||||
|
2. Отправить `/settings` и проверить, что бот показывает текущие значения:
|
||||||
|
- озвучивание финальных ответов;
|
||||||
|
- адаптацию текста перед озвучкой;
|
||||||
|
- режим одного редактируемого сообщения в личке.
|
||||||
|
3. По очереди переключить:
|
||||||
|
- `/voice_on` и `/voice_off`;
|
||||||
|
- `/voice_rewrite_on` и `/voice_rewrite_off`;
|
||||||
|
- `/single_message_on` и `/single_message_off`.
|
||||||
|
4. После каждого переключения снова вызвать `/settings` и убедиться, что статус изменился и сохранился.
|
||||||
|
5. При `/single_message_on` отправить обычный запрос в личку и проверить, что бот ведёт его через одно редактируемое сообщение.
|
||||||
|
6. При `/single_message_off` отправить обычный запрос в личку и проверить, что бот снова шлёт отдельные сообщения по этапам и отдельный финальный ответ.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
- `/settings` стал основной точкой входа для пользовательских настроек.
|
||||||
|
- `/help` стал короче и не дублирует список команд настроек.
|
||||||
|
- Режим ответа в личке реально переключается персонально для пользователя и сохраняется после перезапуска сервиса.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
`pending`
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Solana user_pda v2
|
|
||||||
|
|
||||||
## Краткое описание
|
|
||||||
|
|
||||||
Функции `create_user_pda` и `update_user_pda` в Solana-модуле переведены на блочный формат пользовательской PDA-записи `format_major = 2`.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
- Создание `user_pda` через `create_user_pda`.
|
|
||||||
- Обновление `user_pda` через `update_user_pda`.
|
|
||||||
- Проверку root-подписи записи.
|
|
||||||
- Проверку подписи `LastBlockState` ключом `blockchain_public_key`.
|
|
||||||
- Корректную запись блоков `RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `TrustedState`.
|
|
||||||
- Рост `paid_limit_bytes`, `used_bytes` и `last_block_number` без возможности уменьшения.
|
|
||||||
- Совместимость тестового клиента с актуальной IDL после `anchor build`.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
Пользовательская PDA создается и обновляется в формате `format_major = 2`, содержит один основной блокчейн `blockchain_type = 1` с именем `<login>-001`, а неверные подписи или попытки уменьшить счетчики отклоняются программой.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
pending
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
# Solana: init регистрации + деплой обязательных программ
|
|
||||||
|
|
||||||
- дата: 2026-05-24 20:35 (Europe/Moscow)
|
|
||||||
- статус: `pending`
|
|
||||||
|
|
||||||
## Кратко
|
|
||||||
|
|
||||||
Добавлена dev-страница в UI для вызова `init_users_economy_config` программы `shine_users` через подключённый кошелёк Phantom.
|
|
||||||
Задеплоены и зафиксированы адреса двух обязательных программ регистрации: `shine_users` и `shine_login_guard`.
|
|
||||||
|
|
||||||
## Что проверять вручную
|
|
||||||
|
|
||||||
1. Открыть UI и перейти в `Настройки разработчика`.
|
|
||||||
2. Нажать `Solana: init регистрации`.
|
|
||||||
3. Подключить Phantom devnet-кошелёк.
|
|
||||||
4. Выполнить `init_users_economy_config`.
|
|
||||||
5. Проверить отображение статуса и хэша транзакции.
|
|
||||||
6. Повторно нажать init и убедиться, что корректно показывается "уже инициализировано".
|
|
||||||
7. Выполнить тестовую регистрацию пользователя и убедиться, что CPI-вызов `shine_login_guard` не падает.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- Первая транзакция выполняется успешно (если PDA ещё не создан).
|
|
||||||
- Вторая попытка возвращает ожидаемую ошибку о повторной инициализации.
|
|
||||||
- UI не падает, статус понятный, Program ID отображается корректно.
|
|
||||||
- Регистрация пользователя проходит с подключённым `shine_login_guard`.
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
# Отчёт private-запросов агента в группу
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
|
|
||||||
После успешной обработки задачи из личного чата Айдара Telegram-бот агента отправляет итоговую копию в группу `@shine_writing`:
|
|
||||||
|
|
||||||
- первым сообщением исходный запрос;
|
|
||||||
- вторым сообщением, reply на первое, финальный ответ Codex.
|
|
||||||
|
|
||||||
Промежуточные статусы выполнения в группу не дублируются.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
1. Отправить боту личный текстовый запрос.
|
|
||||||
2. Дождаться полного ответа в личном чате.
|
|
||||||
3. Проверить, что в `@shine_writing` появилось сообщение с запросом.
|
|
||||||
4. Проверить, что итоговый ответ опубликован reply на это сообщение.
|
|
||||||
5. Отправить личный voice-запрос и проверить, что в отчёте есть распознанный текст.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
Личный чат работает как раньше, а группа получает только итоговую пару сообщений по завершённой задаче.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
pending
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Отчёт voice/audio-запросов с исходным файлом
|
|
||||||
|
|
||||||
## Краткое описание
|
|
||||||
|
|
||||||
Публичный отчёт по приватным voice/audio-запросам агента должен отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. `file_id` не должен показываться пользователям в тексте отчёта.
|
|
||||||
|
|
||||||
## Что проверить
|
|
||||||
|
|
||||||
1. Отправить боту приватный voice-запрос от Айдара.
|
|
||||||
2. Дождаться обработки Codex.
|
|
||||||
3. Проверить группу/канал публичных отчётов.
|
|
||||||
4. Повторить сценарий для audio-файла, если он используется.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- В публичном отчёте появляется исходное голосовое/audio-сообщение.
|
|
||||||
- В подписи к нему есть распознанный текст.
|
|
||||||
- В отчёте нет строки `Голосовой file_id` и самого `file_id`.
|
|
||||||
- Итоговый ответ Codex отправляется ответом на сообщение с исходным файлом.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
pending
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Улучшенная обработка длинных voice/audio
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
- Увеличены и вынесены в `.env` тайм-ауты скачивания Telegram-файла и OpenAI-распознавания.
|
|
||||||
- Добавлены подробные логи стадий распознавания: старт, скачивание, размер файла, завершение, причина ошибки.
|
|
||||||
- Ошибки voice/audio теперь показываются пользователю как ошибка распознавания с понятной причиной.
|
|
||||||
|
|
||||||
## Как проверять
|
|
||||||
- Отправить короткое голосовое сообщение и убедиться, что оно распознаётся и передаётся в Codex.
|
|
||||||
- Отправить длинное голосовое сообщение и убедиться, что сервис не падает на прежнем коротком тайм-ауте.
|
|
||||||
- Смоделировать ошибку распознавания или временно указать неверный OpenAI-ключ и проверить текст ошибки в Telegram.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
- При успешном распознавании пользователь видит распознанный текст и задача уходит в Codex.
|
|
||||||
- При ошибке пользователь видит, что не удалось именно распознать voice/audio, и получает конкретную причину.
|
|
||||||
- В логах сервиса видны стадия и техническая причина сбоя.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
pending
|
|
||||||
@ -162,9 +162,13 @@ UI чата строится на этих типах: текстовые соо
|
|||||||
## 7) Доставка и backlog
|
## 7) Доставка и backlog
|
||||||
|
|
||||||
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
||||||
- Для офлайн/недоступных сессий остаётся pending-запись доставки.
|
- Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`.
|
||||||
- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`.
|
- При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`:
|
||||||
- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`.
|
- для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»;
|
||||||
|
- отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`;
|
||||||
|
- лимита на количество сообщений нет — передаётся вся история без ограничений.
|
||||||
|
- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует.
|
||||||
|
- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки.
|
||||||
|
|
||||||
## 8) Read-receipt логика
|
## 8) Read-receipt логика
|
||||||
|
|
||||||
@ -183,19 +187,63 @@ UI чата строится на этих типах: текстовые соо
|
|||||||
|
|
||||||
## 9) Логика UI-клиента
|
## 9) Логика UI-клиента
|
||||||
|
|
||||||
В UI:
|
### Хранилище сообщений
|
||||||
|
|
||||||
|
- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу.
|
||||||
|
- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`.
|
||||||
|
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`.
|
||||||
|
|
||||||
|
### Жизненный цикл при старте/подключении
|
||||||
|
|
||||||
|
1. `hydrateMessagesFromStore()` — читает все сообщения из IndexedDB в `state.chats` (до WebSocket-соединения).
|
||||||
|
2. После установки WebSocket-сессии сервер присылает backlog (`SignedMessageArrived(backlog=true)`) для всех недоставленных сообщений.
|
||||||
|
3. Клиент дедублирует через `knownMessageKeys` — уже имеющиеся в IndexedDB игнорируются.
|
||||||
|
4. Новые сообщения в реальном времени приходят теми же WebSocket-событиями.
|
||||||
|
|
||||||
|
### Очистка при выходе и смене пользователя
|
||||||
|
|
||||||
|
- При любом логауте (`terminateCurrentSession`) IndexedDB с сообщениями **удаляется полностью**.
|
||||||
|
- При входе нового пользователя через QR — IndexedDB удаляется явно до вызова `terminateCurrentSession`.
|
||||||
|
- При входе нового пользователя через логин/пароль — IndexedDB удаляется в `registration-keys-view.js` прямо перед `authorizeSession()`.
|
||||||
|
- Это гарантирует: при любом способе входа старые сообщения предыдущего пользователя не попадут к следующему.
|
||||||
|
|
||||||
|
### UI-поведение
|
||||||
|
|
||||||
- чат хранится в `state.chats[chatId]`;
|
|
||||||
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`;
|
|
||||||
- непрочитанные считаются по `from='in' && unread=true`;
|
- непрочитанные считаются по `from='in' && unread=true`;
|
||||||
- доставка/прочтение исходящих:
|
- доставка/прочтение исходящих:
|
||||||
- `firstTick` — сообщение принято в парный поток,
|
- `firstTick` — сообщение принято сервером,
|
||||||
- `secondTick` — пришло подтверждение прочтения;
|
- `secondTick` — пришло подтверждение прочтения;
|
||||||
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
||||||
- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости;
|
- после отправки нового сообщения UI сразу прокручивает ленту вниз.
|
||||||
- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`).
|
|
||||||
|
|
||||||
## 10) Инварианты (обязательно соблюдать при доработках)
|
## 10) Синхронизация личных сообщений между серверами
|
||||||
|
|
||||||
|
Когда пользователи зарегистрированы на разных серверах SHiNE, серверы должны синхронизировать DM между собой.
|
||||||
|
|
||||||
|
### Общий принцип
|
||||||
|
|
||||||
|
- Сервер A получает DM-блок, адресованный пользователю на сервере B.
|
||||||
|
- Сервер A пересылает этот блок серверу B (межсерверный relay).
|
||||||
|
- Сервер B сохраняет блок и доставляет его в активные сессии получателя.
|
||||||
|
- Серверы, между которыми идёт синхронизация, задаются списком `sync_servers` в PDA пользователя-сервера.
|
||||||
|
|
||||||
|
### Что синхронизируется
|
||||||
|
|
||||||
|
- Все DM-блоки типов `1/2` (текстовые сообщения) и `3/4` (read-receipt).
|
||||||
|
- Синхронизация двусторонняя: оба сервера должны уметь принимать и пересылать блоки.
|
||||||
|
|
||||||
|
### Идемпотентность
|
||||||
|
|
||||||
|
- Блоки имеют уникальный `message_key` (`from|to|timeMs|nonce|type`).
|
||||||
|
- Повторная доставка одного и того же блока безопасна — дедупликация происходит по `message_key`.
|
||||||
|
|
||||||
|
### Статус реализации
|
||||||
|
|
||||||
|
Межсерверная синхронизация DM **пока не реализована**. Текущая версия работает только в рамках одного сервера. Это задача для следующего этапа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Инварианты (обязательно соблюдать при доработках)
|
||||||
|
|
||||||
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
|
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
|
||||||
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
||||||
@ -203,7 +251,7 @@ UI чата строится на этих типах: текстовые соо
|
|||||||
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
||||||
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
||||||
|
|
||||||
## 11) Ключевые файлы реализации
|
## 12) Ключевые файлы реализации
|
||||||
|
|
||||||
- UI:
|
- UI:
|
||||||
- `shine-UI/js/services/auth-service.js`
|
- `shine-UI/js/services/auth-service.js`
|
||||||
|
|||||||
@ -26,12 +26,18 @@
|
|||||||
|
|
||||||
Адрес пользовательской PDA вычисляется по логину:
|
Адрес пользовательской PDA вычисляется по логину:
|
||||||
|
|
||||||
- seed prefix: `login=`;
|
- seed prefix: `user_login=`;
|
||||||
- второй seed: нормализованный логин в нижнем регистре;
|
- второй seed: нормализованный логин в нижнем регистре;
|
||||||
- program id: программа `shine_users`.
|
- program id: программа `shine_users`.
|
||||||
|
|
||||||
Один логин соответствует одной `user_pda`.
|
Один логин соответствует одной `user_pda`.
|
||||||
|
|
||||||
|
## 2.1. Кто оплачивает create/update PDA
|
||||||
|
|
||||||
|
- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `device_key`.
|
||||||
|
- `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer.
|
||||||
|
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `device_key`.
|
||||||
|
|
||||||
## 3. Общие правила кодирования
|
## 3. Общие правила кодирования
|
||||||
|
|
||||||
- Числа кодируются в Little Endian.
|
- Числа кодируются в Little Endian.
|
||||||
@ -84,17 +90,18 @@ UserPdaRecordV1
|
|||||||
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
|
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
|
||||||
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
|
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
|
||||||
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
|
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
|
||||||
| `50` | `TrustedStateBlock` | Счетчик trusted-связей. |
|
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. |
|
||||||
|
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
|
||||||
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
|
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
|
||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
|
|
||||||
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
|
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
|
||||||
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
|
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
|
||||||
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
|
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`;
|
||||||
- каждый обязательный блок должен встречаться ровно один раз;
|
- каждый обязательный блок должен встречаться ровно один раз;
|
||||||
- порядок блоков в записи фиксируется для простоты проверки:
|
- порядок блоков в записи фиксируется для простоты проверки:
|
||||||
`RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `TrustedState`.
|
`RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
|
||||||
|
|
||||||
## 6. RootKeyBlock
|
## 6. RootKeyBlock
|
||||||
|
|
||||||
@ -197,6 +204,7 @@ Arweave `tx_id` - обычное поле внутри записи конкре
|
|||||||
- `used_bytes <= paid_limit_bytes`;
|
- `used_bytes <= paid_limit_bytes`;
|
||||||
- если `last_block_number` увеличился, то должны быть переданы новый `last_block_hash` и новая `last_block_signature`;
|
- если `last_block_number` увеличился, то должны быть переданы новый `last_block_hash` и новая `last_block_signature`;
|
||||||
- `last_block_signature` проверяется через Ed25519-инструкцию Solana: подпись должна соответствовать хэшу сообщения `LastBlockState` и `blockchain_public_key`;
|
- `last_block_signature` проверяется через Ed25519-инструкцию Solana: подпись должна соответствовать хэшу сообщения `LastBlockState` и `blockchain_public_key`;
|
||||||
|
- в транзакции `create_user_pda` / `update_user_pda` две Ed25519-инструкции должны идти непосредственно перед вызовом `shine_users`: сначала подпись `root_key`, затем подпись `blockchain_public_key`;
|
||||||
- `arweave_tx_id` можно добавить или заменить на новый, если пользователь выгрузил более актуальное состояние в Arweave;
|
- `arweave_tx_id` можно добавить или заменить на новый, если пользователь выгрузил более актуальное состояние в Arweave;
|
||||||
- уменьшать лимит, число блоков или занятый размер нельзя.
|
- уменьшать лимит, число блоков или занятый размер нельзя.
|
||||||
|
|
||||||
@ -230,7 +238,8 @@ ServerProfileBlock
|
|||||||
- block_type: u8 = 30
|
- block_type: u8 = 30
|
||||||
- block_version: u8 = 0
|
- block_version: u8 = 0
|
||||||
- is_server: u8
|
- is_server: u8
|
||||||
- server_key: [u8; 32], только если is_server = 1
|
- address_format_type: u8, только если is_server = 1
|
||||||
|
- address_format_version: u8, только если is_server = 1
|
||||||
- server_address: string, только если is_server = 1
|
- server_address: string, только если is_server = 1
|
||||||
- sync_servers_count: u8, только если is_server = 1
|
- sync_servers_count: u8, только если is_server = 1
|
||||||
- sync_servers: string[sync_servers_count], только если is_server = 1
|
- sync_servers: string[sync_servers_count], только если is_server = 1
|
||||||
@ -240,9 +249,11 @@ ServerProfileBlock
|
|||||||
|
|
||||||
- `is_server = 0` означает, что серверных данных нет;
|
- `is_server = 0` означает, что серверных данных нет;
|
||||||
- `is_server = 1` означает, что пользователь публикует серверный профиль;
|
- `is_server = 1` означает, что пользователь публикует серверный профиль;
|
||||||
|
- `address_format_type` — тип формата адреса сервера: `1` = URL-строка (например `https://shineup.me/ws`);
|
||||||
|
- `address_format_version` — версия формата адреса, сейчас `0`;
|
||||||
- `sync_servers_count` максимум `32`;
|
- `sync_servers_count` максимум `32`;
|
||||||
- `server_address` - строковый адрес сервера в формате, который будет отдельно закреплен на уровне приложения;
|
- `server_address` - строковый адрес сервера в соответствии с `address_format_type`;
|
||||||
- `sync_servers` - логины пользователей системы, через которых этот сервер пытается синхронизироваться. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
|
- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
|
||||||
|
|
||||||
## 12. AccessServersBlock
|
## 12. AccessServersBlock
|
||||||
|
|
||||||
@ -263,20 +274,66 @@ AccessServersBlock
|
|||||||
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
|
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
|
||||||
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
|
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
|
||||||
|
|
||||||
## 13. TrustedStateBlock
|
## 13. SessionsBlock
|
||||||
|
|
||||||
|
Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком.
|
||||||
|
|
||||||
|
```text
|
||||||
|
SessionsBlock
|
||||||
|
- block_type: u8 = 50
|
||||||
|
- block_version: u8 = 0
|
||||||
|
- sessions_mode: u8
|
||||||
|
- sessions_count: u8
|
||||||
|
- sessions: SessionRecord[sessions_count]
|
||||||
|
```
|
||||||
|
|
||||||
|
`sessions_mode`:
|
||||||
|
|
||||||
|
| Значение | Смысл |
|
||||||
|
|----------|-------|
|
||||||
|
| `1` | Можно использовать и сессии, зарегистрированные в PDA, и сессии, созданные вне PDA. |
|
||||||
|
| `10` | Зарезервировано на будущее: можно использовать только сессии, опубликованные в PDA. |
|
||||||
|
|
||||||
|
Сейчас рабочий режим по умолчанию: `sessions_mode = 1`. Серверная логика пока не реализует особое поведение для `10`; это задел под будущее расширение.
|
||||||
|
|
||||||
|
```text
|
||||||
|
SessionRecord
|
||||||
|
- session_type: u8
|
||||||
|
- session_version: u8
|
||||||
|
- session_name: string
|
||||||
|
- session_pub_key: [u8; 32]
|
||||||
|
```
|
||||||
|
|
||||||
|
`session_type`:
|
||||||
|
|
||||||
|
| Значение | Смысл |
|
||||||
|
|----------|-------|
|
||||||
|
| `1` | Обычная пользовательская сессия. |
|
||||||
|
| `100` | Саб-сервер пользователя. |
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- максимум `64` записей на пользователя;
|
||||||
|
- `session_name` не пустой, максимум `64` байта;
|
||||||
|
- `session_name` может содержать только символы `[A-Za-z0-9_]`;
|
||||||
|
- `session_version` сейчас должна быть равна `1`;
|
||||||
|
- внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`;
|
||||||
|
- на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически.
|
||||||
|
|
||||||
|
## 14. TrustedStateBlock
|
||||||
|
|
||||||
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
|
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TrustedStateBlock
|
TrustedStateBlock
|
||||||
- block_type: u8 = 50
|
- block_type: u8 = 70
|
||||||
- block_version: u8 = 0
|
- block_version: u8 = 0
|
||||||
- trusted_count: u8 = 0
|
- trusted_count: u8 = 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
|
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
|
||||||
|
|
||||||
## 14. Подпись user_pda
|
## 15. Подпись user_pda
|
||||||
|
|
||||||
Подписывается не вся PDA целиком, а unsigned-часть записи:
|
Подписывается не вся PDA целиком, а unsigned-часть записи:
|
||||||
|
|
||||||
@ -293,10 +350,11 @@ signature = Ed25519(root_key, message)
|
|||||||
```
|
```
|
||||||
|
|
||||||
Solana-программа проверяет подпись через встроенную Ed25519-инструкцию. Подписантом должен быть `root_key` из `RootKeyBlock`.
|
Solana-программа проверяет подпись через встроенную Ed25519-инструкцию. Подписантом должен быть `root_key` из `RootKeyBlock`.
|
||||||
|
Для `shine_users` эта инструкция должна стоять в транзакции сразу перед Ed25519-инструкцией `last_block_signature` и непосредственно перед самой `create/update`-инструкцией программы.
|
||||||
|
|
||||||
Смену формата подписи сейчас не трогаем.
|
Смену формата подписи сейчас не трогаем.
|
||||||
|
|
||||||
## 15. Регистрация пользователя
|
## 16. Регистрация пользователя
|
||||||
|
|
||||||
При регистрации:
|
При регистрации:
|
||||||
|
|
||||||
@ -307,13 +365,14 @@ Solana-программа проверяет подпись через встр
|
|||||||
- `created_at_ms = updated_at_ms`;
|
- `created_at_ms = updated_at_ms`;
|
||||||
- обязательные блоки присутствуют;
|
- обязательные блоки присутствуют;
|
||||||
- создается минимум один `BlockchainRecord`;
|
- создается минимум один `BlockchainRecord`;
|
||||||
|
- новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`;
|
||||||
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
|
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
|
||||||
- `used_bytes <= paid_limit_bytes`;
|
- `used_bytes <= paid_limit_bytes`;
|
||||||
- пользователь платит регистрационную комиссию;
|
- пользователь платит регистрационную комиссию;
|
||||||
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
|
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
|
||||||
- вся unsigned-часть записи подписана `root_key`.
|
- вся unsigned-часть записи подписана `root_key`.
|
||||||
|
|
||||||
## 16. Обновление пользователя
|
## 17. Обновление пользователя
|
||||||
|
|
||||||
При обновлении:
|
При обновлении:
|
||||||
|
|
||||||
@ -328,7 +387,7 @@ Solana-программа проверяет подпись через встр
|
|||||||
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
|
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
|
||||||
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
|
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
|
||||||
|
|
||||||
## 17. Отличия от старого линейного формата
|
## 18. Отличия от старого линейного формата
|
||||||
|
|
||||||
Старый формат после `login` хранил поля линейно:
|
Старый формат после `login` хранил поля линейно:
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
Связанные документы:
|
Связанные документы:
|
||||||
|
|
||||||
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей.
|
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей.
|
||||||
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md` — точный формат `user_pda` для `shine_users`.
|
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` — точный формат `user_pda` для `shine_users`.
|
||||||
- `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля.
|
- `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля.
|
||||||
|
|
||||||
## Кратко
|
## Кратко
|
||||||
|
|||||||
@ -4,10 +4,15 @@
|
|||||||
|
|
||||||
## Базовый сервер
|
## Базовый сервер
|
||||||
|
|
||||||
- SSH: `player@45.136.124.227`
|
- SSH: `player@shineup.me`
|
||||||
- Домен: `shineup.me`
|
- Домен: `shineup.me`
|
||||||
- Базовый путь: `/home/player`
|
- Базовый путь: `/home/player`
|
||||||
|
|
||||||
|
Для всех рабочих инструкций и скриптов использовать доменное имя `shineup.me`, а не фиксированный IP:
|
||||||
|
|
||||||
|
- актуальный IP должен браться через DNS-резолв на момент подключения;
|
||||||
|
- ручное дублирование IP в документации и deploy-скриптах не поддерживать.
|
||||||
|
|
||||||
## Локальные команды
|
## Локальные команды
|
||||||
|
|
||||||
- Деплой сервера: `./gradlew deployServer`
|
- Деплой сервера: `./gradlew deployServer`
|
||||||
@ -26,6 +31,20 @@
|
|||||||
- `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI`
|
- `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI`
|
||||||
- `EXPECTED_CADDY_SITE=example.com ./gradlew deployUI`
|
- `EXPECTED_CADDY_SITE=example.com ./gradlew deployUI`
|
||||||
|
|
||||||
|
## Временные тестовые сайты Solana tickets
|
||||||
|
|
||||||
|
- Для HTML UI программы `shine_payments` используется отдельный временный тестовый сайт.
|
||||||
|
- Основной каталог публикации:
|
||||||
|
- `/home/player/sites/test-solana-tickets.shineup.me`
|
||||||
|
- Рабочие домены:
|
||||||
|
- `https://test-solana-tickets.shineup.me`
|
||||||
|
- `https://test-solana-tickets.shiningpeople.ru`
|
||||||
|
- Назначение:
|
||||||
|
- ручная проверка сценариев покупки билетов;
|
||||||
|
- проверка DAO-инструментов и лимитов менеджеров;
|
||||||
|
- проверка ручного добавления билетов и `step_payout`.
|
||||||
|
- Эти сайты не считать основным UI SHiNE; это отдельная тестовая публикация под Solana-часть.
|
||||||
|
|
||||||
### Важно для локального UI (history-router / Ctrl+F5)
|
### Важно для локального UI (history-router / Ctrl+F5)
|
||||||
|
|
||||||
- Локальный UI **обязательно** поднимать только через `./gradlew startLocal`.
|
- Локальный UI **обязательно** поднимать только через `./gradlew startLocal`.
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
# Сервер `45.136.124.227` (`shineup.me`) — основной
|
|
||||||
|
|
||||||
- Пользователь: `player`
|
|
||||||
- Базовый путь: `/home/player`
|
|
||||||
- Каталог SHiNE: `/home/player/SHiNE`
|
|
||||||
- UI публикация: `/home/player/SHiNE/shine-ui`
|
|
||||||
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
|
||||||
- Данные: `/home/player/SHiNE/shine-server/data/`
|
|
||||||
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
|
|
||||||
|
|
||||||
## Сервисы
|
|
||||||
|
|
||||||
- `shine-server.service` (systemd)
|
|
||||||
- `caddy.service` (systemd)
|
|
||||||
|
|
||||||
## Caddy
|
|
||||||
|
|
||||||
- Активный конфиг (через systemd `ExecStart`): `/home/player/SHiNE/caddy/Caddyfile`
|
|
||||||
- Для UI:
|
|
||||||
- `root * /home/player/SHiNE/shine-ui`
|
|
||||||
- `try_files {path} /index.html` (SPA fallback)
|
|
||||||
- no-cache заголовки
|
|
||||||
- `reverse_proxy /ws* -> 127.0.0.1:7070`
|
|
||||||
@ -18,7 +18,7 @@
|
|||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
- Резервный сервер для SHiNE.
|
- Резервный сервер для SHiNE.
|
||||||
- Основной прод-сервер: `45.136.124.227` (`shineup.me`).
|
- Основной прод-сервер: `shineup.me` (подключение через `player@shineup.me`, IP определяется через DNS).
|
||||||
|
|
||||||
## Caddy
|
## Caddy
|
||||||
|
|
||||||
|
|||||||
35
Dev_Docs/deploy/servers/shineup.me_main.md
Normal file
35
Dev_Docs/deploy/servers/shineup.me_main.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Сервер `shineup.me` — основной
|
||||||
|
|
||||||
|
- SSH: `player@shineup.me`
|
||||||
|
- Определение IP: через DNS-резолв домена `shineup.me` на момент подключения
|
||||||
|
- Пользователь: `player`
|
||||||
|
- Базовый путь: `/home/player`
|
||||||
|
- Каталог SHiNE: `/home/player/SHiNE`
|
||||||
|
- UI публикация: `/home/player/SHiNE/shine-ui`
|
||||||
|
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
||||||
|
- Данные: `/home/player/SHiNE/shine-server/data/`
|
||||||
|
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
|
||||||
|
## Сервисы
|
||||||
|
|
||||||
|
- `shine-server.service` (systemd)
|
||||||
|
- `caddy.service` (systemd)
|
||||||
|
|
||||||
|
## Caddy
|
||||||
|
|
||||||
|
- Активный конфиг (через systemd `ExecStart`): `/home/player/SHiNE/caddy/Caddyfile`
|
||||||
|
- Для UI:
|
||||||
|
- `root * /home/player/SHiNE/shine-ui`
|
||||||
|
- `try_files {path} /index.html` (SPA fallback)
|
||||||
|
- no-cache заголовки
|
||||||
|
- `reverse_proxy /ws* -> 127.0.0.1:7070`
|
||||||
|
|
||||||
|
## Дополнительно
|
||||||
|
|
||||||
|
- Для отдельной админки `shine_payments` используется каталог:
|
||||||
|
- `/home/player/sites/test-solana-tickets.shineup.me`
|
||||||
|
- Эта публикация используется как временный тестовый сайт для сценариев покупки билетов и выплат `shine_payments`.
|
||||||
|
- Домены этой публикации:
|
||||||
|
- `https://test-solana-tickets.shineup.me`
|
||||||
|
- `https://test-solana-tickets.shiningpeople.ru`
|
||||||
|
- Для всех deploy-скриптов и инструкций использовать именно `player@shineup.me`, без жёсткой фиксации IP.
|
||||||
@ -82,10 +82,17 @@ anchor deploy -p shine_users
|
|||||||
- seed: `shine_users_economy_config`
|
- seed: `shine_users_economy_config`
|
||||||
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
|
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
|
||||||
|
|
||||||
|
## Кто оплачивает create/update user_pda
|
||||||
|
|
||||||
|
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`.
|
||||||
|
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
|
||||||
|
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
|
||||||
|
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`.
|
||||||
|
|
||||||
## Важно
|
## Важно
|
||||||
|
|
||||||
- `init_users_economy_config` выполняется один раз на программу.
|
- `init_users_economy_config` выполняется один раз на программу.
|
||||||
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
|
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
|
||||||
- Серверные приватные ключи для Solana не используются: подписание делается кошельком пользователя в UI.
|
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`.
|
||||||
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
|
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
|
||||||
Несовпадение адреса приведёт к ошибке регистрации.
|
Несовпадение адреса приведёт к ошибке регистрации.
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
- `audio` — динамик/аудио-кодек (пример `07_ES8311`)
|
- `audio` — динамик/аудио-кодек (пример `07_ES8311`)
|
||||||
- `hello` — базовый тест экрана (пример `01_HelloWorld`)
|
- `hello` — базовый тест экрана (пример `01_HelloWorld`)
|
||||||
- `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU)
|
- `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU)
|
||||||
|
- `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости)
|
||||||
|
|
||||||
Запуск:
|
Запуск:
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
// BLAKE2b state
|
||||||
|
struct B2State {
|
||||||
|
uint64_t h[8], t[2], f[2];
|
||||||
|
uint8_t buf[128];
|
||||||
|
size_t buflen, outlen;
|
||||||
|
};
|
||||||
@ -15,9 +15,10 @@ case "${MODE}" in
|
|||||||
widgets) SKETCH_DIR="${DEMO_BASE}/examples/05_LVGL_Widgets" ;;
|
widgets) SKETCH_DIR="${DEMO_BASE}/examples/05_LVGL_Widgets" ;;
|
||||||
audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;;
|
audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;;
|
||||||
simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;;
|
simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;;
|
||||||
|
argon2) SKETCH_DIR="${ROOT_DIR}/argon2_sd_test" ;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown mode: ${MODE}" >&2
|
echo "Unknown mode: ${MODE}" >&2
|
||||||
echo "Use one of: hello, widgets, audio, simple" >&2
|
echo "Use one of: hello, widgets, audio, simple, argon2" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
# Задание для Айдара: навести порядок в инструкциях агентов SHiNE
|
||||||
|
|
||||||
|
## Кратко
|
||||||
|
Нужно согласовать и оформить единый порядок инструкций для Codex/Telegram-агентов в проекте SHiNE, чтобы агенты стабильно понимали структуру проекта, границы ответственности и правила работы с сервером, UI, Solana-модулем, Telegram-ботом и игроками.
|
||||||
|
|
||||||
|
## Зачем это нужно
|
||||||
|
Сейчас проект состоит из нескольких связанных, но разных частей:
|
||||||
|
|
||||||
|
- основной сервер `SHiNE-server/`;
|
||||||
|
- UI `shine-UI/`;
|
||||||
|
- Solana/Anchor-модуль `shine-solana/shine/`;
|
||||||
|
- Telegram-агент-кодер `SHiNE-agent-bot-coder/`;
|
||||||
|
- TURN-сервер;
|
||||||
|
- документация `Dev_Docs/`;
|
||||||
|
- отдельные рабочие папки игроков `Players/`.
|
||||||
|
|
||||||
|
Без явных инструкций агент может путать эти зоны: например, смешать деплой Solana с деплоем сервера, изменить код от имени игрока, не обновить документацию API/DM/блокчейна или неправильно трактовать файл инструкций.
|
||||||
|
|
||||||
|
## Что предлагается сделать
|
||||||
|
1. Утвердить корневой `AGENTS.md` как главный набор правил проекта.
|
||||||
|
2. Проверить и при необходимости уточнить локальный `AGENTS.md` внутри `shine-solana/shine/`.
|
||||||
|
3. Оставить отдельные служебные инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENT.md`.
|
||||||
|
4. Оставить автоматически читаемые инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENTS.md`.
|
||||||
|
5. Явно закрепить режим игроков:
|
||||||
|
- игроки могут задавать вопросы, просить анализ, идеи и ТЗ;
|
||||||
|
- игроки не меняют код проекта напрямую;
|
||||||
|
- материалы игроков сохраняются только в `Players/<username>/`.
|
||||||
|
6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`.
|
||||||
|
7. Добавить простой процесс согласования изменений инструкций:
|
||||||
|
- Дима или другой участник готовит предложение;
|
||||||
|
- Айдар получает уведомление/заявку;
|
||||||
|
- Айдар отвечает: одобрить, отклонить или попросить доработать;
|
||||||
|
- только после одобрения агент вносит изменения в проектные инструкции.
|
||||||
|
|
||||||
|
## Предлагаемая логика уведомления Айдару
|
||||||
|
Минимальный вариант без сложной разработки:
|
||||||
|
|
||||||
|
1. Агент готовит текст заявки.
|
||||||
|
2. Текст отправляется Айдару в Telegram или в общий рабочий чат.
|
||||||
|
3. В заявке явно указаны варианты ответа:
|
||||||
|
- `одобрить`;
|
||||||
|
- `отклонить`;
|
||||||
|
- `доработать: ...`.
|
||||||
|
4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка.
|
||||||
|
|
||||||
|
Более удобный вариант на будущее:
|
||||||
|
|
||||||
|
- добавить в Telegram-бота команду или сценарий согласования задач, например:
|
||||||
|
- `/approve <id>`;
|
||||||
|
- `/reject <id> причина`;
|
||||||
|
- `/revise <id> комментарий`.
|
||||||
|
|
||||||
|
Но для начала достаточно простого текстового согласования через Telegram.
|
||||||
|
|
||||||
|
## Что нужно от Айдара
|
||||||
|
Подтвердить, что такой порядок подходит:
|
||||||
|
|
||||||
|
1. Корневой `AGENTS.md` остается главным правилом проекта.
|
||||||
|
2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила.
|
||||||
|
3. Игроки не меняют код напрямую, а готовят материалы и предложения.
|
||||||
|
4. Изменения инструкций выполняются только после явного одобрения Айдара.
|
||||||
|
5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
После одобрения:
|
||||||
|
|
||||||
|
- агенты будут стабильнее понимать границы проекта;
|
||||||
|
- снизится риск случайных изменений не в той части системы;
|
||||||
|
- появится понятный порядок согласования задач от игроков;
|
||||||
|
- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов.
|
||||||
|
|
||||||
@ -1 +1,7 @@
|
|||||||
# -
|
# SHiNE
|
||||||
|
|
||||||
|
## План запуска DAO
|
||||||
|
|
||||||
|
План запуска DAO зафиксирован в [DAO_запуск/README.md](DAO_запуск/README.md).
|
||||||
|
|
||||||
|
Это рабочий список задач по `этап1` и `этап2`. Дальше ведём его как основной чек-лист запуска DAO и отмечаем в нём выполненные пункты по мере готовности.
|
||||||
|
|||||||
@ -32,10 +32,12 @@
|
|||||||
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
||||||
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
|
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
|
||||||
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
|
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
|
||||||
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`.
|
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию.
|
||||||
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`, `/voice_rewrite_status`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает короткую голосовую версию без длинных хэшей, путей, команд и технического шума.
|
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа.
|
||||||
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
|
- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше.
|
||||||
- Команда `/status` должна показывать состояние очереди и персональные состояния голосовых функций: включены ли voice-ответы и адаптация текста перед озвучкой.
|
- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения.
|
||||||
|
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
|
||||||
|
- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке.
|
||||||
|
|
||||||
## Правила голосовой версии ответа
|
## Правила голосовой версии ответа
|
||||||
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
|
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
|
||||||
@ -54,13 +56,21 @@
|
|||||||
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
|
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
|
||||||
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
|
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
|
||||||
|
|
||||||
|
## Центр задач и предложений
|
||||||
|
- Сервис хранит простые задачи и предложения в `data/task_center/items.json`.
|
||||||
|
- Айдар может смотреть список через `/tasks` или естественные фразы вроде «покажи мои задачи», «покажи задачи Миланы».
|
||||||
|
- Айдар может ставить задачи игрокам фразой вида «поставь задачу Милане: ...».
|
||||||
|
- Игроки могут отправлять предложения Айдару фразой вида `предложение: ...`, `идея: ...` или `заявка: ...`.
|
||||||
|
- Статусы меняются фразами с ID: `одобрить TC-0001`, `отклонить TC-0001`, `доработать TC-0001`, `закрыть TC-0001`.
|
||||||
|
- После финального ответа в личном чате сервис добавляет короткое напоминание, если у пользователя есть активные задачи или предложения.
|
||||||
|
|
||||||
## Локальный запуск и systemd
|
## Локальный запуск и systemd
|
||||||
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
|
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
|
||||||
- Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится.
|
- Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится.
|
||||||
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
|
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
|
||||||
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
|
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
|
||||||
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
|
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
|
||||||
- Команда Telegram `/restart_service` (и алиас `/restart`) доступна только Айдару.
|
- Команда Telegram `/restart` (`/restart_service`) доступна только Айдару и выполняет отложенный рестарт после текущей задачи, до взятия следующей. Аварийный жёсткий рестарт доступен только Айдару командами `/restart_hard`, `/restart_now`, `/restart_force`.
|
||||||
|
|
||||||
## Правила ответа
|
## Правила ответа
|
||||||
- Пиши содержательно и коротко.
|
- Пиши содержательно и коротко.
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
- обрабатывает задачи строго последовательно;
|
- обрабатывает задачи строго последовательно;
|
||||||
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
|
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
|
||||||
- вызывает Codex CLI и отправляет ответ в Telegram;
|
- вызывает Codex CLI и отправляет ответ в Telegram;
|
||||||
|
- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам;
|
||||||
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
|
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
|
||||||
- при рестарте восстанавливает незавершённые задачи;
|
- при рестарте восстанавливает незавершённые задачи;
|
||||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
||||||
@ -32,8 +33,15 @@
|
|||||||
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
||||||
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
|
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
|
||||||
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
||||||
|
- `TELEGRAM_API_BASE_URL` — базовый URL Bot API; по умолчанию `https://api.telegram.org`. Для очень больших voice/audio можно поднять локальный `telegram-bot-api` и направить бота туда.
|
||||||
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
|
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
|
||||||
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
|
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
|
||||||
|
- `OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES` — безопасный лимит размера одного куска для OpenAI transcription, по умолчанию `24 MiB`.
|
||||||
|
- `OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS` — максимальная длина одного куска при длинном аудио, по умолчанию `900` секунд.
|
||||||
|
- `OPENAI_TRANSCRIBE_OVERLAP_SECONDS` — перекрытие соседних кусков для более ровной склейки текста, по умолчанию `2` секунды.
|
||||||
|
- `OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS` — битрейт локального пережатия длинного аудио через `ffmpeg`, по умолчанию `24`.
|
||||||
|
- `OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS` — тайм-аут локальной обработки длинного аудио через `ffmpeg`/`ffprobe`, по умолчанию `1800`.
|
||||||
|
- `FFMPEG_BIN` и `FFPROBE_BIN` — пути к локальным бинарям `ffmpeg`/`ffprobe`, если они не лежат в `PATH`.
|
||||||
- `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`.
|
- `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`.
|
||||||
- `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`.
|
- `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`.
|
||||||
- `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`.
|
- `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`.
|
||||||
@ -47,6 +55,19 @@
|
|||||||
python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"
|
python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Длинные voice/audio
|
||||||
|
- Если аудио короткое, бот отправляет его в OpenAI как раньше.
|
||||||
|
- Если аудио большое или длинное, бот локально пережимает его через `ffmpeg`, при необходимости режет на куски и распознаёт последовательно.
|
||||||
|
- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI.
|
||||||
|
- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`.
|
||||||
|
|
||||||
|
## Статусы в личке
|
||||||
|
- Для `private`-чата бот поддерживает персональную настройку режима ответа.
|
||||||
|
- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам.
|
||||||
|
- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно.
|
||||||
|
- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа.
|
||||||
|
- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением.
|
||||||
|
|
||||||
## Запуск как systemd-сервис
|
## Запуск как systemd-сервис
|
||||||
Файлы для установки:
|
Файлы для установки:
|
||||||
- `scripts/systemd/shine-agent-bot-coder.service`
|
- `scripts/systemd/shine-agent-bot-coder.service`
|
||||||
@ -64,11 +85,16 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
|
|||||||
|
|
||||||
## Telegram-команды
|
## Telegram-команды
|
||||||
- `/status` — активная задача и размер очереди.
|
- `/status` — активная задача и размер очереди.
|
||||||
|
- `/settings` — текущие пользовательские настройки и команды для их изменения.
|
||||||
- `/queue` — список задач в очереди.
|
- `/queue` — список задач в очереди.
|
||||||
- `/stop` — остановить текущую задачу.
|
- `/stop` — остановить текущую задачу.
|
||||||
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
||||||
- `/new` — архивировать текущую историю и начать новый диалог.
|
- `/new` — архивировать текущую историю и начать новый диалог.
|
||||||
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_status` — показать состояние озвучивания для текущего пользователя.
|
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
||||||
- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.
|
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
|
||||||
|
- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение.
|
||||||
|
- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ.
|
||||||
|
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
|
||||||
|
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
69
SHiNE-server/AGENTS.md
Normal file
69
SHiNE-server/AGENTS.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# AGENTS.md — SHiNE-server
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
SHiNE-server — серверная часть мессенджера SHiNE: WebSocket-сервер, хранение блоков блокчейна
|
||||||
|
пользователей, доставка личных сообщений (DM), звонки.
|
||||||
|
|
||||||
|
## Структура папок
|
||||||
|
|
||||||
|
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
|
||||||
|
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
|
||||||
|
- `shine-server-db/` — DAO, SQL-схема, SQLite
|
||||||
|
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
|
||||||
|
- `shine-server-crypto/` — криптографические утилиты
|
||||||
|
- `shine-server-config/` — конфигурация сервера
|
||||||
|
- `shine-server-log/` — логирование
|
||||||
|
- `shine-server-geo/` — геолокация IP
|
||||||
|
|
||||||
|
## Настройка сервера в Solana (Solana PDA)
|
||||||
|
|
||||||
|
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
|
||||||
|
В PDA хранятся:
|
||||||
|
|
||||||
|
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
|
||||||
|
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
||||||
|
с которыми синхронизируются блоки и DM;
|
||||||
|
- **корневой ключ** сервера (`root_key`).
|
||||||
|
|
||||||
|
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
||||||
|
|
||||||
|
**Управление серверной PDA выполняется через Web-панель администратора:**
|
||||||
|
|
||||||
|
```
|
||||||
|
shine-UI/server-ui.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Страницы:
|
||||||
|
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
|
||||||
|
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers.
|
||||||
|
|
||||||
|
Для регистрации нужен полный keyBundle (root + device + blockchain).
|
||||||
|
Для обновления — только root + device (blockchain-ключ не нужен).
|
||||||
|
|
||||||
|
Актуальные адреса программ Solana (devnet):
|
||||||
|
- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
|
||||||
|
- `shine_payments`: `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR`
|
||||||
|
|
||||||
|
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
|
||||||
|
|
||||||
|
## Синхронизация с партнёрскими серверами
|
||||||
|
|
||||||
|
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
|
||||||
|
Детали: `Dev_Docs/Blockchain/sync-between-servers.md`
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew deployServer
|
||||||
|
```
|
||||||
|
|
||||||
|
Хост по умолчанию: `player@93.170.12.154` (shineup.me).
|
||||||
|
|
||||||
|
Логи на проде:
|
||||||
|
- `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log`
|
||||||
|
|
||||||
|
## Язык
|
||||||
|
|
||||||
|
Комментарии в коде, документация и commit-сообщения — на русском языке.
|
||||||
@ -182,7 +182,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId, int limit) throws Exception {
|
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String fillSql = """
|
String fillSql = """
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||||
@ -210,12 +210,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
ON d.message_key = m.message_key
|
ON d.message_key = m.message_key
|
||||||
WHERE d.session_id = ? AND d.delivered = 0
|
WHERE d.session_id = ? AND d.delivered = 0
|
||||||
ORDER BY m.time_ms ASC, m.created_at_ms ASC
|
ORDER BY m.time_ms ASC, m.created_at_ms ASC
|
||||||
LIMIT ?
|
|
||||||
""";
|
""";
|
||||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, sessionId);
|
ps.setString(1, sessionId);
|
||||||
ps.setInt(2, Math.max(1, limit));
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) out.add(mapRow(rs));
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,6 @@ public final class SolanaUserPdaImportService {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(SolanaUserPdaImportService.class);
|
private static final Logger log = LoggerFactory.getLogger(SolanaUserPdaImportService.class);
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||||
private static final int USER_PDA_SPACE = 768;
|
|
||||||
private static final String MAGIC = "SHiNE";
|
private static final String MAGIC = "SHiNE";
|
||||||
|
|
||||||
private SolanaUserPdaImportService() {}
|
private SolanaUserPdaImportService() {}
|
||||||
@ -72,14 +71,13 @@ public final class SolanaUserPdaImportService {
|
|||||||
{
|
{
|
||||||
"encoding":"base64",
|
"encoding":"base64",
|
||||||
"filters":[
|
"filters":[
|
||||||
{"dataSize":%d},
|
|
||||||
{"memcmp":{"offset":61,"bytes":"%s"}},
|
{"memcmp":{"offset":61,"bytes":"%s"}},
|
||||||
{"memcmp":{"offset":62,"bytes":"%s"}}
|
{"memcmp":{"offset":62,"bytes":"%s"}}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""".formatted(SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID, USER_PDA_SPACE, lenB58, loginB58);
|
""".formatted(SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID, lenB58, loginB58);
|
||||||
|
|
||||||
HttpRequest req = HttpRequest.newBuilder()
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(SolanaProgramsConfig.SOLANA_RPC_URL))
|
.uri(URI.create(SolanaProgramsConfig.SOLANA_RPC_URL))
|
||||||
@ -171,8 +169,11 @@ public final class SolanaUserPdaImportService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (blockType == 4) {
|
} else if (blockType == 30) {
|
||||||
c += 1 + 32;
|
int isServer = u8(raw, c++);
|
||||||
|
if (isServer == 1) {
|
||||||
|
c += 1; // address_format_type
|
||||||
|
c += 1; // address_format_version
|
||||||
int addrLen = u8(raw, c++);
|
int addrLen = u8(raw, c++);
|
||||||
c += addrLen;
|
c += addrLen;
|
||||||
int syncCount = u8(raw, c++);
|
int syncCount = u8(raw, c++);
|
||||||
@ -180,6 +181,9 @@ public final class SolanaUserPdaImportService {
|
|||||||
int n = u8(raw, c++);
|
int n = u8(raw, c++);
|
||||||
c += n;
|
c += n;
|
||||||
}
|
}
|
||||||
|
} else if (isServer != 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} else if (blockType == 40) {
|
} else if (blockType == 40) {
|
||||||
int accessCount = u8(raw, c++);
|
int accessCount = u8(raw, c++);
|
||||||
for (int j = 0; j < accessCount; j++) {
|
for (int j = 0; j < accessCount; j++) {
|
||||||
@ -187,6 +191,18 @@ public final class SolanaUserPdaImportService {
|
|||||||
c += n;
|
c += n;
|
||||||
}
|
}
|
||||||
} else if (blockType == 50) {
|
} else if (blockType == 50) {
|
||||||
|
int sessionsMode = u8(raw, c++);
|
||||||
|
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
||||||
|
int sessionsCount = u8(raw, c++);
|
||||||
|
if (sessionsCount > 64) return null;
|
||||||
|
for (int j = 0; j < sessionsCount; j++) {
|
||||||
|
c += 1; // session_type
|
||||||
|
c += 1; // session_version
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
c += n;
|
||||||
|
c += 32; // session_pub_key
|
||||||
|
}
|
||||||
|
} else if (blockType == 70) {
|
||||||
c += 1;
|
c += 1;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
@ -264,4 +280,3 @@ public final class SolanaUserPdaImportService {
|
|||||||
long paidLimitBytes
|
long paidLimitBytes
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,6 @@ import java.util.List;
|
|||||||
public final class SignedMessagesRealtime {
|
public final class SignedMessagesRealtime {
|
||||||
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final int LOGIN_BACKLOG_LIMIT = 500;
|
|
||||||
|
|
||||||
private SignedMessagesRealtime() {}
|
private SignedMessagesRealtime() {}
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(
|
static DeliveryCounters deliverToTargetSessions(
|
||||||
@ -57,7 +55,7 @@ public final class SignedMessagesRealtime {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
||||||
.listPendingForSession(login, sessionId, LOGIN_BACKLOG_LIMIT);
|
.listPendingForSession(login, sessionId);
|
||||||
for (SignedMessageV2Entry e : pending) {
|
for (SignedMessageV2Entry e : pending) {
|
||||||
sendEventToSessionIfOnline(sessionId, e, true);
|
sendEventToSessionIfOnline(sessionId, e, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
|||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
import server.logic.ws_protocol.JSON.JsonInboundProcessor;
|
import server.logic.ws_protocol.JSON.JsonInboundProcessor;
|
||||||
|
|
||||||
|
import java.nio.channels.ClosedChannelException;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@ -131,9 +132,19 @@ public class BlockchainWsEndpoint {
|
|||||||
|
|
||||||
@OnWebSocketError
|
@OnWebSocketError
|
||||||
public void onError(Throwable cause) {
|
public void onError(Throwable cause) {
|
||||||
|
if (isNormalClosedChannel(cause)) {
|
||||||
|
log.info("WS channel already closed during normal shutdown: {}", cause.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
log.error("WS error", cause);
|
log.error("WS error", cause);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isNormalClosedChannel(Throwable cause) {
|
||||||
|
if (cause == null) return false;
|
||||||
|
if (cause instanceof ClosedChannelException) return true;
|
||||||
|
return String.valueOf(cause).contains("ClosedChannelException");
|
||||||
|
}
|
||||||
|
|
||||||
private void trySendJsonError() {
|
private void trySendJsonError() {
|
||||||
if (session != null && session.isOpen()) {
|
if (session != null && session.isOpen()) {
|
||||||
String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"
|
String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"
|
||||||
|
|||||||
@ -19,10 +19,10 @@ import java.util.jar.JarFile;
|
|||||||
*/
|
*/
|
||||||
public class IT_DeployBackupCleanAndRunRemoteMain {
|
public class IT_DeployBackupCleanAndRunRemoteMain {
|
||||||
|
|
||||||
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
|
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me");
|
||||||
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
|
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "player");
|
||||||
|
|
||||||
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
|
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server");
|
||||||
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
|
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
|
||||||
private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");
|
private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");
|
||||||
private static final String REMOTE_BACKUP_DIR = System.getProperty("it.remoteBackupDir", REMOTE_DIR + "/backup");
|
private static final String REMOTE_BACKUP_DIR = System.getProperty("it.remoteBackupDir", REMOTE_DIR + "/backup");
|
||||||
|
|||||||
@ -11,10 +11,10 @@ import java.util.Objects;
|
|||||||
public class IT_DeployRestartAndRunRemoteMain {
|
public class IT_DeployRestartAndRunRemoteMain {
|
||||||
|
|
||||||
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
|
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
|
||||||
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
|
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me");
|
||||||
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
|
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "player");
|
||||||
|
|
||||||
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
|
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server");
|
||||||
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
|
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
|
||||||
private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");
|
private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,10 @@ public class IT_DeployRestartNoCleanNoTestsMain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void ensureSudoNoPasswordOrThrow() {
|
private static void ensureSudoNoPasswordOrThrow() {
|
||||||
int code = ssh("sudo -n systemctl status " + SERVICE_NAME + " >/dev/null 2>&1");
|
// Проверяем именно возможность sudo без пароля.
|
||||||
|
// systemctl status может возвращать non-zero для inactive/failed сервиса,
|
||||||
|
// и это не должно считаться проблемой прав доступа.
|
||||||
|
int code = ssh("sudo -n true");
|
||||||
if (code == 0) return;
|
if (code == 0) return;
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
"Remote sudo requires password for " + REMOTE_USER + "@" + REMOTE_HOST
|
"Remote sudo requires password for " + REMOTE_USER + "@" + REMOTE_HOST
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.102
|
client.version=1.2.131
|
||||||
server.version=1.2.96
|
server.version=1.2.123
|
||||||
|
|||||||
@ -196,11 +196,11 @@ tasks.register('deployServer', JavaExec) {
|
|||||||
// можно переопределить при запуске:
|
// можно переопределить при запуске:
|
||||||
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
||||||
dependsOn shadowJar
|
dependsOn shadowJar
|
||||||
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "45.136.124.227")
|
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "shineup.me")
|
||||||
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player")
|
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player")
|
||||||
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server")
|
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server")
|
||||||
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
||||||
systemProperty "it.localJar", System.getProperty("it.localJar", "SHiNE-server/build/libs/shine-server.jar")
|
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
||||||
|
|
||||||
dependsOn testClasses
|
dependsOn testClasses
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SRC_DIR="shine-UI"
|
SRC_DIR="shine-UI"
|
||||||
REMOTE_HOST="${REMOTE_HOST:-player@45.136.124.227}"
|
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
|
||||||
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
|
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
|
||||||
EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}"
|
EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}"
|
||||||
ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}"
|
ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}"
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
# UI deploy
|
|
||||||
|
|
||||||
Актуальный UI-деплой выполняется одной командой:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew deployUI
|
|
||||||
```
|
|
||||||
|
|
||||||
По умолчанию:
|
|
||||||
|
|
||||||
- хост: `player@93.170.12.154`
|
|
||||||
- домен: `https://shineup.me`
|
|
||||||
- путь: `/home/player/SHiNE/SHiNE-UI`
|
|
||||||
|
|
||||||
Переопределение при необходимости:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
REMOTE_HOST=player@93.170.12.154 REMOTE_BASE_DIR=/home/player/SHiNE bash deploy_shine-PWA.sh
|
|
||||||
```
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# MVP notes: Web Push
|
|
||||||
|
|
||||||
## Временное поведение (сделано для тестового стенда)
|
|
||||||
|
|
||||||
- Клиент отправляет push-подписку на сервер при каждом запуске после авторизации, даже если подписка не изменилась.
|
|
||||||
- Причина: на тестовом сервере/после переустановки БД запись о токене может пропасть, а клиент этого не узнает.
|
|
||||||
|
|
||||||
## Что доработать для production
|
|
||||||
|
|
||||||
- Вернуть режим "отправлять только при изменении подписки" как основной.
|
|
||||||
- Добавить безопасный механизм ресинхронизации:
|
|
||||||
- Вариант 1: периодическая принудительная отправка (например, 1 раз в N дней).
|
|
||||||
- Вариант 2: endpoint на сервере "есть ли подписка", и отправка только при отсутствии/рассинхроне.
|
|
||||||
- В логах разделить обычную отправку и принудительную, чтобы видеть лишний трафик.
|
|
||||||
- Добавить e2e-тесты сценариев:
|
|
||||||
- Переустановка сервера (потеря токена в БД).
|
|
||||||
- Смена браузерной подписки.
|
|
||||||
- Повторный запуск клиента без изменений.
|
|
||||||
29
server-backup/AGENTS.md
Normal file
29
server-backup/AGENTS.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# AGENTS для server-backup
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
- Папка `server-backup/` хранит:
|
||||||
|
- тяжёлые локальные бэкапы сервера (НЕ в git);
|
||||||
|
- лёгкую схему восстановления (в git), чтобы можно было поднять сервер даже без полного архива.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
- `archive/YYYY-MM-DD/` — полный бэкап конкретной даты.
|
||||||
|
- `scheme/shineup.me/` — схема восстановления и ключевые конфиги.
|
||||||
|
- `backup-version.properties` — версия контура бэкапа.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
- Полный бэкап складывать только в `server-backup/archive/`.
|
||||||
|
- `server-backup/archive/**` не коммитить.
|
||||||
|
- Любое изменение схемы восстановления фиксировать в git.
|
||||||
|
- После обновления схемы увеличивать `backup.schema.version`.
|
||||||
|
- После нового полного бэкапа увеличивать `backup.full.version`.
|
||||||
|
|
||||||
|
## Как обновлять бэкап
|
||||||
|
1. Обновить схему:
|
||||||
|
- `bash server-backup/scheme/shineup.me/scripts/refresh_scheme.sh`
|
||||||
|
2. Сделать новый полный бэкап:
|
||||||
|
- `bash server-backup/scheme/shineup.me/scripts/backup_full.sh`
|
||||||
|
3. Проверить `server-backup/archive/<дата>/MANIFEST.txt`.
|
||||||
|
4. Поднять версии в `server-backup/backup-version.properties`.
|
||||||
|
|
||||||
|
## Как восстанавливать
|
||||||
|
- Смотреть `server-backup/scheme/shineup.me/docs/RESTORE.md`.
|
||||||
7
server-backup/README.md
Normal file
7
server-backup/README.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# server-backup
|
||||||
|
|
||||||
|
- `archive/` — локальные полные бэкапы по датам (не коммитятся).
|
||||||
|
- `scheme/` — лёгкая схема восстановления (коммитится).
|
||||||
|
- `backup-version.properties` — версии схемы и полного бэкапа.
|
||||||
|
|
||||||
|
Текущий сервер-источник: `shineup.me`.
|
||||||
1
server-backup/archive/.gitkeep
Normal file
1
server-backup/archive/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
3
server-backup/backup-version.properties
Normal file
3
server-backup/backup-version.properties
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
backup.schema.version=1
|
||||||
|
backup.full.version=1
|
||||||
|
last.full.backup.date=2026-06-01
|
||||||
32
server-backup/scheme/shineup.me/captures/01_host_disk.txt
Normal file
32
server-backup/scheme/shineup.me/captures/01_host_disk.txt
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
vm85188.geo.hosting
|
||||||
|
---
|
||||||
|
Linux vm85188.geo.hosting 5.4.0-169-generic #187-Ubuntu SMP Thu Nov 23 14:52:28 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
|
||||||
|
---
|
||||||
|
Filesystem Size Used Avail Use% Mounted on
|
||||||
|
udev 1.9G 0 1.9G 0% /dev
|
||||||
|
tmpfs 392M 1.4M 391M 1% /run
|
||||||
|
/dev/vda2 79G 6.2G 69G 9% /
|
||||||
|
tmpfs 2.0G 0 2.0G 0% /dev/shm
|
||||||
|
tmpfs 5.0M 0 5.0M 0% /run/lock
|
||||||
|
tmpfs 2.0G 0 2.0G 0% /sys/fs/cgroup
|
||||||
|
/dev/loop0 92M 92M 0 100% /snap/lxd/24061
|
||||||
|
/dev/loop2 41M 41M 0 100% /snap/snapd/20290
|
||||||
|
/dev/loop4 50M 50M 0 100% /snap/snapd/26865
|
||||||
|
/dev/loop3 92M 92M 0 100% /snap/lxd/38688
|
||||||
|
/dev/loop5 64M 64M 0 100% /snap/core20/2769
|
||||||
|
tmpfs 392M 0 392M 0% /run/user/1000
|
||||||
|
tmpfs 392M 0 392M 0% /run/user/0
|
||||||
|
/dev/loop6 64M 64M 0 100% /snap/core20/2866
|
||||||
|
---
|
||||||
|
NAME FSTYPE LABEL UUID FSAVAIL FSUSE% MOUNTPOINT
|
||||||
|
loop0 squashfs 0 100% /snap/lxd/24061
|
||||||
|
loop1 squashfs
|
||||||
|
loop2 squashfs 0 100% /snap/snapd/20290
|
||||||
|
loop3 squashfs 0 100% /snap/lxd/38688
|
||||||
|
loop4 squashfs 0 100% /snap/snapd/26865
|
||||||
|
loop5 squashfs 0 100% /snap/core20/2769
|
||||||
|
loop6 squashfs 0 100% /snap/core20/2866
|
||||||
|
sr0
|
||||||
|
vda
|
||||||
|
├─vda1
|
||||||
|
└─vda2 ext4 b3a7aca2-1c7b-470b-a2ee-dc7204eba7d3 68.4G 8% /
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
UNIT FILE STATE VENDOR PRESET
|
||||||
|
accounts-daemon.service enabled enabled
|
||||||
|
agent-memory.service enabled enabled
|
||||||
|
apparmor.service enabled enabled
|
||||||
|
atd.service enabled enabled
|
||||||
|
autovt@.service enabled enabled
|
||||||
|
blk-availability.service enabled enabled
|
||||||
|
caddy.service enabled enabled
|
||||||
|
cloud-config.service enabled enabled
|
||||||
|
cloud-final.service enabled enabled
|
||||||
|
cloud-init-local.service enabled enabled
|
||||||
|
cloud-init.service enabled enabled
|
||||||
|
console-setup.service enabled enabled
|
||||||
|
containerd.service enabled enabled
|
||||||
|
coturn.service enabled enabled
|
||||||
|
cron.service enabled enabled
|
||||||
|
dbus-org.freedesktop.ModemManager1.service enabled enabled
|
||||||
|
dbus-org.freedesktop.resolve1.service enabled enabled
|
||||||
|
dbus-org.freedesktop.thermald.service enabled enabled
|
||||||
|
dbus-org.freedesktop.timesync1.service enabled enabled
|
||||||
|
dmesg.service enabled enabled
|
||||||
|
docker.service enabled enabled
|
||||||
|
e2scrub_reap.service enabled enabled
|
||||||
|
finalrd.service enabled enabled
|
||||||
|
getty@.service enabled enabled
|
||||||
|
grub-common.service enabled enabled
|
||||||
|
grub-initrd-fallback.service enabled enabled
|
||||||
|
irqbalance.service enabled enabled
|
||||||
|
iscsi.service enabled enabled
|
||||||
|
keyboard-setup.service enabled enabled
|
||||||
|
lvm2-monitor.service enabled enabled
|
||||||
|
lxd-agent-9p.service enabled enabled
|
||||||
|
lxd-agent.service enabled enabled
|
||||||
|
ModemManager.service enabled enabled
|
||||||
|
multipath-tools.service enabled enabled
|
||||||
|
multipathd.service enabled enabled
|
||||||
|
networkd-dispatcher.service enabled enabled
|
||||||
|
networking.service enabled enabled
|
||||||
|
ondemand.service enabled enabled
|
||||||
|
open-iscsi.service enabled enabled
|
||||||
|
open-vm-tools.service enabled enabled
|
||||||
|
pollinate.service enabled enabled
|
||||||
|
rsync.service enabled enabled
|
||||||
|
rsyslog.service enabled enabled
|
||||||
|
secureboot-db.service enabled enabled
|
||||||
|
setvtrgb.service enabled enabled
|
||||||
|
shine-server.service enabled enabled
|
||||||
|
snap.lxd.activate.service enabled enabled
|
||||||
|
snapd.apparmor.service enabled enabled
|
||||||
|
snapd.autoimport.service enabled enabled
|
||||||
|
snapd.core-fixup.service enabled enabled
|
||||||
|
snapd.recovery-chooser-trigger.service enabled enabled
|
||||||
|
snapd.seeded.service enabled enabled
|
||||||
|
snapd.service enabled enabled
|
||||||
|
snapd.system-shutdown.service enabled enabled
|
||||||
|
ssh.service enabled enabled
|
||||||
|
sshd.service enabled enabled
|
||||||
|
syslog.service enabled enabled
|
||||||
|
systemd-networkd-wait-online.service enabled enabled
|
||||||
|
systemd-networkd.service enabled enabled
|
||||||
|
systemd-pstore.service enabled enabled
|
||||||
|
systemd-resolved.service enabled enabled
|
||||||
|
systemd-timesyncd.service enabled enabled
|
||||||
|
thermald.service enabled enabled
|
||||||
|
ua-reboot-cmds.service enabled enabled
|
||||||
|
ubuntu-advantage.service enabled enabled
|
||||||
|
ubuntu-fan.service enabled enabled
|
||||||
|
udisks2.service enabled enabled
|
||||||
|
ufw.service enabled enabled
|
||||||
|
unattended-upgrades.service enabled enabled
|
||||||
|
vgauth.service enabled enabled
|
||||||
|
vmtoolsd.service enabled enabled
|
||||||
|
|
||||||
|
71 unit files listed.
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
UNIT LOAD ACTIVE SUB DESCRIPTION
|
||||||
|
accounts-daemon.service loaded active running Accounts Service
|
||||||
|
agent-memory.service loaded active running agent-memory service
|
||||||
|
atd.service loaded active running Deferred execution scheduler
|
||||||
|
caddy.service loaded active running Caddy
|
||||||
|
containerd.service loaded active running containerd container runtime
|
||||||
|
coturn.service loaded active running coTURN STUN/TURN Server
|
||||||
|
cron.service loaded active running Regular background program processing daemon
|
||||||
|
dbus.service loaded active running D-Bus System Message Bus
|
||||||
|
docker.service loaded active running Docker Application Container Engine
|
||||||
|
getty@tty1.service loaded active running Getty on tty1
|
||||||
|
irqbalance.service loaded active running irqbalance daemon
|
||||||
|
ModemManager.service loaded active running Modem Manager
|
||||||
|
multipathd.service loaded active running Device-Mapper Multipath Device Controller
|
||||||
|
networkd-dispatcher.service loaded active running Dispatcher daemon for systemd-networkd
|
||||||
|
polkit.service loaded active running Authorization Manager
|
||||||
|
qemu-guest-agent.service loaded active running QEMU Guest Agent
|
||||||
|
rsyslog.service loaded active running System Logging Service
|
||||||
|
shine-server.service loaded active running SHiNE Server
|
||||||
|
snapd.service loaded active running Snap Daemon
|
||||||
|
ssh.service loaded active running OpenBSD Secure Shell server
|
||||||
|
systemd-hostnamed.service loaded active running Hostname Service
|
||||||
|
systemd-journald.service loaded active running Journal Service
|
||||||
|
systemd-logind.service loaded active running Login Service
|
||||||
|
systemd-networkd.service loaded active running Network Service
|
||||||
|
systemd-resolved.service loaded active running Network Name Resolution
|
||||||
|
systemd-timesyncd.service loaded active running Network Time Synchronization
|
||||||
|
systemd-udevd.service loaded active running udev Kernel Device Manager
|
||||||
|
udisks2.service loaded active running Disk Manager
|
||||||
|
unattended-upgrades.service loaded active running Unattended Upgrades Shutdown
|
||||||
|
upower.service loaded active running Daemon for power management
|
||||||
|
user@0.service loaded active running User Manager for UID 0
|
||||||
|
user@1000.service loaded active running User Manager for UID 1000
|
||||||
|
uuidd.service loaded active running Daemon for generating UUIDs
|
||||||
|
|
||||||
|
LOAD = Reflects whether the unit definition was properly loaded.
|
||||||
|
ACTIVE = The high-level unit activation state, i.e. generalization of SUB.
|
||||||
|
SUB = The low-level unit activation state, values depend on unit type.
|
||||||
|
|
||||||
|
33 loaded units listed.
|
||||||
50
server-backup/scheme/shineup.me/captures/04_listen_ports.txt
Normal file
50
server-backup/scheme/shineup.me/captures/04_listen_ports.txt
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
||||||
|
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=114218,fd=13))
|
||||||
|
LISTEN 0 4096 0.0.0.0:2222 0.0.0.0:* users:(("docker-proxy",pid=195899,fd=4))
|
||||||
|
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=718,fd=13))
|
||||||
|
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=63620,fd=3))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=99))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=94))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=82))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=72))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=67))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=66))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=53))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=49))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=32))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=29))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=23))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=22))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=102))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=97))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=85))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=78))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=79))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=69))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=55))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=51))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=35))
|
||||||
|
LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=33))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=27))
|
||||||
|
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=26))
|
||||||
|
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=195885,fd=4))
|
||||||
|
LISTEN 0 4096 127.0.0.1:37021 0.0.0.0:* users:(("containerd",pid=188233,fd=11))
|
||||||
|
LISTEN 0 50 *:7070 *:* users:(("java",pid=2278064,fd=9))
|
||||||
|
LISTEN 0 4096 [::]:2222 [::]:* users:(("docker-proxy",pid=195906,fd=4))
|
||||||
|
LISTEN 0 100 *:8080 *:* users:(("java",pid=304246,fd=11))
|
||||||
|
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=114218,fd=16))
|
||||||
|
LISTEN 0 100 *:8018 *:* users:(("java",pid=321572,fd=13))
|
||||||
|
LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=63620,fd=4))
|
||||||
|
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=38))
|
||||||
|
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=41))
|
||||||
|
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=57))
|
||||||
|
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=84))
|
||||||
|
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=88))
|
||||||
|
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=106))
|
||||||
|
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=40))
|
||||||
|
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=47))
|
||||||
|
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=59))
|
||||||
|
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=90))
|
||||||
|
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=91))
|
||||||
|
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=113))
|
||||||
|
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=114218,fd=12))
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
NAMES IMAGE STATUS PORTS
|
||||||
|
gitea gitea/gitea:1.22.6 Up 3 weeks 127.0.0.1:3000->3000/tcp, 0.0.0.0:2222->22/tcp, :::2222->22/tcp
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
4.0K /home/player/hosts.codex.test
|
||||||
|
16K /home/player/logs
|
||||||
|
796K /home/player/sites
|
||||||
|
15M /home/player/gitea
|
||||||
|
36M /home/player/agent-memory
|
||||||
|
200M /home/player/SHiNE
|
||||||
12
server-backup/scheme/shineup.me/captures/07_var_sizes.txt
Normal file
12
server-backup/scheme/shineup.me/captures/07_var_sizes.txt
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
4.0K /var/crash
|
||||||
|
4.0K /var/local
|
||||||
|
4.0K /var/mail
|
||||||
|
4.0K /var/opt
|
||||||
|
28K /var/spool
|
||||||
|
60K /var/snap
|
||||||
|
76K /var/tmp
|
||||||
|
1.9M /var/backups
|
||||||
|
280M /var/cache
|
||||||
|
1.2G /var/lib
|
||||||
|
1.2G /var/log
|
||||||
|
2.7G /var
|
||||||
@ -0,0 +1 @@
|
|||||||
|
2026-06-01T08:53:07Z
|
||||||
54
server-backup/scheme/shineup.me/configs/Caddyfile
Normal file
54
server-backup/scheme/shineup.me/configs/Caddyfile
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
openmindsoft.io, www.openmindsoft.io {
|
||||||
|
encode zstd gzip
|
||||||
|
root * /home/player/sites/OpenMindSoft.io
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
shineup.me {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
@ws path /ws /ws/*
|
||||||
|
handle @ws {
|
||||||
|
reverse_proxy 127.0.0.1:7070
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
root * /home/player/SHiNE/shine-ui
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
header -Etag
|
||||||
|
header {
|
||||||
|
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
Pragma "no-cache"
|
||||||
|
Expires "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
@ws path /ws /ws/*
|
||||||
|
handle @ws {
|
||||||
|
reverse_proxy 127.0.0.1:7070
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
root * /home/player/SHiNE/shine-ui
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
header -Etag
|
||||||
|
header {
|
||||||
|
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
Pragma "no-cache"
|
||||||
|
Expires "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
git.shineup.me {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=agent-memory service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=player
|
||||||
|
Group=player
|
||||||
|
WorkingDirectory=/home/player/agent-memory
|
||||||
|
ExecStart=/usr/bin/java -jar /home/player/agent-memory/agent-gpt-memory.jar --spring.config.location=optional:file:/home/player/agent-memory/app.properties
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillMode=control-group
|
||||||
|
LimitNOFILE=65535
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=/home/player/agent-memory/data /home/player/agent-memory/logs
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SHiNE Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=player
|
||||||
|
Group=player
|
||||||
|
WorkingDirectory=/home/player/SHiNE/shine-server
|
||||||
|
ExecStart=/usr/bin/java -Dserver.1port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
709
server-backup/scheme/shineup.me/configs/turnserver.conf
Normal file
709
server-backup/scheme/shineup.me/configs/turnserver.conf
Normal file
@ -0,0 +1,709 @@
|
|||||||
|
# Coturn TURN SERVER configuration file
|
||||||
|
#
|
||||||
|
# Boolean values note: where boolean value is supposed to be used,
|
||||||
|
# you can use '0', 'off', 'no', 'false', 'f' as 'false,
|
||||||
|
# and you can use '1', 'on', 'yes', 'true', 't' as 'true'
|
||||||
|
# If the value is missed, then it means 'true'.
|
||||||
|
#
|
||||||
|
|
||||||
|
# Listener interface device (optional, Linux only).
|
||||||
|
# NOT RECOMMENDED.
|
||||||
|
#
|
||||||
|
#listening-device=eth0
|
||||||
|
|
||||||
|
# TURN listener port for UDP and TCP (Default: 3478).
|
||||||
|
# Note: actually, TLS & DTLS sessions can connect to the
|
||||||
|
# "plain" TCP & UDP port(s), too - if allowed by configuration.
|
||||||
|
#
|
||||||
|
#listening-port=3478
|
||||||
|
|
||||||
|
# TURN listener port for TLS (Default: 5349).
|
||||||
|
# Note: actually, "plain" TCP & UDP sessions can connect to the TLS & DTLS
|
||||||
|
# port(s), too - if allowed by configuration. The TURN server
|
||||||
|
# "automatically" recognizes the type of traffic. Actually, two listening
|
||||||
|
# endpoints (the "plain" one and the "tls" one) are equivalent in terms of
|
||||||
|
# functionality; but we keep both endpoints to satisfy the RFC 5766 specs.
|
||||||
|
# For secure TCP connections, we currently support SSL version 3 and
|
||||||
|
# TLS version 1.0, 1.1 and 1.2.
|
||||||
|
# For secure UDP connections, we support DTLS version 1.
|
||||||
|
#
|
||||||
|
#tls-listening-port=5349
|
||||||
|
|
||||||
|
# Alternative listening port for UDP and TCP listeners;
|
||||||
|
# default (or zero) value means "listening port plus one".
|
||||||
|
# This is needed for RFC 5780 support
|
||||||
|
# (STUN extension specs, NAT behavior discovery). The TURN Server
|
||||||
|
# supports RFC 5780 only if it is started with more than one
|
||||||
|
# listening IP address of the same family (IPv4 or IPv6).
|
||||||
|
# RFC 5780 is supported only by UDP protocol, other protocols
|
||||||
|
# are listening to that endpoint only for "symmetry".
|
||||||
|
#
|
||||||
|
#alt-listening-port=0
|
||||||
|
|
||||||
|
# Alternative listening port for TLS and DTLS protocols.
|
||||||
|
# Default (or zero) value means "TLS listening port plus one".
|
||||||
|
#
|
||||||
|
#alt-tls-listening-port=0
|
||||||
|
|
||||||
|
# Listener IP address of relay server. Multiple listeners can be specified.
|
||||||
|
# If no IP(s) specified in the config file or in the command line options,
|
||||||
|
# then all IPv4 and IPv6 system IPs will be used for listening.
|
||||||
|
#
|
||||||
|
#listening-ip=172.17.19.101
|
||||||
|
#listening-ip=10.207.21.238
|
||||||
|
#listening-ip=2607:f0d0:1002:51::4
|
||||||
|
|
||||||
|
# Auxiliary STUN/TURN server listening endpoint.
|
||||||
|
# Aux servers have almost full TURN and STUN functionality.
|
||||||
|
# The (minor) limitations are:
|
||||||
|
#
|
||||||
|
# 1) Auxiliary servers do not have alternative ports and
|
||||||
|
# they do not support STUN RFC 5780 functionality (CHANGE REQUEST).
|
||||||
|
#
|
||||||
|
# 2) Auxiliary servers also are never returning ALTERNATIVE-SERVER reply.
|
||||||
|
#
|
||||||
|
# Valid formats are 1.2.3.4:5555 for IPv4 and [1:2::3:4]:5555 for IPv6.
|
||||||
|
#
|
||||||
|
# There may be multiple aux-server options, each will be used for listening
|
||||||
|
# to client requests.
|
||||||
|
#
|
||||||
|
#aux-server=172.17.19.110:33478
|
||||||
|
#aux-server=[2607:f0d0:1002:51::4]:33478
|
||||||
|
|
||||||
|
# (recommended for older Linuxes only)
|
||||||
|
# Automatically balance UDP traffic over auxiliary servers (if configured).
|
||||||
|
# The load balancing is using the ALTERNATE-SERVER mechanism.
|
||||||
|
# The TURN client must support 300 ALTERNATE-SERVER response for this
|
||||||
|
# functionality.
|
||||||
|
#
|
||||||
|
#udp-self-balance
|
||||||
|
|
||||||
|
# Relay interface device for relay sockets (optional, Linux only).
|
||||||
|
# NOT RECOMMENDED.
|
||||||
|
#
|
||||||
|
#relay-device=eth1
|
||||||
|
|
||||||
|
# Relay address (the local IP address that will be used to relay the
|
||||||
|
# packets to the peer).
|
||||||
|
# Multiple relay addresses may be used.
|
||||||
|
# The same IP(s) can be used as both listening IP(s) and relay IP(s).
|
||||||
|
#
|
||||||
|
# If no relay IP(s) specified, then the turnserver will apply the default
|
||||||
|
# policy: it will decide itself which relay addresses to be used, and it
|
||||||
|
# will always be using the client socket IP address as the relay IP address
|
||||||
|
# of the TURN session (if the requested relay address family is the same
|
||||||
|
# as the family of the client socket).
|
||||||
|
#
|
||||||
|
#relay-ip=172.17.19.105
|
||||||
|
#relay-ip=2607:f0d0:1002:51::5
|
||||||
|
|
||||||
|
# For Amazon EC2 users:
|
||||||
|
#
|
||||||
|
# TURN Server public/private address mapping, if the server is behind NAT.
|
||||||
|
# In that situation, if a -X is used in form "-X <ip>" then that ip will be reported
|
||||||
|
# as relay IP address of all allocations. This scenario works only in a simple case
|
||||||
|
# when one single relay address is be used, and no RFC5780 functionality is required.
|
||||||
|
# That single relay address must be mapped by NAT to the 'external' IP.
|
||||||
|
# The "external-ip" value, if not empty, is returned in XOR-RELAYED-ADDRESS field.
|
||||||
|
# For that 'external' IP, NAT must forward ports directly (relayed port 12345
|
||||||
|
# must be always mapped to the same 'external' port 12345).
|
||||||
|
#
|
||||||
|
# In more complex case when more than one IP address is involved,
|
||||||
|
# that option must be used several times, each entry must
|
||||||
|
# have form "-X <public-ip/private-ip>", to map all involved addresses.
|
||||||
|
# RFC5780 NAT discovery STUN functionality will work correctly,
|
||||||
|
# if the addresses are mapped properly, even when the TURN server itself
|
||||||
|
# is behind A NAT.
|
||||||
|
#
|
||||||
|
# By default, this value is empty, and no address mapping is used.
|
||||||
|
#
|
||||||
|
#external-ip=60.70.80.91
|
||||||
|
#
|
||||||
|
#OR:
|
||||||
|
#
|
||||||
|
#external-ip=60.70.80.91/172.17.19.101
|
||||||
|
#external-ip=60.70.80.92/172.17.19.102
|
||||||
|
|
||||||
|
|
||||||
|
# Number of the relay threads to handle the established connections
|
||||||
|
# (in addition to authentication thread and the listener thread).
|
||||||
|
# If explicitly set to 0 then application runs relay process in a
|
||||||
|
# single thread, in the same thread with the listener process
|
||||||
|
# (the authentication thread will still be a separate thread).
|
||||||
|
#
|
||||||
|
# If this parameter is not set, then the default OS-dependent
|
||||||
|
# thread pattern algorithm will be employed. Usually the default
|
||||||
|
# algorithm is the most optimal, so you have to change this option
|
||||||
|
# only if you want to make some fine tweaks.
|
||||||
|
#
|
||||||
|
# In the older systems (Linux kernel before 3.9),
|
||||||
|
# the number of UDP threads is always one thread per network listening
|
||||||
|
# endpoint - including the auxiliary endpoints - unless 0 (zero) or
|
||||||
|
# 1 (one) value is set.
|
||||||
|
#
|
||||||
|
#relay-threads=0
|
||||||
|
|
||||||
|
# Lower and upper bounds of the UDP relay endpoints:
|
||||||
|
# (default values are 49152 and 65535)
|
||||||
|
#
|
||||||
|
#min-port=49152
|
||||||
|
#max-port=65535
|
||||||
|
|
||||||
|
# Uncomment to run TURN server in 'normal' 'moderate' verbose mode.
|
||||||
|
# By default the verbose mode is off.
|
||||||
|
#verbose
|
||||||
|
|
||||||
|
# Uncomment to run TURN server in 'extra' verbose mode.
|
||||||
|
# This mode is very annoying and produces lots of output.
|
||||||
|
# Not recommended under any normal circumstances.
|
||||||
|
#
|
||||||
|
#Verbose
|
||||||
|
|
||||||
|
# Uncomment to use fingerprints in the TURN messages.
|
||||||
|
# By default the fingerprints are off.
|
||||||
|
#
|
||||||
|
#fingerprint
|
||||||
|
|
||||||
|
# Uncomment to use long-term credential mechanism.
|
||||||
|
# By default no credentials mechanism is used (any user allowed).
|
||||||
|
#
|
||||||
|
#lt-cred-mech
|
||||||
|
|
||||||
|
# This option is opposite to lt-cred-mech.
|
||||||
|
# (TURN Server with no-auth option allows anonymous access).
|
||||||
|
# If neither option is defined, and no users are defined,
|
||||||
|
# then no-auth is default. If at least one user is defined,
|
||||||
|
# in this file or in command line or in usersdb file, then
|
||||||
|
# lt-cred-mech is default.
|
||||||
|
#
|
||||||
|
#no-auth
|
||||||
|
|
||||||
|
# TURN REST API flag.
|
||||||
|
# (Time Limited Long Term Credential)
|
||||||
|
# Flag that sets a special authorization option that is based upon authentication secret.
|
||||||
|
#
|
||||||
|
# This feature's purpose is to support "TURN Server REST API", see
|
||||||
|
# "TURN REST API" link in the project's page
|
||||||
|
# https://github.com/coturn/coturn/
|
||||||
|
#
|
||||||
|
# This option is used with timestamp:
|
||||||
|
#
|
||||||
|
# usercombo -> "timestamp:userid"
|
||||||
|
# turn user -> usercombo
|
||||||
|
# turn password -> base64(hmac(secret key, usercombo))
|
||||||
|
#
|
||||||
|
# This allows TURN credentials to be accounted for a specific user id.
|
||||||
|
# If you don't have a suitable id, the timestamp alone can be used.
|
||||||
|
# This option is just turning on secret-based authentication.
|
||||||
|
# The actual value of the secret is defined either by option static-auth-secret,
|
||||||
|
# or can be found in the turn_secret table in the database (see below).
|
||||||
|
#
|
||||||
|
# Read more about it:
|
||||||
|
# - https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
|
||||||
|
# - https://www.ietf.org/proceedings/87/slides/slides-87-behave-10.pdf
|
||||||
|
#
|
||||||
|
# Be aware that use-auth-secret overrides some part of lt-cred-mech.
|
||||||
|
# Notice that this feature depends internally on lt-cred-mech, so if you set
|
||||||
|
# use-auth-secret then it enables internally automatically lt-cred-mech option
|
||||||
|
# like if you enable both.
|
||||||
|
#
|
||||||
|
# You can use only one of the to auth mechanisms in the same time because,
|
||||||
|
# both mechanism use the username and password validation in different way.
|
||||||
|
#
|
||||||
|
# This way be aware that you can't use both auth mechnaism in the same time!
|
||||||
|
# Use in config either the lt-cred-mech or the use-auth-secret
|
||||||
|
# to avoid any confusion.
|
||||||
|
#
|
||||||
|
use-auth-secret
|
||||||
|
|
||||||
|
# 'Static' authentication secret value (a string) for TURN REST API only.
|
||||||
|
# If not set, then the turn server
|
||||||
|
# will try to use the 'dynamic' value in turn_secret table
|
||||||
|
# in user database (if present). The database-stored value can be changed on-the-fly
|
||||||
|
# by a separate program, so this is why that other mode is 'dynamic'.
|
||||||
|
#
|
||||||
|
#static-auth-secret=north
|
||||||
|
|
||||||
|
# Server name used for
|
||||||
|
# the oAuth authentication purposes.
|
||||||
|
# The default value is the realm name.
|
||||||
|
#
|
||||||
|
#server-name=blackdow.carleon.gov
|
||||||
|
|
||||||
|
# Flag that allows oAuth authentication.
|
||||||
|
#
|
||||||
|
#oauth
|
||||||
|
|
||||||
|
# 'Static' user accounts for long term credentials mechanism, only.
|
||||||
|
# This option cannot be used with TURN REST API.
|
||||||
|
# 'Static' user accounts are NOT dynamically checked by the turnserver process,
|
||||||
|
# so that they can NOT be changed while the turnserver is running.
|
||||||
|
#
|
||||||
|
#user=username1:key1
|
||||||
|
#user=username2:key2
|
||||||
|
# OR:
|
||||||
|
#user=username1:password1
|
||||||
|
#user=username2:password2
|
||||||
|
#
|
||||||
|
# Keys must be generated by turnadmin utility. The key value depends
|
||||||
|
# on user name, realm, and password:
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# $ turnadmin -k -u ninefingers -r north.gov -p youhavetoberealistic
|
||||||
|
# Output: 0xbc807ee29df3c9ffa736523fb2c4e8ee
|
||||||
|
# ('0x' in the beginning of the key is what differentiates the key from
|
||||||
|
# password. If it has 0x then it is a key, otherwise it is a password).
|
||||||
|
#
|
||||||
|
# The corresponding user account entry in the config file will be:
|
||||||
|
#
|
||||||
|
#user=ninefingers:0xbc807ee29df3c9ffa736523fb2c4e8ee
|
||||||
|
# Or, equivalently, with open clear password (less secure):
|
||||||
|
#user=ninefingers:youhavetoberealistic
|
||||||
|
#
|
||||||
|
|
||||||
|
# SQLite database file name.
|
||||||
|
#
|
||||||
|
# Default file name is /var/db/turndb or /usr/local/var/db/turndb or
|
||||||
|
# /var/lib/turn/turndb.
|
||||||
|
#
|
||||||
|
#userdb=/var/db/turndb
|
||||||
|
|
||||||
|
# PostgreSQL database connection string in the case that we are using PostgreSQL
|
||||||
|
# as the user database.
|
||||||
|
# This database can be used for long-term credential mechanism
|
||||||
|
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
|
||||||
|
# See http://www.postgresql.org/docs/8.4/static/libpq-connect.html for 8.x PostgreSQL
|
||||||
|
# versions connection string format, see
|
||||||
|
# http://www.postgresql.org/docs/9.2/static/libpq-connect.html#LIBPQ-CONNSTRING
|
||||||
|
# for 9.x and newer connection string formats.
|
||||||
|
#
|
||||||
|
#psql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> connect_timeout=30"
|
||||||
|
|
||||||
|
# MySQL database connection string in the case that we are using MySQL
|
||||||
|
# as the user database.
|
||||||
|
# This database can be used for long-term credential mechanism
|
||||||
|
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
|
||||||
|
#
|
||||||
|
# Optional connection string parameters for the secure communications (SSL):
|
||||||
|
# ca, capath, cert, key, cipher
|
||||||
|
# (see http://dev.mysql.com/doc/refman/5.1/en/ssl-options.html for the
|
||||||
|
# command options description).
|
||||||
|
#
|
||||||
|
# Use string format as below (space separated parameters, all optional):
|
||||||
|
#
|
||||||
|
#mysql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> port=<port> connect_timeout=<seconds> read_timeout=<seconds>"
|
||||||
|
|
||||||
|
# If you want to use in the MySQL connection string the password in encrypted format,
|
||||||
|
# then set in this option the MySQL password encryption secret key file.
|
||||||
|
#
|
||||||
|
# Warning: If this option is set, then mysql password must be set in "mysql-userdb" in encrypted format!
|
||||||
|
# If you want to use cleartext password then do not set this option!
|
||||||
|
#
|
||||||
|
# This is the file path which contain secret key of aes encryption while using password encryption.
|
||||||
|
#
|
||||||
|
#secret-key-file=/path/
|
||||||
|
|
||||||
|
# MongoDB database connection string in the case that we are using MongoDB
|
||||||
|
# as the user database.
|
||||||
|
# This database can be used for long-term credential mechanism
|
||||||
|
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
|
||||||
|
# Use string format is described at http://hergert.me/docs/mongo-c-driver/mongoc_uri.html
|
||||||
|
#
|
||||||
|
#mongo-userdb="mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]"
|
||||||
|
|
||||||
|
# Redis database connection string in the case that we are using Redis
|
||||||
|
# as the user database.
|
||||||
|
# This database can be used for long-term credential mechanism
|
||||||
|
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
|
||||||
|
# Use string format as below (space separated parameters, all optional):
|
||||||
|
#
|
||||||
|
#redis-userdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
|
||||||
|
|
||||||
|
# Redis status and statistics database connection string, if used (default - empty, no Redis stats DB used).
|
||||||
|
# This database keeps allocations status information, and it can be also used for publishing
|
||||||
|
# and delivering traffic and allocation event notifications.
|
||||||
|
# The connection string has the same parameters as redis-userdb connection string.
|
||||||
|
# Use string format as below (space separated parameters, all optional):
|
||||||
|
#
|
||||||
|
#redis-statsdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
|
||||||
|
|
||||||
|
# The default realm to be used for the users when no explicit
|
||||||
|
# origin/realm relationship was found in the database, or if the TURN
|
||||||
|
# server is not using any database (just the commands-line settings
|
||||||
|
# and the userdb file). Must be used with long-term credentials
|
||||||
|
# mechanism or with TURN REST API.
|
||||||
|
#
|
||||||
|
# Note: If default realm is not specified at all, then realm falls back to the host domain name.
|
||||||
|
# If domain name is empty string, or '(None)', then it is initialized to am empty string.
|
||||||
|
#
|
||||||
|
#realm=mycompany.org
|
||||||
|
|
||||||
|
# The flag that sets the origin consistency
|
||||||
|
# check: across the session, all requests must have the same
|
||||||
|
# main ORIGIN attribute value (if the ORIGIN was
|
||||||
|
# initially used by the session).
|
||||||
|
#
|
||||||
|
#check-origin-consistency
|
||||||
|
|
||||||
|
# Per-user allocation quota.
|
||||||
|
# default value is 0 (no quota, unlimited number of sessions per user).
|
||||||
|
# This option can also be set through the database, for a particular realm.
|
||||||
|
#
|
||||||
|
#user-quota=0
|
||||||
|
|
||||||
|
# Total allocation quota.
|
||||||
|
# default value is 0 (no quota).
|
||||||
|
# This option can also be set through the database, for a particular realm.
|
||||||
|
#
|
||||||
|
#total-quota=0
|
||||||
|
|
||||||
|
# Max bytes-per-second bandwidth a TURN session is allowed to handle
|
||||||
|
# (input and output network streams are treated separately). Anything above
|
||||||
|
# that limit will be dropped or temporary suppressed (within
|
||||||
|
# the available buffer limits).
|
||||||
|
# This option can also be set through the database, for a particular realm.
|
||||||
|
#
|
||||||
|
#max-bps=0
|
||||||
|
|
||||||
|
#
|
||||||
|
# Maximum server capacity.
|
||||||
|
# Total bytes-per-second bandwidth the TURN server is allowed to allocate
|
||||||
|
# for the sessions, combined (input and output network streams are treated separately).
|
||||||
|
#
|
||||||
|
# bps-capacity=0
|
||||||
|
|
||||||
|
# Uncomment if no UDP client listener is desired.
|
||||||
|
# By default UDP client listener is always started.
|
||||||
|
#
|
||||||
|
#no-udp
|
||||||
|
|
||||||
|
# Uncomment if no TCP client listener is desired.
|
||||||
|
# By default TCP client listener is always started.
|
||||||
|
#
|
||||||
|
#no-tcp
|
||||||
|
|
||||||
|
# Uncomment if no TLS client listener is desired.
|
||||||
|
# By default TLS client listener is always started.
|
||||||
|
#
|
||||||
|
#no-tls
|
||||||
|
|
||||||
|
# Uncomment if no DTLS client listener is desired.
|
||||||
|
# By default DTLS client listener is always started.
|
||||||
|
#
|
||||||
|
#no-dtls
|
||||||
|
|
||||||
|
# Uncomment if no UDP relay endpoints are allowed.
|
||||||
|
# By default UDP relay endpoints are enabled (like in RFC 5766).
|
||||||
|
#
|
||||||
|
#no-udp-relay
|
||||||
|
|
||||||
|
# Uncomment if no TCP relay endpoints are allowed.
|
||||||
|
# By default TCP relay endpoints are enabled (like in RFC 6062).
|
||||||
|
#
|
||||||
|
#no-tcp-relay
|
||||||
|
|
||||||
|
# Uncomment if extra security is desired,
|
||||||
|
# with nonce value having limited lifetime.
|
||||||
|
# By default, the nonce value is unique for a session,
|
||||||
|
# and has unlimited lifetime.
|
||||||
|
# Set this option to limit the nonce lifetime.
|
||||||
|
# It defaults to 600 secs (10 min) if no value is provided. After that delay,
|
||||||
|
# the client will get 438 error and will have to re-authenticate itself.
|
||||||
|
#
|
||||||
|
#stale-nonce=600
|
||||||
|
|
||||||
|
# Uncomment if you want to set the maximum allocation
|
||||||
|
# time before it has to be refreshed.
|
||||||
|
# Default is 3600s.
|
||||||
|
#
|
||||||
|
#max-allocate-lifetime=3600
|
||||||
|
|
||||||
|
|
||||||
|
# Uncomment to set the lifetime for the channel.
|
||||||
|
# Default value is 600 secs (10 minutes).
|
||||||
|
# This value MUST not be changed for production purposes.
|
||||||
|
#
|
||||||
|
#channel-lifetime=600
|
||||||
|
|
||||||
|
# Uncomment to set the permission lifetime.
|
||||||
|
# Default to 300 secs (5 minutes).
|
||||||
|
# In production this value MUST not be changed,
|
||||||
|
# however it can be useful for test purposes.
|
||||||
|
#
|
||||||
|
#permission-lifetime=300
|
||||||
|
|
||||||
|
# Certificate file.
|
||||||
|
# Use an absolute path or path relative to the
|
||||||
|
# configuration file.
|
||||||
|
#
|
||||||
|
#cert=/usr/local/etc/turn_server_cert.pem
|
||||||
|
|
||||||
|
# Private key file.
|
||||||
|
# Use an absolute path or path relative to the
|
||||||
|
# configuration file.
|
||||||
|
# Use PEM file format.
|
||||||
|
#
|
||||||
|
#pkey=/usr/local/etc/turn_server_pkey.pem
|
||||||
|
|
||||||
|
# Private key file password, if it is in encoded format.
|
||||||
|
# This option has no default value.
|
||||||
|
#
|
||||||
|
#pkey-pwd=...
|
||||||
|
|
||||||
|
# Allowed OpenSSL cipher list for TLS/DTLS connections.
|
||||||
|
# Default value is "DEFAULT".
|
||||||
|
#
|
||||||
|
#cipher-list="DEFAULT"
|
||||||
|
|
||||||
|
# CA file in OpenSSL format.
|
||||||
|
# Forces TURN server to verify the client SSL certificates.
|
||||||
|
# By default it is not set: there is no default value and the client
|
||||||
|
# certificate is not checked.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
#CA-file=/etc/ssh/id_rsa.cert
|
||||||
|
|
||||||
|
# Curve name for EC ciphers, if supported by OpenSSL
|
||||||
|
# library (TLS and DTLS). The default value is prime256v1,
|
||||||
|
# if pre-OpenSSL 1.0.2 is used. With OpenSSL 1.0.2+,
|
||||||
|
# an optimal curve will be automatically calculated, if not defined
|
||||||
|
# by this option.
|
||||||
|
#
|
||||||
|
#ec-curve-name=prime256v1
|
||||||
|
|
||||||
|
# Use 566 bits predefined DH TLS key. Default size of the key is 1066.
|
||||||
|
#
|
||||||
|
#dh566
|
||||||
|
|
||||||
|
# Use 2066 bits predefined DH TLS key. Default size of the key is 1066.
|
||||||
|
#
|
||||||
|
#dh2066
|
||||||
|
|
||||||
|
# Use custom DH TLS key, stored in PEM format in the file.
|
||||||
|
# Flags --dh566 and --dh2066 are ignored when the DH key is taken from a file.
|
||||||
|
#
|
||||||
|
#dh-file=<DH-PEM-file-name>
|
||||||
|
|
||||||
|
# Flag to prevent stdout log messages.
|
||||||
|
# By default, all log messages are going to both stdout and to
|
||||||
|
# the configured log file. With this option everything will be
|
||||||
|
# going to the configured log only (unless the log file itself is stdout).
|
||||||
|
#
|
||||||
|
#no-stdout-log
|
||||||
|
|
||||||
|
# Option to set the log file name.
|
||||||
|
# By default, the turnserver tries to open a log file in
|
||||||
|
# /var/log, /var/tmp, /tmp and current directories directories
|
||||||
|
# (which open operation succeeds first that file will be used).
|
||||||
|
# With this option you can set the definite log file name.
|
||||||
|
# The special names are "stdout" and "-" - they will force everything
|
||||||
|
# to the stdout. Also, the "syslog" name will force everything to
|
||||||
|
# the system log (syslog).
|
||||||
|
# In the runtime, the logfile can be reset with the SIGHUP signal
|
||||||
|
# to the turnserver process.
|
||||||
|
#
|
||||||
|
#log-file=/var/tmp/turn.log
|
||||||
|
|
||||||
|
# Option to redirect all log output into system log (syslog).
|
||||||
|
#
|
||||||
|
syslog
|
||||||
|
|
||||||
|
# This flag means that no log file rollover will be used, and the log file
|
||||||
|
# name will be constructed as-is, without PID and date appendage.
|
||||||
|
# This option can be used, for example, together with the logrotate tool.
|
||||||
|
#
|
||||||
|
#simple-log
|
||||||
|
|
||||||
|
# Option to set the "redirection" mode. The value of this option
|
||||||
|
# will be the address of the alternate server for UDP & TCP service in form of
|
||||||
|
# <ip>[:<port>]. The server will send this value in the attribute
|
||||||
|
# ALTERNATE-SERVER, with error 300, on ALLOCATE request, to the client.
|
||||||
|
# Client will receive only values with the same address family
|
||||||
|
# as the client network endpoint address family.
|
||||||
|
# See RFC 5389 and RFC 5766 for ALTERNATE-SERVER functionality description.
|
||||||
|
# The client must use the obtained value for subsequent TURN communications.
|
||||||
|
# If more than one --alternate-server options are provided, then the functionality
|
||||||
|
# can be more accurately described as "load-balancing" than a mere "redirection".
|
||||||
|
# If the port number is omitted, then the default port
|
||||||
|
# number 3478 for the UDP/TCP protocols will be used.
|
||||||
|
# Colon (:) characters in IPv6 addresses may conflict with the syntax of
|
||||||
|
# the option. To alleviate this conflict, literal IPv6 addresses are enclosed
|
||||||
|
# in square brackets in such resource identifiers, for example:
|
||||||
|
# [2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478 .
|
||||||
|
# Multiple alternate servers can be set. They will be used in the
|
||||||
|
# round-robin manner. All servers in the pool are considered of equal weight and
|
||||||
|
# the load will be distributed equally. For example, if we have 4 alternate servers,
|
||||||
|
# then each server will receive 25% of ALLOCATE requests. A alternate TURN server
|
||||||
|
# address can be used more than one time with the alternate-server option, so this
|
||||||
|
# can emulate "weighting" of the servers.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
#alternate-server=1.2.3.4:5678
|
||||||
|
#alternate-server=11.22.33.44:56789
|
||||||
|
#alternate-server=5.6.7.8
|
||||||
|
#alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
|
||||||
|
|
||||||
|
# Option to set alternative server for TLS & DTLS services in form of
|
||||||
|
# <ip>:<port>. If the port number is omitted, then the default port
|
||||||
|
# number 5349 for the TLS/DTLS protocols will be used. See the previous
|
||||||
|
# option for the functionality description.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
#tls-alternate-server=1.2.3.4:5678
|
||||||
|
#tls-alternate-server=11.22.33.44:56789
|
||||||
|
#tls-alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
|
||||||
|
|
||||||
|
# Option to suppress TURN functionality, only STUN requests will be processed.
|
||||||
|
# Run as STUN server only, all TURN requests will be ignored.
|
||||||
|
# By default, this option is NOT set.
|
||||||
|
#
|
||||||
|
#stun-only
|
||||||
|
|
||||||
|
# Option to suppress STUN functionality, only TURN requests will be processed.
|
||||||
|
# Run as TURN server only, all STUN requests will be ignored.
|
||||||
|
# By default, this option is NOT set.
|
||||||
|
#
|
||||||
|
#no-stun
|
||||||
|
|
||||||
|
# This is the timestamp/username separator symbol (character) in TURN REST API.
|
||||||
|
# The default value is ':'.
|
||||||
|
# rest-api-separator=:
|
||||||
|
|
||||||
|
# Flag that can be used to allow peers on the loopback addresses (127.x.x.x and ::1).
|
||||||
|
# This is an extra security measure.
|
||||||
|
#
|
||||||
|
# (To avoid any security issue that allowing loopback access may raise,
|
||||||
|
# the no-loopback-peers option is replaced by allow-loopback-peers.)
|
||||||
|
#
|
||||||
|
# Allow it only for testing in a development environment!
|
||||||
|
# In production it adds a possible security vulnerability, so for security reasons
|
||||||
|
# it is not allowed using it together with empty cli-password.
|
||||||
|
#
|
||||||
|
#allow-loopback-peers
|
||||||
|
|
||||||
|
# Flag that can be used to disallow peers on well-known broadcast addresses (224.0.0.0 and above, and FFXX:*).
|
||||||
|
# This is an extra security measure.
|
||||||
|
#
|
||||||
|
#no-multicast-peers
|
||||||
|
|
||||||
|
# Option to set the max time, in seconds, allowed for full allocation establishment.
|
||||||
|
# Default is 60 seconds.
|
||||||
|
#
|
||||||
|
#max-allocate-timeout=60
|
||||||
|
|
||||||
|
# Option to allow or ban specific ip addresses or ranges of ip addresses.
|
||||||
|
# If an ip address is specified as both allowed and denied, then the ip address is
|
||||||
|
# considered to be allowed. This is useful when you wish to ban a range of ip
|
||||||
|
# addresses, except for a few specific ips within that range.
|
||||||
|
#
|
||||||
|
# This can be used when you do not want users of the turn server to be able to access
|
||||||
|
# machines reachable by the turn server, but would otherwise be unreachable from the
|
||||||
|
# internet (e.g. when the turn server is sitting behind a NAT)
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# denied-peer-ip=83.166.64.0-83.166.95.255
|
||||||
|
# allowed-peer-ip=83.166.68.45
|
||||||
|
|
||||||
|
# File name to store the pid of the process.
|
||||||
|
# Default is /var/run/turnserver.pid (if superuser account is used) or
|
||||||
|
# /var/tmp/turnserver.pid .
|
||||||
|
#
|
||||||
|
#pidfile="/var/run/turnserver.pid"
|
||||||
|
|
||||||
|
# Require authentication of the STUN Binding request.
|
||||||
|
# By default, the clients are allowed anonymous access to the STUN Binding functionality.
|
||||||
|
#
|
||||||
|
#secure-stun
|
||||||
|
|
||||||
|
# Mobility with ICE (MICE) specs support.
|
||||||
|
#
|
||||||
|
#mobility
|
||||||
|
|
||||||
|
# Allocate Address Family according
|
||||||
|
# If enabled then TURN server allocates address family according the TURN
|
||||||
|
# Client <=> Server communication address family.
|
||||||
|
# (By default coTURN works according RFC 6156.)
|
||||||
|
# !!Warning: Enabling this option breaks RFC6156 section-4.2 (violates use default IPv4)!!
|
||||||
|
#
|
||||||
|
#keep-address-family
|
||||||
|
|
||||||
|
|
||||||
|
# User name to run the process. After the initialization, the turnserver process
|
||||||
|
# will make an attempt to change the current user ID to that user.
|
||||||
|
#
|
||||||
|
#proc-user=<user-name>
|
||||||
|
|
||||||
|
# Group name to run the process. After the initialization, the turnserver process
|
||||||
|
# will make an attempt to change the current group ID to that group.
|
||||||
|
#
|
||||||
|
#proc-group=<group-name>
|
||||||
|
|
||||||
|
# Turn OFF the CLI support.
|
||||||
|
# By default it is always ON.
|
||||||
|
# See also options cli-ip and cli-port.
|
||||||
|
#
|
||||||
|
#no-cli
|
||||||
|
|
||||||
|
#Local system IP address to be used for CLI server endpoint. Default value
|
||||||
|
# is 127.0.0.1.
|
||||||
|
#
|
||||||
|
#cli-ip=127.0.0.1
|
||||||
|
|
||||||
|
# CLI server port. Default is 5766.
|
||||||
|
#
|
||||||
|
#cli-port=5766
|
||||||
|
|
||||||
|
# CLI access password. Default is empty (no password).
|
||||||
|
# For the security reasons, it is recommended to use the encrypted
|
||||||
|
# for of the password (see the -P command in the turnadmin utility).
|
||||||
|
#
|
||||||
|
# Secure form for password 'qwerty':
|
||||||
|
#
|
||||||
|
#cli-password=$5$79a316b350311570$81df9cfb9af7f5e5a76eada31e7097b663a0670f99a3c07ded3f1c8e59c5658a
|
||||||
|
#
|
||||||
|
# Or unsecure form for the same password:
|
||||||
|
#
|
||||||
|
#cli-password=qwerty
|
||||||
|
|
||||||
|
# Enable Web-admin support on https. By default it is Disabled.
|
||||||
|
# If it is enabled it also enables a http a simple static banner page
|
||||||
|
# with a small reminder that the admin page is available only on https.
|
||||||
|
#
|
||||||
|
#web-admin
|
||||||
|
|
||||||
|
# Local system IP address to be used for Web-admin server endpoint. Default value is 127.0.0.1.
|
||||||
|
#
|
||||||
|
#web-admin-ip=127.0.0.1
|
||||||
|
|
||||||
|
# Web-admin server port. Default is 8080.
|
||||||
|
#
|
||||||
|
#web-admin-port=8080
|
||||||
|
|
||||||
|
# Web-admin server listen on STUN/TURN worker threads
|
||||||
|
# By default it is disabled for security resons! (Not recommended in any production environment!)
|
||||||
|
#
|
||||||
|
#web-admin-listen-on-workers
|
||||||
|
|
||||||
|
# Server relay. NON-STANDARD AND DANGEROUS OPTION.
|
||||||
|
# Only for those applications when we want to run
|
||||||
|
# server applications on the relay endpoints.
|
||||||
|
# This option eliminates the IP permissions check on
|
||||||
|
# the packets incoming to the relay endpoints.
|
||||||
|
#
|
||||||
|
#server-relay
|
||||||
|
|
||||||
|
# Maximum number of output sessions in ps CLI command.
|
||||||
|
# This value can be changed on-the-fly in CLI. The default value is 256.
|
||||||
|
#
|
||||||
|
#cli-max-output-sessions
|
||||||
|
|
||||||
|
# Set network engine type for the process (for internal purposes).
|
||||||
|
#
|
||||||
|
#ne=[1|2|3]
|
||||||
|
|
||||||
|
# Do not allow an TLS/DTLS version of protocol
|
||||||
|
#
|
||||||
|
#no-tlsv1
|
||||||
|
#no-tlsv1_1
|
||||||
|
#no-tlsv1_2
|
||||||
|
static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
|
||||||
62
server-backup/scheme/shineup.me/docs/RESTORE.md
Normal file
62
server-backup/scheme/shineup.me/docs/RESTORE.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Восстановление shineup.me на новый VPS
|
||||||
|
|
||||||
|
## 1. Подготовка сервера
|
||||||
|
- ОС: Ubuntu (рекомендуется близкая версия к текущей).
|
||||||
|
- Пользователь: `player` с sudo.
|
||||||
|
- DNS пока не переключать.
|
||||||
|
|
||||||
|
## 2. Установка базовых пакетов
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y rsync caddy coturn docker.io
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Восстановление файлов из полного бэкапа
|
||||||
|
Предполагается, что полный бэкап лежит локально в `server-backup/archive/YYYY-MM-DD/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -a server-backup/archive/YYYY-MM-DD/home-player/SHiNE/ player@NEW_SERVER:/home/player/SHiNE/
|
||||||
|
rsync -a server-backup/archive/YYYY-MM-DD/home-player/sites/ player@NEW_SERVER:/home/player/sites/
|
||||||
|
rsync -a server-backup/archive/YYYY-MM-DD/home-player/gitea/ player@NEW_SERVER:/home/player/gitea/
|
||||||
|
rsync -a server-backup/archive/YYYY-MM-DD/home-player/agent-memory/ player@NEW_SERVER:/home/player/agent-memory/
|
||||||
|
|
||||||
|
rsync -a server-backup/archive/YYYY-MM-DD/etc-system/caddy/ player@NEW_SERVER:/tmp/restore-caddy/
|
||||||
|
rsync -a server-backup/archive/YYYY-MM-DD/var-lib/caddy/ player@NEW_SERVER:/tmp/restore-var-lib-caddy/
|
||||||
|
rsync -a server-backup/archive/YYYY-MM-DD/etc-system/turnserver.conf player@NEW_SERVER:/tmp/turnserver.conf
|
||||||
|
rsync -a server-backup/archive/YYYY-MM-DD/etc-system/*.service player@NEW_SERVER:/tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
Далее на новом сервере:
|
||||||
|
```bash
|
||||||
|
sudo rsync -a /tmp/restore-caddy/ /etc/caddy/
|
||||||
|
sudo rsync -a /tmp/restore-var-lib-caddy/ /var/lib/caddy/
|
||||||
|
sudo cp /tmp/turnserver.conf /etc/turnserver.conf
|
||||||
|
sudo cp /tmp/*.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Запуск сервисов
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now caddy
|
||||||
|
sudo systemctl enable --now coturn
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
sudo systemctl enable --now shine-server
|
||||||
|
sudo systemctl enable --now agent-memory
|
||||||
|
```
|
||||||
|
|
||||||
|
`SHiNE-promo-solana-devnet.service` не включать: сервис снят с эксплуатации.
|
||||||
|
|
||||||
|
## 5. Проверка
|
||||||
|
```bash
|
||||||
|
sudo systemctl status caddy --no-pager
|
||||||
|
sudo systemctl status shine-server --no-pager
|
||||||
|
sudo ss -ltnp
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверить:
|
||||||
|
- `https://shineup.me`
|
||||||
|
- `https://git.shineup.me`
|
||||||
|
- WebSocket `/ws`
|
||||||
|
|
||||||
|
## 6. Переключение DNS
|
||||||
|
- После полной проверки поменять A-записи доменов на IP нового VPS.
|
||||||
55
server-backup/scheme/shineup.me/scripts/backup_full.sh
Executable file
55
server-backup/scheme/shineup.me/scripts/backup_full.sh
Executable file
@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
TODAY="$(date +%F)"
|
||||||
|
DEST_NAME="${DEST_NAME:-${TODAY}}"
|
||||||
|
DEST_DIR="${ROOT_DIR}/archive/${DEST_NAME}"
|
||||||
|
RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
|
||||||
|
|
||||||
|
mkdir -p "${DEST_DIR}"
|
||||||
|
|
||||||
|
echo "[1/4] Создаю структуру бэкапа: ${DEST_DIR}"
|
||||||
|
mkdir -p "${DEST_DIR}/home-player" "${DEST_DIR}/etc-system" "${DEST_DIR}/var-lib/docker-images"
|
||||||
|
|
||||||
|
echo "[2/4] Копирую основные данные /home/player"
|
||||||
|
rsync -aH --delete \
|
||||||
|
"${RSYNC_REMOTE_SUDO[@]}" \
|
||||||
|
--exclude='SHiNE/shine-server/logs/' \
|
||||||
|
--exclude='SHiNE/**/.elaira_logs/' \
|
||||||
|
--exclude='**/*.log' \
|
||||||
|
"${REMOTE_HOST}:/home/player/SHiNE/" "${DEST_DIR}/home-player/SHiNE/"
|
||||||
|
|
||||||
|
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/sites/" "${DEST_DIR}/home-player/sites/"
|
||||||
|
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/gitea/" "${DEST_DIR}/home-player/gitea/"
|
||||||
|
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/agent-memory/" "${DEST_DIR}/home-player/agent-memory/"
|
||||||
|
|
||||||
|
echo "[3/4] Копирую системные конфиги"
|
||||||
|
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/" "${DEST_DIR}/etc-system/caddy/"
|
||||||
|
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/var/lib/caddy/" "${DEST_DIR}/var-lib/caddy/"
|
||||||
|
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf"
|
||||||
|
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/"
|
||||||
|
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/"
|
||||||
|
ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar"
|
||||||
|
|
||||||
|
echo "[4/4] Создаю манифест"
|
||||||
|
{
|
||||||
|
echo "backup_date=${TODAY}"
|
||||||
|
echo "remote_host=${REMOTE_HOST}"
|
||||||
|
echo "created_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
echo
|
||||||
|
echo "sizes:"
|
||||||
|
du -sh "${DEST_DIR}/home-player/SHiNE" || true
|
||||||
|
du -sh "${DEST_DIR}/home-player/sites" || true
|
||||||
|
du -sh "${DEST_DIR}/home-player/gitea" || true
|
||||||
|
du -sh "${DEST_DIR}/home-player/agent-memory" || true
|
||||||
|
du -sh "${DEST_DIR}/etc-system" || true
|
||||||
|
du -sh "${DEST_DIR}/var-lib/caddy" || true
|
||||||
|
du -sh "${DEST_DIR}/var-lib/docker-images" || true
|
||||||
|
echo
|
||||||
|
echo "total:"
|
||||||
|
du -sh "${DEST_DIR}" || true
|
||||||
|
} > "${DEST_DIR}/MANIFEST.txt"
|
||||||
|
|
||||||
|
echo "Готово: ${DEST_DIR}"
|
||||||
29
server-backup/scheme/shineup.me/scripts/refresh_scheme.sh
Executable file
29
server-backup/scheme/shineup.me/scripts/refresh_scheme.sh
Executable file
@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
|
||||||
|
SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
CAP_DIR="${SCHEME_DIR}/captures"
|
||||||
|
CFG_DIR="${SCHEME_DIR}/configs"
|
||||||
|
|
||||||
|
mkdir -p "${CAP_DIR}" "${CFG_DIR}/systemd"
|
||||||
|
|
||||||
|
echo "[1/3] Обновляю инвентарь"
|
||||||
|
ssh "${REMOTE_HOST}" 'hostnamectl --static; echo ---; uname -a; echo ---; df -h; echo ---; lsblk -f' > "${CAP_DIR}/01_host_disk.txt"
|
||||||
|
ssh "${REMOTE_HOST}" 'systemctl list-unit-files --type=service --state=enabled --no-pager' > "${CAP_DIR}/02_enabled_services.txt"
|
||||||
|
ssh "${REMOTE_HOST}" 'sudo -n systemctl list-units --type=service --state=running --no-pager' > "${CAP_DIR}/03_running_services.txt"
|
||||||
|
ssh "${REMOTE_HOST}" 'sudo -n ss -ltnp' > "${CAP_DIR}/04_listen_ports.txt"
|
||||||
|
ssh "${REMOTE_HOST}" 'sudo -n docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"' > "${CAP_DIR}/05_docker_ps.txt"
|
||||||
|
ssh "${REMOTE_HOST}" 'du -sh /home/player/* 2>/dev/null | sort -h' > "${CAP_DIR}/06_home_player_sizes.txt"
|
||||||
|
ssh "${REMOTE_HOST}" 'sudo -n du -xhd1 /var 2>/dev/null | sort -h' > "${CAP_DIR}/07_var_sizes.txt"
|
||||||
|
|
||||||
|
echo "[2/3] Обновляю ключевые конфиги"
|
||||||
|
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
|
||||||
|
rsync -a "${REMOTE_HOST}:/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
|
||||||
|
rsync -a "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/"
|
||||||
|
rsync -a "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/"
|
||||||
|
|
||||||
|
echo "[3/3] Метка обновления"
|
||||||
|
date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt"
|
||||||
|
|
||||||
|
echo "Готово: ${SCHEME_DIR}"
|
||||||
@ -9,12 +9,6 @@ const ITEMS = [
|
|||||||
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
|
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
|
||||||
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
|
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
|
||||||
];
|
];
|
||||||
const CHANNEL_HOLD_MS = 260;
|
|
||||||
const CHANNEL_MODES = Object.freeze([
|
|
||||||
{ key: 'feed', label: 'Каналы' },
|
|
||||||
{ key: 'dialogs', label: 'Чаты' },
|
|
||||||
{ key: 'my', label: 'Мои' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
function getTotalUnreadMessages() {
|
function getTotalUnreadMessages() {
|
||||||
const chats = Object.values(state.chats || {});
|
const chats = Object.values(state.chats || {});
|
||||||
@ -91,7 +85,7 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
btn.append(badge);
|
btn.append(badge);
|
||||||
}
|
}
|
||||||
if (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
installChannelsHoldSwitcher(btn, navigate);
|
btn.addEventListener('click', () => navigate('channels-list/feed'));
|
||||||
} else {
|
} else {
|
||||||
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
||||||
}
|
}
|
||||||
@ -100,90 +94,3 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function installChannelsHoldSwitcher(button, navigate) {
|
|
||||||
let holdTimer = 0;
|
|
||||||
let pressed = false;
|
|
||||||
let holdActive = false;
|
|
||||||
let overlay = null;
|
|
||||||
let selectedMode = 'feed';
|
|
||||||
|
|
||||||
const clearTimer = () => {
|
|
||||||
if (holdTimer) {
|
|
||||||
window.clearTimeout(holdTimer);
|
|
||||||
holdTimer = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeOverlay = () => {
|
|
||||||
if (overlay) overlay.remove();
|
|
||||||
overlay = null;
|
|
||||||
holdActive = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSelectedModeByX = (clientX) => {
|
|
||||||
if (!overlay) return;
|
|
||||||
const rect = overlay.getBoundingClientRect();
|
|
||||||
const part = rect.width / 3;
|
|
||||||
const localX = Math.max(0, Math.min(rect.width - 1, clientX - rect.left));
|
|
||||||
const index = Math.max(0, Math.min(2, Math.floor(localX / Math.max(1, part))));
|
|
||||||
selectedMode = CHANNEL_MODES[index].key;
|
|
||||||
const buttons = overlay.querySelectorAll('.toolbar-channels-hold-item');
|
|
||||||
buttons.forEach((el, idx) => {
|
|
||||||
el.classList.toggle('is-active', idx === index);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openOverlay = () => {
|
|
||||||
const rect = button.getBoundingClientRect();
|
|
||||||
overlay = document.createElement('div');
|
|
||||||
overlay.className = 'toolbar-channels-hold-overlay';
|
|
||||||
overlay.innerHTML = CHANNEL_MODES.map((mode) => (
|
|
||||||
`<button type="button" class="toolbar-channels-hold-item${mode.key === selectedMode ? ' is-active' : ''}" data-mode="${mode.key}">${mode.label}</button>`
|
|
||||||
)).join('');
|
|
||||||
overlay.style.left = `${Math.round(rect.left + rect.width / 2)}px`;
|
|
||||||
overlay.style.top = `${Math.round(rect.top - 12)}px`;
|
|
||||||
document.body.append(overlay);
|
|
||||||
holdActive = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
button.addEventListener('pointerdown', (event) => {
|
|
||||||
pressed = true;
|
|
||||||
holdActive = false;
|
|
||||||
selectedMode = 'feed';
|
|
||||||
clearTimer();
|
|
||||||
holdTimer = window.setTimeout(() => {
|
|
||||||
if (!pressed) return;
|
|
||||||
openOverlay();
|
|
||||||
setSelectedModeByX(event.clientX);
|
|
||||||
}, CHANNEL_HOLD_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
button.addEventListener('pointermove', (event) => {
|
|
||||||
if (holdActive) setSelectedModeByX(event.clientX);
|
|
||||||
});
|
|
||||||
|
|
||||||
button.addEventListener('pointerup', () => {
|
|
||||||
clearTimer();
|
|
||||||
const wasHold = holdActive;
|
|
||||||
const mode = selectedMode;
|
|
||||||
pressed = false;
|
|
||||||
closeOverlay();
|
|
||||||
if (wasHold) {
|
|
||||||
navigate(`channels-list/${mode}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate('channels-list/feed');
|
|
||||||
});
|
|
||||||
|
|
||||||
button.addEventListener('pointercancel', () => {
|
|
||||||
clearTimer();
|
|
||||||
pressed = false;
|
|
||||||
closeOverlay();
|
|
||||||
});
|
|
||||||
|
|
||||||
button.addEventListener('contextmenu', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
writeChannelNotificationsState,
|
writeChannelNotificationsState,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
|
import { navigateBack } from '../router.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
|
|
||||||
@ -1176,26 +1177,12 @@ export function render({ navigate, route }) {
|
|||||||
const topBarLeft = document.createElement('div');
|
const topBarLeft = document.createElement('div');
|
||||||
topBarLeft.className = 'channels-top-left';
|
topBarLeft.className = 'channels-top-left';
|
||||||
|
|
||||||
const backToFeedBtn = document.createElement('button');
|
const backBtn = document.createElement('button');
|
||||||
backToFeedBtn.type = 'button';
|
backBtn.type = 'button';
|
||||||
backToFeedBtn.className = 'icon-btn channels-top-back-btn';
|
backBtn.className = 'icon-btn channels-top-back-btn';
|
||||||
backToFeedBtn.textContent = '←';
|
backBtn.textContent = '←';
|
||||||
backToFeedBtn.setAttribute('aria-label', 'Назад');
|
backBtn.setAttribute('aria-label', 'Назад');
|
||||||
backToFeedBtn.addEventListener('click', () => {
|
backBtn.addEventListener('click', () => navigateBack());
|
||||||
if (listState.activeTab === 'feed') return;
|
|
||||||
listState.activeTab = 'feed';
|
|
||||||
rerenderList();
|
|
||||||
});
|
|
||||||
|
|
||||||
const allChannelsBtn = document.createElement('button');
|
|
||||||
allChannelsBtn.type = 'button';
|
|
||||||
allChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
|
|
||||||
allChannelsBtn.textContent = 'Все каналы';
|
|
||||||
allChannelsBtn.addEventListener('click', () => {
|
|
||||||
if (listState.activeTab === 'feed') return;
|
|
||||||
listState.activeTab = 'feed';
|
|
||||||
rerenderList();
|
|
||||||
});
|
|
||||||
|
|
||||||
const topTitle = document.createElement('strong');
|
const topTitle = document.createElement('strong');
|
||||||
topTitle.className = 'channels-top-title';
|
topTitle.className = 'channels-top-title';
|
||||||
@ -1210,6 +1197,16 @@ export function render({ navigate, route }) {
|
|||||||
rerenderList();
|
rerenderList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const topBarRight = document.createElement('div');
|
||||||
|
topBarRight.className = 'channels-top-right';
|
||||||
|
|
||||||
|
const findChannelBtn = document.createElement('button');
|
||||||
|
findChannelBtn.type = 'button';
|
||||||
|
findChannelBtn.className = 'icon-btn channels-top-search-btn';
|
||||||
|
findChannelBtn.textContent = '🔎';
|
||||||
|
findChannelBtn.setAttribute('aria-label', 'Найти канал');
|
||||||
|
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
||||||
|
|
||||||
const createInMyBtn = document.createElement('button');
|
const createInMyBtn = document.createElement('button');
|
||||||
createInMyBtn.type = 'button';
|
createInMyBtn.type = 'button';
|
||||||
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
||||||
@ -1217,8 +1214,19 @@ export function render({ navigate, route }) {
|
|||||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||||
|
|
||||||
topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle, myChannelsBtn);
|
const switchToAllBtn = document.createElement('button');
|
||||||
topBarEl.append(topBarLeft, createInMyBtn);
|
switchToAllBtn.type = 'button';
|
||||||
|
switchToAllBtn.className = 'secondary-btn channels-top-switch-btn';
|
||||||
|
switchToAllBtn.textContent = 'Все каналы';
|
||||||
|
switchToAllBtn.addEventListener('click', () => {
|
||||||
|
if (listState.activeTab === 'feed') return;
|
||||||
|
listState.activeTab = 'feed';
|
||||||
|
rerenderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
topBarLeft.append(backBtn, topTitle);
|
||||||
|
topBarRight.append(myChannelsBtn, findChannelBtn, createInMyBtn);
|
||||||
|
topBarEl.append(topBarLeft, topBarRight);
|
||||||
|
|
||||||
const bottomCta = document.createElement('button');
|
const bottomCta = document.createElement('button');
|
||||||
bottomCta.type = 'button';
|
bottomCta.type = 'button';
|
||||||
@ -1248,17 +1256,21 @@ export function render({ navigate, route }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (listState.activeTab === 'my' && !isGuest) {
|
if (listState.activeTab === 'my' && !isGuest) {
|
||||||
backToFeedBtn.style.display = '';
|
|
||||||
allChannelsBtn.style.display = '';
|
|
||||||
myChannelsBtn.style.display = 'none';
|
myChannelsBtn.style.display = 'none';
|
||||||
topTitle.textContent = 'Мои каналы';
|
topTitle.textContent = 'Мои каналы';
|
||||||
|
findChannelBtn.style.display = 'none';
|
||||||
|
switchToAllBtn.style.display = '';
|
||||||
createInMyBtn.style.display = '';
|
createInMyBtn.style.display = '';
|
||||||
|
if (!switchToAllBtn.isConnected) topBarLeft.append(switchToAllBtn);
|
||||||
|
if (topTitle.parentElement !== topBarRight) topBarRight.prepend(topTitle);
|
||||||
} else {
|
} else {
|
||||||
backToFeedBtn.style.display = 'none';
|
|
||||||
allChannelsBtn.style.display = 'none';
|
|
||||||
myChannelsBtn.style.display = isGuest ? 'none' : '';
|
myChannelsBtn.style.display = isGuest ? 'none' : '';
|
||||||
topTitle.textContent = 'Каналы';
|
topTitle.textContent = 'Все каналы';
|
||||||
|
findChannelBtn.style.display = '';
|
||||||
|
switchToAllBtn.style.display = 'none';
|
||||||
createInMyBtn.style.display = 'none';
|
createInMyBtn.style.display = 'none';
|
||||||
|
if (switchToAllBtn.isConnected) switchToAllBtn.remove();
|
||||||
|
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBottomCta({
|
updateBottomCta({
|
||||||
@ -1304,6 +1316,9 @@ export function render({ navigate, route }) {
|
|||||||
isTabEmpty: true,
|
isTabEmpty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||||
|
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||||
|
rerenderList();
|
||||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
|
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
|
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ export function render({ navigate }) {
|
|||||||
<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-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-blockchain" ${state.deviceConnect.blockchain ? 'checked' : ''} /> blockchain key</label>
|
||||||
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> device key</label>
|
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> device key</label>
|
||||||
|
<p class="meta-muted" id="connect-keys-status">Проверяем ключи на этом устройстве...</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
|
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
|
||||||
</div>
|
</div>
|
||||||
@ -33,6 +35,8 @@ export function render({ navigate }) {
|
|||||||
const rootToggle = card.querySelector('#connect-root');
|
const rootToggle = card.querySelector('#connect-root');
|
||||||
const blockchainToggle = card.querySelector('#connect-blockchain');
|
const blockchainToggle = card.querySelector('#connect-blockchain');
|
||||||
const deviceToggle = card.querySelector('#connect-device');
|
const deviceToggle = card.querySelector('#connect-device');
|
||||||
|
const statusEl = card.querySelector('#connect-keys-status');
|
||||||
|
const openQrBtn = card.querySelector('#open-qr');
|
||||||
deviceToggle.checked = true;
|
deviceToggle.checked = true;
|
||||||
|
|
||||||
rootToggle.addEventListener('change', () => {
|
rootToggle.addEventListener('change', () => {
|
||||||
@ -85,6 +89,47 @@ export function render({ navigate }) {
|
|||||||
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
|
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
|
||||||
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
|
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!state.session.login || !state.session.storagePwdInMemory) {
|
||||||
|
throw new Error('Нет активной сессии');
|
||||||
|
}
|
||||||
|
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||||
|
const hasRoot = Boolean(savedKeys.rootKey);
|
||||||
|
const hasBlockchain = Boolean(savedKeys.blockchainKey);
|
||||||
|
const hasDevice = Boolean(savedKeys.deviceKey);
|
||||||
|
|
||||||
|
rootToggle.disabled = !hasRoot;
|
||||||
|
blockchainToggle.disabled = !hasBlockchain;
|
||||||
|
deviceToggle.disabled = true;
|
||||||
|
state.deviceConnect.root = hasRoot && rootToggle.checked;
|
||||||
|
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
|
||||||
|
state.deviceConnect.device = hasDevice;
|
||||||
|
rootToggle.checked = state.deviceConnect.root;
|
||||||
|
blockchainToggle.checked = state.deviceConnect.blockchain;
|
||||||
|
deviceToggle.checked = hasDevice;
|
||||||
|
openQrBtn.disabled = !hasDevice;
|
||||||
|
|
||||||
|
const available = [
|
||||||
|
hasDevice ? 'device' : '',
|
||||||
|
hasBlockchain ? 'blockchain' : '',
|
||||||
|
hasRoot ? 'root' : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
statusEl.textContent = available.length
|
||||||
|
? `На этом устройстве доступны: ${available.join(', ')}.`
|
||||||
|
: 'На этом устройстве нет сохранённых ключей для передачи.';
|
||||||
|
} catch {
|
||||||
|
rootToggle.disabled = true;
|
||||||
|
blockchainToggle.disabled = true;
|
||||||
|
deviceToggle.checked = false;
|
||||||
|
state.deviceConnect.root = false;
|
||||||
|
state.deviceConnect.blockchain = false;
|
||||||
|
state.deviceConnect.device = false;
|
||||||
|
openQrBtn.disabled = true;
|
||||||
|
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
helpModal.addEventListener('click', (event) => {
|
helpModal.addEventListener('click', (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (target instanceof HTMLElement && target.dataset.close === 'true') {
|
if (target instanceof HTMLElement && target.dataset.close === 'true') {
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { profile } from '../mock-data.js';
|
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
|
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
|
import {
|
||||||
|
describeTransferKeys,
|
||||||
|
makeKeyTransferText,
|
||||||
|
renderQrSvg,
|
||||||
|
} from '../services/qr-key-transfer-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
|
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
|
||||||
|
|
||||||
@ -8,11 +13,6 @@ export function render({ navigate }) {
|
|||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
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(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Показать QR-код',
|
title: 'Показать QR-код',
|
||||||
@ -23,12 +23,44 @@ export function render({ navigate }) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack qr-card';
|
card.className = 'card stack qr-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<img class="qr-image" src="img/device-qr-64.svg" width="64" height="64" alt="QR-код для подключения" />
|
<div class="qr-image" id="device-transfer-qr" aria-label="QR-код для переноса ключей"></div>
|
||||||
<p class="meta-muted">Логин пользователя: ${profile.login}</p>
|
<p class="meta-muted" id="device-transfer-login">Логин: ...</p>
|
||||||
<p class="meta-muted">Передаваемые ключи: ${selectedKeys.join(', ')}</p>
|
<p class="meta-muted" id="device-transfer-keys">Ключи: ...</p>
|
||||||
|
<p class="status-line is-unavailable" id="device-transfer-status" style="display:none;"></p>
|
||||||
<button class="primary-btn" type="button" id="qr-ok">OK</button>
|
<button class="primary-btn" type="button" id="qr-ok">OK</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const qrEl = card.querySelector('#device-transfer-qr');
|
||||||
|
const loginEl = card.querySelector('#device-transfer-login');
|
||||||
|
const keysEl = card.querySelector('#device-transfer-keys');
|
||||||
|
const statusEl = card.querySelector('#device-transfer-status');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!state.session.login || !state.session.storagePwdInMemory) {
|
||||||
|
throw new Error('Нет активной сессии для чтения ключей');
|
||||||
|
}
|
||||||
|
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||||
|
const keys = {
|
||||||
|
deviceKey: savedKeys.deviceKey || '',
|
||||||
|
blockchainKey: state.deviceConnect.blockchain ? (savedKeys.blockchainKey || '') : '',
|
||||||
|
rootKey: state.deviceConnect.root ? (savedKeys.rootKey || '') : '',
|
||||||
|
};
|
||||||
|
if (!keys.deviceKey) throw new Error('На этом устройстве нет device key');
|
||||||
|
|
||||||
|
const qrText = makeKeyTransferText({ login: state.session.login, keys });
|
||||||
|
qrEl.innerHTML = renderQrSvg(qrText);
|
||||||
|
loginEl.textContent = `Логин: ${state.session.login}`;
|
||||||
|
keysEl.textContent = `Ключи: ${describeTransferKeys(keys).join(', ')}`;
|
||||||
|
} catch (error) {
|
||||||
|
qrEl.textContent = '';
|
||||||
|
loginEl.textContent = 'Логин: нет данных';
|
||||||
|
keysEl.textContent = 'Ключи: нет данных';
|
||||||
|
statusEl.textContent = error?.message || 'Не удалось подготовить QR-код.';
|
||||||
|
statusEl.style.display = '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
|
card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
|
||||||
|
|
||||||
screen.append(card);
|
screen.append(card);
|
||||||
|
|||||||
@ -36,9 +36,11 @@ export function render({ navigate }) {
|
|||||||
actions.className = 'card stack';
|
actions.className = 'card stack';
|
||||||
actions.innerHTML = `
|
actions.innerHTML = `
|
||||||
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||||
|
<button class="ghost-btn" type="button" id="connect-device-btn">Подключить устройство</button>
|
||||||
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
|
||||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||||
|
|
||||||
const sessionsBlock = document.createElement('div');
|
const sessionsBlock = document.createElement('div');
|
||||||
|
|||||||
@ -19,11 +19,14 @@ function readWalletFromUrl() {
|
|||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
|
screen.style.width = '100%';
|
||||||
|
screen.style.justifyItems = 'center';
|
||||||
|
|
||||||
const targetWallet = readWalletFromUrl();
|
const targetWallet = readWalletFromUrl();
|
||||||
|
|
||||||
const senderBox = document.createElement('div');
|
const senderBox = document.createElement('div');
|
||||||
senderBox.className = 'card stack';
|
senderBox.className = 'card stack';
|
||||||
|
senderBox.style.width = 'min(100%, 320px)';
|
||||||
senderBox.innerHTML = `
|
senderBox.innerHTML = `
|
||||||
<strong>Тестовый DEVNET-кошелёк</strong>
|
<strong>Тестовый DEVNET-кошелёк</strong>
|
||||||
<p class="meta-muted" id="devnet-topup-sender-address">Адрес: ...</p>
|
<p class="meta-muted" id="devnet-topup-sender-address">Адрес: ...</p>
|
||||||
@ -32,6 +35,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const targetBox = document.createElement('div');
|
const targetBox = document.createElement('div');
|
||||||
targetBox.className = 'card stack';
|
targetBox.className = 'card stack';
|
||||||
|
targetBox.style.width = 'min(100%, 320px)';
|
||||||
targetBox.innerHTML = `
|
targetBox.innerHTML = `
|
||||||
<strong>Кошелёк получателя</strong>
|
<strong>Кошелёк получателя</strong>
|
||||||
<p class="meta-muted" style="word-break:break-all;">${targetWallet || 'Не передан параметр wallet'}</p>
|
<p class="meta-muted" style="word-break:break-all;">${targetWallet || 'Не передан параметр wallet'}</p>
|
||||||
@ -40,6 +44,10 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
status.className = 'meta-muted';
|
status.className = 'meta-muted';
|
||||||
|
status.style.width = 'min(100%, 320px)';
|
||||||
|
status.style.overflowWrap = 'anywhere';
|
||||||
|
status.style.wordBreak = 'break-word';
|
||||||
|
status.style.whiteSpace = 'pre-wrap';
|
||||||
status.textContent = 'Готово к пополнению.';
|
status.textContent = 'Готово к пополнению.';
|
||||||
|
|
||||||
const fillBtn = document.createElement('button');
|
const fillBtn = document.createElement('button');
|
||||||
@ -55,6 +63,8 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
|
actions.style.width = 'min(100%, 320px)';
|
||||||
|
actions.style.justifySelf = 'center';
|
||||||
actions.append(fillBtn, backBtn);
|
actions.append(fillBtn, backBtn);
|
||||||
|
|
||||||
let senderAddress = '';
|
let senderAddress = '';
|
||||||
@ -90,7 +100,7 @@ export function render({ navigate }) {
|
|||||||
amountSol: TRANSFER_AMOUNT_SOL,
|
amountSol: TRANSFER_AMOUNT_SOL,
|
||||||
});
|
});
|
||||||
await updateSenderBalance();
|
await updateSenderBalance();
|
||||||
status.textContent = `Готово. Signature: ${tx.signature}`;
|
status.textContent = `Готово.\nSignature: ${tx.signature}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = `Ошибка перевода: ${error?.message || 'unknown'}`;
|
status.textContent = `Ошибка перевода: ${error?.message || 'unknown'}`;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -129,6 +129,15 @@ export function render({ navigate }) {
|
|||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
|
|
||||||
|
const serverUiButton = document.createElement('button');
|
||||||
|
serverUiButton.className = 'ghost-btn';
|
||||||
|
serverUiButton.type = 'button';
|
||||||
|
serverUiButton.textContent = 'Настроить свой сервер';
|
||||||
|
serverUiButton.addEventListener('click', () => {
|
||||||
|
const url = new URL('server-ui.html', window.location.href);
|
||||||
|
window.open(url.toString(), '_blank', 'noopener');
|
||||||
|
});
|
||||||
|
|
||||||
const cancelButton = document.createElement('button');
|
const cancelButton = document.createElement('button');
|
||||||
cancelButton.className = 'ghost-btn';
|
cancelButton.className = 'ghost-btn';
|
||||||
cancelButton.type = 'button';
|
cancelButton.type = 'button';
|
||||||
@ -144,7 +153,7 @@ export function render({ navigate }) {
|
|||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(cancelButton, saveButton);
|
actions.append(serverUiButton, cancelButton, saveButton);
|
||||||
|
|
||||||
const help = document.createElement('button');
|
const help = document.createElement('button');
|
||||||
help.className = 'help-fab';
|
help.className = 'help-fab';
|
||||||
|
|||||||
@ -1,47 +1,238 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import {
|
||||||
|
authService,
|
||||||
|
authorizeSession,
|
||||||
|
clearAuthMessages,
|
||||||
|
clearBrowserClientData,
|
||||||
|
refreshSessions,
|
||||||
|
setAuthBusy,
|
||||||
|
setAuthError,
|
||||||
|
setAuthInfo,
|
||||||
|
state,
|
||||||
|
terminateCurrentSession,
|
||||||
|
} from '../state.js';
|
||||||
|
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
|
import { clearStoredMessages } from '../services/message-store.js';
|
||||||
|
import {
|
||||||
|
describeTransferKeys,
|
||||||
|
parseKeyTransferText,
|
||||||
|
} from '../services/qr-key-transfer-service.js';
|
||||||
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'login-camera-view', title: 'Войти по камере', showAppChrome: false };
|
export const pageMeta = { id: 'login-camera-view', title: 'Войти по QR-коду', showAppChrome: false };
|
||||||
|
|
||||||
|
function canUseBarcodeDetector() {
|
||||||
|
return typeof window.BarcodeDetector === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createQrDetector() {
|
||||||
|
if (!canUseBarcodeDetector()) return null;
|
||||||
|
try {
|
||||||
|
const formats = await window.BarcodeDetector.getSupportedFormats?.();
|
||||||
|
if (Array.isArray(formats) && !formats.includes('qr_code')) return null;
|
||||||
|
} catch {
|
||||||
|
// Некоторые браузеры не реализуют getSupportedFormats, но сам detector работает.
|
||||||
|
}
|
||||||
|
return new window.BarcodeDetector({ formats: ['qr_code'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(statusEl, message, kind = 'info') {
|
||||||
|
statusEl.classList.toggle('is-unavailable', kind === 'error');
|
||||||
|
statusEl.classList.toggle('is-available', kind !== 'error');
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.style.display = message ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderParsedTransfer(resultEl, transfer) {
|
||||||
|
const keys = describeTransferKeys(transfer.keys);
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<p class="meta-muted">Отсканированный логин: <strong>${escapeHtml(transfer.login)}</strong></p>
|
||||||
|
<p class="meta-muted">Получены ключи: <strong>${escapeHtml(keys.join(', '))}</strong></p>
|
||||||
|
<p class="meta-muted">Войти под этим логином и очистить локальную историю старого логина?</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
|
|
||||||
|
clearAuthMessages();
|
||||||
|
|
||||||
const frame = document.createElement('div');
|
const frame = document.createElement('div');
|
||||||
frame.className = 'camera-shell';
|
frame.className = 'camera-shell';
|
||||||
frame.innerHTML = `
|
frame.innerHTML = `
|
||||||
<video class="camera-video" autoplay playsinline muted></video>
|
<video class="camera-video" autoplay playsinline muted></video>
|
||||||
<div class="camera-frame"></div>
|
<div class="camera-frame"></div>
|
||||||
<div class="camera-hint">Наведите QR-код в рамку</div>
|
<div class="camera-hint">Наведите QR-код переноса ключей в рамку</div>
|
||||||
|
<div class="camera-error" id="login-camera-error" style="display:none;"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const manualCard = document.createElement('details');
|
||||||
|
manualCard.className = 'card stack';
|
||||||
|
manualCard.innerHTML = `
|
||||||
|
<summary>Ввести QR-текст вручную</summary>
|
||||||
|
<textarea class="input" id="login-qr-manual" rows="4" placeholder="shine-key-transfer-v1:..."></textarea>
|
||||||
|
<button class="ghost-btn" type="button" id="login-qr-manual-parse">Проверить QR-текст</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resultCard = document.createElement('div');
|
||||||
|
resultCard.className = 'card stack';
|
||||||
|
resultCard.style.display = 'none';
|
||||||
|
resultCard.innerHTML = `
|
||||||
|
<div id="login-qr-result"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost-btn" type="button" id="login-qr-cancel">Нет</button>
|
||||||
|
<button class="primary-btn" type="button" id="login-qr-confirm">Да</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const status = document.createElement('p');
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.style.display = 'none';
|
||||||
|
|
||||||
|
const backButton = document.createElement('button');
|
||||||
|
backButton.className = 'ghost-btn';
|
||||||
|
backButton.type = 'button';
|
||||||
|
backButton.textContent = 'Назад';
|
||||||
|
|
||||||
const video = frame.querySelector('video');
|
const video = frame.querySelector('video');
|
||||||
|
const cameraError = frame.querySelector('#login-camera-error');
|
||||||
|
const manualInput = manualCard.querySelector('#login-qr-manual');
|
||||||
|
const parseManualButton = manualCard.querySelector('#login-qr-manual-parse');
|
||||||
|
const resultEl = resultCard.querySelector('#login-qr-result');
|
||||||
|
const cancelButton = resultCard.querySelector('#login-qr-cancel');
|
||||||
|
const confirmButton = resultCard.querySelector('#login-qr-confirm');
|
||||||
|
|
||||||
let stream = null;
|
let stream = null;
|
||||||
|
let detector = null;
|
||||||
|
let scanTimer = 0;
|
||||||
|
let scannedTransfer = null;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
const stopCamera = () => {
|
const stopCamera = () => {
|
||||||
|
stopped = true;
|
||||||
|
if (scanTimer) {
|
||||||
|
window.clearTimeout(scanTimer);
|
||||||
|
scanTimer = 0;
|
||||||
|
}
|
||||||
if (stream) {
|
if (stream) {
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
stream = null;
|
stream = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (navigator.mediaDevices?.getUserMedia) {
|
const showTransfer = (transfer) => {
|
||||||
navigator.mediaDevices
|
scannedTransfer = transfer;
|
||||||
.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
|
stopCamera();
|
||||||
.then((nextStream) => {
|
renderParsedTransfer(resultEl, transfer);
|
||||||
stream = nextStream;
|
resultCard.style.display = '';
|
||||||
video.srcObject = nextStream;
|
setStatus(status, '', 'info');
|
||||||
})
|
};
|
||||||
.catch(() => {
|
|
||||||
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Не удалось открыть камеру. Проверьте разрешения браузера.</div>');
|
const parseTransferText = (text) => {
|
||||||
});
|
try {
|
||||||
} else {
|
const transfer = parseKeyTransferText(text);
|
||||||
frame.insertAdjacentHTML('beforeend', '<div class="camera-error">Камера не поддерживается в этом браузере.</div>');
|
if (!transfer.keys.deviceKey) {
|
||||||
}
|
throw new Error('В QR-коде нет device key для входа');
|
||||||
|
}
|
||||||
|
showTransfer(transfer);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(status, error?.message || 'Не удалось прочитать QR-код.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanLoop = async () => {
|
||||||
|
if (stopped || !detector || !video || video.readyState < 2) {
|
||||||
|
if (!stopped) scanTimer = window.setTimeout(scanLoop, 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const codes = await detector.detect(video);
|
||||||
|
const text = String(codes?.[0]?.rawValue || '').trim();
|
||||||
|
if (text) {
|
||||||
|
parseTransferText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ошибки отдельных кадров игнорируем, камера продолжит сканирование.
|
||||||
|
}
|
||||||
|
if (!stopped) scanTimer = window.setTimeout(scanLoop, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
detector = await createQrDetector();
|
||||||
|
if (!detector) {
|
||||||
|
throw new Error('Этот браузер не поддерживает сканирование QR через камеру. Используйте ручной ввод QR-текста.');
|
||||||
|
}
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
throw new Error('Камера не поддерживается в этом браузере.');
|
||||||
|
}
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
|
||||||
|
video.srcObject = stream;
|
||||||
|
await video.play?.();
|
||||||
|
scanLoop();
|
||||||
|
} catch (error) {
|
||||||
|
cameraError.textContent = error?.message || 'Не удалось открыть камеру. Проверьте разрешения браузера.';
|
||||||
|
cameraError.style.display = '';
|
||||||
|
setStatus(status, cameraError.textContent, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
parseManualButton.addEventListener('click', () => parseTransferText(manualInput.value));
|
||||||
|
cancelButton.addEventListener('click', () => {
|
||||||
|
scannedTransfer = null;
|
||||||
|
resultCard.style.display = 'none';
|
||||||
|
stopped = false;
|
||||||
|
void startCamera();
|
||||||
|
});
|
||||||
|
confirmButton.addEventListener('click', async () => {
|
||||||
|
if (!scannedTransfer) return;
|
||||||
|
confirmButton.disabled = true;
|
||||||
|
cancelButton.disabled = true;
|
||||||
|
setAuthBusy(true);
|
||||||
|
setAuthError('');
|
||||||
|
setStatus(status, 'Входим по QR-коду...', 'info');
|
||||||
|
try {
|
||||||
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
|
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
||||||
|
await clearStoredMessages().catch(() => {});
|
||||||
|
clearBrowserClientData();
|
||||||
|
await clearClientAuthData().catch(() => {});
|
||||||
|
await terminateCurrentSession();
|
||||||
|
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
||||||
|
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||||
|
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||||
|
authorizeSession({
|
||||||
|
login: resumed.login || session.login,
|
||||||
|
sessionId: resumed.sessionId || session.sessionId,
|
||||||
|
storagePwd: resumed.storagePwd || session.storagePwd,
|
||||||
|
});
|
||||||
|
state.loginDraft.login = resumed.login || session.login;
|
||||||
|
state.loginDraft.password = '';
|
||||||
|
await refreshSessions();
|
||||||
|
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
|
||||||
|
navigate('profile-view');
|
||||||
|
} catch (error) {
|
||||||
|
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
|
||||||
|
setAuthError(message);
|
||||||
|
setStatus(status, message, 'error');
|
||||||
|
} finally {
|
||||||
|
setAuthBusy(false);
|
||||||
|
confirmButton.disabled = false;
|
||||||
|
cancelButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
|
||||||
backButton.className = 'ghost-btn';
|
|
||||||
backButton.type = 'button';
|
|
||||||
backButton.textContent = 'Назад';
|
|
||||||
backButton.addEventListener('click', () => {
|
backButton.addEventListener('click', () => {
|
||||||
stopCamera();
|
stopCamera();
|
||||||
navigate('login-view');
|
navigate('login-view');
|
||||||
@ -49,7 +240,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Войти по камере',
|
title: 'Войти по QR-коду',
|
||||||
leftAction: {
|
leftAction: {
|
||||||
label: '←',
|
label: '←',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@ -59,9 +250,13 @@ export function render({ navigate }) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
frame,
|
frame,
|
||||||
|
manualCard,
|
||||||
|
resultCard,
|
||||||
|
status,
|
||||||
backButton,
|
backButton,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void startCamera();
|
||||||
screen.cleanup = stopCamera;
|
screen.cleanup = stopCamera;
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,12 +22,19 @@ export function render({ navigate }) {
|
|||||||
const loginInput = document.createElement('input');
|
const loginInput = document.createElement('input');
|
||||||
loginInput.className = 'input';
|
loginInput.className = 'input';
|
||||||
loginInput.type = 'text';
|
loginInput.type = 'text';
|
||||||
|
loginInput.autocomplete = 'off';
|
||||||
|
loginInput.autocapitalize = 'off';
|
||||||
|
loginInput.spellcheck = false;
|
||||||
loginInput.value = state.loginDraft.login;
|
loginInput.value = state.loginDraft.login;
|
||||||
loginInput.placeholder = 'Введите логин';
|
loginInput.placeholder = 'Введите логин';
|
||||||
|
|
||||||
const passwordInput = document.createElement('input');
|
const passwordInput = document.createElement('input');
|
||||||
passwordInput.className = 'input';
|
passwordInput.className = 'input';
|
||||||
passwordInput.type = 'password';
|
passwordInput.type = 'password';
|
||||||
|
passwordInput.name = 'shine-login-password';
|
||||||
|
passwordInput.autocomplete = 'new-password';
|
||||||
|
passwordInput.autocapitalize = 'off';
|
||||||
|
passwordInput.spellcheck = false;
|
||||||
passwordInput.value = state.loginDraft.password;
|
passwordInput.value = state.loginDraft.password;
|
||||||
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
|
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
|
||||||
|
|
||||||
@ -39,10 +46,10 @@ export function render({ navigate }) {
|
|||||||
advanced.className = 'card stack';
|
advanced.className = 'card stack';
|
||||||
advanced.innerHTML = `
|
advanced.innerHTML = `
|
||||||
<summary>Расширенные</summary>
|
<summary>Расширенные</summary>
|
||||||
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
|
<p class="meta-muted">Схема derivation ключей: логин нормализуется как `trim().toLowerCase()`. При непустом пароле используется Argon2id, затем из результата строится секрет для root/bch/dev ключей.</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">Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export function render({ navigate }) {
|
|||||||
const cameraButton = document.createElement('button');
|
const cameraButton = document.createElement('button');
|
||||||
cameraButton.className = 'primary-btn';
|
cameraButton.className = 'primary-btn';
|
||||||
cameraButton.type = 'button';
|
cameraButton.type = 'button';
|
||||||
cameraButton.textContent = 'Войти по камере';
|
cameraButton.textContent = 'Отсканировать QR-код';
|
||||||
cameraButton.addEventListener('click', () => navigate('login-camera-view'));
|
cameraButton.addEventListener('click', () => navigate('login-camera-view'));
|
||||||
|
|
||||||
const loginButton = document.createElement('button');
|
const loginButton = document.createElement('button');
|
||||||
|
|||||||
@ -21,12 +21,19 @@ export function render({ navigate }) {
|
|||||||
const loginInput = document.createElement('input');
|
const loginInput = document.createElement('input');
|
||||||
loginInput.className = 'input';
|
loginInput.className = 'input';
|
||||||
loginInput.type = 'text';
|
loginInput.type = 'text';
|
||||||
|
loginInput.autocomplete = 'off';
|
||||||
|
loginInput.autocapitalize = 'off';
|
||||||
|
loginInput.spellcheck = false;
|
||||||
loginInput.value = state.registrationDraft.login;
|
loginInput.value = state.registrationDraft.login;
|
||||||
loginInput.placeholder = 'Введите логин';
|
loginInput.placeholder = 'Введите логин';
|
||||||
|
|
||||||
const passwordInput = document.createElement('input');
|
const passwordInput = document.createElement('input');
|
||||||
passwordInput.className = 'input';
|
passwordInput.className = 'input';
|
||||||
passwordInput.type = 'password';
|
passwordInput.type = 'password';
|
||||||
|
passwordInput.name = 'shine-register-password';
|
||||||
|
passwordInput.autocomplete = 'new-password';
|
||||||
|
passwordInput.autocapitalize = 'off';
|
||||||
|
passwordInput.spellcheck = false;
|
||||||
passwordInput.value = state.registrationDraft.password;
|
passwordInput.value = state.registrationDraft.password;
|
||||||
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
|
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import { bytesToBase64 } from '../services/crypto-utils.js';
|
import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js';
|
||||||
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
|
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false };
|
export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false };
|
||||||
@ -15,24 +15,43 @@ function makeSecretField({ label, value }) {
|
|||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'inline-input-row';
|
row.className = 'inline-input-row';
|
||||||
|
const eyeIcon = `
|
||||||
|
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
const eyeOffIcon = `
|
||||||
|
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.className = 'input';
|
input.className = 'input key-input';
|
||||||
input.type = 'password';
|
input.type = 'password';
|
||||||
input.readOnly = true;
|
input.readOnly = true;
|
||||||
input.value = value;
|
input.value = value;
|
||||||
|
|
||||||
const toggleBtn = document.createElement('button');
|
const toggleBtn = document.createElement('button');
|
||||||
toggleBtn.className = 'ghost-btn';
|
toggleBtn.className = 'icon-btn key-toggle-btn';
|
||||||
toggleBtn.type = 'button';
|
toggleBtn.type = 'button';
|
||||||
toggleBtn.textContent = 'Показать';
|
toggleBtn.innerHTML = eyeOffIcon;
|
||||||
|
toggleBtn.setAttribute('aria-label', 'Показать ключ');
|
||||||
|
toggleBtn.title = 'Показать ключ';
|
||||||
toggleBtn.addEventListener('click', () => {
|
toggleBtn.addEventListener('click', () => {
|
||||||
if (input.type === 'password') {
|
if (input.type === 'password') {
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
toggleBtn.textContent = 'Скрыть';
|
toggleBtn.innerHTML = eyeIcon;
|
||||||
|
toggleBtn.setAttribute('aria-label', 'Скрыть ключ');
|
||||||
|
toggleBtn.title = 'Скрыть ключ';
|
||||||
} else {
|
} else {
|
||||||
input.type = 'password';
|
input.type = 'password';
|
||||||
toggleBtn.textContent = 'Показать';
|
toggleBtn.innerHTML = eyeOffIcon;
|
||||||
|
toggleBtn.setAttribute('aria-label', 'Показать ключ');
|
||||||
|
toggleBtn.title = 'Показать ключ';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -50,7 +69,7 @@ function makePublicField({ label, value }) {
|
|||||||
labelEl.textContent = label;
|
labelEl.textContent = label;
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.className = 'input';
|
input.className = 'input key-input';
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.readOnly = true;
|
input.readOnly = true;
|
||||||
input.value = value;
|
input.value = value;
|
||||||
@ -81,39 +100,56 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
card.append(warning);
|
card.append(warning);
|
||||||
|
|
||||||
// Секрет (root key seed)
|
// Master secret
|
||||||
let secretB64 = '';
|
let secretB58 = '';
|
||||||
try {
|
try {
|
||||||
const rootSeed32 = extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64);
|
secretB58 = bytesToBase58(base64ToBytes(keyBundle.masterSecretB64));
|
||||||
secretB64 = bytesToBase64(rootSeed32);
|
|
||||||
} catch {
|
} catch {
|
||||||
secretB64 = '(не удалось извлечь)';
|
secretB58 = '(не удалось извлечь)';
|
||||||
}
|
}
|
||||||
card.append(makeSecretField({ label: 'Секрет (root seed, 32 байта)', value: secretB64 }));
|
card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 }));
|
||||||
|
|
||||||
// Root key
|
// Root key
|
||||||
const rootSep = document.createElement('p');
|
const rootSep = document.createElement('p');
|
||||||
rootSep.className = 'field-label';
|
rootSep.className = 'field-label';
|
||||||
rootSep.textContent = 'Root key';
|
rootSep.textContent = 'Root key';
|
||||||
card.append(rootSep);
|
card.append(rootSep);
|
||||||
card.append(makePublicField({ label: 'Root — публичный', value: keyBundle.rootPair.publicKeyB64 }));
|
card.append(makePublicField({
|
||||||
card.append(makeSecretField({ label: 'Root — приватный (PKCS8)', value: keyBundle.rootPair.privatePkcs8B64 }));
|
label: 'Root — публичный (base58)',
|
||||||
|
value: bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64)),
|
||||||
|
}));
|
||||||
|
card.append(makeSecretField({
|
||||||
|
label: 'Root — приватный (seed base58, 32 байта)',
|
||||||
|
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64)),
|
||||||
|
}));
|
||||||
|
|
||||||
// Blockchain key
|
// Blockchain key
|
||||||
const bchSep = document.createElement('p');
|
const bchSep = document.createElement('p');
|
||||||
bchSep.className = 'field-label';
|
bchSep.className = 'field-label';
|
||||||
bchSep.textContent = 'Blockchain key';
|
bchSep.textContent = 'Blockchain key';
|
||||||
card.append(bchSep);
|
card.append(bchSep);
|
||||||
card.append(makePublicField({ label: 'Blockchain — публичный', value: keyBundle.blockchainPair.publicKeyB64 }));
|
card.append(makePublicField({
|
||||||
card.append(makeSecretField({ label: 'Blockchain — приватный (PKCS8)', value: keyBundle.blockchainPair.privatePkcs8B64 }));
|
label: 'Blockchain — публичный (base58)',
|
||||||
|
value: bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64)),
|
||||||
|
}));
|
||||||
|
card.append(makeSecretField({
|
||||||
|
label: 'Blockchain — приватный (seed base58, 32 байта)',
|
||||||
|
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.blockchainPair.privatePkcs8B64)),
|
||||||
|
}));
|
||||||
|
|
||||||
// Device key
|
// Device key
|
||||||
const devSep = document.createElement('p');
|
const devSep = document.createElement('p');
|
||||||
devSep.className = 'field-label';
|
devSep.className = 'field-label';
|
||||||
devSep.textContent = 'Device key (= Solana wallet)';
|
devSep.textContent = 'Device key (= Solana wallet)';
|
||||||
card.append(devSep);
|
card.append(devSep);
|
||||||
card.append(makePublicField({ label: 'Device — публичный', value: keyBundle.devicePair.publicKeyB64 }));
|
card.append(makePublicField({
|
||||||
card.append(makeSecretField({ label: 'Device — приватный (PKCS8)', value: keyBundle.devicePair.privatePkcs8B64 }));
|
label: 'Device — публичный (base58)',
|
||||||
|
value: bytesToBase58(base64ToBytes(keyBundle.devicePair.publicKeyB64)),
|
||||||
|
}));
|
||||||
|
card.append(makeSecretField({
|
||||||
|
label: 'Device — приватный (seed base58, 32 байта)',
|
||||||
|
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64)),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
|
import { clearStoredMessages } from '../services/message-store.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||||
@ -111,6 +112,8 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clearStoredMessages().catch(() => {});
|
||||||
|
|
||||||
authorizeSession({
|
authorizeSession({
|
||||||
login: state.registrationDraft.login,
|
login: state.registrationDraft.login,
|
||||||
sessionId: state.registrationDraft.sessionId,
|
sessionId: state.registrationDraft.sessionId,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
|
import { bytesToBase58 } from '../services/crypto-utils.js';
|
||||||
|
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
|
||||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
|
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
|
||||||
@ -38,40 +40,63 @@ export function render({ navigate }) {
|
|||||||
const renderField = (id, label) => {
|
const renderField = (id, label) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'key-card stack';
|
row.className = 'key-card stack';
|
||||||
|
const eyeIcon = `
|
||||||
|
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
const eyeOffIcon = `
|
||||||
|
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="field-label">${label}</span>
|
<span class="field-label">${label}</span>
|
||||||
<button class="icon-btn small-btn" type="button" data-toggle="${id}">Показать</button>
|
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeOffIcon}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="key-value" data-value="${id}">*****</div>
|
<div class="key-value key-value--compact" data-value="${id}">*****</div>
|
||||||
`;
|
`;
|
||||||
|
row._eyeIcon = eyeIcon;
|
||||||
|
row._eyeOffIcon = eyeOffIcon;
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
|
|
||||||
card.append(
|
card.append(
|
||||||
renderField('root', 'root key'),
|
renderField('root', 'root key (base58)'),
|
||||||
renderField('blockchain', 'blockchain.key'),
|
renderField('blockchain', 'blockchain.key (base58)'),
|
||||||
renderField('device', 'device key'),
|
renderField('device', 'device key (base58)'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const setMissingState = (id) => {
|
const setMissingState = (id) => {
|
||||||
const valueEl = card.querySelector(`[data-value="${id}"]`);
|
const valueEl = card.querySelector(`[data-value="${id}"]`);
|
||||||
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
|
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
|
||||||
|
const field = btnEl?.closest('.key-card');
|
||||||
valueEl.textContent = 'нет данных';
|
valueEl.textContent = 'нет данных';
|
||||||
btnEl.disabled = true;
|
btnEl.disabled = true;
|
||||||
btnEl.textContent = 'Нет';
|
btnEl.innerHTML = field?._eyeOffIcon || '';
|
||||||
|
btnEl.setAttribute('aria-label', 'Нет данных');
|
||||||
|
btnEl.title = 'Нет данных';
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateField = (id) => {
|
const updateField = (id) => {
|
||||||
const valueEl = card.querySelector(`[data-value="${id}"]`);
|
const valueEl = card.querySelector(`[data-value="${id}"]`);
|
||||||
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
|
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
|
||||||
|
const field = btnEl?.closest('.key-card');
|
||||||
if (!keys[id]) {
|
if (!keys[id]) {
|
||||||
setMissingState(id);
|
setMissingState(id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
valueEl.textContent = visible[id] ? keys[id] : '*****';
|
valueEl.textContent = visible[id] ? keys[id] : '*****';
|
||||||
btnEl.disabled = false;
|
btnEl.disabled = false;
|
||||||
btnEl.textContent = visible[id] ? 'Скрыть' : 'Показать';
|
btnEl.innerHTML = visible[id]
|
||||||
|
? field?._eyeIcon || ''
|
||||||
|
: field?._eyeOffIcon || '';
|
||||||
|
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
|
||||||
|
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
|
||||||
};
|
};
|
||||||
|
|
||||||
card.querySelectorAll('[data-toggle]').forEach((button) => {
|
card.querySelectorAll('[data-toggle]').forEach((button) => {
|
||||||
@ -106,9 +131,13 @@ export function render({ navigate }) {
|
|||||||
state.session.storagePwdInMemory,
|
state.session.storagePwdInMemory,
|
||||||
);
|
);
|
||||||
|
|
||||||
keys.root = savedKeys.rootKey || '';
|
const rootSeed32 = savedKeys.rootKey ? extractSeed32FromPkcs8B64(savedKeys.rootKey) : null;
|
||||||
keys.blockchain = savedKeys.blockchainKey || '';
|
const blockchainSeed32 = savedKeys.blockchainKey ? extractSeed32FromPkcs8B64(savedKeys.blockchainKey) : null;
|
||||||
keys.device = savedKeys.deviceKey || '';
|
const deviceSeed32 = savedKeys.deviceKey ? extractSeed32FromPkcs8B64(savedKeys.deviceKey) : null;
|
||||||
|
|
||||||
|
keys.root = rootSeed32 ? bytesToBase58(rootSeed32) : '';
|
||||||
|
keys.blockchain = blockchainSeed32 ? bytesToBase58(blockchainSeed32) : '';
|
||||||
|
keys.device = deviceSeed32 ? bytesToBase58(deviceSeed32) : '';
|
||||||
|
|
||||||
if (keys.root || keys.blockchain || keys.device) {
|
if (keys.root || keys.blockchain || keys.device) {
|
||||||
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';
|
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
getBalanceSol,
|
getBalanceSol,
|
||||||
getTopupSiteUrl,
|
getTopupSiteUrl,
|
||||||
getWalletFromStoredDeviceKey,
|
getWalletFromStoredDeviceKey,
|
||||||
requestAirdropSol,
|
|
||||||
transferSol,
|
transferSol,
|
||||||
} from '../services/solana-wallet-service.js';
|
} from '../services/solana-wallet-service.js';
|
||||||
import {
|
import {
|
||||||
@ -738,32 +737,7 @@ export function render({ navigate }) {
|
|||||||
setStatus('Кошелёк не инициализирован.');
|
setStatus('Кошелёк не инициализирован.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
window.location.assign(getTopupSiteUrl(walletAddress));
|
||||||
const openSite = window.confirm(
|
|
||||||
'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).',
|
|
||||||
);
|
|
||||||
if (openSite) {
|
|
||||||
window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer');
|
|
||||||
setStatus('Открыт сайт пополнения. Пока также доступно тестовое пополнение.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
topupBtn.disabled = true;
|
|
||||||
try {
|
|
||||||
const drop = await requestAirdropSol({
|
|
||||||
endpoint: state.entrySettings.solanaServer,
|
|
||||||
address: walletAddress,
|
|
||||||
amountSol: 1,
|
|
||||||
});
|
|
||||||
if (modeToken !== activeModeToken) return;
|
|
||||||
setStatus(`Тестовое пополнение выполнено (airdrop 1 SOL). Signature: ${drop.signature}`);
|
|
||||||
await refreshBalance();
|
|
||||||
} catch (error) {
|
|
||||||
if (modeToken !== activeModeToken) return;
|
|
||||||
setStatus(`Ошибка тестового пополнения: ${error?.message || 'unknown'}`);
|
|
||||||
} finally {
|
|
||||||
topupBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
content.append(backBtn, card, actions, generatedCard);
|
content.append(backBtn, card, actions, generatedCard);
|
||||||
@ -924,19 +898,22 @@ export function render({ navigate }) {
|
|||||||
setStatus('Генерация Arweave-кошелька...');
|
setStatus('Генерация Arweave-кошелька...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let wasFirstTimeGeneration = false;
|
||||||
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({
|
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({
|
||||||
...sessionArgsOrThrow(),
|
...sessionArgsOrThrow(),
|
||||||
onStatus: (message) => {
|
onStatus: (message) => {
|
||||||
const text = String(message || '').trim();
|
const text = String(message || '').trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (text.includes('впервые получаем Arweave-кошелёк')) {
|
if (text.includes('впервые получаем Arweave-кошелёк')) {
|
||||||
setStatus('Сейчас мы впервые получаем Arweave-кошелёк из вашего приватного device key. Это может занять немного времени. После этого кошелёк будет храниться только в зашифрованном контейнере этого устройства.');
|
wasFirstTimeGeneration = true;
|
||||||
|
setStatus('Подождите — ваш Arweave-ключ вычисляется из device key. Это происходит только один раз, потом будет мгновенно.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(text);
|
setStatus(text);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (modeToken !== activeModeToken) return;
|
if (modeToken !== activeModeToken) return;
|
||||||
|
if (wasFirstTimeGeneration) setStatus('');
|
||||||
walletAddress = arweaveWalletCtx.address;
|
walletAddress = arweaveWalletCtx.address;
|
||||||
addressEl.textContent = walletAddress;
|
addressEl.textContent = walletAddress;
|
||||||
await refreshBalance();
|
await refreshBalance();
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
exportPkcs8B64,
|
exportPkcs8B64,
|
||||||
generateEd25519Pair,
|
generateEd25519Pair,
|
||||||
importPkcs8Ed25519,
|
importPkcs8Ed25519,
|
||||||
|
publicKeyB64FromPkcs8Ed25519,
|
||||||
randomBase64,
|
randomBase64,
|
||||||
sha256Bytes,
|
sha256Bytes,
|
||||||
signBytes,
|
signBytes,
|
||||||
@ -749,7 +750,12 @@ export class AuthService {
|
|||||||
|
|
||||||
if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
|
if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
|
||||||
const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key');
|
const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key');
|
||||||
const result = { rootPair, blockchainPair, devicePair };
|
const result = {
|
||||||
|
masterSecretB64: bytesToBase64(masterSecret),
|
||||||
|
rootPair,
|
||||||
|
blockchainPair,
|
||||||
|
devicePair,
|
||||||
|
};
|
||||||
this.passwordKeyBundleCache.set(cacheKey, result);
|
this.passwordKeyBundleCache.set(cacheKey, result);
|
||||||
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
|
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
|
||||||
return result;
|
return result;
|
||||||
@ -857,6 +863,23 @@ export class AuthService {
|
|||||||
return { ...session, keyBundle };
|
return { ...session, keyBundle };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createSessionFromImportedSecrets(login, secrets) {
|
||||||
|
const cleanLogin = (login || '').trim();
|
||||||
|
if (!cleanLogin) throw new Error('В QR-коде нет логина');
|
||||||
|
const deviceKey = String(secrets?.deviceKey || '').trim();
|
||||||
|
if (!deviceKey) throw new Error('В QR-коде нет device key для входа');
|
||||||
|
|
||||||
|
const privateKey = await importPkcs8Ed25519(deviceKey);
|
||||||
|
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(deviceKey);
|
||||||
|
const session = await this.createAuthSession(cleanLogin, {
|
||||||
|
devicePair: {
|
||||||
|
privateKey,
|
||||||
|
publicKeyB64,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
|
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
|
||||||
let currentSecrets = {};
|
let currentSecrets = {};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -25,6 +25,61 @@ function base64UrlToBase64(value) {
|
|||||||
return normalized + '='.repeat(padLen);
|
return normalized + '='.repeat(padLen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
export function bytesToBase58(bytes) {
|
||||||
|
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
|
||||||
|
if (input.length === 0) return '';
|
||||||
|
|
||||||
|
const digits = [];
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
let carry = input[i];
|
||||||
|
for (let j = 0; j < digits.length; j += 1) {
|
||||||
|
const value = (digits[j] * 256) + carry;
|
||||||
|
digits[j] = value % 58;
|
||||||
|
carry = Math.floor(value / 58);
|
||||||
|
}
|
||||||
|
while (carry > 0) {
|
||||||
|
digits.push(carry % 58);
|
||||||
|
carry = Math.floor(carry / 58);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length && input[i] === 0; i += 1) {
|
||||||
|
digits.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base58ToBytes(value) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) return new Uint8Array();
|
||||||
|
|
||||||
|
const digits = [];
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
const char = text[i];
|
||||||
|
const index = BASE58_ALPHABET.indexOf(char);
|
||||||
|
if (index < 0) throw new Error(`Недопустимый символ base58: ${char}`);
|
||||||
|
let carry = index;
|
||||||
|
for (let j = 0; j < digits.length; j += 1) {
|
||||||
|
const acc = (digits[j] * 58) + carry;
|
||||||
|
digits[j] = acc & 0xff;
|
||||||
|
carry = acc >> 8;
|
||||||
|
}
|
||||||
|
while (carry > 0) {
|
||||||
|
digits.push(carry & 0xff);
|
||||||
|
carry >>= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length && text[i] === '1'; i += 1) {
|
||||||
|
digits.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(digits.reverse());
|
||||||
|
}
|
||||||
|
|
||||||
export function randomBase64(byteLen = 32) {
|
export function randomBase64(byteLen = 32) {
|
||||||
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
|
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
|
||||||
return bytesToBase64(bytes);
|
return bytesToBase64(bytes);
|
||||||
@ -254,6 +309,13 @@ export async function importPkcs8Ed25519(pkcs8B64) {
|
|||||||
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
|
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function publicKeyB64FromPkcs8Ed25519(pkcs8B64) {
|
||||||
|
const privateKey = await getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, true, ['sign']);
|
||||||
|
const jwk = await getSubtleApi().exportKey('jwk', privateKey);
|
||||||
|
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
|
||||||
|
return bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x)));
|
||||||
|
}
|
||||||
|
|
||||||
export async function signBase64(privateKey, text) {
|
export async function signBase64(privateKey, text) {
|
||||||
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
|
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
|
||||||
return bytesToBase64(new Uint8Array(signature));
|
return bytesToBase64(new Uint8Array(signature));
|
||||||
|
|||||||
87
shine-UI/js/services/qr-key-transfer-service.js
Normal file
87
shine-UI/js/services/qr-key-transfer-service.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import qrcode from '../vendor-qrcode-generator.js';
|
||||||
|
|
||||||
|
const TRANSFER_PREFIX = 'shine-key-transfer-v1:';
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function bytesToBase64Url(bytes) {
|
||||||
|
let binary = '';
|
||||||
|
bytes.forEach((b) => {
|
||||||
|
binary += String.fromCharCode(b);
|
||||||
|
});
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToBytes(value) {
|
||||||
|
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyLabel(id) {
|
||||||
|
if (id === 'root') return 'root';
|
||||||
|
if (id === 'blockchain') return 'blockchain';
|
||||||
|
if (id === 'device') return 'device';
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeTransferKeys(keys = {}) {
|
||||||
|
const out = [];
|
||||||
|
if (keys.deviceKey) out.push('device');
|
||||||
|
if (keys.blockchainKey) out.push('blockchain');
|
||||||
|
if (keys.rootKey) out.push('root');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeKeyTransferText({ login, keys }) {
|
||||||
|
const payload = {
|
||||||
|
v: 1,
|
||||||
|
type: 'shine-key-transfer',
|
||||||
|
login: String(login || '').trim(),
|
||||||
|
keys: {
|
||||||
|
deviceKey: String(keys?.deviceKey || ''),
|
||||||
|
blockchainKey: String(keys?.blockchainKey || ''),
|
||||||
|
rootKey: String(keys?.rootKey || ''),
|
||||||
|
},
|
||||||
|
createdAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
return `${TRANSFER_PREFIX}${bytesToBase64Url(encoder.encode(json))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKeyTransferText(text) {
|
||||||
|
const raw = String(text || '').trim();
|
||||||
|
if (!raw.startsWith(TRANSFER_PREFIX)) {
|
||||||
|
throw new Error('Это не QR-код переноса ключей SHiNE');
|
||||||
|
}
|
||||||
|
const json = decoder.decode(base64UrlToBytes(raw.slice(TRANSFER_PREFIX.length)));
|
||||||
|
const payload = JSON.parse(json);
|
||||||
|
if (payload?.v !== 1 || payload?.type !== 'shine-key-transfer') {
|
||||||
|
throw new Error('Неподдерживаемый формат QR-кода');
|
||||||
|
}
|
||||||
|
const login = String(payload.login || '').trim();
|
||||||
|
if (!login) throw new Error('В QR-коде нет логина');
|
||||||
|
const keys = payload.keys && typeof payload.keys === 'object' ? payload.keys : {};
|
||||||
|
if (!keys.deviceKey && !keys.blockchainKey && !keys.rootKey) {
|
||||||
|
throw new Error('В QR-коде нет ключей');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
login,
|
||||||
|
keys: {
|
||||||
|
deviceKey: String(keys.deviceKey || ''),
|
||||||
|
blockchainKey: String(keys.blockchainKey || ''),
|
||||||
|
rootKey: String(keys.rootKey || ''),
|
||||||
|
},
|
||||||
|
keyTypes: describeTransferKeys(keys),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderQrSvg(text, { cellSize = 4, margin = 4 } = {}) {
|
||||||
|
const qr = qrcode(0, 'L');
|
||||||
|
qr.addData(String(text || ''), 'Byte');
|
||||||
|
qr.make();
|
||||||
|
return qr.createSvgTag(cellSize, margin);
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ export const DERIVATION_NAME = 'SAWD-v1';
|
|||||||
export const MASTER_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MASTER';
|
export const MASTER_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MASTER';
|
||||||
export const STREAM_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/STREAM';
|
export const STREAM_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/STREAM';
|
||||||
export const MR_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MILLER-RABIN';
|
export const MR_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MILLER-RABIN';
|
||||||
export const MILLER_RABIN_ROUNDS = 64;
|
export const MILLER_RABIN_ROUNDS = 42;
|
||||||
export const SMALL_PRIME_LIMIT = 10000;
|
export const SMALL_PRIME_LIMIT = 10000;
|
||||||
|
|
||||||
function getSubtle() {
|
function getSubtle() {
|
||||||
|
|||||||
@ -1,491 +1,7 @@
|
|||||||
import { importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
|
export {
|
||||||
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
|
calcLimitTopupPriceLamports,
|
||||||
import { SHINE_PAYMENTS_PROGRAM_ID, SHINE_USERS_ECONOMY_CONFIG_SEED, SHINE_USERS_PROGRAM_ID } from '../solana-programs.js';
|
getLimitStepBytes,
|
||||||
|
getShineBlockchainUsage,
|
||||||
const MAGIC = 'SHiNE';
|
getShineUsersEconomyConfig,
|
||||||
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
|
updateShineUserPdaOnSolana,
|
||||||
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
|
} from './shine-user-pda-service.js';
|
||||||
const LIMIT_STEP = 10_000n;
|
|
||||||
const UPDATE_USER_PDA_DISCRIMINATOR = new Uint8Array([42, 133, 114, 232, 38, 245, 167, 234]);
|
|
||||||
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
|
|
||||||
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
|
|
||||||
|
|
||||||
const BLOCK_TYPE_ROOT_KEY = 1;
|
|
||||||
const BLOCK_TYPE_DEVICE_KEY = 2;
|
|
||||||
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
|
|
||||||
const BLOCK_TYPE_SERVER_PROFILE = 30;
|
|
||||||
const BLOCK_TYPE_ACCESS_SERVERS = 40;
|
|
||||||
const BLOCK_TYPE_TRUSTED_STATE = 50;
|
|
||||||
|
|
||||||
let solanaLibPromise = null;
|
|
||||||
function loadSolanaLib() {
|
|
||||||
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
|
|
||||||
return solanaLibPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushU32LE(buf, v) {
|
|
||||||
const n = Number(v) >>> 0;
|
|
||||||
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
|
|
||||||
}
|
|
||||||
function pushU64LE(buf, v) {
|
|
||||||
const b = BigInt(v);
|
|
||||||
const lo = Number(b & 0xffffffffn) >>> 0;
|
|
||||||
const hi = Number((b >> 32n) & 0xffffffffn) >>> 0;
|
|
||||||
pushU32LE(buf, lo);
|
|
||||||
pushU32LE(buf, hi);
|
|
||||||
}
|
|
||||||
function pushStrU8(buf, value) {
|
|
||||||
const bytes = new TextEncoder().encode(String(value || ''));
|
|
||||||
if (bytes.length > 255) throw new Error('Слишком длинная строка для формата U8');
|
|
||||||
buf.push(bytes.length);
|
|
||||||
for (const x of bytes) buf.push(x);
|
|
||||||
}
|
|
||||||
function pushStrU32(buf, value) {
|
|
||||||
const bytes = new TextEncoder().encode(String(value || ''));
|
|
||||||
pushU32LE(buf, bytes.length);
|
|
||||||
for (const x of bytes) buf.push(x);
|
|
||||||
}
|
|
||||||
function pushVecU8(buf, bytes) {
|
|
||||||
const data = bytes || new Uint8Array();
|
|
||||||
pushU32LE(buf, data.length);
|
|
||||||
for (const x of data) buf.push(x);
|
|
||||||
}
|
|
||||||
function pushVecStrU32(buf, values) {
|
|
||||||
const arr = Array.isArray(values) ? values : [];
|
|
||||||
pushU32LE(buf, arr.length);
|
|
||||||
for (const s of arr) pushStrU32(buf, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReader(bytes) {
|
|
||||||
let o = 0;
|
|
||||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
||||||
const ensure = (n) => { if (o + n > bytes.length) throw new Error('Повреждённый формат PDA'); };
|
|
||||||
const readU8 = () => { ensure(1); const v = dv.getUint8(o); o += 1; return v; };
|
|
||||||
const readU16 = () => { ensure(2); const v = dv.getUint16(o, true); o += 2; return v; };
|
|
||||||
const readU32 = () => { ensure(4); const v = dv.getUint32(o, true); o += 4; return v; };
|
|
||||||
const readU64 = () => { ensure(8); const v = dv.getBigUint64(o, true); o += 8; return v; };
|
|
||||||
const readBytes = (n) => { ensure(n); const out = bytes.slice(o, o + n); o += n; return out; };
|
|
||||||
const readStrU8 = () => {
|
|
||||||
const len = readU8();
|
|
||||||
return new TextDecoder().decode(readBytes(len));
|
|
||||||
};
|
|
||||||
return { readU8, readU16, readU32, readU64, readBytes, readStrU8 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseShineUserPda(dataBytes) {
|
|
||||||
const r = makeReader(dataBytes);
|
|
||||||
const magic = new TextDecoder().decode(r.readBytes(5));
|
|
||||||
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
|
|
||||||
r.readU8();
|
|
||||||
r.readU8();
|
|
||||||
r.readU16();
|
|
||||||
const createdAtMs = r.readU64();
|
|
||||||
const updatedAtMs = r.readU64();
|
|
||||||
const recordNumber = r.readU32();
|
|
||||||
const prevRecordHash = r.readBytes(32);
|
|
||||||
const login = r.readStrU8();
|
|
||||||
const blocksCount = r.readU8();
|
|
||||||
|
|
||||||
const out = {
|
|
||||||
createdAtMs,
|
|
||||||
updatedAtMs,
|
|
||||||
recordNumber,
|
|
||||||
prevRecordHash,
|
|
||||||
login,
|
|
||||||
rootKey: null,
|
|
||||||
deviceKey: null,
|
|
||||||
blockchain: null,
|
|
||||||
isServer: false,
|
|
||||||
serverKey: new Uint8Array(32),
|
|
||||||
serverAddress: '',
|
|
||||||
syncServers: [],
|
|
||||||
accessServers: [],
|
|
||||||
trustedCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < blocksCount; i += 1) {
|
|
||||||
const type = r.readU8();
|
|
||||||
r.readU8();
|
|
||||||
if (type === BLOCK_TYPE_ROOT_KEY) { out.rootKey = r.readBytes(32); continue; }
|
|
||||||
if (type === BLOCK_TYPE_DEVICE_KEY) { out.deviceKey = r.readBytes(32); continue; }
|
|
||||||
if (type === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
|
|
||||||
const count = r.readU8();
|
|
||||||
for (let j = 0; j < count; j += 1) {
|
|
||||||
const blockchainType = r.readU8();
|
|
||||||
const blockchainName = r.readStrU8();
|
|
||||||
const blockchainPublicKey = r.readBytes(32);
|
|
||||||
const paidLimitBytes = r.readU64();
|
|
||||||
const usedBytes = r.readU64();
|
|
||||||
const lastBlockNumber = r.readU32();
|
|
||||||
const lastBlockHash = r.readBytes(32);
|
|
||||||
const lastBlockSignature = r.readBytes(64);
|
|
||||||
const arPresent = r.readU8();
|
|
||||||
const arweaveTxId = arPresent ? r.readStrU8() : '';
|
|
||||||
if (!out.blockchain) {
|
|
||||||
out.blockchain = {
|
|
||||||
blockchainType,
|
|
||||||
blockchainName,
|
|
||||||
blockchainPublicKey,
|
|
||||||
paidLimitBytes,
|
|
||||||
usedBytes,
|
|
||||||
lastBlockNumber,
|
|
||||||
lastBlockHash,
|
|
||||||
lastBlockSignature,
|
|
||||||
arweaveTxId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (type === BLOCK_TYPE_SERVER_PROFILE) {
|
|
||||||
out.isServer = r.readU8() === 1;
|
|
||||||
out.serverKey = r.readBytes(32);
|
|
||||||
out.serverAddress = r.readStrU8();
|
|
||||||
const syncCount = r.readU8();
|
|
||||||
out.syncServers = [];
|
|
||||||
for (let k = 0; k < syncCount; k += 1) out.syncServers.push(r.readStrU8());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (type === BLOCK_TYPE_ACCESS_SERVERS) {
|
|
||||||
const accessCount = r.readU8();
|
|
||||||
out.accessServers = [];
|
|
||||||
for (let k = 0; k < accessCount; k += 1) out.accessServers.push(r.readStrU8());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (type === BLOCK_TYPE_TRUSTED_STATE) {
|
|
||||||
out.trustedCount = r.readU8();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw new Error(`Неизвестный блок PDA: ${type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!out.rootKey || !out.deviceKey || !out.blockchain) {
|
|
||||||
throw new Error('В PDA отсутствуют обязательные блоки');
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeUnsignedRecordFromState(stateLike) {
|
|
||||||
const buf = [];
|
|
||||||
const login = String(stateLike.login || '');
|
|
||||||
const bch = stateLike.blockchain;
|
|
||||||
buf.push(0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0);
|
|
||||||
pushU64LE(buf, stateLike.createdAtMs);
|
|
||||||
pushU64LE(buf, stateLike.updatedAtMs);
|
|
||||||
pushU32LE(buf, stateLike.recordNumber);
|
|
||||||
for (const x of stateLike.prevRecordHash) buf.push(x);
|
|
||||||
pushStrU8(buf, login);
|
|
||||||
const blocksCount = stateLike.isServer ? 6 : 5;
|
|
||||||
buf.push(blocksCount);
|
|
||||||
|
|
||||||
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
|
|
||||||
for (const x of stateLike.rootKey) buf.push(x);
|
|
||||||
buf.push(BLOCK_TYPE_DEVICE_KEY, 0);
|
|
||||||
for (const x of stateLike.deviceKey) buf.push(x);
|
|
||||||
|
|
||||||
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1);
|
|
||||||
buf.push(bch.blockchainType);
|
|
||||||
pushStrU8(buf, bch.blockchainName);
|
|
||||||
for (const x of bch.blockchainPublicKey) buf.push(x);
|
|
||||||
pushU64LE(buf, bch.paidLimitBytes);
|
|
||||||
pushU64LE(buf, bch.usedBytes);
|
|
||||||
pushU32LE(buf, bch.lastBlockNumber);
|
|
||||||
for (const x of bch.lastBlockHash) buf.push(x);
|
|
||||||
for (const x of bch.lastBlockSignature) buf.push(x);
|
|
||||||
if (String(bch.arweaveTxId || '').trim()) {
|
|
||||||
buf.push(1);
|
|
||||||
pushStrU8(buf, bch.arweaveTxId);
|
|
||||||
} else {
|
|
||||||
buf.push(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateLike.isServer) {
|
|
||||||
buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1);
|
|
||||||
for (const x of stateLike.serverKey) buf.push(x);
|
|
||||||
pushStrU8(buf, stateLike.serverAddress);
|
|
||||||
const sync = Array.isArray(stateLike.syncServers) ? stateLike.syncServers : [];
|
|
||||||
buf.push(sync.length & 0xff);
|
|
||||||
for (const s of sync) pushStrU8(buf, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0);
|
|
||||||
const access = Array.isArray(stateLike.accessServers) ? stateLike.accessServers : [];
|
|
||||||
buf.push(access.length & 0xff);
|
|
||||||
for (const s of access) pushStrU8(buf, s);
|
|
||||||
|
|
||||||
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, Number(stateLike.trustedCount || 0) & 0xff);
|
|
||||||
const recLen = buf.length + 64;
|
|
||||||
buf[7] = recLen & 0xff;
|
|
||||||
buf[8] = (recLen >>> 8) & 0xff;
|
|
||||||
return new Uint8Array(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) {
|
|
||||||
const buf = [];
|
|
||||||
for (const x of new TextEncoder().encode(LAST_BLOCK_STATE_PREFIX)) buf.push(x);
|
|
||||||
pushStrU8(buf, login);
|
|
||||||
pushStrU8(buf, blockchainName);
|
|
||||||
pushU32LE(buf, lastBlockNumber);
|
|
||||||
for (const x of lastBlockHash32) buf.push(x);
|
|
||||||
pushU64LE(buf, usedBytes);
|
|
||||||
return new Uint8Array(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
|
|
||||||
const sigOff = 16;
|
|
||||||
const pkOff = sigOff + 64;
|
|
||||||
const msgOff = pkOff + 32;
|
|
||||||
const data = new Uint8Array(msgOff + 32);
|
|
||||||
const v = new DataView(data.buffer);
|
|
||||||
data[0] = 1;
|
|
||||||
data[1] = 0;
|
|
||||||
v.setUint16(2, sigOff, true);
|
|
||||||
v.setUint16(4, 0xffff, true);
|
|
||||||
v.setUint16(6, pkOff, true);
|
|
||||||
v.setUint16(8, 0xffff, true);
|
|
||||||
v.setUint16(10, msgOff, true);
|
|
||||||
v.setUint16(12, 32, true);
|
|
||||||
v.setUint16(14, 0xffff, true);
|
|
||||||
data.set(sig64, sigOff);
|
|
||||||
data.set(pubkey32, pkOff);
|
|
||||||
data.set(msgHash32, msgOff);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeUpdateUserPdaArgs(args) {
|
|
||||||
const b = [];
|
|
||||||
for (const x of UPDATE_USER_PDA_DISCRIMINATOR) b.push(x);
|
|
||||||
pushStrU32(b, args.login);
|
|
||||||
for (const x of args.rootKey32) b.push(x);
|
|
||||||
pushU64LE(b, args.createdAtMs);
|
|
||||||
pushU64LE(b, args.updatedAtMs);
|
|
||||||
pushU32LE(b, args.version);
|
|
||||||
pushVecU8(b, args.prevHash32);
|
|
||||||
pushU64LE(b, args.additionalLimitBytes);
|
|
||||||
for (const x of args.deviceKey32) b.push(x);
|
|
||||||
for (const x of args.blockchainPublicKey32) b.push(x);
|
|
||||||
pushStrU32(b, args.blockchainName);
|
|
||||||
pushU64LE(b, args.usedBytes);
|
|
||||||
pushU32LE(b, args.lastBlockNumber);
|
|
||||||
pushVecU8(b, args.lastBlockHash32);
|
|
||||||
pushVecU8(b, args.lastBlockSignature64);
|
|
||||||
pushStrU32(b, args.arweaveTxId);
|
|
||||||
b.push(args.isServer ? 1 : 0);
|
|
||||||
for (const x of args.serverKey32) b.push(x);
|
|
||||||
pushStrU32(b, args.serverAddress);
|
|
||||||
pushVecStrU32(b, args.syncServers);
|
|
||||||
pushVecStrU32(b, args.accessServers);
|
|
||||||
b.push(Number(args.trustedCount || 0) & 0xff);
|
|
||||||
pushVecU8(b, args.rootSignature64);
|
|
||||||
return new Uint8Array(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUsersEconomyConfig(dataBytes) {
|
|
||||||
const v = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength);
|
|
||||||
if (dataBytes.byteLength < 25) throw new Error('Некорректный economy config');
|
|
||||||
return {
|
|
||||||
version: v.getUint8(0),
|
|
||||||
registrationFeeLamports: v.getBigUint64(1, true),
|
|
||||||
lamportsPerLimitStep: v.getBigUint64(9, true),
|
|
||||||
startBonusLimit: v.getBigUint64(17, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getShineUsersEconomyConfig({ solanaEndpoint }) {
|
|
||||||
const endpoint = String(solanaEndpoint || '').trim();
|
|
||||||
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
|
||||||
const solana = await loadSolanaLib();
|
|
||||||
const connection = new solana.Connection(endpoint, 'confirmed');
|
|
||||||
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
|
||||||
const [economyPda] = solana.PublicKey.findProgramAddressSync(
|
|
||||||
[new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)],
|
|
||||||
usersProgram,
|
|
||||||
);
|
|
||||||
const ai = await connection.getAccountInfo(economyPda, 'confirmed');
|
|
||||||
if (!ai?.data) throw new Error('Economy config PDA не найден');
|
|
||||||
const economy = parseUsersEconomyConfig(ai.data);
|
|
||||||
return { endpoint, economyPda: economyPda.toBase58(), ...economy };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
|
|
||||||
const cleanLogin = String(login || '').trim().toLowerCase();
|
|
||||||
const endpoint = String(solanaEndpoint || '').trim();
|
|
||||||
if (!cleanLogin) throw new Error('Не указан логин');
|
|
||||||
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
|
||||||
const solana = await loadSolanaLib();
|
|
||||||
const connection = new solana.Connection(endpoint, 'confirmed');
|
|
||||||
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
|
|
||||||
const ai = await connection.getAccountInfo(userPda, 'confirmed');
|
|
||||||
if (!ai?.data) throw new Error('Пользовательский PDA не найден в Solana');
|
|
||||||
const parsed = parseShineUserPda(ai.data);
|
|
||||||
const bch = parsed.blockchain;
|
|
||||||
const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n;
|
|
||||||
return {
|
|
||||||
endpoint,
|
|
||||||
userPda: userPda.toBase58(),
|
|
||||||
login: parsed.login,
|
|
||||||
recordNumber: parsed.recordNumber,
|
|
||||||
paidLimitBytes: bch.paidLimitBytes,
|
|
||||||
usedBytes: bch.usedBytes,
|
|
||||||
leftBytes,
|
|
||||||
lastBlockNumber: bch.lastBlockNumber,
|
|
||||||
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateShineUserPdaOnSolana({
|
|
||||||
login,
|
|
||||||
solanaEndpoint,
|
|
||||||
rootPrivatePkcs8B64,
|
|
||||||
devicePrivatePkcs8B64,
|
|
||||||
blockchainPrivatePkcs8B64,
|
|
||||||
additionalLimitBytes = 0n,
|
|
||||||
nextUsedBytes,
|
|
||||||
nextLastBlockNumber,
|
|
||||||
nextLastBlockHashHex,
|
|
||||||
}) {
|
|
||||||
const cleanLogin = String(login || '').trim().toLowerCase();
|
|
||||||
if (!cleanLogin) throw new Error('Не указан логин');
|
|
||||||
const endpoint = String(solanaEndpoint || '').trim();
|
|
||||||
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
|
||||||
|
|
||||||
const solana = await loadSolanaLib();
|
|
||||||
const connection = new solana.Connection(endpoint, 'confirmed');
|
|
||||||
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
|
||||||
const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
|
|
||||||
const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID);
|
|
||||||
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
|
|
||||||
const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
|
|
||||||
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
|
|
||||||
|
|
||||||
const userAi = await connection.getAccountInfo(userPda, 'confirmed');
|
|
||||||
if (!userAi?.data) throw new Error('PDA пользователя не найден');
|
|
||||||
const current = parseShineUserPda(userAi.data);
|
|
||||||
const currentBch = current.blockchain;
|
|
||||||
|
|
||||||
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
|
|
||||||
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
|
|
||||||
const effectiveLastHash = nextLastBlockHashHex
|
|
||||||
? Uint8Array.from(String(nextLastBlockHashHex).match(/.{1,2}/g).map((h) => parseInt(h, 16)))
|
|
||||||
: currentBch.lastBlockHash;
|
|
||||||
|
|
||||||
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
|
|
||||||
const addLimit = BigInt(additionalLimitBytes || 0);
|
|
||||||
if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит');
|
|
||||||
if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
|
|
||||||
|
|
||||||
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
|
|
||||||
const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64);
|
|
||||||
const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);
|
|
||||||
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
|
|
||||||
|
|
||||||
const updatedAtMs = BigInt(Date.now());
|
|
||||||
const newPaid = currentBch.paidLimitBytes + addLimit;
|
|
||||||
const newRecordNumber = current.recordNumber + 1;
|
|
||||||
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
|
|
||||||
|
|
||||||
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, currentBch.blockchainName, effectiveLastNum, effectiveLastHash, effectiveUsed);
|
|
||||||
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
|
|
||||||
const lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash);
|
|
||||||
|
|
||||||
const nextState = {
|
|
||||||
...current,
|
|
||||||
updatedAtMs,
|
|
||||||
recordNumber: newRecordNumber,
|
|
||||||
prevRecordHash: prevHash,
|
|
||||||
blockchain: {
|
|
||||||
...currentBch,
|
|
||||||
paidLimitBytes: newPaid,
|
|
||||||
usedBytes: effectiveUsed,
|
|
||||||
lastBlockNumber: effectiveLastNum,
|
|
||||||
lastBlockHash: effectiveLastHash,
|
|
||||||
lastBlockSignature: lastBlockSig64,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const unsignedNext = serializeUnsignedRecordFromState(nextState);
|
|
||||||
const unsignedNextHash = await sha256Bytes(unsignedNext);
|
|
||||||
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
|
|
||||||
|
|
||||||
const ixData = serializeUpdateUserPdaArgs({
|
|
||||||
login: cleanLogin,
|
|
||||||
rootKey32: current.rootKey,
|
|
||||||
createdAtMs: current.createdAtMs,
|
|
||||||
updatedAtMs,
|
|
||||||
version: newRecordNumber,
|
|
||||||
prevHash32: prevHash,
|
|
||||||
additionalLimitBytes: addLimit,
|
|
||||||
deviceKey32: current.deviceKey,
|
|
||||||
blockchainPublicKey32: currentBch.blockchainPublicKey,
|
|
||||||
blockchainName: currentBch.blockchainName,
|
|
||||||
usedBytes: effectiveUsed,
|
|
||||||
lastBlockNumber: effectiveLastNum,
|
|
||||||
lastBlockHash32: effectiveLastHash,
|
|
||||||
lastBlockSignature64: lastBlockSig64,
|
|
||||||
arweaveTxId: currentBch.arweaveTxId,
|
|
||||||
isServer: current.isServer,
|
|
||||||
serverKey32: current.serverKey,
|
|
||||||
serverAddress: current.serverAddress,
|
|
||||||
syncServers: current.syncServers,
|
|
||||||
accessServers: current.accessServers,
|
|
||||||
trustedCount: current.trustedCount,
|
|
||||||
rootSignature64: rootSig64,
|
|
||||||
});
|
|
||||||
|
|
||||||
const edIxRoot = new solana.TransactionInstruction({
|
|
||||||
programId: ed25519Program,
|
|
||||||
keys: [],
|
|
||||||
data: buildEd25519IxData(rootSig64, current.rootKey, unsignedNextHash),
|
|
||||||
});
|
|
||||||
const edIxBch = new solana.TransactionInstruction({
|
|
||||||
programId: ed25519Program,
|
|
||||||
keys: [],
|
|
||||||
data: buildEd25519IxData(lastBlockSig64, currentBch.blockchainPublicKey, lastBlockStateHash),
|
|
||||||
});
|
|
||||||
const updIx = new solana.TransactionInstruction({
|
|
||||||
programId: usersProgram,
|
|
||||||
keys: [
|
|
||||||
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
|
|
||||||
{ pubkey: userPda, isSigner: false, isWritable: true },
|
|
||||||
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
|
||||||
{ pubkey: inflowVault, isSigner: false, isWritable: true },
|
|
||||||
{ pubkey: sysvarInstructions, isSigner: false, isWritable: false },
|
|
||||||
{ pubkey: economyPda, isSigner: false, isWritable: false },
|
|
||||||
],
|
|
||||||
data: ixData,
|
|
||||||
});
|
|
||||||
const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 });
|
|
||||||
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 131_072 });
|
|
||||||
|
|
||||||
const signature = await solana.sendAndConfirmTransaction(
|
|
||||||
connection,
|
|
||||||
new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updIx),
|
|
||||||
[deviceKeypair],
|
|
||||||
{ commitment: 'confirmed' },
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
signature,
|
|
||||||
userPda: userPda.toBase58(),
|
|
||||||
paidLimitBytes: newPaid,
|
|
||||||
usedBytes: effectiveUsed,
|
|
||||||
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
|
|
||||||
lastBlockNumber: effectiveLastNum,
|
|
||||||
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calcLimitTopupPriceLamports(additionalLimitBytes, lamportsPerLimitStep) {
|
|
||||||
const add = BigInt(additionalLimitBytes || 0);
|
|
||||||
const pricePerStep = BigInt(lamportsPerLimitStep || 0);
|
|
||||||
if (add < 0n) throw new Error('Некорректный размер увеличения лимита');
|
|
||||||
if (add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`);
|
|
||||||
return (add / LIMIT_STEP) * pricePerStep;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLimitStepBytes() {
|
|
||||||
return LIMIT_STEP;
|
|
||||||
}
|
|
||||||
|
|||||||
1063
shine-UI/js/services/shine-user-pda-service.js
Normal file
1063
shine-UI/js/services/shine-user-pda-service.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,16 +1,11 @@
|
|||||||
import { sha256Bytes, signBytes, importPkcs8Ed25519, base64ToBytes } from './crypto-utils.js';
|
import { registerUserOnSolana as registerUserOnSolanaShared } from './shine-user-pda-service.js';
|
||||||
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
|
|
||||||
import {
|
import {
|
||||||
SHINE_USERS_PROGRAM_ID,
|
SHINE_USERS_PROGRAM_ID,
|
||||||
SHINE_PAYMENTS_PROGRAM_ID,
|
|
||||||
SHINE_LOGIN_GUARD_PROGRAM_ID,
|
SHINE_LOGIN_GUARD_PROGRAM_ID,
|
||||||
} from '../solana-programs.js';
|
} from '../solana-programs.js';
|
||||||
|
|
||||||
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
|
|
||||||
const CLASSIFY_LOGIN_DISCRIMINATOR = new Uint8Array([112, 97, 152, 32, 255, 73, 108, 86]);
|
const CLASSIFY_LOGIN_DISCRIMINATOR = new Uint8Array([112, 97, 152, 32, 255, 73, 108, 86]);
|
||||||
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
|
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
|
||||||
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
|
|
||||||
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
|
|
||||||
|
|
||||||
let solanaLibPromise = null;
|
let solanaLibPromise = null;
|
||||||
function loadSolanaLib() {
|
function loadSolanaLib() {
|
||||||
@ -23,168 +18,19 @@ function pushU32LE(buf, v) {
|
|||||||
buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF);
|
buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushU64LE(buf, bigV) {
|
|
||||||
const b = typeof bigV === 'bigint' ? bigV : BigInt(bigV);
|
|
||||||
const lo = Number(b & 0xFFFFFFFFn) >>> 0;
|
|
||||||
const hi = Number((b >> 32n) & 0xFFFFFFFFn) >>> 0;
|
|
||||||
pushU32LE(buf, lo);
|
|
||||||
pushU32LE(buf, hi);
|
|
||||||
}
|
|
||||||
|
|
||||||
class BorshBuf {
|
class BorshBuf {
|
||||||
constructor() { this._b = []; }
|
constructor() { this._b = []; }
|
||||||
u8(v) { this._b.push(v & 0xFF); }
|
u8(v) { this._b.push(v & 0xFF); }
|
||||||
u32(v) { pushU32LE(this._b, v); }
|
u32(v) { pushU32LE(this._b, v); }
|
||||||
u64(v) { pushU64LE(this._b, v); }
|
|
||||||
bool(v) { this.u8(v ? 1 : 0); }
|
|
||||||
bytes32(b) { for (const x of b) this._b.push(x); }
|
|
||||||
vecU8(b) { this.u32(b.length); for (const x of b) this._b.push(x); }
|
|
||||||
str(s) {
|
str(s) {
|
||||||
const enc = new TextEncoder().encode(s);
|
const enc = new TextEncoder().encode(s);
|
||||||
this.u32(enc.length);
|
this.u32(enc.length);
|
||||||
for (const x of enc) this._b.push(x);
|
for (const x of enc) this._b.push(x);
|
||||||
}
|
}
|
||||||
vecStr(arr) {
|
|
||||||
this.u32(arr.length);
|
|
||||||
for (const s of arr) this.str(s);
|
|
||||||
}
|
|
||||||
raw(bytes) { for (const x of bytes) this._b.push(x); }
|
raw(bytes) { for (const x of bytes) this._b.push(x); }
|
||||||
result() { return new Uint8Array(this._b); }
|
result() { return new Uint8Array(this._b); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches Rust serialize_last_block_state (initial zero state)
|
|
||||||
function buildLastBlockStateBytes(login, blockchainName) {
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const prefix = enc.encode('SHiNE_LAST_BLOCK');
|
|
||||||
const loginB = enc.encode(login);
|
|
||||||
const bchB = enc.encode(blockchainName);
|
|
||||||
const buf = [];
|
|
||||||
for (const x of prefix) buf.push(x);
|
|
||||||
buf.push(loginB.length);
|
|
||||||
for (const x of loginB) buf.push(x);
|
|
||||||
buf.push(bchB.length);
|
|
||||||
for (const x of bchB) buf.push(x);
|
|
||||||
pushU32LE(buf, 0); // last_block_number = 0
|
|
||||||
for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32]
|
|
||||||
pushU64LE(buf, 0n); // used_bytes = 0
|
|
||||||
return new Uint8Array(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matches Rust serialize_unsigned_record for initial registration
|
|
||||||
function buildUnsignedRecordBytes(
|
|
||||||
login, createdAtMs, rootKey32, deviceKey32, blockchainKey32,
|
|
||||||
blockchainName, paidLimitBytes, lastBlockSig64,
|
|
||||||
) {
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const loginB = enc.encode(login);
|
|
||||||
const bchB = enc.encode(blockchainName);
|
|
||||||
const accessB = enc.encode('shineup.me');
|
|
||||||
const buf = [];
|
|
||||||
|
|
||||||
// Fixed header: MAGIC(5) + FORMAT_MAJOR(1) + FORMAT_MINOR(1) + record_len_placeholder(2)
|
|
||||||
buf.push(0x53, 0x48, 0x69, 0x4E, 0x45, 1, 0, 0, 0); // indices 0..8
|
|
||||||
|
|
||||||
pushU64LE(buf, createdAtMs); // created_at_ms
|
|
||||||
pushU64LE(buf, createdAtMs); // updated_at_ms = same
|
|
||||||
pushU32LE(buf, 0); // record_number = 0
|
|
||||||
for (let i = 0; i < 32; i++) buf.push(0); // prev_record_hash = [0;32]
|
|
||||||
|
|
||||||
buf.push(loginB.length);
|
|
||||||
for (const x of loginB) buf.push(x);
|
|
||||||
|
|
||||||
buf.push(5); // blocks_count (non-server)
|
|
||||||
|
|
||||||
// RootKeyBlock (type=1, ver=0)
|
|
||||||
buf.push(1, 0);
|
|
||||||
for (const x of rootKey32) buf.push(x);
|
|
||||||
|
|
||||||
// DeviceKeyBlock (type=2, ver=0)
|
|
||||||
buf.push(2, 0);
|
|
||||||
for (const x of deviceKey32) buf.push(x);
|
|
||||||
|
|
||||||
// BlockchainRegistryBlock (type=3, ver=0, count=1)
|
|
||||||
buf.push(3, 0, 1, 1); // type, ver, count=1, blockchain_type=1(MAIN_USER)
|
|
||||||
buf.push(bchB.length);
|
|
||||||
for (const x of bchB) buf.push(x);
|
|
||||||
for (const x of blockchainKey32) buf.push(x);
|
|
||||||
pushU64LE(buf, paidLimitBytes); // paid_limit_bytes
|
|
||||||
pushU64LE(buf, 0n); // used_bytes = 0
|
|
||||||
pushU32LE(buf, 0); // last_block_number = 0
|
|
||||||
for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32]
|
|
||||||
for (const x of lastBlockSig64) buf.push(x); // last_block_signature
|
|
||||||
buf.push(0); // arweave_present = 0
|
|
||||||
|
|
||||||
// AccessServersBlock (type=40, ver=0)
|
|
||||||
buf.push(40, 0, 1, accessB.length);
|
|
||||||
for (const x of accessB) buf.push(x);
|
|
||||||
|
|
||||||
// TrustedStateBlock (type=50, ver=0, trusted_count=0)
|
|
||||||
buf.push(50, 0, 0);
|
|
||||||
|
|
||||||
// Patch record_len at indices 7-8: total = buf.length + 64 (signature)
|
|
||||||
const recLen = buf.length + 64;
|
|
||||||
buf[7] = recLen & 0xFF;
|
|
||||||
buf[8] = (recLen >> 8) & 0xFF;
|
|
||||||
|
|
||||||
return new Uint8Array(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builds Ed25519 program instruction data for one signature
|
|
||||||
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
|
|
||||||
const sigOff = 16;
|
|
||||||
const pkOff = sigOff + 64; // 80
|
|
||||||
const msgOff = pkOff + 32; // 112
|
|
||||||
const data = new Uint8Array(msgOff + 32); // 144 bytes total
|
|
||||||
const v = new DataView(data.buffer);
|
|
||||||
data[0] = 1; data[1] = 0; // num_signatures=1, padding
|
|
||||||
v.setUint16(2, sigOff, true);
|
|
||||||
v.setUint16(4, 0xFFFF, true); // same instruction
|
|
||||||
v.setUint16(6, pkOff, true);
|
|
||||||
v.setUint16(8, 0xFFFF, true);
|
|
||||||
v.setUint16(10, msgOff, true);
|
|
||||||
v.setUint16(12, 32, true); // message_data_size = 32
|
|
||||||
v.setUint16(14, 0xFFFF, true);
|
|
||||||
data.set(sig64, sigOff);
|
|
||||||
data.set(pubkey32, pkOff);
|
|
||||||
data.set(msgHash32, msgOff);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStartBonusLimit(data) {
|
|
||||||
// Borsh: version(u8=1) + reg_fee(u64=8) + lamports_per_step(u64=8) + start_bonus_limit(u64=8)
|
|
||||||
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
return v.getBigUint64(17, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeCreateUserPdaArgs(
|
|
||||||
login, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
|
|
||||||
blockchainName, lastBlockSig64, rootSig64,
|
|
||||||
) {
|
|
||||||
const b = new BorshBuf();
|
|
||||||
b.raw(CREATE_USER_PDA_DISCRIMINATOR);
|
|
||||||
b.str(login);
|
|
||||||
b.bytes32(rootKey32);
|
|
||||||
b.u64(createdAtMs);
|
|
||||||
b.u64(0n); // additional_limit
|
|
||||||
// UserMutableFields:
|
|
||||||
b.bytes32(deviceKey32);
|
|
||||||
b.bytes32(blockchainKey32);
|
|
||||||
b.str(blockchainName);
|
|
||||||
b.u64(0n); // used_bytes
|
|
||||||
b.u32(0); // last_block_number
|
|
||||||
b.vecU8(new Uint8Array(32)); // last_block_hash
|
|
||||||
b.vecU8(lastBlockSig64); // last_block_signature
|
|
||||||
b.str(''); // arweave_tx_id
|
|
||||||
b.bool(false); // is_server
|
|
||||||
b.bytes32(new Uint8Array(32)); // server_key (default)
|
|
||||||
b.str(''); // server_address
|
|
||||||
b.vecStr([]); // sync_servers
|
|
||||||
b.vecStr(['shineup.me']); // access_servers
|
|
||||||
b.u8(0); // trusted_count
|
|
||||||
b.vecU8(rootSig64); // signature
|
|
||||||
return b.result();
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeClassifyLoginArgs(login) {
|
function serializeClassifyLoginArgs(login) {
|
||||||
const b = new BorshBuf();
|
const b = new BorshBuf();
|
||||||
b.raw(CLASSIFY_LOGIN_DISCRIMINATOR);
|
b.raw(CLASSIFY_LOGIN_DISCRIMINATOR);
|
||||||
@ -283,98 +129,5 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
|
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
|
||||||
const solana = await loadSolanaLib();
|
return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint });
|
||||||
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');
|
|
||||||
|
|
||||||
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
|
||||||
const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
|
|
||||||
const loginGuardProgram = new solana.PublicKey(SHINE_LOGIN_GUARD_PROGRAM_ID);
|
|
||||||
const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID);
|
|
||||||
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
|
|
||||||
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const loginNorm = login.toLowerCase();
|
|
||||||
const blockchainName = `${loginNorm}-001`;
|
|
||||||
|
|
||||||
const [userPda] = solana.PublicKey.findProgramAddressSync(
|
|
||||||
[enc.encode('login='), enc.encode(loginNorm)],
|
|
||||||
usersProgram,
|
|
||||||
);
|
|
||||||
const [economyConfigPda] = solana.PublicKey.findProgramAddressSync(
|
|
||||||
[enc.encode('shine_users_economy_config')],
|
|
||||||
usersProgram,
|
|
||||||
);
|
|
||||||
const [inflowVault] = solana.PublicKey.findProgramAddressSync(
|
|
||||||
[enc.encode('shine_payments_inflow_vault')],
|
|
||||||
paymentsProgram,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
|
||||||
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
|
|
||||||
const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64);
|
|
||||||
|
|
||||||
const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
|
|
||||||
const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
|
|
||||||
|
|
||||||
const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64);
|
|
||||||
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
|
|
||||||
|
|
||||||
const ecoAccount = await connection.getAccountInfo(economyConfigPda);
|
|
||||||
if (!ecoAccount) {
|
|
||||||
throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.');
|
|
||||||
}
|
|
||||||
const startBonusLimit = readStartBonusLimit(ecoAccount.data);
|
|
||||||
const createdAtMs = BigInt(Date.now());
|
|
||||||
|
|
||||||
// Sign LastBlockState with blockchain key
|
|
||||||
const lbsBytes = buildLastBlockStateBytes(loginNorm, blockchainName);
|
|
||||||
const lbsHash = await sha256Bytes(lbsBytes);
|
|
||||||
const lastBlockSig64 = await signBytes(bchPrivKey, lbsHash);
|
|
||||||
|
|
||||||
// Build and sign unsigned PDA record with root key
|
|
||||||
const unsignedRecord = buildUnsignedRecordBytes(
|
|
||||||
loginNorm, createdAtMs, rootKey32, deviceKey32, blockchainKey32,
|
|
||||||
blockchainName, startBonusLimit, lastBlockSig64,
|
|
||||||
);
|
|
||||||
const unsignedHash = await sha256Bytes(unsignedRecord);
|
|
||||||
const rootSig64 = await signBytes(rootPrivKey, unsignedHash);
|
|
||||||
|
|
||||||
const ixData = serializeCreateUserPdaArgs(
|
|
||||||
loginNorm, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
|
|
||||||
blockchainName, lastBlockSig64, rootSig64,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ed25519 instructions must precede create_user_pda
|
|
||||||
const ed25519RootIx = new solana.TransactionInstruction({
|
|
||||||
programId: ed25519Program,
|
|
||||||
keys: [],
|
|
||||||
data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash),
|
|
||||||
});
|
|
||||||
const ed25519BchIx = new solana.TransactionInstruction({
|
|
||||||
programId: ed25519Program,
|
|
||||||
keys: [],
|
|
||||||
data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash),
|
|
||||||
});
|
|
||||||
const createUserIx = new solana.TransactionInstruction({
|
|
||||||
programId: usersProgram,
|
|
||||||
keys: [
|
|
||||||
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
|
|
||||||
{ pubkey: userPda, isSigner: false, isWritable: true },
|
|
||||||
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
|
||||||
{ pubkey: inflowVault, isSigner: false, isWritable: true },
|
|
||||||
{ pubkey: sysvarInstructions, isSigner: false, isWritable: false },
|
|
||||||
{ pubkey: economyConfigPda, isSigner: false, isWritable: false },
|
|
||||||
{ pubkey: loginGuardProgram, isSigner: false, isWritable: false },
|
|
||||||
],
|
|
||||||
data: ixData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sig = await solana.sendAndConfirmTransaction(
|
|
||||||
connection,
|
|
||||||
new solana.Transaction().add(ed25519RootIx, ed25519BchIx, createUserIx),
|
|
||||||
[deviceKeypair],
|
|
||||||
{ commitment: 'confirmed' },
|
|
||||||
);
|
|
||||||
|
|
||||||
return { signature: sig, blockchainName };
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user