Compare commits
15 Commits
ba5efcc152
...
475db28095
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
475db28095 | ||
|
|
823a41c027 | ||
|
|
2a834f1b14 | ||
|
|
c8ffb6cf29 | ||
|
|
ecc9efd434 | ||
|
|
dd35e56029 | ||
|
|
d0e7998650 | ||
|
|
fec5e49304 | ||
|
|
3b12e14e71 | ||
|
|
86eaf2139d | ||
|
|
65fad993ad | ||
|
|
55e6e477be | ||
|
|
a788d8bcf5 | ||
|
|
cc074a941f | ||
|
|
47574100f9 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -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/
|
||||
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@ -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>
|
||||
18
AGENTS.md
18
AGENTS.md
@ -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` (клиент → сервер).
|
||||
|
||||
@ -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==",
|
||||
|
||||
@ -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 для входа в существующую сессию |
|
||||
|
||||
176
Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md
Normal file
176
Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md
Normal 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...`;
|
||||
- сценарий нужен как переходный бесплатный путь для маленьких аватаров.
|
||||
@ -45,4 +45,4 @@
|
||||
|
||||
### Дальнее будущее
|
||||
|
||||
- Сейчас задач нет.
|
||||
- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
|
||||
|
||||
@ -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-файлообмену.
|
||||
@ -1,5 +1,7 @@
|
||||
# Дальнее будущее
|
||||
|
||||
Сейчас в этом горизонте нет активных идей.
|
||||
|
||||
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
|
||||
|
||||
## Идеи
|
||||
|
||||
- `2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через сервер SHiNE или WebRTC DataChannel, а также файловый обмен чанками с хранением на SD-карте.
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
# Быстрое скрытие экрана звонка и остановка гудков при отклонении
|
||||
|
||||
- краткое описание фичи:
|
||||
- Исправлена нижняя подпись вкладки личных сообщений на `личные`.
|
||||
- Исправлена логика звонка, когда одна из входящих сессий отклоняет вызов до принятия: исходящая сессия теперь должна прекращать гудки даже если ранее `RINGING` пришёл от другой входящей сессии.
|
||||
- На устройстве, где пользователь нажимает отмену/сброс звонка, экран вызова теперь скрывается сразу локально, без ожидания сетевого ответа.
|
||||
|
||||
- что именно проверять:
|
||||
- В нижней панели с 5 кнопками подпись первой кнопки должна отображаться как `личные`.
|
||||
- Сценарий: один пользователь звонит, у второго входящий вызов приходит на несколько устройств; на любом одном входящем устройстве нажать `Сбросить`.
|
||||
- Проверить, что у звонящего сразу прекращаются гудки и экран вызова корректно завершается.
|
||||
- Проверить, что на устройстве, где нажали `Сбросить` или `Положить трубку`, overlay звонка исчезает сразу, без заметной задержки.
|
||||
- Проверить, что после принятия звонка на одном устройстве поздние отмены с других устройств не ломают уже выбранную пару соединения.
|
||||
|
||||
- ожидаемый результат:
|
||||
- Подпись в нижней панели корректная.
|
||||
- При отклонении входящего звонка любым устройством звонящего не оставляет в состоянии бесконечных гудков.
|
||||
- Локальный экран звонка скрывается мгновенно после нажатия кнопки отмены.
|
||||
- Уже зафиксированный сценарий соединения после `ACCEPT` не сбивается другими сессиями.
|
||||
|
||||
- статус:
|
||||
- `pending`
|
||||
@ -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
|
||||
@ -0,0 +1,15 @@
|
||||
# Фикс самообрыва звонка из-за `stop_call` push своей же сессии
|
||||
|
||||
- краткое описание:
|
||||
- исправлена ситуация, когда активный звонок мог оборваться сразу после соединения;
|
||||
- причина была в том, что `stop_call` push, предназначенный для других сессий того же пользователя, обрабатывался и в исходной сессии.
|
||||
- что проверять:
|
||||
- открыть несколько вкладок/устройств одного пользователя;
|
||||
- принять звонок на одной сессии;
|
||||
- убедиться, что активная сессия не обрывает звонок сразу после соединения;
|
||||
- убедиться, что лишние сессии при этом закрывают свой локальный экран звонка.
|
||||
- ожидаемый результат:
|
||||
- звонок не завершается сразу после `call_connected`;
|
||||
- `accepted_on_other_device` и связанные `stop_call` события больше не убивают исходную активную сессию.
|
||||
- статус:
|
||||
- pending
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -0,0 +1,25 @@
|
||||
# Регистрация: FAQ и режим пароля из 12 слов
|
||||
|
||||
- краткое описание:
|
||||
- на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки;
|
||||
- добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API;
|
||||
- такой же режим добавлен и на экран входа по логину и паролю.
|
||||
|
||||
- что проверять:
|
||||
- на стартовом экране открыть `Зарегистрироваться`;
|
||||
- убедиться, что внизу экрана есть кнопки FAQ;
|
||||
- открыть несколько вопросов и проверить возврат обратно на регистрацию;
|
||||
- включить галочку `Представить пароль в виде 12 слов`;
|
||||
- убедиться, что появляется сетка с нумерованными полями в 3 колонки;
|
||||
- ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают;
|
||||
- выключить галочку и проверить, что пароль остаётся собранным в одном поле;
|
||||
- открыть экран входа по паролю и повторить те же проверки для режима `12 слов`;
|
||||
- пройти регистрацию до шага оплаты без ошибок интерфейса.
|
||||
|
||||
- ожидаемый результат:
|
||||
- FAQ открывается отдельным экраном и содержит понятные ответы;
|
||||
- режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль;
|
||||
- пароль не отправляется в новом формате, а продолжает использоваться как одна строка.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -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
|
||||
@ -0,0 +1,19 @@
|
||||
# Исправление chatId личных сообщений через lowercase
|
||||
|
||||
- краткое описание фичи:
|
||||
- В клиентском UI SHiNE для личных сообщений технический `chatId` теперь канонизируется через `trim().toLowerCase()` при приёме DM, открытии чата и восстановлении сообщений из IndexedDB.
|
||||
- Цель: исключить рассинхрон, когда unread-индикатор есть, а входящие сообщения конкретного собеседника не видны из-за разного регистра логина.
|
||||
|
||||
- что именно проверять:
|
||||
- Отправить личные сообщения между двумя пользователями, у одного из которых логин отображается с заглавными буквами.
|
||||
- Убедиться, что входящие сообщения показываются внутри открытого чата, а не только в общем unread-индикаторе.
|
||||
- Перезагрузить страницу и проверить, что история чата после гидрации из IndexedDB остаётся в одном диалоге.
|
||||
- Проверить, что переход в чат из списка диалогов и из графа связей открывает тот же диалог без дублирования.
|
||||
|
||||
- ожидаемый результат:
|
||||
- Все сообщения одного собеседника попадают в один и тот же DM-чат независимо от регистра логина.
|
||||
- Общий unread, список диалогов и содержимое открытого чата совпадают между собой.
|
||||
- После перезагрузки UI не появляется отдельный дубль диалога с тем же логином в другом регистре.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -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`.
|
||||
|
||||
42
Dev_Docs/deploy/servers/193.8.215.70_test2_main.md
Normal file
42
Dev_Docs/deploy/servers/193.8.215.70_test2_main.md
Normal 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`
|
||||
@ -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`
|
||||
- Эти задачи пока не использовать без отдельной причины.
|
||||
|
||||
@ -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` сюда больше не относятся.
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
|
||||
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
|
||||
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
|
||||
- рассчитывает короткий код `shortCode` из `7` цифр;
|
||||
- рассчитывает короткий код `shortCode` из `10` цифр;
|
||||
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
|
||||
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
||||
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 ?? '')}`;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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("""
|
||||
|
||||
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
+ "}";
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.217
|
||||
server.version=1.2.204
|
||||
client.version=1.2.230
|
||||
server.version=1.2.216
|
||||
|
||||
54
build.gradle
54
build.gradle
@ -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"
|
||||
|
||||
@ -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
128
deploy_shine-server_test.sh
Normal 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"
|
||||
75
deploy_shine-server_test2.sh
Normal file
75
deploy_shine-server_test2.sh
Normal 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
20
deploy_shine-ui_test.sh
Normal 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
21
deploy_shine-ui_test2.sh
Normal 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 {} +"
|
||||
|
||||
80
scripts/install_test2_caddyfile.sh
Normal file
80
scripts/install_test2_caddyfile.sh
Normal 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"
|
||||
|
||||
76
scripts/install_test_caddyfile.sh
Normal file
76
scripts/install_test_caddyfile.sh
Normal 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"
|
||||
@ -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),
|
||||
|
||||
@ -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 || '')
|
||||
: '';
|
||||
|
||||
@ -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('&', '&')
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -599,8 +599,6 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
async function onChangeAvatarClick() {
|
||||
const confirmed = window.confirm('Сменить аватар?');
|
||||
if (!confirmed) return;
|
||||
status.className = 'status-line';
|
||||
status.textContent = 'Открываем мастер аватара...';
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
229
shine-UI/js/pages/registration-faq-view.js
Normal file
229
shine-UI/js/pages/registration-faq-view.js
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
18
shine-UI/js/services/password-words.js
Normal file
18
shine-UI/js/services/password-words.js
Normal 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 }, () => '');
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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; /* крупнее (бар-иконки); герой ниже ещё больше */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user