Compare commits

..

15 Commits

Author SHA256 Message Date
AidarKC
475db28095 main: это именно та версия, которая уже стоит на тестовом продакшен сервере
Да, звучит немного удивительно, но именно так: люди уже могут регистрироваться, писать друг другу личные сообщения, звонить и пользоваться каналами.

При этом регистрация пока идёт через тестовые Solana/devnet, так что это всё ещё этап тестирования. И в UI ещё куча всего не доделана.

Но тем не менее всё равно.

Прикольно.
2026-06-21 13:14:02 +04:00
AidarKC
823a41c027 chore idea vcs mappings cleanup 2026-06-21 13:10:51 +04:00
AidarKC
2a834f1b14 fix ui dm chatid lowercase normalization 2026-06-21 12:27:41 +04:00
AidarKC
c8ffb6cf29 Настроить test2 как основной контур деплоя 2026-06-20 23:34:41 +04:00
AidarKC
ecc9efd434 UI: скрыть пароль при режиме 12 слов 2026-06-20 21:33:44 +04:00
AidarKC
dd35e56029 Добавить временную бесплатную загрузку аватаров в Arweave 2026-06-20 21:29:35 +04:00
AidarKC
d0e7998650 UI: обновить экраны входа 2026-06-20 20:15:40 +04:00
AidarKC
fec5e49304 UI: FAQ регистрации и режим пароля из 12 слов 2026-06-20 19:05:45 +04:00
AidarKC
3b12e14e71 Docs: добавить идею homeserver команд и обмена файлами 2026-06-20 17:21:47 +04:00
AidarKC
86eaf2139d UI: улучшить личные сообщения и поиск контактов 2026-06-20 17:19:32 +04:00
AidarKC
65fad993ad UI: вернуть старую вкладку Личные и починить аватары в Связях 2026-06-20 16:43:53 +04:00
AidarKC
55e6e477be merge(main): объединить esp-and-wallet и UI Pixel 2026-06-20 12:17:11 +04:00
AidarKC
a788d8bcf5 Обновить pairing устройств и доработать ESP32 UI 2026-06-19 20:47:56 +04:00
AidarKC
cc074a941f Исправить маршрутизацию call push по sessionId 2026-06-19 19:18:16 +04:00
AidarKC
47574100f9 Исправить самообрыв звонка и обновить TURN 2026-06-19 18:25:47 +04:00
77 changed files with 4251 additions and 448 deletions

1
.gitignore vendored
View File

@ -76,6 +76,7 @@ shine-solana/shine/scripts/**/*.env
shine-solana/shine/scripts/**/TEMP_*.md
# Локальные артефакты и внешние материалы ESP32-подпроекта
ESP32/esp32-config-tool/
ESP32/**/.git/
ESP32/**/.idea/
ESP32-wallet/.idea/

4
.idea/vcs.xml generated
View File

@ -2,7 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
<mapping directory="$PROJECT_DIR$/tools/understand-anything-lab/upstream" vcs="Git" />
</component>
</project>
</project>

View File

@ -83,14 +83,26 @@
## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`).
- Production-хост SHiNE: `player@shineup.me` (`185.229.109.118`).
- Основной test-хост SHiNE: `player@193.8.215.70` (`test2.shineup.me`).
- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`.
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`.
- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `test2.shineup.me`.
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
- Production server deploy: `./gradlew deployServerProduction`.
- Production UI deploy: `./gradlew deployUIProduction`.
- Резервный test deploy на `test.shineup.me`: `./gradlew deployServerTest` и `./gradlew deployUITest`, но пока их не использовать без отдельной причины.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
- Для временной бесплатной загрузки аватаров в Arweave секретный JWK нельзя хранить в git и нельзя прописывать в репозиторный `application.properties`.
- Для продовой настройки тестового Arweave-кошелька JWK-файл нужно хранить только на сервере, например: `/home/player/SHiNE/secrets/test-free-avatar-wallet.json`.
- Для этой временной фичи на проде должны быть заданы параметры `test.freeAvatar.walletJwkPath` и `test.freeAvatar.walletAddress` через серверный override-конфиг/секреты на хосте.
- После изменения продовых значений `test.freeAvatar.*` нужно заново выполнить серверный деплой или перезапуск сервера, чтобы настройки были перечитаны приложением.
- При таких изменениях в git допускается коммитить только документацию и код чтения настроек, но не сам JWK, не содержимое секрета и не реальные приватные ключи.
## Логи звонков (установка соединения)
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).

View File

@ -281,6 +281,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`.
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
Поле `shortCode` теперь содержит `10` цифр. В UI его рекомендуется показывать как `5` пар, например: `49 20 70 91 23`.
TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам.
@ -295,7 +296,7 @@ TTL заявки фиксирован на сервере и сейчас все
"payload": {
"pairingId": "base64url",
"state": "created",
"shortCode": "4920709",
"shortCode": "4920709123",
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
"expiresAtMs": 1781441990538,
"trustedSessionOnline": true
@ -337,7 +338,7 @@ TTL заявки фиксирован на сервере и сейчас все
"requesterSessionType": 1,
"requesterClientPlatform": "Android",
"payloadType": 1,
"shortCode": "4920709",
"shortCode": "4920709123",
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
"createdAtMs": 1781441810538,
"expiresAtMs": 1781441990538,
@ -425,7 +426,7 @@ TTL заявки фиксирован на сервере и сейчас все
"payload": {
"pairingId": "base64url",
"state": "approved",
"shortCode": "4920709",
"shortCode": "4920709123",
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
"payloadType": 1,
"encryptedPayload": "AQIDBA==",

View File

@ -15,6 +15,8 @@
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |

View File

@ -0,0 +1,176 @@
# Временное Test API для бесплатной загрузки аватаров в Arweave
> Статус: **временное тестовое решение**.
> Все операции из этого файла начинаются с `Test...`, чтобы это было видно сразу и в коде, и в UI.
## Назначение
Этот временный API даёт пользователю ограниченную бесплатную загрузку маленьких аватаров в Arweave:
- загрузка идёт через **серверный Arweave-кошелёк**;
- лимит на пользователя: по умолчанию `3` загрузки за всё время;
- лимит хранится в SQLite-таблице `test_free_avatar_uploads`;
- если лимит исчерпан, сервер возвращает понятную ошибку;
- загружать можно только маленький итоговый файл аватара, по умолчанию до `128 KB`.
## Настройки сервера
В `application.properties`:
```properties
test.freeAvatar.enabled=true
test.freeAvatar.gateway=https://arweave.net
test.freeAvatar.limitPerUser=3
test.freeAvatar.maxBytes=131072
test.freeAvatar.walletAddress=
test.freeAvatar.walletJwkPath=
```
Пояснения:
- `test.freeAvatar.enabled` - включить или выключить временный API;
- `test.freeAvatar.gateway` - Arweave gateway для `price/tx/wallet`;
- `test.freeAvatar.limitPerUser` - пожизненный бесплатный лимит на пользователя;
- `test.freeAvatar.maxBytes` - максимальный размер итогового файла;
- `test.freeAvatar.walletAddress` - публичный адрес серверного Arweave-кошелька;
- `test.freeAvatar.walletJwkPath` - путь к приватному JWK-файлу серверного кошелька.
Важно:
- приватный JWK хранится вне кода;
- если `walletAddress` указан и не совпадает с адресом, вычисленным из JWK, сервер вернёт ошибку настройки.
## `TestGetFreeAvatarQuota`
Возвращает остаток бесплатных загрузок для текущего авторизованного пользователя.
### Запрос
```json
{
"op": "TestGetFreeAvatarQuota",
"requestId": "req-test-avatar-quota-1",
"payload": {}
}
```
### Успешный ответ
```json
{
"op": "TestGetFreeAvatarQuota",
"requestId": "req-test-avatar-quota-1",
"status": 200,
"ok": true,
"payload": {
"enabled": true,
"limit": 3,
"usedCount": 1,
"remainingCount": 2,
"maxBytes": 131072
}
}
```
### Поля ответа
- `enabled` - временный API сейчас включён на сервере или нет;
- `limit` - полный лимит бесплатных загрузок;
- `usedCount` - сколько уже израсходовано;
- `remainingCount` - сколько ещё осталось;
- `maxBytes` - максимальный размер итогового файла.
### Ошибки
- `422 NOT_AUTHENTICATED` - требуется авторизация.
## `TestUploadFreeAvatar`
Временная бесплатная загрузка маленькой аватарки в Arweave через серверный кошелёк.
### Правила
- операция требует авторизованную сессию;
- сервер использует текущий login из сессии;
- сервер принимает только:
- `image/jpeg`
- `image/png`
- `image/webp`
- размер итогового файла должен быть не больше `maxBytes` из квоты;
- если пользователь уже сделал `limit` бесплатных загрузок, операция запрещена.
### Запрос
`fileBytesBase64` - это обычный Base64 байт итогового подготовленного файла.
```json
{
"op": "TestUploadFreeAvatar",
"requestId": "req-test-avatar-upload-1",
"payload": {
"contentType": "image/webp",
"fileBytesBase64": "UklGRiQAAABXRUJQVlA4WAoAAAAQAAAA...",
"sha256Hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
}
```
### Успешный ответ
```json
{
"op": "TestUploadFreeAvatar",
"requestId": "req-test-avatar-upload-1",
"status": 200,
"ok": true,
"payload": {
"txId": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"sha256Hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"usedCount": 2,
"remainingCount": 1,
"limit": 3,
"gateway": "https://arweave.net"
}
}
```
### Поля ответа
- `txId` - Arweave Transaction ID загруженного файла;
- `sha256Hex` - SHA-256 загруженного файла;
- `usedCount` - сколько бесплатных загрузок уже израсходовано после этой операции;
- `remainingCount` - сколько бесплатных загрузок осталось;
- `limit` - общий лимит;
- `gateway` - gateway, через который сервер отправлял транзакцию.
### Ошибки
- `422 NOT_AUTHENTICATED` - требуется авторизация;
- `400 BAD_FIELDS` - не переданы `contentType` или `fileBytesBase64`;
- `400 BAD_BASE64` - `fileBytesBase64` не декодируется;
- `400 BAD_AVATAR_FILE` - файл не проходит ограничения сервера;
- `400 FREE_AVATAR_LIMIT_EXHAUSTED` - бесплатный лимит аватарок исчерпан;
- `501 FREE_AVATAR_TEMP_DISABLED` - временная функция выключена или сервер не настроен;
- `500 INTERNAL_ERROR` - внутренняя ошибка сервера.
## Как это используется в UI
На экране редактирования профиля в мастере смены аватара есть временный сценарий:
- `Залить аватар бесплатно`
UI:
1. вызывает `TestGetFreeAvatarQuota`;
2. показывает остаток лимита;
3. локально подготавливает уменьшенный файл аватара;
4. проверяет, что итоговый файл не превышает `maxBytes`;
5. вызывает `TestUploadFreeAvatar`;
6. после получения `txId` обычным путём записывает `avatar.ar` в профиль через `AddBlock`.
## Почему решение временное
- используется общий серверный Arweave-кошелёк;
- лимит хранится отдельной технической таблицей;
- операции имеют префикс `Test...`;
- сценарий нужен как переходный бесплатный путь для маленьких аватаров.

View File

@ -45,4 +45,4 @@
### Дальнее будущее
- Сейчас задач нет.
- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.

View File

@ -0,0 +1,114 @@
# Homeserver: технические команды и передача файлов через SHiNE/WebRTC
## Зачем нужна фича
Идея на дальнее будущее: дать возможность обращаться к homeserver не только как к участнику сети SHiNE, но и как к удалённой технической точке управления.
Цели:
- отправлять на homeserver технические команды в текстовом виде;
- получать текстовый ответ на команду;
- при наличии WebRTC DataChannel передавать части файлов в обе стороны;
- хранить полученные файлы на SD-карте homeserver;
- использовать единый механизм доставки как через сервер SHiNE, так и напрямую через DataChannel.
## Горизонт
`far` - идея без ближайшего срока реализации. Сейчас приоритет ниже, чем запуск и стабилизация основного проекта.
## Что именно имеется в виду
### 1. Единая модель технической команды
Техническая команда должна иметь единый смысл независимо от транспорта доставки:
- через любой доступный сервер SHiNE;
- через уже установленный WebRTC DataChannel.
Если конкретный транспорт недоступен, ответ по нему может не прийти. Это считается нормальным поведением протокола.
### 2. Команда как короткоживущий подписанный сигнал
У команды должны быть:
- `commandId`;
- временная метка;
- TTL около 10 секунд;
- криптографическая подпись.
Смысл такой:
- если команда быстро дошла, homeserver подтверждает принятие;
- если не дошла вовремя, команда считается протухшей;
- отправитель может безопасно послать повтор;
- при повторе homeserver отвечает либо `команда принята`, либо `уже выполнено ранее`.
Это даёт дедупликацию и безопасный resend без повторного выполнения действия.
### 3. Текстовые технические команды
Базовый сценарий похож на короткий удалённый shell-протокол, но на уровне строго ограниченных команд:
- отправил строку-команду;
- получил строку-ответ.
Команды не обязаны исполнять произвольный shell. Предпочтительная модель - белый список операций с контролируемым форматом аргументов и ответа.
### 4. Передача файлов только при наличии DataChannel
Если между устройствами есть WebRTC DataChannel, через него можно передавать технические сообщения для файлового обмена.
Предварительная модель:
- имя файла = `SHA-256` содержимого;
- можно запросить диапазон байт `from..to`;
- можно отправить диапазон байт `from..to`;
- homeserver хранит полученные данные на SD-карте;
- если DataChannel нет, на запрос файловой передачи возвращается ответ в духе `не могу передать, нет data channel`.
Фактически файл-обмен должен быть частным случаем общего протокола технических команд.
### 5. Установка data-соединения по явной команде
Нужна техническая команда уровня:
- `установить data-соединение`.
Ответ:
- либо `да`, после чего запускается обычная процедура `offer/answer/ICE`;
- либо `нет` и причина отказа.
### 6. Доставка на пользовательские сессии
Логика должна быть совместима с общей моделью SHiNE, где технические сигналы можно отправлять на конкретные активные сессии пользователя.
Идея:
- на любую активную сессию пользователя можно посылать техническую команду;
- контакт пользователя может инициировать такую техническую коммуникацию так же, как он уже инициирует звонок или другой служебный сигнал.
## Что нужно будет сделать при возврате к задаче
- Спроектировать отдельный формат технических команд и ack-ответов.
- Решить, будет ли это новый тип служебных сообщений в существующем протоколе блокчейн/сигналинга или отдельная ветка поверх уже имеющихся transport-операций.
- Отдельно продумать авторизацию: кто именно из контактов и какие команды имеет право слать.
- Ограничить набор допустимых команд, чтобы не превратить механизм в небезопасный удалённый shell.
- Спроектировать протокол чанков файлов: размер чанка, нумерация, повторная отправка, контроль целостности, дозагрузка, завершение файла.
- Продумать хранение на SD-карте: временные файлы, сборка чанков, проверка итогового `SHA-256`, очистка мусора.
- Продумать поведение при отсутствии DataChannel, таймаутах и дублирующихся командах.
- Проверить, как это лучше встраивать в текущие клиентские сессии, звонки и homeserver-логику.
## Вопросы для будущего уточнения
- Это должен быть строго служебный протокол или пользователь сможет вызывать его и вручную из UI.
- Нужен ли доступ только к заранее разрешённым каталогам/файлам.
- Нужна ли двусторонняя синхронизация файлов или достаточно ручных команд `запросить кусок` / `отправить кусок`.
- Нужно ли разрешать передачу файлов через сервер SHiNE как fallback, или файл-обмен должен идти только через DataChannel.
- Какой максимальный размер файлов и допустимый объём хранения на SD-карте.
## Что уже сделано
Пока только зафиксирована идея и базовая концепция. Реализация не начиналась.
## Какие документы нужно будет обновить при реализации
- `Dev_Docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд.
- `Dev_Docs/API/` если изменится публичный серверный API или появятся новые операции.
- `Dev_Docs/Personal_Messages/README.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий.
- Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве.
## С какого места продолжать позже
Возвращаться к задаче только после стабилизации запуска проекта и базовых текущих функций. Начинать с проектирования протокола команд и матрицы прав доступа, а уже потом переходить к DataChannel-файлообмену.

View File

@ -1,5 +1,7 @@
# Дальнее будущее
Сейчас в этом горизонте нет активных идей.
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
## Идеи
- `2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через сервер SHiNE или WebRTC DataChannel, а также файловый обмен чанками с хранением на SD-карте.

View File

@ -0,0 +1,22 @@
# Быстрое скрытие экрана звонка и остановка гудков при отклонении
- краткое описание фичи:
- Исправлена нижняя подпись вкладки личных сообщений на `личные`.
- Исправлена логика звонка, когда одна из входящих сессий отклоняет вызов до принятия: исходящая сессия теперь должна прекращать гудки даже если ранее `RINGING` пришёл от другой входящей сессии.
- На устройстве, где пользователь нажимает отмену/сброс звонка, экран вызова теперь скрывается сразу локально, без ожидания сетевого ответа.
- что именно проверять:
- В нижней панели с 5 кнопками подпись первой кнопки должна отображаться как `личные`.
- Сценарий: один пользователь звонит, у второго входящий вызов приходит на несколько устройств; на любом одном входящем устройстве нажать `Сбросить`.
- Проверить, что у звонящего сразу прекращаются гудки и экран вызова корректно завершается.
- Проверить, что на устройстве, где нажали `Сбросить` или `Положить трубку`, overlay звонка исчезает сразу, без заметной задержки.
- Проверить, что после принятия звонка на одном устройстве поздние отмены с других устройств не ломают уже выбранную пару соединения.
- ожидаемый результат:
- Подпись в нижней панели корректная.
- При отклонении входящего звонка любым устройством звонящего не оставляет в состоянии бесконечных гудков.
- Локальный экран звонка скрывается мгновенно после нажатия кнопки отмены.
- Уже зафиксированный сценарий соединения после `ACCEPT` не сбивается другими сессиями.
- статус:
- `pending`

View File

@ -0,0 +1,14 @@
# Отключение устаревшего TURN-узла `45.136.124.227`
- краткое описание:
- из конфигурации звонков убран устаревший TURN-узел `45.136.124.227:3478`;
- основным и единственным выдаваемым TURN-узлом оставлен `93.170.12.154:3478`.
- что проверять:
- сделать несколько тестовых звонков между разными устройствами/сетями;
- убедиться, что звонок доходит до стадии соединения и появляется звук;
- убедиться, что в логах `CallDeliveryReport` больше не фигурирует `45.136.124.227`.
- ожидаемый результат:
- клиентам больше не выдаётся устаревший TURN-адрес;
- звонки не заваливаются из-за попыток использовать отключённый TURN-узел.
- статус:
- pending

View File

@ -0,0 +1,15 @@
# Фикс самообрыва звонка из-за `stop_call` push своей же сессии
- краткое описание:
- исправлена ситуация, когда активный звонок мог оборваться сразу после соединения;
- причина была в том, что `stop_call` push, предназначенный для других сессий того же пользователя, обрабатывался и в исходной сессии.
- что проверять:
- открыть несколько вкладок/устройств одного пользователя;
- принять звонок на одной сессии;
- убедиться, что активная сессия не обрывает звонок сразу после соединения;
- убедиться, что лишние сессии при этом закрывают свой локальный экран звонка.
- ожидаемый результат:
- звонок не завершается сразу после `call_connected`;
- `accepted_on_other_device` и связанные `stop_call` события больше не убивают исходную активную сессию.
- статус:
- pending

View File

@ -0,0 +1,16 @@
# Фикс привязки call push к целевой sessionId
- краткое описание:
- push-события `incoming_call` и `stop_call` теперь помечаются целевой `sessionId`;
- UI и service worker обрабатывают call push только для своей целевой сессии;
- `stop_call` для лишних сессий закрывает локальный экран тихо, без обратных сигналов и без лишних тех-сообщений.
- что проверять:
- держать несколько сессий одного пользователя в одном браузере/на одном origin;
- позвонить этому пользователю и убедиться, что входящий экран закрывается корректно только на целевых сессиях;
- после `ACCEPT` одной сессии остальные должны тихо убрать экран вызова и не ломать выбранную пару;
- после отмены входящей сессией исходящая сессия должна централизованно завершить сценарий.
- ожидаемый результат:
- push одного session endpoint больше не влияет на чужие сессии этого же origin;
- исчезают ложные `stop_call_push:accepted_on_other_device` и `terminal_call_signal_150` на неправильных сессиях.
- статус:
- pending

View File

@ -0,0 +1,24 @@
# ESP32 homeserver: заявки на подключение устройств
- краткое описание фичи:
- на ESP32 homeserver в `SETTINGS` добавлен первый пункт `Device requests`, который появляется только после авторизации homeserver в SHiNE;
- экран показывает список активных pairing-заявок, позволяет открыть каждую заявку и подтвердить или отклонить её;
- формат кода подключения изменён на `10` цифр и показывается как `5` пар.
- что проверять:
- на обычном клиенте и в wallet-plugin код отображается как `XX XX XX XX XX`;
- на доверенном веб-клиенте экран `Подключить по коду` показывает все активные заявки без поля ручного ввода;
- на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым;
- `REFRESH` реально загружает активные заявки;
- на экране видно две плитки, список листается вертикально;
- client-session заявка после `YES` подключается с передачей только `device key`;
- wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session;
- `NO` отклоняет заявку и она исчезает из списка активных.
- ожидаемый результат:
- все три клиента используют единый формат кода;
- активные заявки видны без ручного ввода кода;
- ESP32 может одобрять и отклонять живые pairing-заявки пользователя.
- статус:
- pending

View File

@ -0,0 +1,22 @@
# Тестовые deploy-контуры `test2.shineup.me` и `test.shineup.me`
- краткое описание:
- default deploy-задачи `deployServer` и `deployUI` переведены на основной тестовый сервер `test2.shineup.me`;
- production-задачи вынесены в `deployServerProduction` и `deployUIProduction`;
- `test.shineup.me` оставлен как резервный тестовый сервер без обычного deploy по умолчанию.
- что проверять:
- `./gradlew deployServer` и `./gradlew deployUI` действительно направлены на `test2.shineup.me`;
- `./gradlew deployServerProduction` и `./gradlew deployUIProduction` больше не используются как default;
- `https://test2.shineup.me` открывает UI;
- `wss://test2.shineup.me/ws` отвечает;
- на `test2.shineup.me` после deploy есть копия продовой `shine.sqlite` и `.bch`.
- ожидаемый результат:
- default deploy идёт только на `test2.shineup.me`;
- production `shineup.me` меняется только после отдельного подтверждения;
- `test.shineup.me` остаётся резервным тестовым сервером;
- тестовый deploy не гоняет удалённые тесты и не создаёт пустую БД.
- статус:
- pending

View File

@ -0,0 +1,25 @@
# Регистрация: FAQ и режим пароля из 12 слов
- краткое описание:
- на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки;
- добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API;
- такой же режим добавлен и на экран входа по логину и паролю.
- что проверять:
- на стартовом экране открыть `Зарегистрироваться`;
- убедиться, что внизу экрана есть кнопки FAQ;
- открыть несколько вопросов и проверить возврат обратно на регистрацию;
- включить галочку `Представить пароль в виде 12 слов`;
- убедиться, что появляется сетка с нумерованными полями в 3 колонки;
- ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают;
- выключить галочку и проверить, что пароль остаётся собранным в одном поле;
- открыть экран входа по паролю и повторить те же проверки для режима `12 слов`;
- пройти регистрацию до шага оплаты без ошибок интерфейса.
- ожидаемый результат:
- FAQ открывается отдельным экраном и содержит понятные ответы;
- режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль;
- пароль не отправляется в новом формате, а продолжает использоваться как одна строка.
- статус:
- pending

View File

@ -0,0 +1,25 @@
# Временная бесплатная загрузка аватара в Arweave
- краткое описание фичи:
Добавлены два временных `Test...` API для бесплатной загрузки маленьких аватаров в Arweave через серверный кошелёк с лимитом `3` загрузки на пользователя. В UI мастера смены аватара добавлен пункт `Залить аватар бесплатно`.
- что именно проверять:
1. Пользователь с активной сессией открывает редактирование профиля.
2. По нажатию на аватар открывается мастер `Сменить аватар`.
3. В мастере есть пункт `Залить аватар бесплатно`.
4. До первой загрузки UI показывает остаток `3 из 3`.
5. Маленький JPEG/PNG/WebP после уменьшения до файла <= `128 KB` успешно уходит через `TestUploadFreeAvatar`.
6. После загрузки приходит `txId`, и аватар сохраняется в профиль как `avatar.ar`.
7. Остаток уменьшается: `2`, `1`, `0`.
8. На четвёртой попытке сервер отвечает понятной ошибкой про исчерпанный бесплатный лимит.
9. Если итоговый уменьшенный файл всё ещё > `128 KB`, UI не отправляет его и показывает понятную ошибку.
10. Если серверный Arweave JWK/path не настроен, UI получает понятную ошибку временной функции.
- ожидаемый результат:
- первые 3 маленькие аватарки загружаются через серверный Arweave-кошелёк;
- после каждой успешной загрузки `ava` в профиле указывает на новый `txId`;
- после исчерпания лимита дальнейшая бесплатная загрузка блокируется без записи в профиль;
- обычная загрузка через свой Arweave-кошелёк продолжает работать отдельно.
- статус:
pending

View File

@ -0,0 +1,19 @@
# Исправление chatId личных сообщений через lowercase
- краткое описание фичи:
- В клиентском UI SHiNE для личных сообщений технический `chatId` теперь канонизируется через `trim().toLowerCase()` при приёме DM, открытии чата и восстановлении сообщений из IndexedDB.
- Цель: исключить рассинхрон, когда unread-индикатор есть, а входящие сообщения конкретного собеседника не видны из-за разного регистра логина.
- что именно проверять:
- Отправить личные сообщения между двумя пользователями, у одного из которых логин отображается с заглавными буквами.
- Убедиться, что входящие сообщения показываются внутри открытого чата, а не только в общем unread-индикаторе.
- Перезагрузить страницу и проверить, что история чата после гидрации из IndexedDB остаётся в одном диалоге.
- Проверить, что переход в чат из списка диалогов и из графа связей открывает тот же диалог без дублирования.
- ожидаемый результат:
- Все сообщения одного собеседника попадают в один и тот же DM-чат независимо от регистра логина.
- Общий unread, список диалогов и содержимое открытого чата совпадают между собой.
- После перезагрузки UI не появляется отдельный дубль диалога с тем же логином в другом регистре.
- статус:
- pending

View File

@ -13,12 +13,56 @@
- актуальный IP должен браться через DNS-резолв на момент подключения;
- ручное дублирование IP в документации и deploy-скриптах не поддерживать.
## Контуры деплоя
- Production:
- SSH: `player@shineup.me`
- Домен: `shineup.me`
- IP: `185.229.109.118`
- Main test:
- SSH: `player@193.8.215.70`
- Домен: `test2.shineup.me`
- IP: `193.8.215.70`
- Reserve test:
- SSH: `player@93.170.12.154`
- Домен: `test.shineup.me`
- IP: `93.170.12.154`
## Локальные команды
- Деплой сервера: `./gradlew deployServer`
- Деплой UI: `./gradlew deployUI`
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`
- Production server deploy: `./gradlew deployServerProduction`
- Production UI deploy: `./gradlew deployUIProduction`
- Reserve test server deploy: `./gradlew deployServerTest`
- Reserve test UI deploy: `./gradlew deployUITest`
- Локальный запуск: `./gradlew startLocal`
## Политика подтверждений
- `shineup.me` — production.
- Любые изменения на `shineup.me`, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя.
- Если пользователь пишет просто `задеплой` без уточнения, по умолчанию это означает deploy на `test2.shineup.me`.
## Main test deploy (`test2.shineup.me`)
- Это основной сервер для тестов.
- `deployServer` и `deployUI` по умолчанию направлены именно сюда.
- Серверный deploy не запускает JUnit/IT-тесты на удалённом сервере.
- `deployServer` / `deployServerTest2` делают:
- сборку fat-jar локально;
- синхронизацию `data/` и `shine.sqlite` с production `shineup.me`;
- перенос `application.properties` с production с поправкой `server.ui.indexPath` на `/home/player/SHiNE/shine-ui/index.html`;
- установку `systemd` unit на `193.8.215.70`;
- перезапуск `shine-server.service`;
- установку/проверку Caddy для `test2.shineup.me`.
- `deployUI` / `deployUITest2` публикуют UI в `/home/player/SHiNE/shine-ui` на `193.8.215.70`.
## Reserve test deploy (`test.shineup.me`)
- `test.shineup.me` пока не использовать для обычного deploy.
- Задачи `deployServerTest` и `deployUITest` считаются резервными и требуют отдельной причины.
## UI-деплой и Caddy (обязательно)
- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`.

View File

@ -0,0 +1,42 @@
# Сервер `193.8.215.70` — основной test (`test2.shineup.me`)
- Пользователь: `player`
- Домен: `test2.shineup.me`
- Каталог SHiNE: `/home/player/SHiNE`
- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
- Данные: `/home/player/SHiNE/shine-server/data/`
- `shine.sqlite`
- `*.bch`
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Статус
- Это основной сервер для тестов SHiNE.
- Default deploy по умолчанию должен идти сюда.
- Источник данных для тестовой БД: production `shineup.me`.
## Caddy
- Конфиг: `/etc/caddy/Caddyfile`
- Сайты:
- `test2.shineup.me`
- `agent.shiningpeople.ru`
- Для `test2.shineup.me`:
- `root * /home/player/SHiNE/shine-ui`
- `try_files {path} /index.html`
- `reverse_proxy /ws* -> 127.0.0.1:7070`
## Deploy
- Default server deploy:
- `./gradlew deployServer`
- `./gradlew deployServerTest2`
- Default UI deploy:
- `./gradlew deployUI`
- `./gradlew deployUITest2`

View File

@ -1,14 +1,14 @@
# Сервер `93.170.12.154`резервный
# Сервер `93.170.12.154`test.shineup.me
- Пользователь: `player`
- Каталог SHiNE: `/home/player/SHiNE`
- UI исходник (после rsync): `/home/player/SHiNE/SHiNE-UI`
- UI публикация для Caddy: `/var/www/shine-ui`
- Сервер: `/home/player/SHiNE/SHiNE-server/shine-server.jar`
- Данные: `/home/player/SHiNE/SHiNE-server/data/`
- Домен: `test.shineup.me`
- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
- Данные: `/home/player/SHiNE/shine-server/data/`
- `shine.sqlite`
- `*.bch`
- Логи сервера: `/home/player/SHiNE/SHiNE-server/logs/app.log`
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы
@ -17,8 +17,10 @@
## Статус
- Резервный сервер для SHiNE.
- Основной прод-сервер: `shineup.me` (подключение через `player@shineup.me`, IP определяется через DNS).
- Резервный тестовый сервер для SHiNE.
- Источник данных для тестовой БД: production `shineup.me`.
- Пока не использовать для обычного deploy.
- Основной прод-сервер: `shineup.me` (`185.229.109.118`).
## Caddy
@ -26,4 +28,12 @@
- Настройки:
- `no-store/no-cache` заголовки;
- `try_files {path} /index.html` (SPA fallback);
- `reverse_proxy /ws* -> 127.0.0.1:7070`.
- `reverse_proxy /ws* -> 127.0.0.1:7070`;
- целевой сайт: `test.shineup.me`.
## Deploy
- Резервные задачи:
- `./gradlew deployServerTest`
- `./gradlew deployUITest`
- Эти задачи пока не использовать без отдельной причины.

View File

@ -33,3 +33,15 @@
- `https://test-solana-tickets.shineup.me`
- `https://test-solana-tickets.shiningpeople.ru`
- Для всех deploy-скриптов и инструкций использовать именно `player@shineup.me`, без жёсткой фиксации IP.
## Правило изменений
- `shineup.me` — production.
- Любые изменения на этом сервере делать только после отдельного явного подтверждения пользователя.
## Deploy
- Production deploy-задачи:
- `./gradlew deployServerProduction`
- `./gradlew deployUIProduction`
- Default deploy-задачи `./gradlew deployServer` и `./gradlew deployUI` сюда больше не относятся.

View File

@ -60,7 +60,7 @@
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
- рассчитывает короткий код `shortCode` из `7` цифр;
- рассчитывает короткий код `shortCode` из `10` цифр;
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.

View File

@ -7,6 +7,7 @@
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include <TouchDrvCSTXXX.hpp>
#include <mbedtls/gcm.h>
#include <mbedtls/sha256.h>
#include <mbedtls/base64.h>
#include <Ed25519.h>
@ -93,6 +94,8 @@ enum Screen {
SCREEN_HOME,
SCREEN_WALLET_QR,
SCREEN_SETTINGS_MENU,
SCREEN_PAIRING_REQUESTS,
SCREEN_PAIRING_REQUEST_DETAIL,
SCREEN_WIFI,
SCREEN_SERVER,
SCREEN_ACCOUNT,
@ -121,6 +124,7 @@ enum ActionId {
ACTION_NONE,
ACTION_OPEN_SETTINGS,
ACTION_OPEN_WALLET_QR,
ACTION_OPEN_PAIRING_REQUESTS,
ACTION_OPEN_WIFI,
ACTION_OPEN_SERVER,
ACTION_OPEN_ACCOUNT,
@ -145,6 +149,9 @@ enum ActionId {
ACTION_BACK_SECRET_MENU,
ACTION_BACK_ACCOUNT,
ACTION_REFRESH_BALANCE,
ACTION_PAIRING_REFRESH,
ACTION_PAIRING_APPROVE,
ACTION_PAIRING_REJECT,
ACTION_REGISTER_ACCOUNT,
ACTION_REGISTER_ACCOUNT_EXECUTE,
ACTION_HOMESERVER_PDA_ACTION,
@ -226,8 +233,14 @@ struct SimpleWebSocketClient {
uint16_t port = 0;
};
static const char *kMenuItems[] = {"1. Wi-Fi", "2. Server", "3. Account"};
static const size_t kMenuCount = sizeof(kMenuItems) / sizeof(kMenuItems[0]);
struct PairingRequestUiItem {
String pairingId;
String requesterSessionKey;
int requesterSessionType = 0;
String requesterClientPlatform;
String shortCode;
uint64_t expiresAtMs = 0;
};
static lv_disp_draw_buf_t gDrawBuf;
static lv_color_t *gBuf1 = nullptr;
@ -328,6 +341,10 @@ static unsigned long gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
static uint32_t gWsRequestCounter = 1;
static int64_t gShineServerTimeOffsetMs = 0;
static SimpleWebSocketClient gShineWs;
static std::vector<PairingRequestUiItem> gPairingRequests;
static int gSelectedPairingRequestIndex = -1;
static String gPairingStatusMessage = "Refresh requests";
static bool gPairingBusy = false;
struct DerivedKeyInfo {
String title;
@ -409,7 +426,25 @@ static String bytesToBase64String(const uint8_t *data, size_t len);
static String jsonEscape(const String &value);
static bool jsonStringField(const String &json, const String &field, String &valueOut);
static bool jsonBoolField(const String &json, const String &field, bool &valueOut);
static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut);
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
static String formatPairingShortCode(const String &value);
static bool pairingMenuVisible();
static int settingsMenuCount();
static String settingsMenuLabel(int itemIndex);
static ActionId settingsMenuAction(int itemIndex);
static String pairingSessionKindLabel(int sessionType);
static String pairingSessionNameLabel(const PairingRequestUiItem &item);
static bool findJsonArrayBounds(const String &json, const String &field, int &startOut, int &endOut);
static bool extractJsonObjectAt(const String &json, int startIndex, int &endOut, String &objectOut);
static bool parsePairingRequestsResponse(const String &json, std::vector<PairingRequestUiItem> &itemsOut, String &errorOut);
static bool refreshPairingRequests(String &errorOut);
static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out);
static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut);
static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut);
static bool encryptPairingPayloadForRequester(const String &requesterSessionKey, const String &payloadJson, String &encryptedPayloadOut, String &errorOut);
static bool approvePairingRequest(const PairingRequestUiItem &item, String &errorOut);
static bool rejectPairingRequest(const PairingRequestUiItem &item, String &errorOut);
static String shineWsUrl();
static String shineHomeLine();
static String balanceHomeLine();
@ -855,6 +890,195 @@ static bool jsonBoolField(const String &json, const String &field, bool &valueOu
return false;
}
static String formatPairingShortCode(const String &value) {
String digits;
for (int i = 0; i < (int)value.length(); ++i) {
char ch = value.charAt(i);
if (ch >= '0' && ch <= '9') {
digits += ch;
}
}
while (digits.length() < 10) {
digits = String("0") + digits;
}
if (digits.length() > 10) {
digits = digits.substring(0, 10);
}
String out;
for (int i = 0; i < 10; i += 2) {
if (!out.isEmpty()) out += " ";
out += digits.substring(i, i + 2);
}
return out;
}
static bool pairingMenuVisible() {
return gShineAuthenticated;
}
static int settingsMenuCount() {
return pairingMenuVisible() ? 4 : 3;
}
static String settingsMenuLabel(int itemIndex) {
if (pairingMenuVisible()) {
switch (itemIndex) {
case 0: return "1. Device requests";
case 1: return "2. Wi-Fi";
case 2: return "3. Server";
case 3: return "4. Account";
default: return "";
}
}
switch (itemIndex) {
case 0: return "1. Wi-Fi";
case 1: return "2. Server";
case 2: return "3. Account";
default: return "";
}
}
static ActionId settingsMenuAction(int itemIndex) {
if (pairingMenuVisible()) {
switch (itemIndex) {
case 0: return ACTION_OPEN_PAIRING_REQUESTS;
case 1: return ACTION_OPEN_WIFI;
case 2: return ACTION_OPEN_SERVER;
case 3: return ACTION_OPEN_ACCOUNT;
default: return ACTION_NONE;
}
}
switch (itemIndex) {
case 0: return ACTION_OPEN_WIFI;
case 1: return ACTION_OPEN_SERVER;
case 2: return ACTION_OPEN_ACCOUNT;
default: return ACTION_NONE;
}
}
static String pairingSessionKindLabel(int sessionType) {
return sessionType == 50 ? "Wallet session" : "Client session";
}
static String pairingSessionNameLabel(const PairingRequestUiItem &item) {
String value = item.requesterClientPlatform;
value.trim();
return value.isEmpty() ? "Unknown client" : value;
}
static bool findJsonArrayBounds(const String &json, const String &field, int &startOut, int &endOut) {
String needle = "\"" + field + "\"";
int keyPos = json.indexOf(needle);
if (keyPos < 0) return false;
int colon = json.indexOf(':', keyPos + needle.length());
if (colon < 0) return false;
int start = json.indexOf('[', colon + 1);
if (start < 0) return false;
bool inString = false;
bool escape = false;
int depth = 0;
for (int i = start; i < (int)json.length(); ++i) {
char ch = json.charAt(i);
if (escape) {
escape = false;
continue;
}
if (ch == '\\') {
escape = true;
continue;
}
if (ch == '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch == '[') depth++;
if (ch == ']') {
depth--;
if (depth == 0) {
startOut = start;
endOut = i;
return true;
}
}
}
return false;
}
static bool extractJsonObjectAt(const String &json, int startIndex, int &endOut, String &objectOut) {
int start = json.indexOf('{', startIndex);
if (start < 0) return false;
bool inString = false;
bool escape = false;
int depth = 0;
for (int i = start; i < (int)json.length(); ++i) {
char ch = json.charAt(i);
if (escape) {
escape = false;
continue;
}
if (ch == '\\') {
escape = true;
continue;
}
if (ch == '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch == '{') depth++;
if (ch == '}') {
depth--;
if (depth == 0) {
endOut = i;
objectOut = json.substring(start, i + 1);
return true;
}
}
}
return false;
}
static bool parsePairingRequestsResponse(const String &json, std::vector<PairingRequestUiItem> &itemsOut, String &errorOut) {
itemsOut.clear();
errorOut = "";
uint64_t status = 0;
if (!jsonInt64Field(json, "status", status) || status != 200) {
errorOut = "ListTrustedDeviceLoginRequests rejected";
return false;
}
int arrayStart = 0;
int arrayEnd = 0;
if (!findJsonArrayBounds(json, "requests", arrayStart, arrayEnd)) {
errorOut = "requests array missing";
return false;
}
int cursor = arrayStart + 1;
while (cursor < arrayEnd) {
String objectJson;
int objectEnd = 0;
if (!extractJsonObjectAt(json, cursor, objectEnd, objectJson) || objectEnd > arrayEnd) {
break;
}
PairingRequestUiItem item;
uint64_t sessionType = 0;
uint64_t expiresAtMs = 0;
jsonStringField(objectJson, "pairingId", item.pairingId);
jsonStringField(objectJson, "requesterSessionKey", item.requesterSessionKey);
jsonStringField(objectJson, "requesterClientPlatform", item.requesterClientPlatform);
jsonStringField(objectJson, "shortCode", item.shortCode);
jsonInt64Field(objectJson, "requesterSessionType", sessionType);
jsonInt64Field(objectJson, "expiresAtMs", expiresAtMs);
item.requesterSessionType = (int)sessionType;
item.expiresAtMs = expiresAtMs;
if (!item.pairingId.isEmpty()) {
itemsOut.push_back(item);
}
cursor = objectEnd + 1;
}
return true;
}
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut) {
hostOut = "";
pathOut = "/";
@ -3177,6 +3401,289 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St
return false;
}
static bool refreshPairingRequests(String &errorOut) {
errorOut = "";
String authError;
if (!ensureShineSessionAuthenticated(authError)) {
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
return false;
}
String response;
if (!shineWsRequest(gShineWs, "ListTrustedDeviceLoginRequests", "{}", response, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "ListTrustedDeviceLoginRequests failed";
return false;
}
std::vector<PairingRequestUiItem> items;
if (!parsePairingRequestsResponse(response, items, errorOut)) {
return false;
}
gPairingRequests = items;
if (gSelectedPairingRequestIndex >= (int)gPairingRequests.size()) {
gSelectedPairingRequestIndex = -1;
}
gPairingStatusMessage = "";
return true;
}
static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out) {
static const uint8_t kEd25519Pkcs8Prefix[] = {
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20
};
std::vector<uint8_t> raw;
raw.reserve(sizeof(kEd25519Pkcs8Prefix) + 32);
raw.insert(raw.end(), kEd25519Pkcs8Prefix, kEd25519Pkcs8Prefix + sizeof(kEd25519Pkcs8Prefix));
raw.insert(raw.end(), seed32, seed32 + 32);
pkcs8B64Out = bytesToBase64String(raw.data(), raw.size());
return !pkcs8B64Out.isEmpty();
}
static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) {
payloadJsonOut = "";
errorOut = "";
uint8_t deviceSeed[32] = {};
uint8_t devicePub[32] = {};
uint8_t deviceSec[64] = {};
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
errorOut = "Failed to derive device key";
return false;
}
String devicePkcs8;
if (!buildPkcs8FromSeed32(deviceSeed, devicePkcs8)) {
errorOut = "Failed to encode device key";
return false;
}
payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-pairing-transfer\",\"login\":\"") + jsonEscape(gLoginValue)
+ "\",\"mode\":\"device-only\",\"keys\":{\"deviceKey\":\"" + jsonEscape(devicePkcs8)
+ "\",\"blockchainKey\":\"\",\"rootKey\":\"\"},\"payloadType\":1,\"createdAtMs\":"
+ String((unsigned long long)shineNowMs()) + "}";
return true;
}
static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) {
payloadJsonOut = "";
errorOut = "";
uint8_t deviceSeed[32] = {};
uint8_t devicePub[32] = {};
uint8_t deviceSec[64] = {};
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
errorOut = "Failed to derive device key";
return false;
}
String wsUrl = shineWsUrl();
if (wsUrl.isEmpty()) {
errorOut = "Shine server is not configured";
return false;
}
uint8_t storageRaw[32];
for (size_t i = 0; i < sizeof(storageRaw); ++i) storageRaw[i] = (uint8_t)esp_random();
String storagePwd = bytesToBase64String(storageRaw, sizeof(storageRaw));
SimpleWebSocketClient tempWs;
String wsError;
if (!ensureWebSocketConnected(tempWs, wsUrl, wsError)) {
errorOut = wsError.isEmpty() ? "Delegated session WS connect failed" : wsError;
return false;
}
bool ok = false;
do {
String authResp;
if (!shineWsRequest(tempWs, "AuthChallenge",
String("{\"login\":\"") + jsonEscape(gLoginValue) + "\"}",
authResp, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "Delegated AuthChallenge failed";
break;
}
uint64_t statusCode = 0;
String authNonce;
if (!jsonInt64Field(authResp, "status", statusCode) || statusCode != 200 || !jsonStringField(authResp, "authNonce", authNonce)) {
errorOut = "Delegated AuthChallenge rejected";
break;
}
uint64_t timeMs = shineNowMs();
String preimage = String("AUTH_CREATE_SESSION:") + gLoginValue + ":" + item.requesterSessionKey + ":" + storagePwd + ":" + String((unsigned long long)timeMs) + ":" + authNonce;
uint8_t signature[64] = {};
crypto_sign_ed25519_detached(signature, nullptr,
reinterpret_cast<const unsigned char *>(preimage.c_str()),
preimage.length(), deviceSec);
String clientPlatform = item.requesterClientPlatform;
clientPlatform.trim();
if (clientPlatform.isEmpty()) clientPlatform = "Wallet session";
String createReq = String("{\"login\":\"") + jsonEscape(gLoginValue)
+ "\",\"sessionKey\":\"" + jsonEscape(item.requesterSessionKey)
+ "\",\"storagePwd\":\"" + jsonEscape(storagePwd)
+ "\",\"timeMs\":" + String((unsigned long long)timeMs)
+ ",\"authNonce\":\"" + jsonEscape(authNonce)
+ "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32))
+ "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
+ "\",\"sessionType\":50"
+ ",\"clientPlatform\":\"" + jsonEscape(clientPlatform)
+ "\",\"clientInfo\":\"Wallet session approved via ESP32 pairing\"}";
String createResp;
if (!shineWsRequest(tempWs, "CreateAuthSession", createReq, createResp, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "Delegated CreateAuthSession failed";
break;
}
String sessionId;
if (!jsonInt64Field(createResp, "status", statusCode) || statusCode != 200 || !jsonStringField(createResp, "sessionId", sessionId)) {
errorOut = "Delegated CreateAuthSession rejected";
break;
}
payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-session-attach\",\"login\":\"") + jsonEscape(gLoginValue)
+ "\",\"session\":{\"sessionId\":\"" + jsonEscape(sessionId)
+ "\",\"sessionKey\":\"" + jsonEscape(item.requesterSessionKey)
+ "\",\"storagePwd\":\"" + jsonEscape(storagePwd)
+ "\",\"sessionType\":50,\"clientPlatform\":\"" + jsonEscape(clientPlatform)
+ "\"},\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}";
ok = true;
} while (false);
closeWebSocket(tempWs);
return ok;
}
static bool encryptPairingPayloadForRequester(const String &requesterSessionKey, const String &payloadJson, String &encryptedPayloadOut, String &errorOut) {
encryptedPayloadOut = "";
errorOut = "";
if (!requesterSessionKey.startsWith("ed25519/")) {
errorOut = "Unsupported requesterSessionKey";
return false;
}
std::vector<uint8_t> requesterEdPub;
if (!base64DecodeStd(requesterSessionKey.substring(8), requesterEdPub) || requesterEdPub.size() != 32) {
errorOut = "Bad requester session public key";
return false;
}
uint8_t requesterCurvePub[32] = {};
if (crypto_sign_ed25519_pk_to_curve25519(requesterCurvePub, requesterEdPub.data()) != 0) {
errorOut = "Failed to convert requester public key";
return false;
}
uint8_t ephPub[32] = {};
uint8_t ephSec[32] = {};
if (crypto_box_keypair(ephPub, ephSec) != 0) {
errorOut = "Failed to create ephemeral key";
return false;
}
uint8_t sharedSecret[32] = {};
if (crypto_scalarmult(sharedSecret, ephSec, requesterCurvePub) != 0) {
errorOut = "Failed to derive shared secret";
return false;
}
uint8_t aesKey[32] = {};
mbedtls_sha256(sharedSecret, sizeof(sharedSecret), aesKey, 0);
uint8_t iv[12] = {};
for (size_t i = 0; i < sizeof(iv); ++i) iv[i] = (uint8_t)esp_random();
std::vector<uint8_t> plain(payloadJson.length());
for (int i = 0; i < (int)payloadJson.length(); ++i) plain[i] = (uint8_t)payloadJson.charAt(i);
std::vector<uint8_t> cipher(plain.size());
uint8_t tag[16] = {};
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
bool ok = false;
if (mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, 256) == 0
&& mbedtls_gcm_crypt_and_tag(&gcm,
MBEDTLS_GCM_ENCRYPT,
plain.size(),
iv, sizeof(iv),
nullptr, 0,
plain.data(),
cipher.data(),
sizeof(tag), tag) == 0) {
std::vector<uint8_t> cipherWithTag = cipher;
cipherWithTag.insert(cipherWithTag.end(), tag, tag + sizeof(tag));
String envelopeJson = String("{\"v\":1,\"alg\":\"x25519-aes256-gcm\",\"ephPubB64\":\"")
+ jsonEscape(bytesToBase64String(ephPub, sizeof(ephPub)))
+ "\",\"ivB64\":\"" + jsonEscape(bytesToBase64String(iv, sizeof(iv)))
+ "\",\"cipherB64\":\"" + jsonEscape(bytesToBase64String(cipherWithTag.data(), cipherWithTag.size()))
+ "\",\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}";
String payloadB64 = bytesToBase64String(reinterpret_cast<const uint8_t *>(envelopeJson.c_str()), envelopeJson.length());
payloadB64.replace("+", "-");
payloadB64.replace("/", "_");
while (payloadB64.endsWith("=")) payloadB64.remove(payloadB64.length() - 1);
encryptedPayloadOut = String("shine-esp-pairing-v1:") + payloadB64;
ok = true;
} else {
errorOut = "AES-GCM encryption failed";
}
mbedtls_gcm_free(&gcm);
sodium_memzero(sharedSecret, sizeof(sharedSecret));
sodium_memzero(aesKey, sizeof(aesKey));
sodium_memzero(ephSec, sizeof(ephSec));
return ok;
}
static bool approvePairingRequest(const PairingRequestUiItem &item, String &errorOut) {
errorOut = "";
String payloadJson;
if (item.requesterSessionType == 50) {
if (!createDelegatedWalletSessionPayload(item, payloadJson, errorOut)) {
return false;
}
} else {
if (!buildPairingSecretsPayload(item, payloadJson, errorOut)) {
return false;
}
}
String encryptedPayload;
if (!encryptPairingPayloadForRequester(item.requesterSessionKey, payloadJson, encryptedPayload, errorOut)) {
return false;
}
String authError;
if (!ensureShineSessionAuthenticated(authError)) {
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
return false;
}
String approveReq = String("{\"pairingId\":\"") + jsonEscape(item.pairingId)
+ "\",\"encryptedPayload\":\"" + jsonEscape(encryptedPayload) + "\"}";
String response;
if (!shineWsRequest(gShineWs, "ApproveTrustedDeviceLogin", approveReq, response, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "ApproveTrustedDeviceLogin failed";
return false;
}
uint64_t status = 0;
if (!jsonInt64Field(response, "status", status) || status != 200) {
errorOut = "ApproveTrustedDeviceLogin rejected";
return false;
}
return true;
}
static bool rejectPairingRequest(const PairingRequestUiItem &item, String &errorOut) {
errorOut = "";
String authError;
if (!ensureShineSessionAuthenticated(authError)) {
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
return false;
}
String rejectReq = String("{\"pairingId\":\"") + jsonEscape(item.pairingId)
+ "\",\"reason\":\"rejected_by_user\"}";
String response;
if (!shineWsRequest(gShineWs, "RejectTrustedDeviceLogin", rejectReq, response, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "RejectTrustedDeviceLogin failed";
return false;
}
uint64_t status = 0;
if (!jsonInt64Field(response, "status", status) || status != 200) {
errorOut = "RejectTrustedDeviceLogin rejected";
return false;
}
return true;
}
static bool ensureShineSessionAuthenticated(String &errorOut) {
errorOut = "";
String diagDetails;
@ -3788,6 +4295,10 @@ static int batteryPercentValue() {
return value;
}
static bool batteryIsChargingNow() {
return gPowerReady && gPower.isBatteryConnect() && gPower.isCharging();
}
static int wifiSignalLevel() {
if (WiFi.status() != WL_CONNECTED) {
return 0;
@ -3801,6 +4312,7 @@ static int wifiSignalLevel() {
static void drawTopStatusIndicators() {
int batt = batteryPercentValue();
bool charging = batteryIsChargingNow();
String battText = batt >= 0 ? String(batt) + "%" : "--";
lv_obj_t *battLabel = lv_label_create(gRoot);
@ -3809,6 +4321,18 @@ static void drawTopStatusIndicators() {
lv_obj_set_style_text_color(battLabel, lv_color_hex(0xC9D3DE), 0);
lv_obj_set_pos(battLabel, 297, 18);
if (charging) {
static const lv_point_t boltPoints[] = {
{6, 0}, {1, 9}, {5, 9}, {2, 18}, {10, 7}, {6, 7}
};
lv_obj_t *chargeBolt = lv_line_create(gRoot);
lv_line_set_points(chargeBolt, boltPoints, sizeof(boltPoints) / sizeof(boltPoints[0]));
lv_obj_set_pos(chargeBolt, 337, 15);
lv_obj_set_style_line_width(chargeBolt, 2, 0);
lv_obj_set_style_line_color(chargeBolt, lv_color_hex(0xFFD34D), 0);
lv_obj_set_style_line_rounded(chargeBolt, true, 0);
}
lv_obj_t *battery = lv_obj_create(gRoot);
lv_obj_set_size(battery, 32, 16);
lv_obj_set_pos(battery, 349, 20);
@ -4170,6 +4694,22 @@ static void networkSelectCb(lv_event_t *event) {
true);
}
static void pairingSelectCb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence) {
return;
}
gLastHandledTouchSequence = gTouchSequence;
gSuppressTouchUntilRelease = true;
gBlockClick = true;
int index = static_cast<int>(reinterpret_cast<uintptr_t>(lv_event_get_user_data(event)));
if (index < 0 || index >= (int)gPairingRequests.size()) {
return;
}
gSelectedPairingRequestIndex = index;
showScreen(SCREEN_PAIRING_REQUEST_DETAIL);
}
static void editorKeyCb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence || !gInputTextArea) {
return;
@ -4244,6 +4784,14 @@ static void actionButtonCb(lv_event_t *event) {
case ACTION_OPEN_WALLET_QR:
showScreen(SCREEN_WALLET_QR);
break;
case ACTION_OPEN_PAIRING_REQUESTS: {
String error;
if (!refreshPairingRequests(error)) {
gPairingStatusMessage = error.isEmpty() ? "Failed to load requests" : error;
}
showScreen(SCREEN_PAIRING_REQUESTS);
break;
}
case ACTION_REGISTER_ACCOUNT:
prepareRegisterAccountScreen();
showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM);
@ -4401,6 +4949,44 @@ static void actionButtonCb(lv_event_t *event) {
}
break;
}
case ACTION_PAIRING_REFRESH: {
String error;
if (!refreshPairingRequests(error)) {
gPairingStatusMessage = error.isEmpty() ? "Failed to refresh requests" : error;
}
rebuildScreen();
break;
}
case ACTION_PAIRING_APPROVE: {
if (gSelectedPairingRequestIndex >= 0 && gSelectedPairingRequestIndex < (int)gPairingRequests.size()) {
String error;
if (approvePairingRequest(gPairingRequests[gSelectedPairingRequestIndex], error)) {
gPairingStatusMessage = "Request approved";
refreshPairingRequests(error);
gSelectedPairingRequestIndex = -1;
showScreen(SCREEN_PAIRING_REQUESTS);
} else {
gPairingStatusMessage = error.isEmpty() ? "Approve failed" : error;
rebuildScreen();
}
}
break;
}
case ACTION_PAIRING_REJECT: {
if (gSelectedPairingRequestIndex >= 0 && gSelectedPairingRequestIndex < (int)gPairingRequests.size()) {
String error;
if (rejectPairingRequest(gPairingRequests[gSelectedPairingRequestIndex], error)) {
gPairingStatusMessage = "Request rejected";
refreshPairingRequests(error);
gSelectedPairingRequestIndex = -1;
showScreen(SCREEN_PAIRING_REQUESTS);
} else {
gPairingStatusMessage = error.isEmpty() ? "Reject failed" : error;
rebuildScreen();
}
}
break;
}
case ACTION_EDITOR_SAVE:
applyEditorValue();
break;
@ -4663,27 +5249,32 @@ static void drawSettingsMenu() {
makeTitle("SETTINGS", 18, &lv_font_montserrat_24);
makeBody("Swipe up/down to move. Swipe right to go home.", 56, 420);
int totalItems = settingsMenuCount();
if (totalItems <= 2) {
gSettingsScrollIndex = 0;
} else if (gSettingsScrollIndex > totalItems - 2) {
gSettingsScrollIndex = totalItems - 2;
}
if (gSettingsScrollIndex < 0) {
gSettingsScrollIndex = 0;
}
for (int visibleIndex = 0; visibleIndex < 2; ++visibleIndex) {
int itemIndex = gSettingsScrollIndex + visibleIndex;
if (itemIndex >= static_cast<int>(kMenuCount)) {
if (itemIndex >= totalItems) {
break;
}
ActionId action = ACTION_NONE;
if (itemIndex == 0) action = ACTION_OPEN_WIFI;
if (itemIndex == 1) action = ACTION_OPEN_SERVER;
if (itemIndex == 2) action = ACTION_OPEN_ACCOUNT;
makeButton(kMenuItems[itemIndex], 22, 132 + visibleIndex * 126, 436, 104,
0x355C7D, action, &lv_font_montserrat_24);
String label = settingsMenuLabel(itemIndex);
makeButton(label.c_str(), 22, 132 + visibleIndex * 126, 436, 104,
0x355C7D, settingsMenuAction(itemIndex), &lv_font_montserrat_24);
}
lv_obj_t *hint = lv_label_create(gRoot);
char hintText[48];
snprintf(hintText, sizeof(hintText), "Items %d-%d of %d",
gSettingsScrollIndex + 1,
min(gSettingsScrollIndex + 2, static_cast<int>(kMenuCount)),
static_cast<int>(kMenuCount));
min(gSettingsScrollIndex + 2, totalItems),
totalItems);
lv_label_set_text(hint, hintText);
lv_obj_set_style_text_font(hint, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(hint, lv_color_hex(0xA8B8C7), 0);
@ -4692,6 +5283,161 @@ static void drawSettingsMenu() {
makeVersionTag();
}
static void drawPairingRequestsScreen() {
setRootStyle();
makeTitle("REQUESTS:", 18, &lv_font_montserrat_24);
lv_obj_t *titleLabel = lv_obj_get_child(gRoot, lv_obj_get_child_cnt(gRoot) - 1);
if (titleLabel) {
lv_obj_set_x(titleLabel, 194);
}
makeButton("REFRESH", 20, 18, 160, 44, 0x2A6F97, ACTION_PAIRING_REFRESH, &lv_font_montserrat_18);
lv_obj_t *countLabel = lv_label_create(gRoot);
String countText = String((int)gPairingRequests.size());
lv_label_set_text(countLabel, countText.c_str());
lv_obj_set_style_text_font(countLabel, &lv_font_montserrat_36, 0);
lv_obj_set_style_text_color(countLabel, lv_color_hex(0xD5DEE7), 0);
lv_obj_align(countLabel, LV_ALIGN_TOP_RIGHT, -22, 10);
if (!gPairingStatusMessage.isEmpty()) {
showMessageAt(gPairingStatusMessage, 72);
}
lv_obj_t *panel = lv_obj_create(gRoot);
lv_obj_set_size(panel, 440, 324);
lv_obj_set_pos(panel, 20, gPairingStatusMessage.isEmpty() ? 84 : 104);
lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(panel, 1, 0);
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
lv_obj_set_style_radius(panel, 14, 0);
lv_obj_set_style_pad_all(panel, 10, 0);
lv_obj_set_style_pad_row(panel, 10, 0);
lv_obj_set_scroll_dir(panel, LV_DIR_VER);
lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
if (gPairingRequests.empty()) {
lv_obj_t *empty = lv_label_create(panel);
lv_label_set_text(empty, "No active requests");
lv_obj_set_width(empty, 392);
lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(empty, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(empty, lv_color_hex(0xA8B8C7), 0);
} else {
for (int i = 0; i < (int)gPairingRequests.size(); ++i) {
const PairingRequestUiItem &item = gPairingRequests[i];
String sessionNameText = String("Session: ") + pairingSessionNameLabel(item);
String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType);
lv_obj_t *btn = lv_btn_create(panel);
lv_obj_set_size(btn, 410, 146);
lv_obj_set_style_radius(btn, 14, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(0x203547), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(btn, 2, 0);
lv_obj_set_style_border_color(btn, lv_color_hex(0x4D6C82), 0);
lv_obj_add_event_cb(btn, pairingSelectCb, LV_EVENT_CLICKED, reinterpret_cast<void *>(static_cast<uintptr_t>(i)));
lv_obj_t *codeLabel = lv_label_create(btn);
lv_label_set_text(codeLabel, formatPairingShortCode(item.shortCode).c_str());
lv_obj_set_style_text_font(codeLabel, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(codeLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_pos(codeLabel, 18, 12);
lv_obj_t *nameLabel = lv_label_create(btn);
lv_label_set_text(nameLabel, sessionNameText.c_str());
lv_obj_set_width(nameLabel, 374);
lv_label_set_long_mode(nameLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(nameLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(nameLabel, lv_color_hex(0xD5DEE7), 0);
lv_obj_set_pos(nameLabel, 18, 56);
lv_obj_t *kindLabel = lv_label_create(btn);
lv_label_set_text(kindLabel, sessionKindText.c_str());
lv_obj_set_width(kindLabel, 374);
lv_label_set_long_mode(kindLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(kindLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(kindLabel, lv_color_hex(0xA8D6A2), 0);
lv_obj_set_pos(kindLabel, 18, 88);
}
}
makeVersionTag();
}
static void drawPairingRequestDetailScreen() {
setRootStyle();
makeTitle("REQUEST DETAIL", 18, &lv_font_montserrat_24);
if (gSelectedPairingRequestIndex < 0 || gSelectedPairingRequestIndex >= (int)gPairingRequests.size()) {
showMessageAt("Request not selected", 88);
makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_OPEN_PAIRING_REQUESTS, &lv_font_montserrat_22);
makeVersionTag();
return;
}
const PairingRequestUiItem &item = gPairingRequests[gSelectedPairingRequestIndex];
String question = String("Connect session ") + pairingSessionNameLabel(item) + "?";
String explain = item.requesterSessionType == 50
? "Wallet session. No keys will be transferred."
: "Client session. Only device key will be transferred. No additional keys will be sent.";
String sessionNameText = String("Session: ") + pairingSessionNameLabel(item);
String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType);
lv_obj_t *panel = lv_obj_create(gRoot);
lv_obj_set_size(panel, 440, 248);
lv_obj_set_pos(panel, 20, 86);
lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(panel, 1, 0);
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
lv_obj_set_style_radius(panel, 14, 0);
lv_obj_set_style_pad_all(panel, 16, 0);
lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *qLabel = lv_label_create(panel);
lv_label_set_text(qLabel, question.c_str());
lv_obj_set_width(qLabel, 404);
lv_label_set_long_mode(qLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(qLabel, &lv_font_montserrat_22, 0);
lv_obj_set_style_text_color(qLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_pos(qLabel, 0, 0);
lv_obj_t *codeLabel = lv_label_create(panel);
lv_label_set_text(codeLabel, formatPairingShortCode(item.shortCode).c_str());
lv_obj_set_style_text_font(codeLabel, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(codeLabel, lv_color_hex(0xD5DEE7), 0);
lv_obj_set_pos(codeLabel, 0, 48);
lv_obj_t *nameLabel = lv_label_create(panel);
lv_label_set_text(nameLabel, sessionNameText.c_str());
lv_obj_set_width(nameLabel, 404);
lv_label_set_long_mode(nameLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(nameLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(nameLabel, lv_color_hex(0xD5DEE7), 0);
lv_obj_set_pos(nameLabel, 0, 92);
lv_obj_t *kindLabel = lv_label_create(panel);
lv_label_set_text(kindLabel, sessionKindText.c_str());
lv_obj_set_width(kindLabel, 404);
lv_label_set_long_mode(kindLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(kindLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(kindLabel, lv_color_hex(0xA8D6A2), 0);
lv_obj_set_pos(kindLabel, 0, 124);
lv_obj_t *explainLabel = lv_label_create(panel);
lv_label_set_text(explainLabel, explain.c_str());
lv_obj_set_width(explainLabel, 404);
lv_label_set_long_mode(explainLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(explainLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(explainLabel, lv_color_hex(0xB8C6D3), 0);
lv_obj_set_pos(explainLabel, 0, 164);
makeButton("YES", 40, 364, 180, 76, 0x2A9D8F, ACTION_PAIRING_APPROVE, &lv_font_montserrat_22);
makeButton("NO", 260, 364, 180, 76, 0x7D3A3A, ACTION_PAIRING_REJECT, &lv_font_montserrat_22);
makeVersionTag();
}
static lv_obj_t *makeNetworkButton(const char *text, int index, lv_coord_t y) {
lv_obj_t *btn = lv_btn_create(gRoot);
lv_obj_set_size(btn, 436, 52);
@ -5134,6 +5880,12 @@ static void rebuildScreen() {
case SCREEN_SETTINGS_MENU:
drawSettingsMenu();
break;
case SCREEN_PAIRING_REQUESTS:
drawPairingRequestsScreen();
break;
case SCREEN_PAIRING_REQUEST_DETAIL:
drawPairingRequestDetailScreen();
break;
case SCREEN_WIFI:
drawWifiScreen();
break;
@ -5198,8 +5950,8 @@ static void handleSettingsSwipe(SwipeDirection swipe) {
if (swipe == SWIPE_UP) {
gSettingsScrollIndex++;
if (gSettingsScrollIndex > static_cast<int>(kMenuCount) - 2) {
gSettingsScrollIndex = static_cast<int>(kMenuCount) - 2;
if (gSettingsScrollIndex > settingsMenuCount() - 2) {
gSettingsScrollIndex = settingsMenuCount() - 2;
}
rebuildScreen();
return;
@ -5221,6 +5973,18 @@ static void handleWifiSwipe(SwipeDirection swipe) {
}
}
static void handlePairingRequestsSwipe(SwipeDirection swipe) {
if (swipe == SWIPE_RIGHT) {
showScreen(SCREEN_SETTINGS_MENU);
}
}
static void handlePairingRequestDetailSwipe(SwipeDirection swipe) {
if (swipe == SWIPE_RIGHT) {
showScreen(SCREEN_PAIRING_REQUESTS);
}
}
static void handleServerSwipe(SwipeDirection swipe) {
if (swipe == SWIPE_RIGHT) {
showScreen(SCREEN_SETTINGS_MENU);
@ -5281,6 +6045,12 @@ static void handleSwipe(SwipeDirection swipe) {
case SCREEN_SETTINGS_MENU:
handleSettingsSwipe(swipe);
break;
case SCREEN_PAIRING_REQUESTS:
handlePairingRequestsSwipe(swipe);
break;
case SCREEN_PAIRING_REQUEST_DETAIL:
handlePairingRequestDetailSwipe(swipe);
break;
case SCREEN_WIFI:
handleWifiSwipe(swipe);
break;

View File

@ -26,7 +26,7 @@
- реальное чтение баланса кошелька из `Solana RPC`;
- проверка обязательных условий перед регистрацией;
- живая on-chain регистрация серверного `user_pda` в `shine_users` по нажатию кнопки на главном экране;
- прототип входящих запросов с подтверждением и отклонением;
- живой экран заявок на подключение новых устройств через доверенную homeserver-сессию;
- PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта);
- базовые настройки, статус и главный экран;
- сохранение `PDA` и `tx signature` после успешной регистрации.
@ -34,8 +34,8 @@
Что пока считается именно прототипом, а не финальной интеграцией:
- приём реальных входящих запросов на вход/подпись пока не подключён к живой сети;
- входящие запросы пока демонстрационные, чтобы можно было проверить UX и логику подтверждения.
- пока реализован только сценарий заявок на подключение устройств через доверенную сессию;
- другие типы входящих запросов на подпись и произвольные approval-flow на этом устройстве ещё не подключены.
## Основная идея устройства
@ -46,7 +46,7 @@
- показывает адрес кошелька устройства;
- позволяет пополнить баланс перед регистрацией;
- после выполнения условий даёт зарегистрировать устройство как homeserver;
- после регистрации может принимать входящие запросы на вход и на подпись.
- после авторизации в SHiNE может подтверждать заявки на подключение новых устройств пользователя.
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
Основное сохранение идёт во внутреннюю flash-память через `NVS`.
@ -120,6 +120,11 @@
- Верхняя строка всегда показывает краткий статус устройства:
`PIN`, `Wi-Fi`, `сервер`, `регистрация`.
- В правом верхнем углу рядом с батареей:
- сначала процент заряда;
- затем, если устройство реально заряжается, маленькая иконка молнии;
- затем контур батареи;
- затем индикатор `Wi-Fi`.
- Основной язык прототипа: русский.
- Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`.
- Русские строки на экране временно показываются в ASCII-транслитерации.
@ -452,60 +457,80 @@
## Экран REQUESTS
Показывает список демонстрационных запросов:
- `Вход в сессию`
- `Подпись сообщения`
Для каждого запроса:
- тип;
- источник;
- короткий статус.
Кнопки:
- `Открыть запрос 1`
- `Открыть запрос 2`
- `Назад`
## Экран REQUEST_DETAIL
Показывает детали выбранного запроса:
- тип запроса;
- кто запросил;
- время;
- описание;
- отпечаток/идентификатор.
Кнопки:
- `Разрешить`
- `Отклонить`
- `Назад`
Поведение:
- после разрешения или отклонения запрос помечается обработанным;
- экран списка отражает новый статус.
## Экран SETTINGS
Экран доступен только если homeserver уже авторизован в SHiNE.
Показывает:
- текущий PIN;
- базовые флаги безопасности;
- технические действия прототипа.
- слева сверху кнопку `REFRESH`;
- заголовок `REQUESTS:` немного правее стандартного левого положения;
- справа сверху только большую цифру числа активных заявок;
- ниже прокручиваемый список активных pairing-заявок;
- на экране одновременно видны примерно две плитки.
Каждая плитка показывает:
- код подключения из `10` цифр в виде `5` пар: `XX XX XX XX XX`;
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`.
Поведение:
- список берётся из живой операции `ListTrustedDeviceLoginRequests`;
- если заявок нет, экран показывает `No active requests`;
- отдельная строка вида `Active requests: N` на этом экране не показывается;
- вертикальный скролл позволяет просматривать все активные заявки;
- нажатие по плитке открывает `REQUEST_DETAIL`;
- свайп вправо возвращает в `SETTINGS`.
## Экран REQUEST_DETAIL
Показывает детали выбранной pairing-заявки:
- вопрос `Connect session ...?`;
- код подключения `XX XX XX XX XX`;
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`;
- пояснение:
- для client session: `Only device key will be transferred. No additional keys will be sent.`
- для wallet session: `No keys will be transferred.`
Кнопки:
- `Сменить PIN`
- `Сбросить онлайн`
- `Полный сброс`
- `Назад`
- `YES`
- `NO`
`Полный сброс` очищает весь локальный конфиг и возвращает устройство к стартовому состоянию.
Поведение:
- `YES` подтверждает заявку:
- для client session устройство передаёт только `device key`;
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
- `NO` отклоняет заявку;
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
- свайп вправо возвращает в `REQUESTS`.
## Экран SETTINGS
Показывает вертикальное меню крупных пунктов.
Если homeserver ещё не авторизован в SHiNE:
- `1. Wi-Fi`
- `2. Server`
- `3. Account`
Если homeserver уже авторизован:
- `1. Device requests`
- `2. Wi-Fi`
- `3. Server`
- `4. Account`
Поведение:
- одновременно видны только две карточки;
- список листается свайпом вверх/вниз;
- свайп вправо возвращает на `HOME`;
- пункт `Device requests` должен быть первым и появляется только для авторизованного homeserver.
## Экран PIN_EDIT

View File

@ -362,7 +362,7 @@ async function startPairing({ login, usePassword, password }) {
state.pairingId = String(payload?.pairingId || '').trim();
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
state.shortCode = String(payload?.shortCode || '0000000');
state.shortCode = String(payload?.shortCode || '');
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
if (!state.pairingId) {
throw new Error('Сервер не вернул pairingId.');
@ -375,7 +375,7 @@ async function startPairing({ login, usePassword, password }) {
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
return {
pairingId: state.pairingId,
shortCode: String(payload?.shortCode || '0000000'),
shortCode: String(payload?.shortCode || ''),
expiresAtMs: state.expiresAtMs,
trustedSessionOnline: !!payload?.trustedSessionOnline,
};

View File

@ -59,6 +59,15 @@ export async function createRequesterPairingMaterial() {
};
}
export function normalizePairingShortCode(value, digits = 10) {
return String(value || '').replace(/\D+/g, '').slice(0, digits).padStart(digits, '0');
}
export function formatPairingShortCode(value) {
const normalized = normalizePairingShortCode(value, 10);
return normalized.match(/.{1,2}/g)?.join(' ') || normalized;
}
export async function deriveEspPairingPasswordHash(login, password) {
const loginLower = String(login || '').trim().toLowerCase();
const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`;

View File

@ -78,7 +78,7 @@
<div id="pairing-card" class="card hidden">
<div class="card-title">Код подключения</div>
<div id="short-code" class="code">0000000</div>
<div id="short-code" class="code">00 00 00 00 00</div>
<p id="pairing-hint" class="muted small">
Покажите код на доверенном устройстве в разделе «Подключить по коду».
</p>

View File

@ -1,3 +1,5 @@
import { formatPairingShortCode } from './js/lib/device-pairing.js';
const els = {
serverLoginInfo: document.querySelector('#server-login-info'),
serverAddress: document.querySelector('#server-address'),
@ -159,9 +161,9 @@ function applyState(nextState) {
const pairing = state?.pairing || {};
if (pairing.active) {
els.pairingCard.classList.remove('hidden');
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '0000000');
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '');
els.shortCode.dataset.shortCode = shortCode;
els.shortCode.textContent = shortCode;
els.shortCode.textContent = formatPairingShortCode(shortCode);
els.pairingHint.textContent = pairing.trustedSessionOnline
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
@ -170,7 +172,7 @@ function applyState(nextState) {
els.startBtn.disabled = true;
} else {
els.pairingCard.classList.add('hidden');
els.shortCode.textContent = '0000000';
els.shortCode.textContent = formatPairingShortCode('');
delete els.shortCode.dataset.shortCode;
els.pairingExpire.textContent = '';
els.startBtn.disabled = false;

View File

@ -56,9 +56,28 @@ shine-UI/server-ui.html
```
./gradlew deployServer
./gradlew deployUI
```
Хост по умолчанию: `player@93.170.12.154` (shineup.me).
Default deploy по умолчанию идёт на `test2.shineup.me` (`player@193.8.215.70`).
Production deploy:
```
./gradlew deployServerProduction
./gradlew deployUIProduction
```
Любые изменения на `shineup.me` делать только после отдельного явного подтверждения пользователя.
Резервный test-контур:
```
./gradlew deployServerTest
./gradlew deployUITest
```
`test.shineup.me` пока не использовать для обычного deploy.
Логи на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`

View File

@ -264,6 +264,21 @@ public final class DatabaseInitializer {
ON ip_geo_cache (updated_at_ms);
""");
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
used_count INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL,
last_tx_id TEXT NOT NULL DEFAULT '',
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
ON test_free_avatar_uploads (updated_at_ms);
""");
// 5. blockchain_state
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS blockchain_state (

View File

@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = 7;
private static final int LATEST_SCHEMA_VERSION = 8;
private final String jdbcUrl;
@ -90,6 +90,7 @@ public final class SqliteDbController {
case 5 -> migrateToV5();
case 6 -> migrateToV6();
case 7 -> migrateToV7();
case 8 -> migrateToV8();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@ -249,6 +250,25 @@ public final class SqliteDbController {
}
}
private void migrateToV8() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureTestFreeAvatarUploadsTable(st);
setSchemaVersion(c, 8);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v8 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v8 failed", e);
}
}
private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
@ -432,6 +452,22 @@ public final class SqliteDbController {
""");
}
private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
used_count INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL,
last_tx_id TEXT NOT NULL DEFAULT '',
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
ON test_free_avatar_uploads (updated_at_ms);
""");
}
private static void createConnectionsStateTable(Statement st) throws SQLException {
st.executeUpdate("""

View File

@ -0,0 +1,82 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.TestFreeAvatarUploadEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class TestFreeAvatarUploadsDAO {
private static volatile TestFreeAvatarUploadsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private TestFreeAvatarUploadsDAO() {
}
public static TestFreeAvatarUploadsDAO getInstance() {
if (instance == null) {
synchronized (TestFreeAvatarUploadsDAO.class) {
if (instance == null) instance = new TestFreeAvatarUploadsDAO();
}
}
return instance;
}
public TestFreeAvatarUploadEntry getByLogin(Connection c, String login) throws SQLException {
String sql = """
SELECT login, used_count, updated_at_ms, last_tx_id
FROM test_free_avatar_uploads
WHERE login = ? COLLATE NOCASE
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public TestFreeAvatarUploadEntry getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLogin(c, login);
}
}
public void upsertUsage(Connection c, String login, int usedCount, long updatedAtMs, String lastTxId) throws SQLException {
String sql = """
INSERT INTO test_free_avatar_uploads (login, used_count, updated_at_ms, last_tx_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(login) DO UPDATE SET
used_count = excluded.used_count,
updated_at_ms = excluded.updated_at_ms,
last_tx_id = excluded.last_tx_id
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, usedCount);
ps.setLong(3, updatedAtMs);
ps.setString(4, lastTxId);
ps.executeUpdate();
}
}
public void upsertUsage(String login, int usedCount, long updatedAtMs, String lastTxId) throws SQLException {
try (Connection c = db.getConnection()) {
upsertUsage(c, login, usedCount, updatedAtMs, lastTxId);
}
}
private static TestFreeAvatarUploadEntry mapRow(ResultSet rs) throws SQLException {
return new TestFreeAvatarUploadEntry(
rs.getString("login"),
rs.getInt("used_count"),
rs.getLong("updated_at_ms"),
rs.getString("last_tx_id")
);
}
}

View File

@ -0,0 +1,50 @@
package shine.db.entities;
public class TestFreeAvatarUploadEntry {
private String login;
private int usedCount;
private long updatedAtMs;
private String lastTxId;
public TestFreeAvatarUploadEntry() {
}
public TestFreeAvatarUploadEntry(String login, int usedCount, long updatedAtMs, String lastTxId) {
this.login = login;
this.usedCount = usedCount;
this.updatedAtMs = updatedAtMs;
this.lastTxId = lastTxId;
}
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public int getUsedCount() {
return usedCount;
}
public void setUsedCount(int usedCount) {
this.usedCount = usedCount;
}
public long getUpdatedAtMs() {
return updatedAtMs;
}
public void setUpdatedAtMs(long updatedAtMs) {
this.updatedAtMs = updatedAtMs;
}
public String getLastTxId() {
return lastTxId;
}
public void setLastTxId(String lastTxId) {
this.lastTxId = lastTxId;
}
}

View File

@ -45,7 +45,11 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
// --- NEW: SearchUsers ---
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
@ -127,6 +131,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", new Net_AddUser_Handler()),
Map.entry("GetUser", new Net_GetUser_Handler()),
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth ---
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
@ -200,6 +206,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", Net_AddUser_Request.class),
Map.entry("GetUser", Net_GetUser_Request.class),
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth ---
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),

View File

@ -136,8 +136,8 @@ final class EspPairingSupport {
| ((digest[1] & 0xFFL) << 16)
| ((digest[2] & 0xFFL) << 8)
| (digest[3] & 0xFFL);
long shortCodeNum = code % 10_000_000L;
String shortCode = String.format(Locale.ROOT, "%07d", shortCodeNum);
long shortCodeNum = code % 10_000_000_000L;
String shortCode = String.format(Locale.ROOT, "%010d", shortCodeNum);
return new PairingFingerprint(shortCode, toBase58(digest));
}

View File

@ -0,0 +1,37 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
public class Net_TestGetFreeAvatarQuota_Handler implements JsonMessageHandler {
private final TestFreeAvatarArweaveService service = new TestFreeAvatarArweaveService();
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_TestGetFreeAvatarQuota_Request req = (Net_TestGetFreeAvatarQuota_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String login = String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim();
TestFreeAvatarArweaveService.Quota quota = service.getQuota(login);
Net_TestGetFreeAvatarQuota_Response resp = new Net_TestGetFreeAvatarQuota_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setEnabled(quota.enabled());
resp.setLimit(quota.limit());
resp.setUsedCount(quota.usedCount());
resp.setRemainingCount(quota.remainingCount());
resp.setMaxBytes(service.getMaxBytes());
return resp;
}
}

View File

@ -0,0 +1,60 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import java.util.Base64;
public class Net_TestUploadFreeAvatar_Handler implements JsonMessageHandler {
private final TestFreeAvatarArweaveService service = new TestFreeAvatarArweaveService();
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_TestUploadFreeAvatar_Request req = (Net_TestUploadFreeAvatar_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String fileBase64 = String.valueOf(req.getFileBytesBase64() == null ? "" : req.getFileBytesBase64()).trim();
String contentType = String.valueOf(req.getContentType() == null ? "" : req.getContentType()).trim();
if (fileBase64.isBlank() || contentType.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Нужно передать contentType и fileBytesBase64.");
}
byte[] fileBytes;
try {
fileBytes = Base64.getDecoder().decode(fileBase64);
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_BASE64", "fileBytesBase64 должен быть корректным Base64.");
}
String login = String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim();
try {
TestFreeAvatarArweaveService.UploadResult result = service.uploadAvatar(login, contentType, fileBytes, req.getSha256Hex());
Net_TestUploadFreeAvatar_Response resp = new Net_TestUploadFreeAvatar_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setTxId(result.txId());
resp.setSha256Hex(result.sha256Hex());
resp.setUsedCount(result.usedCount());
resp.setRemainingCount(result.remainingCount());
resp.setLimit(result.limit());
resp.setGateway(result.gateway());
return resp;
} catch (TestFreeAvatarArweaveService.FreeAvatarLimitExceededException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "FREE_AVATAR_LIMIT_EXHAUSTED", e.getMessage());
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_AVATAR_FILE", e.getMessage());
} catch (IllegalStateException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "FREE_AVATAR_TEMP_DISABLED", e.getMessage());
}
}
}

View File

@ -0,0 +1,391 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.TestFreeAvatarUploadsDAO;
import shine.db.entities.TestFreeAvatarUploadEntry;
import utils.config.AppConfig;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.sql.SQLException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class TestFreeAvatarArweaveService {
private static final Logger log = LoggerFactory.getLogger(TestFreeAvatarArweaveService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.build();
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
private static final Base64.Encoder B64URL_NOPAD = Base64.getUrlEncoder().withoutPadding();
private static final int DEFAULT_LIMIT = 3;
private static final int DEFAULT_MAX_BYTES = 128 * 1024;
private static final String DEFAULT_GATEWAY = "https://arweave.net";
private static final ConcurrentHashMap<String, Object> LOGIN_LOCKS = new ConcurrentHashMap<>();
private final AppConfig config = AppConfig.getInstance();
private final TestFreeAvatarUploadsDAO quotaDao = TestFreeAvatarUploadsDAO.getInstance();
public Quota getQuota(String login) throws SQLException {
int limit = getLimitPerUser();
TestFreeAvatarUploadEntry entry = quotaDao.getByLogin(login);
int used = entry == null ? 0 : Math.max(0, entry.getUsedCount());
int remaining = Math.max(0, limit - used);
return new Quota(limit, used, remaining, isEnabled());
}
public UploadResult uploadAvatar(String login, String contentType, byte[] fileBytes, String expectedSha256Hex)
throws Exception {
if (!isEnabled()) {
throw new IllegalStateException("Временная бесплатная загрузка аватаров сейчас отключена на сервере.");
}
String cleanLogin = String.valueOf(login == null ? "" : login).trim();
if (cleanLogin.isBlank()) {
throw new IllegalArgumentException("Пустой login для бесплатной загрузки аватара.");
}
String cleanType = normalizeContentType(contentType);
validatePayload(cleanType, fileBytes);
String actualSha256Hex = sha256Hex(fileBytes);
String expectedSha = String.valueOf(expectedSha256Hex == null ? "" : expectedSha256Hex).trim().toLowerCase();
if (!expectedSha.isBlank() && !actualSha256Hex.equals(expectedSha)) {
throw new IllegalArgumentException("SHA-256 файла не совпадает с присланным клиентом.");
}
Object lock = LOGIN_LOCKS.computeIfAbsent(cleanLogin.toLowerCase(), key -> new Object());
synchronized (lock) {
Quota before = getQuota(cleanLogin);
if (before.remainingCount() <= 0) {
throw new FreeAvatarLimitExceededException("Вы исчерпали бесплатный лимит аватарок.");
}
ArweaveConfig arConfig = loadArweaveConfig();
String txId = postAvatarTransaction(arConfig, cleanLogin, cleanType, fileBytes);
int usedAfter = before.usedCount() + 1;
int remainingAfter = Math.max(0, before.limit() - usedAfter);
quotaDao.upsertUsage(cleanLogin, usedAfter, System.currentTimeMillis(), txId);
return new UploadResult(txId, actualSha256Hex, usedAfter, remainingAfter, before.limit(), arConfig.gateway());
}
}
public boolean isEnabled() {
return config.getBoolean("test.freeAvatar.enabled", true);
}
public int getLimitPerUser() {
int configured = config.getInt("test.freeAvatar.limitPerUser", DEFAULT_LIMIT);
return Math.max(1, configured);
}
public int getMaxBytes() {
int configured = config.getInt("test.freeAvatar.maxBytes", DEFAULT_MAX_BYTES);
return Math.max(1024, configured);
}
private String postAvatarTransaction(ArweaveConfig arConfig, String login, String contentType, byte[] data)
throws IOException, InterruptedException, GeneralSecurityException {
String gateway = arConfig.gateway();
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + data.length);
if (!reward.matches("^\\d+$")) {
throw new IllegalStateException("Arweave gateway вернул некорректную цену загрузки.");
}
if (!arConfig.address().isBlank()) {
String balance = getRequiredText(gateway, "/wallet/" + arConfig.address() + "/balance");
if (balance.matches("^\\d+$")) {
BigInteger balanceWinston = new BigInteger(balance);
BigInteger rewardWinston = new BigInteger(reward);
if (balanceWinston.compareTo(rewardWinston) < 0) {
throw new IllegalStateException("На серверном Arweave-кошельке недостаточно AR для бесплатной загрузки аватара.");
}
}
}
byte[] dataRoot = singleChunkDataRoot(data);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String, String>> jsonTags = new ArrayList<>();
appendTag(jsonTags, tagList, "Content-Type", contentType);
appendTag(jsonTags, tagList, "App-Name", "SHiNE");
appendTag(jsonTags, tagList, "SHiNE-Type", "avatar-free-test");
appendTag(jsonTags, tagList, "SHiNE-Profile-Login", login);
byte[] signaturePayload = deepHash(List.of(
utf8("2"),
b64UrlDecode(arConfig.owner()),
new byte[0],
utf8("0"),
utf8(reward),
b64UrlDecode(anchor),
tagList,
utf8(Integer.toString(data.length)),
dataRoot
));
byte[] rawSignature = signPayload(arConfig.privateKey(), signaturePayload);
String txId = b64UrlEncode(sha256(rawSignature));
Map<String, Object> body = new LinkedHashMap<>();
body.put("format", 2);
body.put("id", txId);
body.put("last_tx", anchor);
body.put("owner", arConfig.owner());
body.put("tags", jsonTags);
body.put("target", "");
body.put("quantity", "0");
body.put("data_root", b64UrlEncode(dataRoot));
body.put("data_size", Integer.toString(data.length));
body.put("data", b64UrlEncode(data));
body.put("reward", reward);
body.put("signature", b64UrlEncode(rawSignature));
String bodyJson = MAPPER.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx"))
.timeout(Duration.ofSeconds(40))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
int status = response.statusCode();
if (status != 200 && status != 208) {
throw new IllegalStateException("Arweave отклонил транзакцию: HTTP " + status + " " + safeBody(response.body()));
}
return txId;
}
private ArweaveConfig loadArweaveConfig() throws IOException, GeneralSecurityException {
String rawGateway = String.valueOf(config.getParam("test.freeAvatar.gateway") == null
? DEFAULT_GATEWAY
: config.getParam("test.freeAvatar.gateway")).trim();
String gateway = rawGateway.isBlank() ? DEFAULT_GATEWAY : rawGateway.replaceAll("/+$", "");
String walletPathRaw = String.valueOf(config.getParam("test.freeAvatar.walletJwkPath") == null
? ""
: config.getParam("test.freeAvatar.walletJwkPath")).trim();
if (walletPathRaw.isBlank()) {
throw new IllegalStateException("Не задан test.freeAvatar.walletJwkPath в настройках сервера.");
}
JsonNode jwk = MAPPER.readTree(Files.readString(Path.of(walletPathRaw), StandardCharsets.UTF_8));
String owner = requiredText(jwk, "n");
PrivateKey privateKey = buildPrivateKeyFromJwk(jwk);
String computedAddress = b64UrlEncode(sha256(b64UrlDecode(owner)));
String expectedAddress = String.valueOf(config.getParam("test.freeAvatar.walletAddress") == null
? ""
: config.getParam("test.freeAvatar.walletAddress")).trim();
if (!expectedAddress.isBlank() && !expectedAddress.equals(computedAddress)) {
throw new IllegalStateException("test.freeAvatar.walletAddress не совпадает с адресом из JWK.");
}
return new ArweaveConfig(gateway, owner, computedAddress, privateKey);
}
private static PrivateKey buildPrivateKeyFromJwk(JsonNode jwk) throws GeneralSecurityException {
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
asBigInt(requiredText(jwk, "n")),
asBigInt(requiredText(jwk, "e")),
asBigInt(requiredText(jwk, "d")),
asBigInt(requiredText(jwk, "p")),
asBigInt(requiredText(jwk, "q")),
asBigInt(requiredText(jwk, "dp")),
asBigInt(requiredText(jwk, "dq")),
asBigInt(requiredText(jwk, "qi"))
);
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
Signature signature = Signature.getInstance("RSASSA-PSS");
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signature.initSign(key);
signature.update(payload);
return signature.sign();
}
private static byte[] singleChunkDataRoot(byte[] data) throws GeneralSecurityException {
byte[] dataHash = sha256(data);
byte[] left = sha256(dataHash);
byte[] right = sha256(intToBuffer32(data.length));
return sha256(concat(left, right));
}
@SuppressWarnings("unchecked")
private static byte[] deepHash(Object data) throws GeneralSecurityException {
if (data instanceof List<?> list) {
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
return deepHashChunks(list, sha384(tag));
}
if (!(data instanceof byte[] bytes)) {
throw new IllegalArgumentException("deepHash поддерживает только byte[] и list.");
}
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
byte[] tagged = concat(sha384(tag), sha384(bytes));
return sha384(tagged);
}
private static byte[] deepHashChunks(List<?> chunks, byte[] acc) throws GeneralSecurityException {
if (chunks.isEmpty()) return acc;
byte[] pair = concat(acc, deepHash(chunks.get(0)));
return deepHashChunks(chunks.subList(1, chunks.size()), sha384(pair));
}
private static void appendTag(List<Map<String, String>> jsonTags, List<List<byte[]>> tagList, String name, String value) {
byte[] nameBytes = utf8(name);
byte[] valueBytes = utf8(value);
Map<String, String> item = new LinkedHashMap<>();
item.put("name", b64UrlEncode(nameBytes));
item.put("value", b64UrlEncode(valueBytes));
jsonTags.add(item);
tagList.add(List.of(nameBytes, valueBytes));
}
private static String getRequiredText(String gateway, String path) throws IOException, InterruptedException {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path))
.timeout(Duration.ofSeconds(20))
.GET()
.build();
HttpResponse<String> response = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IllegalStateException("Arweave gateway вернул HTTP " + response.statusCode() + " для " + path);
}
String body = String.valueOf(response.body() == null ? "" : response.body()).trim();
if (body.isBlank()) {
throw new IllegalStateException("Arweave gateway вернул пустой ответ для " + path);
}
return body;
}
private void validatePayload(String contentType, byte[] fileBytes) {
if (fileBytes == null || fileBytes.length == 0) {
throw new IllegalArgumentException("Файл аватара пустой.");
}
if (fileBytes.length > getMaxBytes()) {
throw new IllegalArgumentException("Файл слишком большой для бесплатной загрузки. Максимум " + getMaxBytes() + " байт.");
}
if (!isSupportedContentType(contentType)) {
throw new IllegalArgumentException("Поддерживаются только JPEG, PNG или WebP.");
}
}
private static boolean isSupportedContentType(String contentType) {
return "image/jpeg".equals(contentType)
|| "image/png".equals(contentType)
|| "image/webp".equals(contentType);
}
private static String normalizeContentType(String contentType) {
return String.valueOf(contentType == null ? "" : contentType).trim().toLowerCase();
}
private static String requiredText(JsonNode node, String field) {
String value = node == null ? "" : String.valueOf(node.path(field).asText("")).trim();
if (value.isBlank()) {
throw new IllegalStateException("В JWK отсутствует поле " + field + ".");
}
return value;
}
private static BigInteger asBigInt(String b64Url) {
return new BigInteger(1, b64UrlDecode(b64Url));
}
private static byte[] b64UrlDecode(String value) {
return B64URL.decode(String.valueOf(value == null ? "" : value).trim());
}
private static String b64UrlEncode(byte[] value) {
return B64URL_NOPAD.encodeToString(value);
}
private static byte[] utf8(String value) {
return String.valueOf(value == null ? "" : value).getBytes(StandardCharsets.UTF_8);
}
private static byte[] concat(byte[]... arrays) {
int total = 0;
for (byte[] array : arrays) total += array.length;
byte[] out = new byte[total];
int offset = 0;
for (byte[] array : arrays) {
System.arraycopy(array, 0, out, offset, array.length);
offset += array.length;
}
return out;
}
private static byte[] intToBuffer32(int value) {
byte[] out = new byte[32];
long current = Integer.toUnsignedLong(value);
for (int i = out.length - 1; i >= 0; i--) {
out[i] = (byte) (current & 0xffL);
current >>>= 8;
}
return out;
}
private static byte[] sha256(byte[] data) throws GeneralSecurityException {
return MessageDigest.getInstance("SHA-256").digest(data);
}
private static byte[] sha384(byte[] data) throws GeneralSecurityException {
return MessageDigest.getInstance("SHA-384").digest(data);
}
private static String sha256Hex(byte[] data) throws GeneralSecurityException {
byte[] hash = sha256(data);
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) sb.append(String.format("%02x", b));
return sb.toString();
}
private static String safeBody(String body) {
String text = String.valueOf(body == null ? "" : body).replace('\n', ' ').replace('\r', ' ').trim();
if (text.length() <= 220) return text;
return text.substring(0, 220) + "...";
}
public record Quota(int limit, int usedCount, int remainingCount, boolean enabled) {
}
public record UploadResult(String txId, String sha256Hex, int usedCount, int remainingCount, int limit, String gateway) {
}
private record ArweaveConfig(String gateway, String owner, String address, PrivateKey privateKey) {
}
public static final class FreeAvatarLimitExceededException extends RuntimeException {
public FreeAvatarLimitExceededException(String message) {
super(message);
}
}
}

View File

@ -0,0 +1,6 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_TestGetFreeAvatarQuota_Request extends Net_Request {
}

View File

@ -0,0 +1,51 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_TestGetFreeAvatarQuota_Response extends Net_Response {
private boolean enabled;
private int limit;
private int usedCount;
private int remainingCount;
private int maxBytes;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public int getLimit() {
return limit;
}
public void setLimit(int limit) {
this.limit = limit;
}
public int getUsedCount() {
return usedCount;
}
public void setUsedCount(int usedCount) {
this.usedCount = usedCount;
}
public int getRemainingCount() {
return remainingCount;
}
public void setRemainingCount(int remainingCount) {
this.remainingCount = remainingCount;
}
public int getMaxBytes() {
return maxBytes;
}
public void setMaxBytes(int maxBytes) {
this.maxBytes = maxBytes;
}
}

View File

@ -0,0 +1,33 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_TestUploadFreeAvatar_Request extends Net_Request {
private String contentType;
private String fileBytesBase64;
private String sha256Hex;
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public String getFileBytesBase64() {
return fileBytesBase64;
}
public void setFileBytesBase64(String fileBytesBase64) {
this.fileBytesBase64 = fileBytesBase64;
}
public String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
}

View File

@ -0,0 +1,60 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_TestUploadFreeAvatar_Response extends Net_Response {
private String txId;
private String sha256Hex;
private int usedCount;
private int remainingCount;
private int limit;
private String gateway;
public String getTxId() {
return txId;
}
public void setTxId(String txId) {
this.txId = txId;
}
public String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
public int getUsedCount() {
return usedCount;
}
public void setUsedCount(int usedCount) {
this.usedCount = usedCount;
}
public int getRemainingCount() {
return remainingCount;
}
public void setRemainingCount(int remainingCount) {
this.remainingCount = remainingCount;
}
public int getLimit() {
return limit;
}
public void setLimit(int limit) {
this.limit = limit;
}
public String getGateway() {
return gateway;
}
public void setGateway(String gateway) {
this.gateway = gateway;
}
}

View File

@ -89,6 +89,7 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
+ ",\"text\":\"Вам звонит " + jsonEscape(from) + "\""
+ ",\"fromLogin\":\"" + jsonEscape(from) + "\""
+ ",\"fromSessionId\":\"" + jsonEscape(ctx.getSessionId()) + "\""
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
+ ",\"toLogin\":\"" + jsonEscape(to) + "\""
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
+ ",\"sentAtMs\":" + timeMs

View File

@ -164,6 +164,7 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
+ ",\"reason\":\"" + jsonEscape(reason) + "\""
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
+ ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\""
+ ",\"sentAtMs\":" + sentAtMs
+ "}";

View File

@ -42,14 +42,14 @@ call.ice.turn.password=
# Каждый блок описывает один TURN-узел. Новые узлы добавляются по индексу.
# Приоритет авторизации на узел: sharedSecret -> статические username/password.
# ------------------------------------------------------------
call.ice.turn.servers.1.id=vps-05
call.ice.turn.servers.1.urls=turn:45.136.124.227:3478?transport=udp,turn:45.136.124.227:3478?transport=tcp
call.ice.turn.servers.1.id=promo-node-93
call.ice.turn.servers.1.urls=turn:93.170.12.154:3478?transport=udp,turn:93.170.12.154:3478?transport=tcp
call.ice.turn.servers.1.sharedSecret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
call.ice.turn.servers.1.username=
call.ice.turn.servers.1.password=
call.ice.turn.servers.2.id=promo-node-93
call.ice.turn.servers.2.urls=turn:93.170.12.154:3478?transport=udp,turn:93.170.12.154:3478?transport=tcp
call.ice.turn.servers.2.id=shineup-main-185
call.ice.turn.servers.2.urls=turn:185.229.109.118:3478?transport=udp,turn:185.229.109.118:3478?transport=tcp
call.ice.turn.servers.2.sharedSecret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
call.ice.turn.servers.2.username=
call.ice.turn.servers.2.password=
@ -61,3 +61,14 @@ call.ice.turn.servers.2.password=
# Если параметр отсутствует, по умолчанию считается false
# ------------------------------------------------------------
debug.tempApi.enabled=true
# ------------------------------------------------------------
# Временная тестовая бесплатная загрузка маленьких аватаров в Arweave
# API только для тестового периода. Ключ хранится вне кода в JWK-файле.
# ------------------------------------------------------------
test.freeAvatar.enabled=true
test.freeAvatar.gateway=https://arweave.net
test.freeAvatar.limitPerUser=3
test.freeAvatar.maxBytes=131072
test.freeAvatar.walletAddress=
test.freeAvatar.walletJwkPath=

View File

@ -1,2 +1,2 @@
client.version=1.2.217
server.version=1.2.204
client.version=1.2.230
server.version=1.2.216

View File

@ -185,16 +185,14 @@ tasks.named('build') {
finalizedBy tasks.named('integrationTest')
}
tasks.register('deployServer', JavaExec) {
tasks.register('deployServerProduction', JavaExec) {
group = "!!deployment"
description = "Build → upload to server → restart service (без удаления БД, без IT тестов)"
description = "Production deploy: build → upload to shineup.me → restart service (только после явного подтверждения)"
classpath = sourceSets.test.runtimeClasspath
mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain"
workingDir = file('SHiNE-server')
// можно переопределить при запуске:
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
dependsOn shadowJar
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "shineup.me")
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player")
@ -205,13 +203,57 @@ tasks.register('deployServer', JavaExec) {
dependsOn testClasses
}
tasks.register('deployUI', Exec) {
tasks.register('deployUIProduction', Exec) {
group = "!!deployment"
description = "Deploy WEB UI (production: shineup.me)"
description = "Production UI deploy: shineup.me (только после явного подтверждения)"
workingDir = rootDir
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath
}
tasks.register('deployServer', Exec) {
group = "!!deployment"
description = "Default deploy server: test2.shineup.me"
dependsOn shadowJar
workingDir = rootDir
environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath
commandLine 'bash', file('deploy_shine-server_test2.sh').absolutePath
}
tasks.register('deployUI', Exec) {
group = "!!deployment"
description = "Default deploy UI: test2.shineup.me"
workingDir = rootDir
commandLine 'bash', file('deploy_shine-ui_test2.sh').absolutePath
}
tasks.register('deployServerTest2') {
group = "!!deployment"
description = "Явный алиас основного test deploy server: test2.shineup.me"
dependsOn tasks.named('deployServer')
}
tasks.register('deployUITest2') {
group = "!!deployment"
description = "Явный алиас основного test deploy UI: test2.shineup.me"
dependsOn tasks.named('deployUI')
}
tasks.register('deployServerTest', Exec) {
group = "!!deployment"
description = "Резервный test deploy: test.shineup.me (пока не использовать)"
dependsOn shadowJar
workingDir = rootDir
environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath
commandLine 'bash', file('deploy_shine-server_test.sh').absolutePath
}
tasks.register('deployUITest', Exec) {
group = "!!deployment"
description = "Резервный test UI deploy: test.shineup.me (пока не использовать)"
workingDir = rootDir
commandLine 'bash', file('deploy_shine-ui_test.sh').absolutePath
}
tasks.register('startLocal', Exec) {
group = "!!run"
description = "Builds server, starts local WS server and local HTTP UI for end-to-end local testing"

View File

@ -24,7 +24,7 @@ if [[ -z "$CLIENT_VERSION" ]]; then
fi
export CLIENT_VERSION
TARGET_URL="https://shineup.me"
TARGET_URL="${TARGET_URL:-https://${EXPECTED_CADDY_SITE}}"
REMOTE_DIR="${REMOTE_UI_DIR}"
cleanup() {

128
deploy_shine-server_test.sh Normal file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
PROD_HOST="${PROD_HOST:-player@shineup.me}"
TEST_HOST="${TEST_HOST:-player@93.170.12.154}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test.shineup.me}"
REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}"
REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}"
REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}"
REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}"
REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}"
LOCAL_JAR="${LOCAL_JAR:-build/libs/shine-server.jar}"
PROD_DATA_DIR="${PROD_DATA_DIR:-/home/player/SHiNE/shine-server/data}"
PROD_APP_PROPS="${PROD_APP_PROPS:-/home/player/SHiNE/shine-server/application.properties}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
require_file() {
local path="$1"
if [[ ! -f "$path" ]]; then
echo "ERROR: файл не найден: $path" >&2
exit 1
fi
}
echo "==> Проверка локального jar"
require_file "$LOCAL_JAR"
jar_size="$(stat -c %s "$LOCAL_JAR")"
if [[ "$jar_size" -lt 10485760 ]]; then
echo "ERROR: jar слишком маленький для fat-jar: $jar_size bytes" >&2
exit 1
fi
echo "==> Проверка SSH и sudo"
ssh -o BatchMode=yes -o ConnectTimeout=10 "$PROD_HOST" "echo SSH OK" >/dev/null
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TEST_HOST" "echo SSH OK" >/dev/null
ssh "$TEST_HOST" "sudo -n true"
echo "==> Подготовка Caddy для $TARGET_DOMAIN"
TEST_HOST="$TEST_HOST" \
TARGET_DOMAIN="$TARGET_DOMAIN" \
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
bash "$(dirname "$0")/scripts/install_test_caddyfile.sh"
echo "==> Забираем продовые данные и application.properties"
mkdir -p "$TMP_DIR/data"
rsync -az --delete "$PROD_HOST:$PROD_DATA_DIR/" "$TMP_DIR/data/"
scp -p "$PROD_HOST:$PROD_APP_PROPS" "$TMP_DIR/application.properties" >/dev/null
if grep -q '^server\.ui\.indexPath=' "$TMP_DIR/application.properties"; then
perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties"
else
printf '\nserver.ui.indexPath=/home/player/SHiNE/shine-ui/index.html\n' >>"$TMP_DIR/application.properties"
fi
cat >"$TMP_DIR/shine-server.service" <<EOF
[Unit]
Description=SHiNE Server
After=network.target
[Service]
Type=simple
User=player
Group=player
WorkingDirectory=$REMOTE_SERVER_DIR
ExecStart=/usr/bin/java -Dserver.port=7070 -jar $REMOTE_SERVER_DIR/shine-server.jar
Restart=always
RestartSec=3
StandardOutput=append:$REMOTE_LOGS_DIR/app.log
StandardError=append:$REMOTE_LOGS_DIR/app.log
[Install]
WantedBy=multi-user.target
EOF
echo "==> Останавливаем текущий сервер на тестовом хосте"
ssh "$TEST_HOST" "sudo systemctl stop $REMOTE_SERVICE_NAME || true"
echo "==> Создаём каталоги"
ssh "$TEST_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_UI_DIR' '$REMOTE_BASE/caddy'"
echo "==> Копируем продовую БД и blockchain-данные"
rsync -az --delete "$TMP_DIR/data/" "$TEST_HOST:$REMOTE_DATA_DIR/"
echo "==> Загружаем новый jar и конфиг"
rsync -az --timeout=120 "$LOCAL_JAR" "$TEST_HOST:$REMOTE_SERVER_DIR/shine-server.jar.new"
rsync -az --timeout=30 "$TMP_DIR/application.properties" "$TEST_HOST:$REMOTE_SERVER_DIR/application.properties.new"
rsync -az --timeout=30 "$TMP_DIR/shine-server.service" "$TEST_HOST:/tmp/shine-server.service.new"
echo "==> Применяем systemd unit и файлы сервера"
ssh "$TEST_HOST" "set -euo pipefail; \
mv -f '$REMOTE_SERVER_DIR/shine-server.jar.new' '$REMOTE_SERVER_DIR/shine-server.jar'; \
mv -f '$REMOTE_SERVER_DIR/application.properties.new' '$REMOTE_SERVER_DIR/application.properties'; \
sudo mv -f /tmp/shine-server.service.new /etc/systemd/system/shine-server.service; \
sudo chown root:root /etc/systemd/system/shine-server.service; \
chmod 644 '$REMOTE_SERVER_DIR/application.properties'; \
chmod 664 '$REMOTE_SERVER_DIR/shine-server.jar'; \
mkdir -p '$REMOTE_LOGS_DIR'; \
touch '$REMOTE_LOGS_DIR/app.log'; \
chown -R player:player '$REMOTE_SERVER_DIR'; \
sudo systemctl daemon-reload; \
sudo systemctl enable '$REMOTE_SERVICE_NAME'; \
sudo systemctl restart '$REMOTE_SERVICE_NAME'"
echo "==> Ждём порт 7070"
for _ in $(seq 1 50); do
if ssh "$TEST_HOST" "ss -ltn '( sport = :7070 )' | grep -q 7070"; then
echo "==> Порт 7070 поднялся"
break
fi
sleep 1
done
if ! ssh "$TEST_HOST" "ss -ltn '( sport = :7070 )' | grep -q 7070"; then
echo "ERROR: тестовый сервер не поднял порт 7070" >&2
exit 1
fi
echo "==> Проверяем статус сервиса"
ssh "$TEST_HOST" "sudo systemctl --no-pager --full status '$REMOTE_SERVICE_NAME' | sed -n '1,20p'"
echo "test_server_deploy_done"

View File

@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
PROD_HOST="${PROD_HOST:-player@shineup.me}"
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}"
REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}"
REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}"
REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}"
REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}"
REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}"
LOCAL_JAR="${LOCAL_JAR:-SHiNE-server/build/libs/shine-server.jar}"
PROD_DATA_DIR="${PROD_DATA_DIR:-/home/player/SHiNE/shine-server/data}"
PROD_APP_PROPS="${PROD_APP_PROPS:-/home/player/SHiNE/shine-server/application.properties}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
if [[ ! -f "$LOCAL_JAR" ]]; then
echo "ERROR: локальный jar не найден: $LOCAL_JAR" >&2
exit 1
fi
ssh -o BatchMode=yes -o ConnectTimeout=20 "$PROD_HOST" "echo SSH OK" >/dev/null
ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null
ssh "$TARGET_HOST" "sudo -n true"
ssh "$TARGET_HOST" "java -version >/dev/null 2>&1"
mkdir -p "$TMP_DIR/data"
rsync -az --delete "$PROD_HOST:$PROD_DATA_DIR/" "$TMP_DIR/data/"
rsync -az "$PROD_HOST:$PROD_APP_PROPS" "$TMP_DIR/application.properties"
perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties"
cat >"$TMP_DIR/shine-server.service" <<EOF
[Unit]
Description=SHiNE Server
After=network.target
[Service]
Type=simple
User=player
Group=player
WorkingDirectory=$REMOTE_SERVER_DIR
ExecStart=/usr/bin/java -Dserver.port=7070 -jar $REMOTE_SERVER_DIR/shine-server.jar
Restart=always
RestartSec=3
StandardOutput=append:$REMOTE_LOGS_DIR/app.log
StandardError=append:$REMOTE_LOGS_DIR/app.log
[Install]
WantedBy=multi-user.target
EOF
TARGET_HOST="$TARGET_HOST" TARGET_DOMAIN="$TARGET_DOMAIN" REMOTE_UI_DIR="$REMOTE_UI_DIR" \
bash "$(dirname "$0")/scripts/install_test2_caddyfile.sh"
ssh "$TARGET_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_UI_DIR'"
rsync -az --delete "$TMP_DIR/data/" "$TARGET_HOST:$REMOTE_DATA_DIR/"
rsync -az --timeout=120 "$LOCAL_JAR" "$TARGET_HOST:$REMOTE_SERVER_DIR/shine-server.jar"
rsync -az "$TMP_DIR/application.properties" "$TARGET_HOST:$REMOTE_SERVER_DIR/application.properties"
rsync -az "$TMP_DIR/shine-server.service" "$TARGET_HOST:/tmp/shine-server.service"
ssh "$TARGET_HOST" "set -euo pipefail; \
sudo mv -f /tmp/shine-server.service /etc/systemd/system/shine-server.service; \
sudo chown root:root /etc/systemd/system/shine-server.service; \
touch '$REMOTE_LOGS_DIR/app.log'; \
chown -R player:player '$REMOTE_SERVER_DIR'; \
sudo systemctl daemon-reload; \
sudo systemctl enable '$REMOTE_SERVICE_NAME'; \
sudo systemctl restart '$REMOTE_SERVICE_NAME'"

20
deploy_shine-ui_test.sh Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
TEST_HOST="${TEST_HOST:-player@93.170.12.154}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test.shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
echo "==> Подготовка Caddy для $TARGET_DOMAIN"
TEST_HOST="$TEST_HOST" \
TARGET_DOMAIN="$TARGET_DOMAIN" \
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
bash "$(dirname "$0")/scripts/install_test_caddyfile.sh"
echo "==> Деплой UI на $TARGET_DOMAIN"
REMOTE_HOST="$TEST_HOST" \
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
EXPECTED_CADDY_UI_ROOT="$REMOTE_UI_DIR" \
EXPECTED_CADDY_SITE="$TARGET_DOMAIN" \
TARGET_URL="https://$TARGET_DOMAIN" \
bash "$(dirname "$0")/deploy_shine-PWA.sh"

21
deploy_shine-ui_test2.sh Normal file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
TARGET_HOST="$TARGET_HOST" TARGET_DOMAIN="$TARGET_DOMAIN" REMOTE_UI_DIR="$REMOTE_UI_DIR" \
bash "$(dirname "$0")/scripts/install_test2_caddyfile.sh"
REMOTE_HOST="$TARGET_HOST" \
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
EXPECTED_CADDY_UI_ROOT="$REMOTE_UI_DIR" \
EXPECTED_CADDY_SITE="$TARGET_DOMAIN" \
TARGET_URL="https://$TARGET_DOMAIN" \
bash "$(dirname "$0")/deploy_shine-PWA.sh"
ssh "$TARGET_HOST" "sudo chmod o+x /home/player /home/player/SHiNE '$REMOTE_UI_DIR'; \
sudo find '$REMOTE_UI_DIR' -type d -exec chmod o+rx {} +; \
sudo find '$REMOTE_UI_DIR' -type f -exec chmod o+r {} +"

View File

@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
cat >"$TMP_DIR/Caddyfile" <<EOF
{
auto_https disable_redirects
}
agent.shiningpeople.ru {
redir / /agent/index.html 308
redir /agent /agent/index.html 308
handle_path /agent/* {
reverse_proxy 127.0.0.1:8765
}
}
$TARGET_DOMAIN {
encode zstd gzip
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:7070
}
handle {
root * $REMOTE_UI_DIR
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 * $REMOTE_UI_DIR
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"
}
}
}
EOF
ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null
ssh "$TARGET_HOST" "sudo -n true"
rsync -az "$TMP_DIR/Caddyfile" "$TARGET_HOST:/tmp/caddy-test2.new"
ssh "$TARGET_HOST" "set -euo pipefail; \
sudo mv -f /tmp/caddy-test2.new '$REMOTE_CADDYFILE'; \
sudo chown root:root '$REMOTE_CADDYFILE'; \
sudo caddy validate --config '$REMOTE_CADDYFILE'; \
sudo systemctl restart caddy"

View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
TEST_HOST="${TEST_HOST:-player@93.170.12.154}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test.shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
cat >"$TMP_DIR/Caddyfile" <<EOF
{
auto_https disable_redirects
}
$TARGET_DOMAIN {
encode zstd gzip
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:7070
}
handle {
root * $REMOTE_UI_DIR
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 * $REMOTE_UI_DIR
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"
}
}
}
EOF
echo "==> Проверка SSH и sudo на $TEST_HOST"
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TEST_HOST" "echo SSH OK" >/dev/null
ssh "$TEST_HOST" "sudo -n true"
echo "==> Установка Caddy-конфига для $TARGET_DOMAIN"
scp -p "$TMP_DIR/Caddyfile" "$TEST_HOST:/tmp/shine-test-caddyfile.new" >/dev/null
ssh "$TEST_HOST" "sudo mkdir -p \"$(dirname "$REMOTE_CADDYFILE")\" && \
sudo mv -f /tmp/shine-test-caddyfile.new \"$REMOTE_CADDYFILE\" && \
sudo chown root:root \"$REMOTE_CADDYFILE\" && \
sudo caddy validate --config \"$REMOTE_CADDYFILE\" && \
sudo systemctl reload caddy"
echo "==> Caddy настроен для $TARGET_DOMAIN"

View File

@ -100,6 +100,7 @@ self.addEventListener('push', (event) => {
const json = decodePushJson(rawText);
const callId = String(json.callId || '').trim();
const fromSessionId = String(json.fromSessionId || '').trim();
const targetSessionId = String(json.targetSessionId || '').trim();
const toLogin = String(json.toLogin || '').trim();
const reason = String(json.reason || '').trim();
const sentAtMs = Number(json.sentAtMs || 0);
@ -139,6 +140,7 @@ self.addEventListener('push', (event) => {
callId,
fromLogin,
fromSessionId,
targetSessionId,
toLogin,
sentAtMs,
expiresAtMs,
@ -165,6 +167,7 @@ self.addEventListener('push', (event) => {
body,
fromLogin,
fromSessionId,
targetSessionId,
toLogin,
callId,
sentAtMs,
@ -186,6 +189,7 @@ self.addEventListener('notificationclick', (event) => {
callId: String(data.callId || '').trim(),
fromLogin: String(data.fromLogin || '').trim(),
fromSessionId: String(data.fromSessionId || '').trim(),
targetSessionId: String(data.targetSessionId || '').trim(),
toLogin: String(data.toLogin || '').trim(),
sentAtMs: Number(data.sentAtMs || 0),
expiresAtMs: Number(data.expiresAtMs || 0),

View File

@ -29,12 +29,14 @@ import {
addSignedMessageToChat,
markIncomingReadByBaseKey,
markOutgoingReadByBaseKey,
normalizeDmChatId,
setContacts,
} from './state.js';
import * as startView from './pages/start-view.js?v=202606142105';
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
import * as registerView from './pages/register-view.js';
import * as registerView from './pages/register-view.js?v=202606201650';
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
import * as registrationKeysView from './pages/registration-keys-view.js';
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
@ -43,7 +45,7 @@ import * as devnetTopupView from './pages/devnet-topup-view.js';
import * as loginView from './pages/login-view.js?v=202606150110';
import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
import * as loginPasswordView from './pages/login-password-view.js';
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
import * as keyStorageView from './pages/key-storage-view.js';
import * as profileView from './pages/profile-view.js';
@ -81,6 +83,7 @@ const routes = {
'start-view': startView,
'entry-settings-view': entrySettingsView,
'register-view': registerView,
'registration-faq-view': registrationFaqView,
'registration-payment-view': registrationPaymentView,
'registration-keys-view': registrationKeysView,
'registration-draft-keys-view': registrationDraftKeysView,
@ -269,6 +272,13 @@ function savePendingCallPushAction(action, payload = {}) {
}
}
function isCallPushTargetForCurrentSession(payload = {}) {
const targetSessionId = String(payload?.targetSessionId || '').trim();
if (!targetSessionId) return true;
const currentSessionId = String(state?.session?.sessionId || '').trim();
return Boolean(currentSessionId) && currentSessionId === targetSessionId;
}
function loadPendingCallPushAction() {
try {
const raw = localStorage.getItem(CALL_PUSH_PENDING_ACTION_KEY);
@ -322,6 +332,7 @@ async function processPendingCallPushActionIfPossible() {
if (!state.session.isAuthorized) return;
const pending = loadPendingCallPushAction();
if (!pending) return;
if (!isCallPushTargetForCurrentSession(pending.payload || {})) return;
clearPendingCallPushAction();
try {
await handleCallPushAction(pending.action, pending.payload || {});
@ -827,6 +838,7 @@ async function init() {
const action = String(data.action || '').trim().toLowerCase();
const payload = data.payload || {};
if (action === 'accept' || action === 'decline') {
if (!isCallPushTargetForCurrentSession(payload)) return;
savePendingCallPushAction(action, payload);
void processPendingCallPushActionIfPossible();
}
@ -835,6 +847,7 @@ async function init() {
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {};
if (!isCallPushTargetForCurrentSession(payload)) return;
const kind = String(payload.kind || '').trim();
const now = Date.now();
try {
@ -900,7 +913,7 @@ async function init() {
const fromLogin = parsed.fromLogin || '';
const toLogin = parsed.toLogin || '';
const messageType = Number(parsed.messageType || 0);
const chatId = messageType === 2 ? toLogin : fromLogin;
const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin);
const text = (messageType === 1 || messageType === 2)
? String(parsed.text || '')
: '';

View File

@ -1,3 +1,4 @@
import { authService } from '../state.js';
import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import {
buildArweaveDataUrl,
@ -9,8 +10,11 @@ import {
validateSha256Hex,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
@ -72,6 +76,8 @@ export function openAvatarWizard({
let priceInfo = null;
let uploadedTxId = '';
let uploadedSha256Hex = '';
let uploadedInfoText = '';
let freeQuotaInfo = null;
function revokePreviewUrl() {
if (!lastPreviewUrl) return;
@ -105,10 +111,11 @@ export function openAvatarWizard({
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Сменить аватар</h3>
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave или загрузить новый файл.</p>
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave, загрузить новый файл со своего кошелька или попробовать временную бесплатную загрузку через сервер.</p>
<div class="avatar-wizard-choice-grid">
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-free">Залить аватар бесплатно</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
@ -121,6 +128,7 @@ export function openAvatarWizard({
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
};
const showStepExistingInput = () => {
@ -339,6 +347,7 @@ export function openAvatarWizard({
uploadBtn.disabled = true;
try {
uploadedInfoText = '';
const uploaded = await uploadArweaveFile({
gateway: cleanGateway,
jwk: walletCtx?.jwk,
@ -363,6 +372,169 @@ export function openAvatarWizard({
});
};
const showStepFreeLimitExhausted = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Лимит исчерпан</h3>
<p class="meta-muted">Вы исчерпали бесплатный лимит аватарок.</p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepFreeUploadForm = () => {
if (closed) return;
const remaining = Number(freeQuotaInfo?.remainingCount || 0);
const limit = Number(freeQuotaInfo?.limit || 3);
const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES);
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Залить аватар бесплатно</h3>
<p class="meta-muted">Осталось бесплатных загрузок: ${remaining} из ${limit}.</p>
<label class="meta-muted" for="avatar-free-file-input">Выберите изображение</label>
<input class="input" id="avatar-free-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед отправкой изображение уменьшается для аватарки. Итоговый файл должен быть не больше ${formatBytes(maxBytes)}.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">Это временная тестовая бесплатная загрузка через серверный кошелёк Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Залить бесплатно</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-free-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
optimized = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
const selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
if (Number(optimized?.file?.size || 0) > maxBytes) {
throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`);
}
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
`;
uploadBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.'));
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
uploadBtn.disabled = true;
try {
const fileBytes = new Uint8Array(await optimized.file.arrayBuffer());
const uploaded = await authService.uploadTestFreeAvatar({
contentType: optimized.contentType,
fileBytesBase64: bytesToBase64(fileBytes),
sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(),
});
uploadedTxId = String(uploaded.txId || '').trim();
uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase();
uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`;
if (!uploadedTxId) {
throw new Error('Сервер не вернул Transaction ID.');
}
showStepUploaded();
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.'));
uploadBtn.disabled = false;
}
});
};
const showStepFreeUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка бесплатной загрузки</h3>
<p class="meta-muted" data-loading="true">Проверяем остаток бесплатных загрузок...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
freeQuotaInfo = await authService.getTestFreeAvatarQuota();
} catch (error) {
setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.');
return;
}
if (!freeQuotaInfo?.enabled) {
setNodeText(loadingEl, '');
setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.');
return;
}
if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) {
showStepFreeLimitExhausted();
return;
}
showStepFreeUploadForm();
};
const showStepUploaded = () => {
if (closed) return;
root.innerHTML = `
@ -373,6 +545,7 @@ export function openAvatarWizard({
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
<p class="meta-muted">SHA-256:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
${uploadedInfoText ? `<p class="meta-muted">${escapeHtml(uploadedInfoText)}</p>` : ''}
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>

View File

@ -10,6 +10,7 @@ import {
markChatRead,
markOutgoingSent,
markReadReceiptSentByBaseKey,
normalizeDmChatId,
authService,
setContacts,
state,
@ -71,7 +72,7 @@ function openMessageActionsMenu({
const menuId = `chat-message-actions-menu-${Date.now()}`;
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
<div class="modal-card stack dm-dialog-card dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
@ -334,11 +335,12 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
}
export function render({ navigate, route }) {
const chatId = route.params.chatId || 'u1';
const contact = directMessages.find((d) => d.id === chatId) || {
const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
id: chatId,
name: chatId,
initials: (chatId[0] || '?').toUpperCase(),
name: String(routeChatId || chatId),
initials: (String(routeChatId || chatId)[0] || '?').toUpperCase(),
};
const screen = document.createElement('section');
@ -579,6 +581,10 @@ export function render({ navigate, route }) {
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
scrollToLatestMessage(log);
window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 90);
window.setTimeout(() => scrollToLatestMessage(log), 220);
addAppLogEntry({
level: 'info',
source: 'outgoing-dm',

View File

@ -61,9 +61,11 @@ function createSearchAvatar(login) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-search-screen';
let searchTimer = 0;
let searchSeq = 0;
const input = document.createElement('input');
input.className = 'input dm-input';
input.className = 'input dm-input contact-search-input';
input.type = 'text';
input.name = 'contact';
input.placeholder = 'Введите начало логина';
@ -71,26 +73,28 @@ export function render({ navigate }) {
input.maxLength = 80;
const resultsCard = document.createElement('section');
resultsCard.className = 'card stack dm-dialog-card';
resultsCard.className = 'card stack contact-search-results-card';
resultsCard.hidden = true;
const status = document.createElement('p');
status.className = 'meta-muted';
status.className = 'contact-search-results-title';
const resultsList = document.createElement('div');
resultsList.className = 'stack dm-list';
const renderResults = (matches, query) => {
resultsList.innerHTML = '';
resultsCard.hidden = false;
if (!query.trim()) {
status.textContent = 'Введите начало логина пользователя.';
status.textContent = '';
resultsCard.hidden = true;
return;
}
resultsCard.hidden = false;
if (!matches.length) {
status.textContent = 'Совпадений не найдено.';
status.textContent = 'Найдено пользователей: 0';
return;
}
@ -101,11 +105,10 @@ export function render({ navigate }) {
row.className = 'list-item dm-dialog-card';
const avatarEl = createSearchAvatar(login);
row.innerHTML = `
<div>
<strong>${login}</strong>
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
<div class="contact-search-result-main">
<strong class="dm-row-title">${login}</strong>
</div>
<div class="meta-muted">Профиль</div>
<span class="dm-chevron" aria-hidden="true"></span>
`;
row.prepend(avatarEl);
row.addEventListener('click', () => {
@ -115,12 +118,9 @@ export function render({ navigate }) {
});
};
const searchButton = document.createElement('button');
searchButton.className = 'primary-btn dm-send-btn';
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', async () => {
const runSearch = async () => {
const query = input.value.trim();
const seq = ++searchSeq;
if (!query) {
renderResults([], '');
return;
@ -128,11 +128,38 @@ export function render({ navigate }) {
try {
const logins = await authService.searchUsers(query);
if (seq !== searchSeq) return;
renderResults((logins || []).slice(0, 5), query);
} catch (e) {
if (seq !== searchSeq) return;
status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`;
resultsCard.hidden = false;
resultsList.innerHTML = '';
}
};
const scheduleSearch = () => {
if (searchTimer) window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
searchTimer = 0;
void runSearch();
}, 2000);
};
const searchButton = document.createElement('button');
searchButton.className = 'primary-btn dm-send-btn';
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', async () => {
if (searchTimer) {
window.clearTimeout(searchTimer);
searchTimer = 0;
}
await runSearch();
});
input.addEventListener('input', () => {
scheduleSearch();
});
const controls = document.createElement('div');
@ -140,7 +167,7 @@ export function render({ navigate }) {
controls.append(searchButton);
const formCard = document.createElement('section');
formCard.className = 'card stack dm-dialog-card';
formCard.className = 'card stack contact-search-form-card';
formCard.append(input, controls);
resultsCard.append(status, resultsList);
@ -154,5 +181,9 @@ export function render({ navigate }) {
resultsCard,
);
screen.cleanup = () => {
if (searchTimer) window.clearTimeout(searchTimer);
};
return screen;
}

View File

@ -14,6 +14,7 @@ import {
buildSessionAttachPayload,
deriveEspPairingPasswordHash,
encryptPairingPayloadForRequester,
formatPairingShortCode,
} from '../services/device-pairing-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@ -29,8 +30,10 @@ function setStatus(statusEl, message, kind = 'info') {
statusEl.style.display = message ? '' : 'none';
}
function normalizeCode(value) {
return String(value || '').replace(/\D+/g, '').slice(0, 7);
const SESSION_TYPE_WALLET = 50;
function pairingSessionKindLabel(sessionType) {
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
}
function buildTransferKeys(savedKeys, { withExtras = false }) {
@ -54,19 +57,19 @@ function buildTransferKeys(savedKeys, { withExtras = false }) {
}
function requestCardHtml(request) {
const shortCode = String(request?.shortCode || '').trim() || '0000000';
const shortCode = formatPairingShortCode(request?.shortCode || '');
const client = String(request?.requesterClientPlatform || 'unknown');
const requesterSessionType = Number(request?.requesterSessionType || 0);
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
const sessionOnly = requesterSessionType === 50;
const sessionOnly = requesterSessionType === SESSION_TYPE_WALLET;
const sessionKind = pairingSessionKindLabel(requesterSessionType);
return `
<div class="card stack" data-pairing-id="${String(request?.pairingId || '')}">
<div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px;">
<div style="font-size:28px; font-weight:700; letter-spacing:0.14em;">${shortCode}</div>
<span class="meta-muted">Платформа: ${client}</span>
<span class="meta-muted">Тип сессии: ${requesterSessionType || '—'}</span>
<span class="meta-muted">Тип payload: ${Number(request?.payloadType || 0)}</span>
<span class="meta-muted">Session name: ${client}</span>
<span class="meta-muted">Session kind: ${sessionKind}</span>
<span class="meta-muted">Истекает: ${expiresText}</span>
</div>
</div>
@ -198,14 +201,11 @@ export function render({ navigate }) {
const requestsCard = document.createElement('div');
requestsCard.className = 'card stack';
requestsCard.innerHTML = `
<div class="row" style="align-items:flex-end; gap:10px; flex-wrap:wrap;">
<label class="stack" style="flex:1 1 180px;">
<span class="field-label">Код нового устройства</span>
<input class="input" id="pairing-code-filter" inputmode="numeric" maxlength="7" placeholder="7 цифр" />
</label>
<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
<span class="field-label">Активные заявки</span>
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
</div>
<p class="meta-muted">Если код не введён, показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
<div class="stack" id="pairing-requests-list"></div>
`;
@ -213,7 +213,6 @@ export function render({ navigate }) {
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary');
const codeFilterInput = requestsCard.querySelector('#pairing-code-filter');
const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests');
const requestsListEl = requestsCard.querySelector('#pairing-requests-list');
@ -413,23 +412,17 @@ export function render({ navigate }) {
};
const renderRequests = () => {
const filterCode = normalizeCode(codeFilterInput.value);
const filtered = filterCode
? requests.filter((item) => normalizeCode(item?.shortCode) === filterCode)
: requests;
requestsListEl.innerHTML = '';
if (!filtered.length) {
if (!requests.length) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = filterCode
? 'Заявка с таким кодом пока не найдена.'
: 'Активных заявок сейчас нет.';
empty.textContent = 'Активных заявок сейчас нет.';
requestsListEl.append(empty);
return;
}
filtered.forEach((request) => {
requests.forEach((request) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = requestCardHtml(request);
requestsListEl.append(wrapper.firstElementChild);
@ -470,12 +463,12 @@ export function render({ navigate }) {
const approveRequest = async (request, mode) => {
const withExtras = mode === 'with-extras';
let payload;
if (!withExtras && Number(request?.requesterSessionType || 0) === 50) {
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
const delegatedSession = await authService.createDelegatedSessionWithDeviceKey({
login: state.session.login,
devicePrivPkcs8: String(savedKeys?.deviceKey || '').trim(),
sessionKey: String(request?.requesterSessionKey || '').trim(),
sessionType: Number(request?.requesterSessionType || 50) || 50,
sessionType: Number(request?.requesterSessionType || SESSION_TYPE_WALLET) || SESSION_TYPE_WALLET,
clientPlatform: String(request?.requesterClientPlatform || '').trim() || 'Wallet plugin',
clientInfo: 'Wallet session approved via device pairing',
});
@ -493,7 +486,7 @@ export function render({ navigate }) {
}
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === 50;
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
showToast(
withExtras
? 'Ключи переданы на новое устройство'
@ -600,10 +593,6 @@ export function render({ navigate }) {
refreshBtn.addEventListener('click', () => {
void reloadRequests();
});
codeFilterInput.addEventListener('input', () => {
codeFilterInput.value = normalizeCode(codeFilterInput.value);
renderRequests();
});
requestsListEl.addEventListener('click', async (event) => {
const target = event.target;

View File

@ -12,7 +12,12 @@ import {
terminateCurrentSession,
} from '../state.js';
import { showToast } from '../services/channels-ux.js';
import { decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash, createRequesterPairingMaterial } from '../services/device-pairing-service.js';
import {
decryptPairingPayloadFromEnvelope,
deriveEspPairingPasswordHash,
createRequesterPairingMaterial,
formatPairingShortCode,
} from '../services/device-pairing-service.js';
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@ -30,7 +35,7 @@ function codeCardHtml() {
return `
<div class="card stack">
<p class="field-label">Код подключения</p>
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.18em;">0000000</div>
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.12em;">00 00 00 00 00</div>
<p class="meta-muted" id="pairing-status-hint">Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».</p>
<p class="meta-muted" id="pairing-online-hint"></p>
<p class="meta-muted" id="pairing-expire-hint"></p>
@ -47,7 +52,7 @@ function formatRemaining(ms) {
function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) {
resultWrap.style.display = 'none';
shortCodeEl.textContent = '0000000';
shortCodeEl.textContent = formatPairingShortCode('');
statusHintEl.textContent = 'Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».';
onlineHintEl.textContent = '';
expireHintEl.textContent = '';
@ -55,7 +60,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack auth-screen auth-screen--lower';
let pollTimer = 0;
let countdownTimer = 0;
let activePairingId = '';
@ -72,6 +77,10 @@ export function render({ navigate }) {
}),
);
const panel = document.createElement('section');
panel.className = 'login-panel login-panel--wide stack';
panel.innerHTML = '<h1 class="login-panel-title">Войти через другое устройство</h1>';
const formCard = document.createElement('div');
formCard.className = 'card stack';
formCard.innerHTML = `
@ -327,7 +336,7 @@ export function render({ navigate }) {
throw new Error('Сервер не вернул pairingId.');
}
shortCodeEl.textContent = String(payload?.shortCode || '0000000');
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
onlineHintEl.textContent = payload?.trustedSessionOnline
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
@ -382,6 +391,7 @@ export function render({ navigate }) {
resultActions.append(cancelBtn);
resultWrap.append(resultActions);
screen.append(formCard, status, resultWrap);
panel.append(formCard, status, resultWrap);
screen.append(panel);
return screen;
}

View File

@ -7,6 +7,55 @@ import {
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
composePasswordFromWords,
emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
function createWordsLayout({ words, onInput }) {
const section = document.createElement('div');
section.className = 'registration-words-block';
const grid = document.createElement('div');
grid.className = 'registration-words-grid';
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
const row = document.createElement('label');
row.className = 'registration-word-row';
const number = document.createElement('span');
number.className = 'registration-word-number';
number.textContent = `${index + 1}.`;
const input = document.createElement('input');
input.className = 'input registration-word-input';
input.type = 'text';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 32;
input.value = words[index];
input.addEventListener('input', () => onInput(index, input.value));
row.append(number, input);
grid.append(row);
return input;
});
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent =
'Можно вводить любые слова на любых языках. Можно заполнить не все 12 полей. В конце они просто склеиваются в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
}
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
@ -19,6 +68,9 @@ export function render({ navigate }) {
const form = document.createElement('div');
form.className = 'card stack';
let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
const loginInput = document.createElement('input');
loginInput.className = 'input';
loginInput.type = 'text';
@ -35,38 +87,102 @@ export function render({ navigate }) {
passwordInput.autocomplete = 'new-password';
passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false;
passwordInput.value = state.loginDraft.password;
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
passwordInput.value = passwordMode === 'single' ? state.loginDraft.password : '';
passwordInput.placeholder = 'Введите пароль';
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
syncDraftState();
},
});
const passwordModeToggle = document.createElement('label');
passwordModeToggle.className = 'registration-toggle';
const passwordModeCheckbox = document.createElement('input');
passwordModeCheckbox.type = 'checkbox';
passwordModeCheckbox.checked = passwordMode === 'words';
const passwordModeLabel = document.createElement('span');
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.';
const advanced = document.createElement('details');
advanced.className = 'card stack';
advanced.innerHTML = `
<summary>Расширенные</summary>
<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">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
`;
hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const testLoginsHint = document.createElement('p');
testLoginsHint.className = 'meta-muted';
testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).';
let passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'status-line';
function getCurrentPassword() {
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
}
function syncDraftState() {
state.loginDraft.login = loginInput.value.trim();
state.loginDraft.passwordMode = passwordMode;
state.loginDraft.passwordWords = normalizePasswordWords(passwordWords);
state.loginDraft.password = getCurrentPassword();
}
function updateWordsPreview() {
const password = getCurrentPassword();
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
passwordInput.style.display = wordsMode ? 'none' : '';
updateWordsPreview();
}
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label>
`;
form.children[0].append(loginInput);
form.children[1].append(passwordInput);
form.append(hint, advanced, status, testLoginsHint);
passwordField = form.children[1];
passwordField.append(passwordInput);
form.append(passwordModeToggle, wordsSection, passwordLengthText, hint, status);
updatePasswordModeVisibility();
syncDraftState();
loginInput.addEventListener('input', syncDraftState);
passwordInput.addEventListener('input', syncDraftState);
passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
passwordInput.value = '';
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
}
passwordMode = nextMode;
updatePasswordModeVisibility();
syncDraftState();
});
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
@ -83,14 +199,18 @@ export function render({ navigate }) {
enterButton.textContent = 'Войти';
enterButton.addEventListener('click', async () => {
status.style.display = 'none';
state.loginDraft.login = loginInput.value.trim();
state.loginDraft.password = passwordInput.value;
syncDraftState();
if (!state.loginDraft.login) {
status.textContent = 'Введите логин.';
status.style.display = '';
return;
}
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) {
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
status.style.display = '';
return;
}
setAuthBusy(true);
setAuthError('');
@ -103,6 +223,8 @@ export function render({ navigate }) {
state.registrationDraft.flowType = 'login';
state.registrationDraft.login = result.login;
state.registrationDraft.password = state.loginDraft.password;
state.registrationDraft.passwordMode = state.loginDraft.passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = result.keyBundle;

View File

@ -4,29 +4,23 @@ export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome:
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const cameraButton = document.createElement('button');
cameraButton.className = 'primary-btn';
cameraButton.type = 'button';
cameraButton.textContent = 'Отсканировать QR-код';
cameraButton.addEventListener('click', () => navigate('login-camera-view'));
screen.className = 'stack auth-screen auth-screen--lower';
const loginButton = document.createElement('button');
loginButton.className = 'ghost-btn';
loginButton.type = 'button';
loginButton.textContent = 'Войти по логину';
loginButton.textContent = 'Войти по паролю';
loginButton.addEventListener('click', () => navigate('login-password-view'));
const otherDeviceButton = document.createElement('button');
otherDeviceButton.className = 'text-btn';
otherDeviceButton.className = 'ghost-btn';
otherDeviceButton.type = 'button';
otherDeviceButton.textContent = 'Войти через другое устройство';
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
const actions = document.createElement('div');
actions.className = 'auth-actions login-actions-wide';
actions.append(cameraButton, loginButton, otherDeviceButton);
actions.append(loginButton, otherDeviceButton);
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
@ -34,13 +28,17 @@ export function render({ navigate }) {
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
const panel = document.createElement('section');
panel.className = 'login-panel stack';
panel.innerHTML = '<h1 class="login-panel-title">Войти</h1>';
panel.append(actions, backButton);
screen.append(
renderHeader({
title: 'Войти',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
actions,
backButton,
panel,
);
return screen;

View File

@ -1,9 +1,15 @@
import { directMessages } from '../mock-data.js';
import { state } from '../state.js';
import {
getChatMessages,
isSessionInvalidError,
normalizeDmChatId,
setContacts,
state,
terminateCurrentSession,
} from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { resolveDmVisualState } from './messages/dm-visual-resolver.js';
import { makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const dmAvatarSnapshotCache = new Map();
@ -30,36 +36,24 @@ async function loadDmAvatarSnapshot(login) {
return pending;
}
// Аватар: реальное фото через renderUserAvatar (lazy), при отсутствии — аккуратные инициалы.
// Ошибка загрузки снапшота не ломает карточку (catch → инициалы остаются).
function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) {
function createDmAvatar(login) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
// Инициалы для fallback берём из имени диалога («Марина К.» → «МК»), а не из служебного id.
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
const firstName = parts[0] || '';
const lastName = parts[1] || '';
const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', firstName, lastName, size: 'small', title });
// Тестовое фото (demo, «как в Связях»): pravatar поверх инициалов через .has-image; офлайн/ошибка → инициалы.
const photoUrl = String(photo || '').trim();
if (photoUrl) {
const img = document.createElement('img');
// eager (не lazy): аватары у верха списка; lazy в некоторых движках/headless не догружает фото.
img.alt = ''; img.loading = 'eager'; img.decoding = 'async';
img.addEventListener('load', () => avatarEl.classList.add('has-image'));
img.addEventListener('error', () => { try { img.remove(); } catch (e) { /* остаются инициалы */ } avatarEl.classList.remove('has-image'); });
img.src = photoUrl;
avatarEl.append(img);
}
// upgrade=false (demo/lab) → остаёмся на тестовом фото/инициалах, без сетевого запроса профиля.
if (!cleanLogin || !upgrade) return avatarEl;
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'small',
title,
});
if (!cleanLogin) return avatarEl;
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
login: cleanLogin,
firstName, lastName,
avatar: snapshot?.avatar?.txId
? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
title,
@ -70,19 +64,24 @@ function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) {
return avatarEl;
}
// Иконки: галочка-подтверждён (gold, БЕЗ текста), цепочка (значок «связь через кого»), шеврон.
const SVG_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8.4 12.4l2.5 2.5 4.7-5.1"/></svg>';
const SVG_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.5 13a4 4 0 0 0 5.66 0l1.84-1.84a4 4 0 1 0-5.66-5.66l-1 1"/><path d="M13.5 11a4 4 0 0 0-5.66 0L6 12.84a4 4 0 1 0 5.66 5.66l1-1"/></svg>';
function formatChatRowTime(ts) {
const value = Number(ts || 0);
if (!Number.isFinite(value) || value <= 0) return '';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
}
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
export function render({ navigate, route }) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'dm-screen dm-list-screen';
// Слева сверху — имя владельца аккаунта (реальный логин из сессии).
screen.className = 'stack dm-screen dm-list-screen';
const login = String(state.session.login || '').trim();
// DM-шапка: grid 1fr auto 1fr (бренд слева, title строго по центру, «+» справа).
const head = document.createElement('header');
head.className = 'dm-head';
head.innerHTML = `
@ -92,129 +91,141 @@ export function render({ navigate, route }) {
<span class="dm-head-name">${login}</span>
</div>
</div>
<h1 class="dm-head-title dm-head-shine">Shine</h1>
<h1 class="dm-head-title">Контакты</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
`;
head.querySelector('.dm-head-plus').addEventListener('click', () => navigate('contact-search-view'));
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
const divider = document.createElement('div');
divider.className = 'dm-divider';
const list = document.createElement('div');
list.className = 'dm-list';
list.className = 'stack dm-list';
function renderRow(item) {
const v = resolveDmVisualState(item); // { tone, shining, confirmed, via, unread }
const cardVariant = v.tone === 'family' ? ' dm-card--family' : (v.tone === 'shining' ? ' dm-card--shining' : '');
const name = item.name || item.id;
const preview = (item.preview || item.lastMessage || '') || 'Диалог пока пуст.';
const row = document.createElement('article');
row.className = `dm-dialog-card${cardVariant}`;
row.tabIndex = 0;
row.setAttribute('role', 'button');
// Галочка-подтверждён — у имени, БЕЗ слова «Подтверждён».
const checkHtml = v.confirmed ? `<span class="dm-name-check" title="Подтверждён" aria-label="Подтверждён">${SVG_CHECK}</span>` : '';
const unreadHtml = v.unread ? `<span class="dm-unread-badge">${v.unread.label}</span>` : '';
row.className = 'list-item dm-dialog-card';
const avatarEl = createDmAvatar(item.id);
avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default';
avatarWrap.append(avatarEl);
row.innerHTML = `
<div class="dm-row-main">
<div class="dm-row-titleline">
<strong class="dm-row-title">${name}</strong>
${checkHtml}
<div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title">${item.name}</strong>
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
</div>
<p class="dm-row-last-message">${item.lastMessage}</p>
</div>
<div class="dm-row-meta-col">
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
<div class="dm-row-meta-line">
${item.time ? `<span class="dm-row-time">${item.time}</span>` : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span>
</div>
<p class="dm-row-last-message">${preview}</p>
</div>
<div class="dm-row-meta">${unreadHtml}<span class="dm-chevron">${SVG_CHEVRON}</span></div>
`;
// Значок «связь через кого» (вместо слова «Связь»): цепочка + мини-аватар первого посредника, сразу за галочкой.
// Тап (stopPropagation) → попап пути «Ты → … → он»; сама карточка по-прежнему открывает чат.
if (v.via && v.via.length) {
const titleline = row.querySelector('.dm-row-titleline');
const viaBtn = document.createElement('button');
viaBtn.type = 'button';
viaBtn.className = 'dm-via';
viaBtn.setAttribute('aria-label', `Связь через ${v.via.map((x) => x.name).join(', ')}`);
viaBtn.innerHTML = `<span class="dm-via-icon">${SVG_LINK}</span>`; // только иконка (без мини-аватара/«+N»)
titleline.appendChild(viaBtn);
// Попап пути: Ты → …посредники… → целевой. Каждый узел — фото-аватар + имя.
// Тап по человеку (кроме «Ты») → его профиль (makeProfileRoute), чтобы отследить цепочку.
const pop = document.createElement('div');
pop.className = 'dm-via-path';
const chain = [
{ name: 'Ты', me: true },
...v.via.map((x) => ({ name: x.name, login: x.login || '', photo: x.photo || '' })),
{ name, login: item.login || item.id, photo: item.photo || '' },
];
chain.forEach((node, i) => {
if (i) {
const arr = document.createElement('span');
arr.className = 'dm-via-arrow';
arr.textContent = '→';
pop.appendChild(arr);
}
const clickable = !node.me && Boolean(node.login);
const el = document.createElement(clickable ? 'button' : 'span');
el.className = 'dm-via-node';
const ava = document.createElement('span');
ava.className = 'dm-via-node-ava';
if (node.me) {
const me = document.createElement('span');
me.className = 'dm-via-me';
me.textContent = (login[0] || 'A').toUpperCase();
ava.appendChild(me);
} else {
ava.appendChild(createDmAvatar(node.login || node.name, { upgrade: false, name: node.name, photo: node.photo }));
}
const nm = document.createElement('span');
nm.className = 'dm-via-node-name';
nm.textContent = node.name;
el.append(ava, nm);
if (clickable) {
el.type = 'button';
el.addEventListener('click', (e) => { e.stopPropagation(); navigate(makeProfileRoute(node.login)); });
}
pop.appendChild(el);
});
row.appendChild(pop);
const toggle = (e) => { e.stopPropagation(); pop.classList.toggle('is-open'); };
viaBtn.addEventListener('click', toggle);
viaBtn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); }
});
}
// Аватар: фото/инициалы без кольца; у сияющего — свечение (класс is-shine).
const avWrap = document.createElement('div');
avWrap.className = `dm-av dm-av--${v.tone}${v.shining ? ' is-shine' : ''}`;
const avatarEl = createDmAvatar(item.id, { upgrade: true, name });
avatarEl.classList.add('avatar');
avWrap.appendChild(avatarEl);
row.prepend(avWrap);
const go = () => navigate(`chat-view/${encodeURIComponent(item.id)}`);
row.addEventListener('click', go);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
});
row.prepend(avatarWrap);
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(normalizeDmChatId(item.id))}`));
return row;
}
// Источник списка — мок directMessages (плейсхолдер). На проде заменяется реальными
// relations/chats (relationFlagsForTarget/shineConfirmed/shine) — карточки и резолвер не меняются.
const items = Array.isArray(directMessages) ? directMessages : [];
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Пока нет диалогов';
list.append(empty);
} else {
items.forEach((item) => list.append(renderRow(item)));
async function loadList() {
try {
const relations = await loadCurrentRelations();
const contacts = relations.outContacts || [];
setContacts(contacts);
list.innerHTML = '';
const contactRows = contacts.map((login) => {
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const canonicalLogin = normalizeDmChatId(login);
const chat = getChatMessages(canonicalLogin);
const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return {
id: canonicalLogin,
name: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: false,
};
});
const allChatIds = Object.keys(state.chats || {})
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
.filter((id) => (getChatMessages(id) || []).length > 0);
const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase()));
const extraRows = allChatIds
.filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
.map((login) => {
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return {
id: login,
name: login,
lastMessage: lastChat?.text || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: true,
};
});
const rows = [...contactRows, ...extraRows];
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Пока нет ни контактов, ни сообщений';
list.append(empty);
return;
}
rows.forEach((item) => list.append(renderRow(item)));
} catch (error) {
if (isSessionInvalidError(error)) {
list.innerHTML = '';
const card = document.createElement('div');
card.className = 'card stack';
const title = document.createElement('strong');
title.textContent = 'Сессия устарела';
const details = document.createElement('p');
details.className = 'meta-muted';
details.textContent = 'Ваша сессия больше не действует. Авторизуйтесь заново.';
const okBtn = document.createElement('button');
okBtn.type = 'button';
okBtn.className = 'primary-btn';
okBtn.textContent = 'ОК';
okBtn.addEventListener('click', async () => {
await terminateCurrentSession({
infoMessage: 'Ваша сессия устарела. Выполните вход заново.',
});
navigate('start-view');
});
card.append(title, details, okBtn);
list.append(card);
return;
}
list.innerHTML = '';
const fail = document.createElement('div');
fail.className = 'card meta-muted';
fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`;
list.append(fail);
}
}
screen.append(head, divider, list);
loadList();
return screen;
}

View File

@ -15,6 +15,8 @@
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js';
import { state } from '../../state.js';
import { buildArweaveDataUrl } from '../../services/arweave-file-service.js';
const SVGNS = 'http://www.w3.org/2000/svg';
@ -105,6 +107,26 @@ function relationColor(relationType) {
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
}
function resolveAvatarPhotoSrc(src) {
const directPhoto = String(src?.photo || '').trim();
if (directPhoto) return directPhoto;
const rawAvatar = src?.avatar;
if (!rawAvatar || rawAvatar === 'url_to_image') return null;
if (typeof rawAvatar === 'string') return String(rawAvatar).trim() || null;
const txId = String(rawAvatar?.ar || '').trim();
if (!txId) return null;
try {
return buildArweaveDataUrl({
gateway: state?.entrySettings?.arweaveServer,
txId,
});
} catch {
return null;
}
}
// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
function ensureShineFilter() {
@ -487,7 +509,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Аватар = SVG-«стеклянный орб» (фото в стеклянной сфере). Хост — .node-dot (масштаб/состояния/
// синхронизация позиций движка). Меняем ТОЛЬКО аватар; бейдж/имя/линии — как раньше.
const photoSrc = src.photo || (src.avatar && src.avatar !== 'url_to_image' ? src.avatar : null);
const photoSrc = resolveAvatarPhotoSrc(src);
const initials = buildAvatarInitials({ login: src.login || src.name || String(src.id), firstName: src.name || '' });
const dot = document.createElement('div');
dot.className = 'avatar node-dot fg-orb-host';

View File

@ -599,8 +599,6 @@ export function render({ navigate }) {
}
async function onChangeAvatarClick() {
const confirmed = window.confirm('Сменить аватар?');
if (!confirmed) return;
status.className = 'status-line';
status.textContent = 'Открываем мастер аватара...';

View File

@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
@ -6,9 +6,59 @@ import {
formatSolanaErrorDetails,
precheckLoginClassOnSolana,
} from '../services/solana-register-service.js';
import {
composePasswordFromWords,
emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
function createWordsLayout({ words, onInput }) {
const section = document.createElement('div');
section.className = 'registration-words-block';
const grid = document.createElement('div');
grid.className = 'registration-words-grid';
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
const row = document.createElement('label');
row.className = 'registration-word-row';
const number = document.createElement('span');
number.className = 'registration-word-number';
number.textContent = `${index + 1}.`;
const input = document.createElement('input');
input.className = 'input registration-word-input';
input.type = 'text';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 32;
input.value = words[index];
input.addEventListener('input', () => onInput(index, input.value));
row.append(number, input);
grid.append(row);
return input;
});
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent =
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@ -18,6 +68,9 @@ export function render({ navigate }) {
const form = document.createElement('div');
form.className = 'card stack';
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords);
const loginInput = document.createElement('input');
loginInput.className = 'input';
loginInput.type = 'text';
@ -34,8 +87,33 @@ export function render({ navigate }) {
passwordInput.autocomplete = 'new-password';
passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false;
passwordInput.value = state.registrationDraft.password;
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
passwordInput.value = passwordMode === 'single' ? state.registrationDraft.password : '';
passwordInput.placeholder = 'Введите пароль';
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
syncDraftState();
},
});
const passwordModeToggle = document.createElement('label');
passwordModeToggle.className = 'registration-toggle';
const passwordModeCheckbox = document.createElement('input');
passwordModeCheckbox.type = 'checkbox';
passwordModeCheckbox.checked = passwordMode === 'words';
const passwordModeLabel = document.createElement('span');
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const statusText = document.createElement('p');
statusText.className = 'meta-muted';
@ -47,35 +125,85 @@ export function render({ navigate }) {
<p class="field-label">Первый сервер SHiNE</p>
<p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</p>
<p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || 'https://shineup.me'}</strong>.</p>
<p class="meta-muted">При регистрации этот сервер будет записан в вашу PDA как первый сервер доступа.</p>
<p class="meta-muted">При желании его можно изменить в настройках до регистрации или позже. В будущем будет поддерживаться мультисерверная модель, но сейчас используется один основной сервер.</p>
<p class="meta-muted">Это первый сервер, на который вам будут писать и звонить после регистрации. Позже его можно будет сменить, а в будущем использовать и несколько серверов сразу.</p>
`;
const faqCard = document.createElement('div');
faqCard.className = 'card stack registration-faq-card';
const faqTitle = document.createElement('p');
faqTitle.className = 'field-label';
faqTitle.textContent = 'Частые вопросы перед регистрацией';
const faqText = document.createElement('p');
faqText.className = 'meta-muted';
faqText.textContent = 'Если хотите подробнее понять схему деривации, ключи, первый сервер и формат 12 слов, откройте отдельный экран с вопросами.';
const faqButton = document.createElement('button');
faqButton.className = 'ghost-btn';
faqButton.type = 'button';
faqButton.textContent = 'Частые вопросы';
faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation'));
faqCard.append(faqTitle, faqText, faqButton);
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
formError.style.display = 'none';
const advanced = document.createElement('details');
advanced.className = 'card stack';
advanced.innerHTML = `
<summary>Расширенные</summary>
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.</p>
<p class="meta-muted">Если пароль пустой используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
`;
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить логин';
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
const nextButton = document.createElement('button');
nextButton.className = 'primary-btn';
nextButton.type = 'button';
nextButton.textContent = 'Далее';
let passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'status-line';
let lastCheckedLogin = '';
let lastCheckedFree = false;
let lastCheckedClassName = '';
let generationRunId = 0;
function getCurrentPassword() {
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
}
function updateWordsPreview() {
const password = getCurrentPassword();
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
passwordInput.style.display = wordsMode ? 'none' : '';
updateWordsPreview();
}
function syncDraftState() {
state.registrationDraft.login = String(loginInput.value.trim());
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.password = getCurrentPassword();
}
async function runAvailabilityCheck() {
const login = loginInput.value.trim();
if (!login) {
@ -87,19 +215,19 @@ export function render({ navigate }) {
if (login === lastCheckedLogin) {
if (!lastCheckedFree) {
statusText.textContent = 'Логин уже занят ❌';
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
} else if (lastCheckedClassName === 'free') {
statusText.textContent = 'Логин свободен ✅';
statusText.className = 'is-available';
statusText.className = 'status-line is-available';
} else if (lastCheckedClassName === 'premium') {
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
} else if (lastCheckedClassName === 'company') {
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
} else {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
}
formError.style.display = 'none';
return lastCheckedFree && lastCheckedClassName === 'free';
@ -132,21 +260,21 @@ export function render({ navigate }) {
lastCheckedClassName = className;
if (!isFree) {
statusText.textContent = 'Логин уже занят ❌';
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
} else if (className === 'free') {
statusText.textContent = precheckWarning
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
: 'Логин свободен ✅';
statusText.className = 'is-available';
statusText.className = 'status-line is-available';
} else if (className === 'premium') {
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
} else if (className === 'company') {
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
} else {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
}
formError.style.display = 'none';
return isFree && className === 'free';
@ -154,7 +282,7 @@ export function render({ navigate }) {
const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error);
statusText.textContent = `${base}. Детали: ${details}`;
statusText.className = 'is-unavailable';
statusText.className = 'status-line is-unavailable';
return false;
} finally {
checkButton.disabled = false;
@ -164,19 +292,32 @@ export function render({ navigate }) {
checkButton.addEventListener('click', runAvailabilityCheck);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
loginInput.addEventListener('input', () => {
syncDraftState();
lastCheckedLogin = '';
});
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
passwordInput.addEventListener('input', () => {
syncDraftState();
});
passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
passwordInput.value = '';
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
}
passwordMode = nextMode;
updatePasswordModeVisibility();
syncDraftState();
});
const nextButton = document.createElement('button');
nextButton.className = 'primary-btn';
nextButton.type = 'button';
nextButton.textContent = 'Далее';
nextButton.addEventListener('click', async () => {
formError.style.display = 'none';
const isFree = await runAvailabilityCheck();
@ -185,16 +326,23 @@ export function render({ navigate }) {
const prevLogin = String(state.registrationDraft.login || '');
const prevPassword = String(state.registrationDraft.password || '');
const nextLogin = String(loginInput.value.trim());
const nextPassword = String(passwordInput.value || '');
const nextPassword = getCurrentPassword();
if (nextPassword.length === 0) {
formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.';
formError.style.display = '';
return;
}
if (nextPassword.length > PASSWORD_MAX_LENGTH) {
formError.textContent = `Пароль получился слишком длинным. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
formError.style.display = '';
return;
}
const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword;
state.registrationDraft.login = nextLogin;
state.registrationDraft.password = nextPassword;
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
if (credsChanged) {
state.registrationDraft.preGeneratedKeyBundle = null;
}
@ -202,20 +350,20 @@ export function render({ navigate }) {
renderSecurityConfirmStage();
});
actions.append(backButton, nextButton);
function renderInputStage() {
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label>
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
`;
form.children[0].append(loginInput);
form.children[1].append(passwordInput);
form.append(serverNotice, checkButton, statusText, advanced, formError);
const loginField = form.children[0];
passwordField = form.children[1];
loginField.append(loginInput);
passwordField.append(passwordInput);
form.append(passwordModeToggle, wordsSection, passwordLengthText, serverNotice, checkButton, statusText, faqCard, formError);
actions.innerHTML = '';
actions.append(backButton, nextButton);
backButton.disabled = false;
nextButton.disabled = false;
updatePasswordModeVisibility();
syncDraftState();
}
function renderSecurityConfirmStage() {
@ -223,8 +371,7 @@ export function render({ navigate }) {
const info = document.createElement('p');
info.className = 'auth-copy';
info.textContent =
'Для повышения безопасности мы генерируем секрет из вашего пароля с помощью Argon2id.';
info.textContent = 'Для повышения безопасности мы генерируем секрет из вашего логина и пароля с помощью Argon2id.';
const details = document.createElement('p');
details.className = 'meta-muted';
@ -232,14 +379,17 @@ export function render({ navigate }) {
const details2 = document.createElement('p');
details2.className = 'meta-muted';
details2.textContent =
'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
details2.textContent = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
const details3 = document.createElement('p');
details3.className = 'meta-muted';
details3.textContent = 'Это необходимо, чтобы усложнить подбор пароля и секрета.';
details3.textContent = 'Замедление нужно специально: оно усложняет подбор пароля и повышает цену атак на видеокартах и GPU.';
form.append(info, details, details2, details3);
const details4 = document.createElement('p');
details4.className = 'meta-muted';
details4.textContent = `Длина вашего текущего пароля: ${getCurrentPassword().length} символов.`;
form.append(info, details, details2, details3, details4);
const back2 = document.createElement('button');
back2.className = 'ghost-btn';
@ -270,17 +420,10 @@ export function render({ navigate }) {
subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.';
const progressWrap = document.createElement('div');
progressWrap.style.width = '100%';
progressWrap.style.height = '10px';
progressWrap.style.border = '1px solid rgba(180,180,180,.5)';
progressWrap.style.borderRadius = '6px';
progressWrap.style.overflow = 'hidden';
progressWrap.className = 'registration-progress';
const progressBar = document.createElement('div');
progressBar.style.height = '100%';
progressBar.style.width = '0%';
progressBar.style.background = 'rgba(80, 160, 255, 0.9)';
progressBar.style.transition = 'width 180ms linear';
progressBar.className = 'registration-progress-bar';
progressWrap.append(progressBar);
const progressText = document.createElement('p');

View File

@ -0,0 +1,229 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
export const pageMeta = { id: 'registration-faq-view', title: 'Вопросы о регистрации', showAppChrome: false };
export const REGISTRATION_FAQ_TOPICS = [
{
id: 'keys-storage',
shortTitle: 'Где ключи',
title: 'У кого хранятся ключи?',
paragraphs: [
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
],
},
{
id: 'reliability',
shortTitle: 'Надёжность',
title: 'Насколько это надёжно?',
paragraphs: [
'Мы делаем ставку на открытость: клиентский код открыт, серверный код открыт, протокол открыт. Это позволяет проверять систему независимо, а не верить обещаниям на слово.',
'Мы рекомендуем использовать браузеры с открытым исходным кодом. Позже планируются отдельные приложения для Android, iPhone, Ubuntu Touch и Linux, тоже с открытым кодом.',
'Проект распространяется по лицензии AGPL v3. Часть важных данных и регистрационных записей также опирается на блокчейн-слой, чтобы уменьшать зависимость от одной закрытой стороны.',
],
},
{
id: 'key-derivation',
shortTitle: 'Деривация',
title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
'Уже из этого секрета детерминированно строятся три основных ключа: root key, blockchain key и device key.',
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
],
},
{
id: 'three-keys',
shortTitle: 'Три ключа',
title: 'Зачем нужны три ключа?',
paragraphs: [
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.',
'Device key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
],
},
{
id: 'generation-time',
shortTitle: 'Зачем время',
title: 'Зачем нужна заметная пауза при генерации?',
paragraphs: [
'Генерация специально сделана не мгновенной. Это усложняет массовый подбор паролей.',
'Argon2id расходует и время, и память, поэтому атаки на GPU и видеокартах становятся заметно дороже и медленнее.',
'Небольшая задержка при создании секрета здесь работает как дополнительная защита.',
],
},
{
id: 'strong-password',
shortTitle: 'Какой пароль',
title: 'Какой пароль считается надёжным?',
paragraphs: [
'Минимально разумный уровень сейчас начинается примерно от 8 символов.',
'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.',
'Если вам удобнее думать словами, можно использовать режим из 12 полей ниже: слова просто склеиваются в один длинный пароль, и система не проверяет орфографию.',
],
},
{
id: 'one-or-twelve',
shortTitle: '1 или 12 слов',
title: 'Чем отличается один пароль от режима 12 слов?',
paragraphs: [
'Технически ничем: это один и тот же пароль. Режим 12 слов нужен только для удобства запоминания и ввода.',
'Можно заполнить все 12 полей, можно только первые 6, можно использовать слова от другого кошелька, разные языки и любые нестандартные символы.',
'Главное помнить, что в конце всё равно получается одна строка длиной до 256 символов.',
],
},
{
id: 'why-own-password',
shortTitle: 'Зачем свой',
title: 'Почему лучше иметь свой пароль и свои ключи?',
paragraphs: [
'Чем дальше, тем проще будет подделывать фотографию, голос, интонацию и даже другие привычные признаки личности с помощью нейросетей.',
'На расстоянии всё сложнее будет понять, что перед вами действительно вы, если опираться только на внешние признаки.',
'Поэтому персональные ключи, которые храните только вы, становятся надёжнее, чем зависимость от сторонней организации, которая держит ключи у себя.',
],
},
{
id: 'first-server',
shortTitle: 'Первый сервер',
title: 'Что такое первый сервер SHiNE?',
paragraphs: [
'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.',
'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.',
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
],
},
{
id: 'hardware-device',
shortTitle: 'ESP32',
title: 'Нужно ли отдельное устройство для ключей?',
paragraphs: [
'Идеальный вариант для важных ключей: отдельное физическое устройство, которое вы контролируете сами.',
'Если пока не хотите покупать отдельное устройство, можно пользоваться телефоном. Но отдельный контроллер или мини-устройство обычно даёт лучший контроль и более понятную модель доверия.',
'Красивая готовая модель Waveshare на ESP32-S3 Touch AMOLED 2.16 стоит около 32 долларов. Есть и более дешёвые варианты на открытых чипах, примерно от 10 до 15 долларов.',
'Если у вас другая модель, под неё можно адаптировать открытую прошивку. Для простых переносов это реально сделать довольно быстро.',
],
links: [
{
label: 'Документация Waveshare ESP32-S3 Touch AMOLED 2.16',
href: 'https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16',
},
],
},
{
id: 'wallet-device',
shortTitle: 'Кошелёк',
title: 'Можно ли использовать такое устройство как кошелёк?',
paragraphs: [
'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.',
'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.',
],
},
];
function getTopicById(topicId) {
return REGISTRATION_FAQ_TOPICS.find((topic) => topic.id === topicId) || REGISTRATION_FAQ_TOPICS[0];
}
export function openRegistrationFaq(navigate, topicId) {
state.registrationHelp.selectedTopic = getTopicById(topicId).id;
navigate('registration-faq-view');
}
export function render({ navigate }) {
const selectedTopic = getTopicById(state.registrationHelp?.selectedTopic);
const screen = document.createElement('section');
screen.className = 'stack';
const heroCard = document.createElement('div');
heroCard.className = 'card stack registration-faq-hero';
heroCard.innerHTML = `
<div class="badge alt">Вопросы о регистрации</div>
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
`;
const topicCard = document.createElement('div');
topicCard.className = 'card stack registration-faq-topic';
const question = document.createElement('h2');
question.className = 'registration-faq-title';
question.textContent = selectedTopic.title;
topicCard.append(question);
selectedTopic.paragraphs.forEach((paragraph) => {
const p = document.createElement('p');
p.className = 'auth-copy';
p.textContent = paragraph;
topicCard.append(p);
});
if (Array.isArray(selectedTopic.links) && selectedTopic.links.length > 0) {
selectedTopic.links.forEach((linkItem) => {
const link = document.createElement('a');
link.className = 'link-card';
link.href = linkItem.href;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = linkItem.label;
topicCard.append(link);
});
}
const topicsCard = document.createElement('div');
topicsCard.className = 'card stack';
const topicsLabel = document.createElement('p');
topicsLabel.className = 'field-label';
topicsLabel.textContent = 'Другие вопросы';
const topicsGrid = document.createElement('div');
topicsGrid.className = 'registration-faq-grid';
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
const button = document.createElement('button');
button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn';
button.type = 'button';
button.textContent = topic.shortTitle;
button.addEventListener('click', () => {
state.registrationHelp.selectedTopic = topic.id;
navigate('registration-faq-view');
});
topicsGrid.append(button);
});
topicsCard.append(topicsLabel, topicsGrid);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('register-view'));
const registerButton = document.createElement('button');
registerButton.className = 'primary-btn';
registerButton.type = 'button';
registerButton.textContent = 'К регистрации';
registerButton.addEventListener('click', () => navigate('register-view'));
actions.append(backButton, registerButton);
screen.append(
renderHeader({
title: 'Вопросы о регистрации',
leftAction: { label: '←', onClick: () => navigate('register-view') },
}),
heroCard,
topicCard,
topicsCard,
actions,
);
return screen;
}

View File

@ -11,6 +11,7 @@ import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
export function render({ navigate }) {
const screen = document.createElement('section');
@ -122,8 +123,12 @@ export function render({ navigate }) {
state.loginDraft.login = state.registrationDraft.login;
state.loginDraft.password = '';
state.loginDraft.passwordMode = 'single';
state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.flowType = '';
state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;

View File

@ -6,6 +6,7 @@ export const PRE_AUTH_PAGES = [
'start-view',
'entry-settings-view',
'register-view',
'registration-faq-view',
'registration-payment-view',
'registration-draft-keys-view',
'registration-keys-view',

View File

@ -2208,6 +2208,22 @@ export class AuthService {
return response.payload || {};
}
async getTestFreeAvatarQuota() {
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
return response.payload || {};
}
async uploadTestFreeAvatar({ contentType, fileBytesBase64, sha256Hex }) {
const response = await this.ws.request('TestUploadFreeAvatar', {
contentType,
fileBytesBase64,
sha256Hex,
}, 60000);
if (response.status !== 200) throw opError('TestUploadFreeAvatar', response);
return response.payload || {};
}
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
const cleanKind = String(kind || '').trim().toLowerCase();
const kinds = CONNECTION_SUBTYPES[cleanKind];

View File

@ -362,6 +362,7 @@ function startTone(nextToneName) {
function getCallStateSnapshot() {
const call = getActiveCall();
if (!call) return null;
if (call.localUiDismissed) return null;
const callPhase = String(call.phase || '').trim();
return {
callId: call.callId,
@ -386,6 +387,12 @@ function notifyCallState() {
});
}
function dismissCallUiLocally(call) {
if (!call) return;
call.localUiDismissed = true;
notifyCallState();
}
function setStatus(call, statusText, phase = '') {
if (!call) return;
call.statusText = String(statusText || '').trim();
@ -831,6 +838,9 @@ async function finalizeCall(call, {
localReasonCode = 'error',
debugReason = '',
notifyRemoteHangup = false,
suppressRemoteSignal = false,
suppressReports = false,
suppressSummary = false,
} = {}) {
if (!call) return;
const diagnosticsBeforeClose = getCallDiagnosticsContext(call);
@ -839,7 +849,8 @@ async function finalizeCall(call, {
stopTone();
const shouldNotifyRemoteFailure =
!notifyRemoteHangup
!suppressRemoteSignal
&& !notifyRemoteHangup
&& Boolean(call.remoteSessionId)
&& String(localReasonCode || '') !== 'completed'
&& String(debugReason || '') !== 'remote_hangup';
@ -868,7 +879,7 @@ async function finalizeCall(call, {
}
const reasonText = debugReason || localReasonCode;
if (String(localReasonCode || '') !== 'completed') {
if (!suppressReports && String(localReasonCode || '') !== 'completed') {
const failureStage = call.phase || '';
const failureContext = {
failureStage,
@ -890,7 +901,9 @@ async function finalizeCall(call, {
}
}
pushCallSummary(call, localReasonCode);
if (!suppressSummary) {
pushCallSummary(call, localReasonCode);
}
call.phase = 'ended';
call.statusText = 'Звонок завершён';
@ -1128,6 +1141,13 @@ function isIncomingCallPushFresh(payload) {
return true;
}
function isCallPushForCurrentSession(payload = {}) {
const targetSessionId = String(payload?.targetSessionId || '').trim();
if (!targetSessionId) return true;
const currentSessionId = String(state?.session?.sessionId || '').trim();
return Boolean(currentSessionId) && currentSessionId === targetSessionId;
}
async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
const callId = String(payload?.callId || '').trim();
const fromLogin = String(payload?.fromLogin || '').trim();
@ -1440,10 +1460,18 @@ export async function acceptIncomingCall() {
export async function declineIncomingCall() {
const call = getActiveCall();
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
try {
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
} catch {}
await finalizeCall(call, { localReasonCode: 'declined', debugReason: 'declined_by_user' });
dismissCallUiLocally(call);
const declinePromise = (async () => {
try {
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
} catch {}
})();
await finalizeCall(call, {
localReasonCode: 'declined',
debugReason: 'declined_by_user',
suppressRemoteSignal: true,
});
await declinePromise;
}
export async function handleIncomingCallSignal(evt) {
@ -1486,14 +1514,35 @@ export async function handleIncomingCallSignal(evt) {
}
}
} else {
const remoteSessionLocked = Boolean(
call.initialOfferSent
|| call.connectedAtMs
|| call.phase === 'connecting'
|| call.phase === 'active'
|| call.phase === 'reconnecting',
);
const terminalSignalFromAnotherSession =
Boolean(call.remoteSessionId)
&& Boolean(fromSessionId)
&& call.remoteSessionId !== fromSessionId
&& (type === TYPES.DECLINE_BUSY || type === TYPES.TIMEOUT || type === TYPES.HANGUP);
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
await emitDebug(
call,
'info',
'signal_from_non_selected_session_ignored',
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
);
return;
if (terminalSignalFromAnotherSession && !remoteSessionLocked) {
await emitDebug(
call,
'info',
'terminal_signal_from_non_selected_session_allowed_before_lock',
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
);
} else {
await emitDebug(
call,
'info',
'signal_from_non_selected_session_ignored',
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
);
return;
}
}
if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
@ -1619,6 +1668,7 @@ export async function handleIncomingCallSignal(evt) {
export async function hangupActiveCall() {
if (!activeCallId) return;
const call = getCall(activeCallId);
dismissCallUiLocally(call);
await finalizeCall(call, {
localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer',
debugReason: 'hangup_by_user',
@ -1627,23 +1677,35 @@ export async function hangupActiveCall() {
}
export async function handleIncomingCallPush(payload = {}) {
if (!isCallPushForCurrentSession(payload)) return;
if (!isIncomingCallPushFresh(payload)) return;
await handleIncomingInvitePayload(payload, { source: 'push' });
}
export async function handleStopCallPush(payload = {}) {
if (!isCallPushForCurrentSession(payload)) return;
const callId = String(payload?.callId || '').trim();
if (!callId) return;
const call = getCall(callId);
if (!call) return;
const fromSessionId = String(payload?.fromSessionId || '').trim();
const reason = String(payload?.reason || 'stop_call_push').trim() || 'stop_call_push';
const currentSessionId = String(state?.session?.sessionId || '').trim();
if (fromSessionId && currentSessionId && fromSessionId === currentSessionId) {
await emitDebug(call, 'info', 'stop_call_push_ignored_for_origin_session', reason);
return;
}
await finalizeCall(call, {
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
debugReason: `stop_call_push:${reason}`,
suppressRemoteSignal: true,
suppressReports: true,
suppressSummary: true,
});
}
export async function handleCallPushAction(action, payload = {}) {
if (!isCallPushForCurrentSession(payload)) return;
const normalized = String(action || '').trim().toLowerCase();
if (normalized !== 'accept' && normalized !== 'decline') return;
if (!isIncomingCallPushFresh(payload)) return;

View File

@ -101,6 +101,15 @@ export async function createRequesterPairingMaterial() {
};
}
export function normalizePairingShortCode(value, digits = 10) {
return String(value || '').replace(/\D+/g, '').slice(0, digits).padStart(digits, '0');
}
export function formatPairingShortCode(value) {
const normalized = normalizePairingShortCode(value, 10);
return normalized.match(/.{1,2}/g)?.join(' ') || normalized;
}
export async function encryptPairingPayloadForRequester(requesterSessionKey, payload) {
const requesterPublicB64 = extractSessionPublicKeyB64(requesterSessionKey);
const requesterMontPub = edwardsToMontgomeryPub(base64ToBytes(requesterPublicB64));

View File

@ -0,0 +1,18 @@
export const PASSWORD_WORDS_COUNT = 12;
export const PASSWORD_MAX_LENGTH = 256;
export function normalizePasswordWords(wordsLike) {
const words = Array.isArray(wordsLike) ? wordsLike : [];
return Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => String(words[index] || ''));
}
export function composePasswordFromWords(wordsLike) {
return normalizePasswordWords(wordsLike)
.map((word) => word.trim())
.filter(Boolean)
.join(' ');
}
export function emptyPasswordWords() {
return Array.from({ length: PASSWORD_WORDS_COUNT }, () => '');
}

View File

@ -7,6 +7,7 @@ import {
DEFAULT_SHINE_SERVER_WS,
resolveShineServerByServerLogin,
} from './services/shine-server-resolver.js';
import { emptyPasswordWords } from './services/password-words.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
@ -91,6 +92,10 @@ const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
export function normalizeDmChatId(value) {
return String(value || '').trim().toLowerCase();
}
function normalizeToolsSettings(rawTools) {
const source = rawTools && typeof rawTools === 'object' ? rawTools : {};
const stt = source.speechToText && typeof source.speechToText === 'object' ? source.speechToText : {};
@ -260,15 +265,22 @@ function createInitialState({ withStoredSession = true } = {}) {
flowType: '',
login: '',
password: '',
passwordMode: 'single',
passwordWords: emptyPasswordWords(),
sessionId: '',
storagePwd: '',
pendingKeyBundle: null,
pendingSessionMaterial: null,
preGeneratedKeyBundle: null,
},
registrationHelp: {
selectedTopic: 'keys-storage',
},
loginDraft: {
login: storedSession?.login || '',
password: '',
passwordMode: 'single',
passwordWords: emptyPasswordWords(),
},
registrationPayment: {
walletAddress: '',
@ -368,11 +380,12 @@ function sortChatMessagesInPlace(chatId) {
}
function persistMessageRecord(chatId, row) {
if (!chatId || !row?.messageKey) return;
const normalizedChatId = normalizeDmChatId(chatId);
if (!normalizedChatId || !row?.messageKey) return;
const resolvedTs = resolveChatMessageTimeMs(row);
void putStoredMessage({
messageKey: row.messageKey,
chatId,
chatId: normalizedChatId,
from: row.from || 'in',
text: String(row.text || ''),
baseKey: String(row.baseKey || ''),
@ -400,7 +413,7 @@ export async function hydrateMessagesFromStore() {
rows
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
.forEach((row) => {
const chatId = String(row?.chatId || '').trim();
const chatId = normalizeDmChatId(row?.chatId);
const messageKey = String(row?.messageKey || '').trim();
if (!chatId || !messageKey) return;
if (state.knownMessageKeys[messageKey]) return;
@ -429,10 +442,12 @@ export async function hydrateMessagesFromStore() {
}
export function getChatMessages(chatId) {
if (!state.chats[chatId]) {
state.chats[chatId] = [];
const normalizedChatId = normalizeDmChatId(chatId);
if (!normalizedChatId) return [];
if (!state.chats[normalizedChatId]) {
state.chats[normalizedChatId] = [];
}
return state.chats[chatId];
return state.chats[normalizedChatId];
}
export function addChatMessage(chatId, text) {
@ -575,9 +590,10 @@ export function addSignedMessageToChat({
revisionTimeMs = 0,
deleted = false,
} = {}) {
const normalizedChatId = normalizeDmChatId(chatId);
const id = String(messageKey || '').trim();
if (!chatId || !id) return false;
const list = getChatMessages(chatId);
if (!normalizedChatId || !id) return false;
const list = getChatMessages(normalizedChatId);
const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id);
const existing = existingIndex >= 0 ? list[existingIndex] : null;
const nextRevision = Number(revisionTimeMs || 0);
@ -591,7 +607,7 @@ export function addSignedMessageToChat({
if (existingIndex >= 0) {
list.splice(existingIndex, 1);
removeStoredMessageRecord(id);
sortChatMessagesInPlace(chatId);
sortChatMessagesInPlace(normalizedChatId);
return true;
}
return false;
@ -615,17 +631,18 @@ export function addSignedMessageToChat({
if (existingIndex < 0) {
list.push(row);
}
sortChatMessagesInPlace(chatId);
persistMessageRecord(chatId, row);
sortChatMessagesInPlace(normalizedChatId);
persistMessageRecord(normalizedChatId, row);
return true;
}
export function markChatRead(chatId) {
const list = getChatMessages(chatId);
const normalizedChatId = normalizeDmChatId(chatId);
const list = getChatMessages(normalizedChatId);
list.forEach((row) => {
if (row?.from === 'in') {
row.unread = false;
persistMessageRecord(chatId, row);
persistMessageRecord(normalizedChatId, row);
}
});
}

View File

@ -407,6 +407,11 @@
text-align: center;
}
.auth-screen--lower {
align-content: start;
padding-top: clamp(80px, 18vh, 180px);
}
.auth-logo {
width: 126px;
height: 126px;
@ -434,6 +439,22 @@
width: 100%;
}
.login-panel {
width: min(100%, 360px);
gap: 14px;
}
.login-panel--wide {
width: min(100%, 420px);
}
.login-panel-title {
font-size: 28px;
font-weight: 700;
color: var(--text);
text-align: center;
}
.auth-footer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -1168,6 +1189,35 @@
gap: 8px;
}
.contact-search-form-card,
.contact-search-results-card {
margin: 0 6px;
padding: 14px;
border-radius: 24px;
background: rgba(7, 10, 18, 0.88);
border: 1px solid rgba(140, 99, 255, 0.24);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.34);
}
.contact-search-input {
min-height: 48px;
border-radius: 16px;
padding: 0 14px;
}
.contact-search-results-title {
font-size: 14px;
font-weight: 700;
color: rgba(252, 234, 192, 0.92);
text-align: center;
}
.contact-search-result-main {
min-width: 0;
display: flex;
align-items: center;
}
.input {
border: 1px solid var(--line);
border-radius: 12px;
@ -3573,8 +3623,8 @@ textarea.input {
grid-template-columns: 60px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-height: 92px; /* карточки не ужимаем; тап-зона ≥44px */
padding: 14px 16px 14px 14px;
min-height: 74px;
padding: 10px 12px 10px 10px;
border-radius: 26px;
background: rgba(7, 10, 18, 0.88);
backdrop-filter: blur(24px);
@ -3636,6 +3686,7 @@ textarea.input {
.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-head-sub { font-size: 11px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-head-title { font-size: 18px; font-weight: 700; color: var(--text); white-space: nowrap; text-align: center; padding: 0 6px; }
.dm-list-screen .dm-head-title { color: #FCEAC0; text-shadow: 0 0 6px rgba(240, 184, 46, 0.32), 0 0 14px rgba(240, 184, 46, 0.12); }
/* Центр шапки — светящийся бренд «Shine» */
.dm-head-shine {
font-size: 21px; letter-spacing: 0.6px; color: #FCEAC0;
@ -3648,7 +3699,7 @@ textarea.input {
}
@media (prefers-reduced-motion: reduce) { .dm-head-shine { animation: none; } }
.dm-head-plus {
justify-self: end; width: 48px; height: 48px; border-radius: 15px;
justify-self: end; width: 48px; height: 48px; border-radius: 50%;
display: grid; place-items: center; font-size: 24px; line-height: 1; font-weight: 300;
color: #FFD98A; border: 1.5px solid rgba(240, 184, 46, 0.6);
background: rgba(12, 12, 16, 0.66);
@ -3660,20 +3711,34 @@ textarea.input {
.dm-divider::after { content: ''; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; transform: translate(-50%, -50%) rotate(45deg); background: var(--rel-family); box-shadow: 0 0 8px var(--rel-family-glow); }
/* список: скролл внутри контента, карточки не ужимаем, отступ снизу под bottom nav (86px) + 16px */
.dm-list { display: flex; flex-direction: column; gap: 8px; padding: 0 14px; padding-bottom: calc(86px + 16px); }
.dm-list { display: flex; flex-direction: column; gap: 8px; padding: 0 6px; padding-bottom: calc(86px + 16px); }
/* текст карточки */
.dm-row-main { min-width: 0; }
.dm-row-titleline { display: flex; align-items: center; gap: 6px; min-width: 0; }
.dm-row-titlewrap { flex-wrap: wrap; row-gap: 6px; }
.dm-dialog-card .dm-row-title { font-size: 16px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-dialog-card .dm-row-last-message { font-size: 14px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-contact-note {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(244, 246, 255, 0.62);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
/* галочка-подтверждён у имени (золотая, без слова «Подтверждён») */
.dm-name-check { display: inline-flex; flex: 0 0 auto; color: var(--rel-family); }
.dm-name-check svg { width: 16px; height: 16px; }
/* AvatarRing — обод/свечение по тону (адаптация орбов «Связей», без тяжёлой магии) */
.dm-av { width: 60px; height: 60px; border-radius: 50%; display: grid; place-items: center; position: relative; }
.dm-av .avatar { width: 56px; height: 56px; min-width: 56px; min-height: 56px; border: none; box-shadow: none; }
.dm-av { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; position: relative; }
.dm-av .avatar { width: 50px; height: 50px; min-width: 50px; min-height: 50px; border: none; box-shadow: none; }
/* Цветные обводки вокруг аватаров (violet/gold) убраны по просьбе. Свечение оставляем только у сияющих (ниже). */
.dm-av--default { box-shadow: none; }
.dm-av--family { box-shadow: none; }
@ -3697,10 +3762,35 @@ textarea.input {
.dm-av--shining::before { animation: none; }
}
/* правая зона: один статус сверху, ниже [unread + chevron] */
/* правая зона: один горизонтальный ряд [статус][unread][chevron] на общей оси */
.dm-dialog-card .dm-row-meta { display: inline-flex; align-items: center; gap: 8px; min-width: 0; white-space: nowrap; }
.dm-dialog-card .dm-row-meta .dm-chevron { margin-left: 4px; } /* отступ бейдж→chevron ≈ 12px */
.dm-row-meta-col {
display: inline-flex;
min-width: 0;
align-self: stretch;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
gap: 4px;
}
.dm-row-meta-line {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
}
.dm-row-meta-spacer {
width: 24px;
height: 24px;
}
.dm-row-time {
font-size: 12px;
font-weight: 600;
color: rgba(244, 246, 255, 0.44);
}
.dm-row-time--empty {
width: 0;
min-width: 0;
overflow: hidden;
}
/* непрочитанные — отдельная violet-сфера (НЕ изумруд) */
.dm-unread-badge {
min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px;
@ -3711,6 +3801,17 @@ textarea.input {
.dm-chevron { display: inline-flex; color: rgba(244, 246, 255, 0.32); }
.dm-chevron svg { width: 16px; height: 16px; }
@media (max-width: 380px) {
.dm-dialog-card {
grid-template-columns: 60px minmax(0, 1fr);
row-gap: 10px;
}
.dm-row-meta-col {
grid-column: 2;
justify-self: end;
}
}
/* Значок «связь через кого» — ТОЛЬКО иконка (кликабельная); детали пути в попапе ниже */
.dm-via {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
@ -3894,9 +3995,14 @@ html, body { overflow-x: hidden; }
}
.dm-message-actions-menu {
width: min(52vw, 240px);
padding: 8px;
gap: 6px;
width: min(72vw, 220px);
padding: 10px;
gap: 8px;
border-radius: 16px;
border: 1px solid rgba(212, 175, 55, 0.22);
background: rgba(10, 12, 18, 0.96);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
.dm-floating-menu-layer {
@ -3925,7 +4031,10 @@ html, body { overflow-x: hidden; }
.dm-message-action-btn {
width: 100%;
min-height: 42px;
justify-content: flex-start;
padding: 0 14px;
border-radius: 12px;
}
.dm-message-action-btn--danger {
@ -4073,6 +4182,118 @@ html, body { overflow-x: hidden; }
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
}
.registration-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(132, 162, 228, 0.22);
background: rgba(20, 31, 52, 0.72);
color: #eef3ff;
line-height: 1.4;
}
.registration-toggle input {
width: 18px;
height: 18px;
accent-color: #d4af37;
}
.registration-words-block[hidden] {
display: none;
}
.registration-words-block {
display: grid;
gap: 10px;
}
.registration-words-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.registration-word-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.registration-word-number {
font-size: 12px;
color: #b2c2e6;
min-width: 18px;
text-align: right;
}
.registration-word-input {
min-height: 44px;
padding-left: 10px;
}
.registration-faq-card {
gap: 12px;
}
.registration-faq-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.registration-faq-grid .ghost-btn,
.registration-faq-grid .secondary-btn {
min-height: 44px;
text-align: left;
}
@media (max-width: 740px) {
.registration-words-grid,
.registration-faq-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 460px) {
.registration-words-grid,
.registration-faq-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.registration-faq-hero {
gap: 12px;
}
.registration-faq-topic {
gap: 12px;
}
.registration-faq-title {
margin: 0;
font-size: 22px;
line-height: 1.2;
color: #f6deb0;
}
.registration-progress {
width: 100%;
height: 10px;
border: 1px solid rgba(180, 180, 180, 0.5);
border-radius: 6px;
overflow: hidden;
}
.registration-progress-bar {
height: 100%;
width: 0%;
background: rgba(80, 160, 255, 0.9);
transition: width 180ms linear;
}
/* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */
.toolbar-icon-img {
--tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */