Compare commits

..

No commits in common. "f2b23ace8b72341781a904e933ae5bbb18670093a1e206cf13d01f2d9879d14a" and "b166013707a97e78cc148f2ea620ac4140a82166ed8879dd34a9742ad7ebfb8b" have entirely different histories.

134 changed files with 2633 additions and 27817 deletions

View File

@ -303,14 +303,12 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
Новые `op`, относящиеся к этому сценарию:
- `GetTrustedDeviceLoginSettings`
- `UpsertTrustedDeviceLoginSettings`
- `StartTrustedDeviceLogin`
- `ListTrustedDeviceLoginRequests`
- `ApproveTrustedDeviceLogin`
- `RejectTrustedDeviceLogin`
- `CancelTrustedDeviceLogin`
- `GetTrustedDeviceLoginStatus`
- `UpsertEspPairingSettings`
- `StartEspPairing`
- `ListEspPairingRequests`
- `ApproveEspPairing`
- `RejectEspPairing`
- `GetEspPairingStatus`
В этом потоке:

View File

@ -9,17 +9,15 @@
Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя:
- `GetTrustedDeviceLoginSettings`
- `UpsertTrustedDeviceLoginSettings`
- `ListTrustedDeviceLoginRequests`
- `ApproveTrustedDeviceLogin`
- `RejectTrustedDeviceLogin`
- `CancelTrustedDeviceLogin`
- `UpsertEspPairingSettings`
- `ListEspPairingRequests`
- `ApproveEspPairing`
- `RejectEspPairing`
Анонимное новое устройство работает с двумя связанными операциями:
- `StartTrustedDeviceLogin`
- `GetTrustedDeviceLoginStatus`
- `StartEspPairing`
- `GetEspPairingStatus`
Логика раздела такая:
@ -168,11 +166,11 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
---
## 5. TrustedDeviceLogin через доверенную сессию
## 5. ESP pairing через доверенную сессию
Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя.
### 5.1. `GetTrustedDeviceLoginSettings`
### 5.1. `UpsertEspPairingSettings`
Доступно для любой уже авторизованной доверенной сессии пользователя.
@ -180,9 +178,12 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
```json
{
"op": "GetTrustedDeviceLoginSettings",
"requestId": "trusted-login-get-001",
"op": "UpsertEspPairingSettings",
"requestId": "esp-set-001",
"payload": {
"enabled": true,
"passwordHash": "argon2id$...",
"ttlSeconds": 180
}
}
```
@ -191,73 +192,23 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
```json
{
"op": "GetTrustedDeviceLoginSettings",
"requestId": "trusted-login-get-001",
"status": 200,
"ok": true,
"payload": {
"enabled": true,
"hasPassword": false
}
}
```
Если отдельной записи настроек на сервере ещё нет, сервер считает состояние по умолчанию таким:
- `enabled = true`
- `hasPassword = false`
### Ошибки
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
### 5.2. `UpsertTrustedDeviceLoginSettings`
Доступно для любой уже авторизованной доверенной сессии пользователя.
### Запрос
```json
{
"op": "UpsertTrustedDeviceLoginSettings",
"requestId": "esp-set-001",
"payload": {
"enabled": true,
"passwordHash": "sha256$0123abcd..."
}
}
```
Если вход через доверенное устройство должен работать **без доп. пароля**, клиент включает его с пустым `passwordHash`.
Если `enabled = false`, сервер автоматически удаляет пароль и запрещает вход через другое устройство.
Формат непустого `passwordHash`:
```text
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```
### Успешный ответ
```json
{
"op": "UpsertTrustedDeviceLoginSettings",
"op": "UpsertEspPairingSettings",
"requestId": "esp-set-001",
"status": 200,
"ok": true,
"payload": {
"enabled": true,
"hasPassword": true
"ttlSeconds": 180
}
}
```
### Ошибки
- `400 / EMPTY_PASSWORD_HASH` — попытка включить pairing без `passwordHash`.
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
### 5.3. `StartTrustedDeviceLogin`
### 5.2. `StartEspPairing`
Эта операция доступна без уже существующей пользовательской сессии.
@ -265,11 +216,11 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json
{
"op": "StartTrustedDeviceLogin",
"op": "StartEspPairing",
"requestId": "esp-start-001",
"payload": {
"login": "alice",
"passwordHash": "sha256$0123abcd...",
"passwordHash": "argon2id$...",
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
"requesterSessionType": 1,
"requesterClientPlatform": "Android",
@ -278,17 +229,13 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
}
```
Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`.
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам.
### Успешный ответ
```json
{
"op": "StartTrustedDeviceLogin",
"op": "StartEspPairing",
"requestId": "esp-start-001",
"status": 200,
"ok": true,
@ -306,25 +253,24 @@ TTL заявки фиксирован на сервере и сейчас все
### Ошибки
- `400 / EMPTY_LOGIN`
- `400 / EMPTY_PASSWORD_HASH`
- `400 / EMPTY_REQUESTER_SESSION_KEY`
- `400 / BAD_REQUESTER_SESSION_KEY`
- `400 / BAD_SESSION_TYPE`
- `400 / BAD_PAYLOAD_TYPE`
- `422 / PAIRING_NOT_AVAILABLE`
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
- `422 / PAIRING_PASSWORD_INVALID`
- `429 / PAIRING_RATE_LIMITED`
### 5.4. `ListTrustedDeviceLoginRequests`
### 5.3. `ListEspPairingRequests`
Доступно для любой уже авторизованной доверенной сессии пользователя.
Возвращает только реально активные pending-заявки со `state = created`. Уже `approved` и `rejected` заявки в этот список больше не попадают.
### Успешный ответ
```json
{
"op": "ListTrustedDeviceLoginRequests",
"op": "ListEspPairingRequests",
"requestId": "esp-list-001",
"status": 200,
"ok": true,
@ -352,7 +298,7 @@ TTL заявки фиксирован на сервере и сейчас все
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
### 5.5. `ApproveTrustedDeviceLogin`
### 5.4. `ApproveEspPairing`
Доступно для любой уже авторизованной доверенной сессии пользователя.
@ -360,7 +306,7 @@ TTL заявки фиксирован на сервере и сейчас все
```json
{
"op": "ApproveTrustedDeviceLogin",
"op": "ApproveEspPairing",
"requestId": "esp-approve-001",
"payload": {
"pairingId": "base64url",
@ -373,7 +319,7 @@ TTL заявки фиксирован на сервере и сейчас все
```json
{
"op": "ApproveTrustedDeviceLogin",
"op": "ApproveEspPairing",
"requestId": "esp-approve-001",
"status": 200,
"ok": true,
@ -394,11 +340,11 @@ TTL заявки фиксирован на сервере и сейчас все
- `422 / PAIRING_EXPIRED`
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
### 5.6. `RejectTrustedDeviceLogin`
### 5.5. `RejectEspPairing`
Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`.
### 5.7. `GetTrustedDeviceLoginStatus`
### 5.6. `GetEspPairingStatus`
Операция для нового устройства.
@ -406,7 +352,7 @@ TTL заявки фиксирован на сервере и сейчас все
```json
{
"op": "GetTrustedDeviceLoginStatus",
"op": "GetEspPairingStatus",
"requestId": "esp-status-001",
"payload": {
"pairingId": "base64url"
@ -418,7 +364,7 @@ TTL заявки фиксирован на сервере и сейчас все
```json
{
"op": "GetTrustedDeviceLoginStatus",
"op": "GetEspPairingStatus",
"requestId": "esp-status-001",
"status": 200,
"ok": true,
@ -439,46 +385,4 @@ TTL заявки фиксирован на сервере и сейчас все
- `created`
- `approved`
- `rejected`
- `canceled`
- `expired`
### 5.8. `CancelTrustedDeviceLogin`
Операция для нового устройства, которое уже создало pairing-заявку и хочет принудительно снять ожидание до истечения TTL.
### Запрос
```json
{
"op": "CancelTrustedDeviceLogin",
"requestId": "esp-cancel-001",
"payload": {
"pairingId": "base64url",
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY"
}
}
```
### Успешный ответ
```json
{
"op": "CancelTrustedDeviceLogin",
"requestId": "esp-cancel-001",
"status": 200,
"ok": true,
"payload": {
"pairingId": "base64url",
"state": "canceled"
}
}
```
### Ошибки
- `400 / EMPTY_PAIRING_ID`
- `400 / EMPTY_REQUESTER_SESSION_KEY`
- `400 / BAD_REQUESTER_SESSION_KEY`
- `404 / PAIRING_NOT_FOUND`
- `422 / PAIRING_OF_ANOTHER_REQUESTER`
- `422 / PAIRING_NOT_PENDING`

View File

@ -19,14 +19,12 @@
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
| `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию |
| `GetTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | чтение текущего режима входа через доверенное устройство |
| `UpsertTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
| `StartTrustedDeviceLogin` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
| `ListTrustedDeviceLoginRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
| `ApproveTrustedDeviceLogin` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
| `RejectTrustedDeviceLogin` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
| `CancelTrustedDeviceLogin` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства |
| `GetTrustedDeviceLoginStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
| `UpsertEspPairingSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
| `StartEspPairing` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
| `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
@ -62,6 +60,5 @@
## Важные замечания
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.

View File

@ -1,10 +1,8 @@
# API для разработчиков: DM, push и сигналы звонков
Документ описывает публичные операции, связанные с личными сообщениями, WebPush и сигналами звонков.
Документ описывает WebSocket-операции для подписанных личных сообщений, WebPush и realtime-сигналов звонков.
Подробная логика DM и бинарного формата:
- `Dev_Docs/Personal_Messages/README.md`
Логика личных сообщений дополнительно описана в `Dev_Docs/Personal_Messages/README.md`; этот файл фиксирует именно публичные `op`, поля запросов и поля ответов.
## 1. `UpsertPushToken`
@ -42,9 +40,11 @@
}
```
---
## 2. `SendTestWebPush`
Требует авторизации.
Требует авторизации. Если `login` передан, он должен совпадать с логином текущей сессии.
### Запрос
@ -61,18 +61,65 @@
}
```
## 3. `SendMessagePair` и `ReceiveOutcomingMessage`
### Успешный ответ
`ReceiveOutcomingMessage` — алиас `SendMessagePair`.
```json
{
"op": "SendTestWebPush",
"requestId": "push-test-001",
"status": 200,
"ok": true,
"payload": {
"targetLogin": "alice",
"attemptedSessions": 1,
"sessionsWithPushConfig": 1,
"delivered": 1,
"failed": 0,
"sentAtMs": 1774700000123
}
}
```
### Назначение
---
Передаёт пару signed DM-блоков:
## 3. `SendDirectMessage`
- `incomingBlobB64` — блок `type=1` или `type=3`
- `outgoingBlobB64` — блок `type=2` или `type=4`
Отправляет один подписанный DM-пакет.
Для контентных сообщений `type=1/2` внутри base64 лежит бинарный формат `SHiNE_DM`.
### Запрос
```json
{
"op": "SendDirectMessage",
"requestId": "dm-001",
"payload": {
"blobB64": "BASE64_SIGNED_DM_PACKET"
}
}
```
### Успешный ответ
```json
{
"op": "SendDirectMessage",
"requestId": "dm-001",
"status": 200,
"ok": true,
"payload": {
"messageId": "dm-1",
"deliveredWsSessions": 1,
"deliveredWebPushSessions": 0,
"sessionNotFound": false
}
}
```
---
## 4. `SendMessagePair` и `ReceiveOutcomingMessage`
`ReceiveOutcomingMessage` сейчас является алиасом `SendMessagePair` и использует тот же request/handler.
### Запрос
@ -96,31 +143,20 @@
"status": 200,
"ok": true,
"payload": {
"baseKey": "from|to|time|nonce",
"incomingKey": "from|to|time|nonce|1",
"outgoingKey": "from|to|time|nonce|2",
"baseKey": "base-key",
"incomingKey": "incoming-key",
"outgoingKey": "outgoing-key",
"deliveredWsSessions": 1,
"deliveredWebPushSessions": 0
}
}
```
### Ошибки
---
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM`
- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0`
- `404 / USER_NOT_FOUND` — один из логинов не найден
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
## 5. `ReceiveIncomingMessage`
## 4. `ReceiveIncomingMessage`
Принимает только один входящий signed DM-блок.
### Назначение
Используется там, где нужно принять только incoming-вариант сообщения.
Принимает входящий подписанный DM-блок.
### Запрос
@ -134,9 +170,28 @@
}
```
## 5. `AckSessionDelivery`
### Успешный ответ
Требует авторизации. Подтверждает доставку в текущую сессию.
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "dm-in-001",
"status": 200,
"ok": true,
"payload": {
"messageKey": "incoming-key",
"baseKey": "base-key",
"deliveredWsSessions": 1,
"deliveredWebPushSessions": 0
}
}
```
---
## 6. `AckSessionDelivery`
Требует авторизации. Подтверждает доставку сообщения в текущую сессию.
### Запрос
@ -145,46 +200,107 @@
"op": "AckSessionDelivery",
"requestId": "ack-001",
"payload": {
"messageKey": "from|to|time|nonce|1"
"messageKey": "incoming-key"
}
}
```
## 6. Событие `SignedMessageArrived`
Сервер присылает его по WebSocket в активные сессии адресата.
### Payload события
### Успешный ответ
```json
{
"messageKey": "from|to|time|nonce|1",
"baseKey": "from|to|time|nonce",
"fromLogin": "alice",
"toLogin": "bob",
"targetLogin": "bob",
"messageType": 1,
"timeMs": 1774700000123,
"nonce": 123456789,
"blobB64": "BASE64_SIGNED_BLOCK",
"backlog": false
"op": "AckSessionDelivery",
"requestId": "ack-001",
"status": 200,
"ok": true,
"payload": {
"messageKey": "incoming-key"
}
}
```
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
---
## 7. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
Требует авторизации. Отправляет приглашение к звонку на активные сессии пользователя `toLogin`.
### Запрос
```json
{
"op": "CallInviteBroadcast",
"requestId": "call-invite-001",
"payload": {
"toLogin": "bob",
"callId": "call-1",
"type": 100
}
}
```
### Успешный ответ
```json
{
"op": "CallInviteBroadcast",
"requestId": "call-invite-001",
"status": 200,
"ok": true,
"payload": {
"callId": "call-1",
"deliveredWsSessions": 1,
"deliveredFcmSessions": 0,
"deliveredWebPushSessions": 0
}
}
```
---
## 8. `CallSignalToSession`
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
Требует авторизации. Отправляет сигнал звонка в конкретную сессию получателя.
## 9. Замечания
### Запрос
- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2`
- контентные DM `type=1/2` используют `SHiNE_DM`
- сервер хранит только последнюю версию контентного сообщения по `messageKey`
- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0`
- HTTP endpoints для DM-файлов сейчас отсутствуют
```json
{
"op": "CallSignalToSession",
"requestId": "call-signal-001",
"payload": {
"toLogin": "bob",
"targetSessionId": "SESSION_ID",
"callId": "call-1",
"type": 101,
"data": "{\"sdp\":\"...\"}"
}
}
```
### Успешный ответ
```json
{
"op": "CallSignalToSession",
"requestId": "call-signal-001",
"status": 200,
"ok": true,
"payload": {
"delivered": true
}
}
```
Если целевая сессия не найдена или доставка не удалась, сервер может вернуть `404`.
## Типовые ошибки
- `422 / NOT_AUTHENTICATED` — требуется авторизация.
- `400 / BAD_FIELDS` — не заполнены обязательные поля.
- `404 / USER_NOT_FOUND` — пользователь не найден.
- `404 / SESSION_NOT_FOUND` — сессия не найдена.
- `422 / BAD_SIGNATURE` — подпись DM не прошла проверку.
- `422 / BAD_DEVICE_KEY` — некорректный device key отправителя.
- `422 / BAD_TIME_WINDOW` — время подписанного сообщения вне допустимого окна.
- `422 / REPLAY` — повторное сообщение заблокировано.

View File

@ -1,36 +0,0 @@
# ui подключение по коду
- краткое описание фичи:
- в UI добавлен новый сценарий подключения устройства через доверенную уже авторизованную сессию пользователя;
- на экране входа появилась кнопка `Войти через другое устройство`;
- на доверённом устройстве в `Подключить устройство` появилась кнопка `Подключить по коду`;
- доверённое устройство может включить pairing с доп. паролем или без него, увидеть заявки, подтвердить подключение только с `device key` или с передачей выбранных ключей.
- что именно проверять:
- на уже авторизованном устройстве включить pairing без доп. пароля;
- на новом устройстве открыть `Войти через другое устройство`, оставить галочку доп. пароля выключенной и получить 7-значный код;
- отдельно включить pairing с доп. паролем;
- на новом устройстве открыть `Войти через другое устройство`, включить галочку доп. пароля, ввести `login + pairing password` и получить 7-значный код;
- на доверённом устройстве открыть `Подключить по коду`, найти заявку по коду и подтвердить её:
- без доп. ключей;
- с передачей выбранных ключей;
- убедиться, что новое устройство реально входит в аккаунт и сохраняет нужные ключи;
- отдельно проверить отклонение заявки и истечение TTL.
- при отклонении заявки убедиться, что на новом устройстве сразу исчезает карточка со старым 7-значным кодом и остаётся только сообщение об отклонении;
- убедиться, что экран `Войти` больше не показывает неработающую QR-заглушку сверху.
- убедиться, что при неверном pairing-пароле и при попытке ввести пароль там, где он не включён, пользователь видит одинаковую ошибку `Пароль подключения не подходит.`;
- убедиться, что без онлайн доверённой сессии новое устройство сразу получает явную ошибку и код вообще не создаётся;
- убедиться, что countdown под кодом убывает в реальном времени;
- убедиться, что кнопка `Отмена` на новом устройстве действительно снимает заявку и она пропадает у доверённого устройства без ожидания TTL.
- убедиться, что на экране `Подключить по коду` блок дополнительного пароля показывает два понятных состояния: пароль не задан / пароль установлен;
- убедиться, что `Задать пароль` и `Изменить пароль` открывают верхний диалог с двумя полями и кнопками-глазами;
- убедиться, что `Убрать пароль` не выключает pairing целиком, а переводит его в режим без дополнительного пароля.
- ожидаемый результат:
- новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить;
- после approve новое устройство автоматически входит в аккаунт;
- в режиме без доп. ключей переносится только `device key`;
- в расширенном режиме переносятся `device key` и отмеченные ключи `blockchain/root`, если они есть на доверённом устройстве.
- статус:
- `pending`

View File

@ -1,22 +0,0 @@
# wallet-session pairing и SHA-256 пароль pairing
- краткое описание:
- добавлен сценарий `session-only` подключения wallet-plugin через доверенное устройство без передачи постоянных ключей;
- для pairing-пароля убран `argon2id`, вместо него используется только формат `sha256$<hex>`;
- новый plugin `SHiNE-browser-plugin-wallet` получает и хранит только `wallet-session`.
- что проверять:
- в `shine-UI` экран `Войти через другое устройство` создаёт заявку и получает `session-only` approve;
- на доверенном устройстве в `Подключить по коду` кнопка `Подключить wallet-session` действительно не передаёт `device/root/blockchain` ключи;
- новый plugin загружается как Chrome MV3 extension и получает wallet-session;
- pairing c доп. паролем работает только с форматом `sha256$<hex>`;
- pairing без доп. пароля продолжает работать.
- ожидаемый результат:
- requester получает только `sessionId/sessionKey/sessionPriv/storagePwd`;
- доверенное устройство не пересылает постоянные ключи в `session-only` режиме;
- сервер принимает только новый формат pairing-пароля;
- логин по сохранённой wallet-session восстанавливается успешно.
- статус:
- pending

View File

@ -1,22 +0,0 @@
# Закрытие сессий и сортировка устройств
- краткое описание фичи:
- добровольный выход и переключение устройства/аккаунта теперь сначала пытаются закрыть текущую серверную сессию, а затем очищают локальные данные;
- на экране `Устройства` сессии сортируются так, чтобы онлайн-сессии шли раньше оффлайн;
- статус онлайн-сессии выделяется зелёным.
- что именно проверять:
- в `Настройки` нажать выход из текущей сессии и убедиться, что запись исчезает из списка сессий после повторного входа;
- в `Устройства` нажать `Завершить текущую сессию` и убедиться, что локальные данные очищены, а серверная сессия удалена;
- выполнить вход/переключение через `Подключить устройство` или QR и убедиться, что старая сессия не остаётся висеть на сервере;
- открыть `Устройства` при наличии нескольких сессий и убедиться, что сначала показаны `Online now`, затем `Offline`;
- проверить, что строки со статусом `Online now` визуально выделены зелёным.
- ожидаемый результат:
- при добровольном завершении сессии серверная запись удаляется;
- при локальном переключении на другой аккаунт старая текущая сессия не остаётся в `active_sessions`;
- порядок сессий в UI соответствует онлайн-статусу сервера;
- зелёный статус виден и не ломает верстку на экране `Устройства`.
- статус:
- pending

View File

@ -1,16 +0,0 @@
# Автоопределение SHiNE-сервера по PDA
- краткое описание:
в основном UI и в `SHiNE-browser-plugin-wallet` ручной ввод адреса SHiNE-сервера заменён на ввод логина серверного аккаунта. Клиент читает `server_address` из PDA сервера и сам строит `https://...` и `wss://...`.
- что проверять:
1. В `shine-UI` на экранах настроек входа и серверов в поле SHiNE вводится логин `shineupme`, а статус показывает точный адрес `https://shineup.me`.
2. После сохранения настроек обычный вход и login через другое устройство продолжают работать.
3. В `SHiNE-browser-plugin-wallet` поле сервера принимает логин `shineupme`, а popup показывает `Текущий адрес: https://shineup.me`.
4. В plugin pairing и повторное восстановление wallet-session продолжают работать через авторазрешённый адрес.
- ожидаемый результат:
пользователь больше не вводит вручную `wss://...`; внутренний WS-адрес строится автоматически из PDA серверного аккаунта.
- статус:
pending

View File

@ -1,18 +0,0 @@
# Wallet plugin: PDA-ключи и выбор homeserver
- краткое описание:
`SHiNE-browser-plugin-wallet` после session-only подключения сохраняет `login`, публичные `root/device/blockchain` ключи из PDA и список опубликованных `homeserver`-сессий. Постоянное подключение не удерживается: plugin остаётся офлайн, а список trusted devices обновляет по запросу.
- что проверять:
0. На стартовом экране plugin для подключения запрашивается только логин пользователя; серверный логин вручную не вводится, а показывается как информационная строка после чтения PDA.
1. После успешного pairing plugin показывает сохранённую wallet-session без автоматического постоянного подключения.
2. В карточке wallet-session виден сокращённый `deviceKey`.
3. Кнопка `Обновить устройства` подтягивает homeserver-сессии из PDA и показывает их список со статусом `online/offline/unknown`.
4. В селекте ключа подписи доступны `rootKey (ed25519, ...)` и `deviceKey (ed25519, ...)`.
5. Кнопка `Запросить подпись` не падает и честно сообщает, что signaling подписи ещё не доделан.
- ожидаемый результат:
кошелёк хранит PDA-метаданные локально, не висит всё время онлайн и показывает каркас выбора ключа и устройства перед будущим этапом signaling подписи.
- статус:
pending

View File

@ -1,24 +0,0 @@
# TrustedDeviceLogin settings и новый режим по умолчанию
- краткое описание:
серверный API сценария входа через доверенное устройство переименован в `TrustedDeviceLogin`, добавлен `GetTrustedDeviceLoginSettings`, а отсутствие серверной записи настроек теперь трактуется как `enabled = true` и `hasPassword = false`. В UI вынесен отдельный экран настроек входа через доверенное устройство.
- что проверять:
1. Для логина без записи в `esp_pairing_settings` `StartTrustedDeviceLogin` работает без предварительного ручного включения.
2. Экран `Подключить по коду` показывает один из трёх статусов:
- вход запрещён;
- вход разрешён без пароля;
- вход разрешён только с паролем.
3. Кнопка `Изменить настройки входа` открывает отдельный экран.
4. На отдельном экране:
- можно запретить вход;
- можно разрешить вход;
- можно задать новый пароль;
- можно сделать вход без пароля.
5. `Войти через другое устройство` в основном UI и в browser wallet работает через новые `TrustedDeviceLogin`-операции.
- ожидаемый результат:
вход через доверенное устройство по умолчанию доступен без лишнего ручного включения, а текущий режим и пароль управляются с отдельного экрана настроек.
- статус:
pending

View File

@ -1,18 +0,0 @@
# AGENTS
## Документация DM в этой папке
- Основной актуальный документ по личным сообщениям:
- `README.md`
- Его считать единственным источником истины по текущей реализованной логике DM.
## Черновик будущих вложений
- Файл ерновик_будущих_DM_вложений.md` не является актуальной спецификацией.
- В нём описан только ранний черновик того, как когда-то планировались:
- формат вложений в DM;
- внешние и внутренние поля вложения;
- предполагаемая механика загрузки файлов.
- Эта схема не была реализована в таком виде и может существенно измениться в будущем.
- Любые решения по текущему коду, протоколу и UI нельзя принимать по этому черновику.
- Если есть расхождение между `README.md` и черновиком вложений, верным всегда считается `README.md`.

View File

@ -1,203 +1,269 @@
# Личные сообщения (DM)
# Личные сообщения (DM): как это устроено
## Текущее состояние
## Коротко (для быстрого понимания)
Сейчас в проекте реализованы:
Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`:
- новый формат контентных личных сообщений `SHiNE_DM`;
- ревизии сообщений через `revisionTimeMs`;
- редактирование сообщения через повторную отправку той же логической пары;
- удаление сообщения через пустую ревизию;
- `upsert` последней версии сообщения на сервере.
- тип `1` — входящее сообщение для собеседника;
- тип `2` — исходящая копия того же сообщения для автора.
Сейчас в проекте **не реализованы**:
Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе.
Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`.
- вложения в DM;
- upload/download файлов для DM;
- UI-кнопка прикрепления файла;
- серверное хранение файловых связей для DM.
Подтверждение прочтения также идёт парой блоков:
Черновик будущих вложений вынесен отдельно:
- тип `3` — «прочитано» для исходящего сообщения автора;
- тип `4` — зеркальная копия для второй стороны.
- `Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md`
UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история.
## Общая схема
---
Личное сообщение по-прежнему отправляется парой signed-блоков:
## Подробно
- `type=1` — входящий блок для получателя;
- `type=2` — исходящая копия для отправителя.
## 1) Общая схема потока
Read-receipt пока остаются в legacy-формате:
1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`.
2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`).
3. Сервер:
- парсит оба блока;
- валидирует пару;
- проверяет существование `from/to` пользователей и подписи;
- атомарно сохраняет пару в `signed_messages_v2`.
4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`.
5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`.
6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих.
- `type=3` — входящее подтверждение прочтения;
- `type=4` — исходящая копия подтверждения.
## 2) Формат signed DM-блока (`SHiNE_dm2`)
Ключи сообщения:
Префикс: `SHiNE_dm2` (ASCII).
- `baseKey = fromLogin|toLogin|timeMs|nonce`
Далее поля (big-endian):
1. `toLoginLen` (`u8`) + `toLogin` (ASCII, 1..60);
2. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, 1..60);
3. `timeMs` (`u64`);
4. `nonce` (`u32`);
5. `messageType` (`u16`);
6. `payloadLen` (`u16`);
7. `payloadBytes` (`1..4096`);
8. `signature` (`64 bytes`, Ed25519).
Ограничения:
- полный пакет: до `8192` байт;
- `messageType` сейчас допустим только `1..4`.
## 3) Типы DM-сообщений
- `1` (`TYPE_INCOMING_TEXT`) — входящий текст для получателя.
- `2` (`TYPE_OUTGOING_COPY`) — исходящая копия в истории автора.
- `3` (`TYPE_READ_INCOMING`) — read-receipt (входящий тип для пары квитанции).
- `4` (`TYPE_READ_OUTGOING_COPY`) — зеркальная копия read-receipt.
Правило пары:
- первый блок должен быть нечётным (`1` или `3`);
- второй должен быть ровно `+1` (`2` или `4`);
- ключевые поля пары совпадают: `toLogin/fromLogin/timeMs/nonce`.
## 4) Ключи сообщений
- `baseKey = from|to|timeMs|nonce`
- `messageKey = baseKey|messageType`
Логический идентификатор письма задаётся парой:
Эти ключи используются:
- `timeMs`
- `nonce`
- для дедупликации;
- для связи read-receipt с исходным сообщением;
- для ACK доставки по сессии.
Эти поля не меняются при редактировании или удалении. Меняется только:
## 5) RPC и события
- `revisionTimeMs`
- содержимое `encryptedBody`
## `SendMessagePair` (алиас `ReceiveOutcomingMessage`)
Сервер хранит только последнюю версию записи для каждого `messageKey`.
Запрос:
## Формат контентного DM: `SHiNE_DM`
```json
{
"op": "SendMessagePair",
"requestId": "req-1",
"payload": {
"incomingBlobB64": "<base64 signed block type 1 or 3>",
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
}
}
```
Префикс бинарного блока:
Успешный ответ:
- `SHiNE_DM`
```json
{
"op": "SendMessagePair",
"requestId": "req-1",
"status": 200,
"ok": true,
"payload": {
"baseKey": "from|to|time|nonce",
"incomingKey": "from|to|time|nonce|1",
"outgoingKey": "from|to|time|nonce|2",
"deliveredWsSessions": 2,
"deliveredWebPushSessions": 1
}
}
```
Поля идут в big-endian порядке:
## `SignedMessageArrived` (server event)
1. `formatVersionMajor` (`u8`) = `1`
2. `formatVersionMinor` (`u8`) = `0`
3. `toLoginLen` (`u8`) + `toLogin` (ASCII, `1..60`)
4. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, `1..60`)
5. `timeMs` (`u64`)
6. `nonce` (`u32`)
7. `messageType` (`u16`) — только `1` или `2`
8. `revisionTimeMs` (`u64`)
9. `attachmentsCount` (`u8`)
10. `encryptedBodyLen` (`u32`)
11. `encryptedBody` (`bytes`)
12. `signature` (`64 bytes`, Ed25519)
Событие в сессию получателя содержит:
### Ограничения
- `messageKey`, `baseKey`;
- `fromLogin`, `toLogin`, `targetLogin`;
- `messageType`, `timeMs`, `nonce`;
- `blobB64`;
- `backlog` (признак догрузки из очереди).
- `attachmentsCount` сейчас всегда должен быть `0`
- `encryptedBodyLen` сейчас ограничен сервером до `16384` байт
- `revisionTimeMs` не может быть отрицательным
## `AckSessionDelivery`
Если приходит `attachmentsCount != 0`, сервер отклоняет такой DM как:
Запрос:
- `ATTACHMENTS_DISABLED`
```json
{
"op": "AckSessionDelivery",
"requestId": "ack-1",
"payload": {
"messageKey": "from|to|time|nonce|1"
}
}
```
## Legacy read-receipt: `SHiNE_dm2`
Ответ: `status=200`, echo `messageKey`.
Подтверждения прочтения `type=3/4` пока используют старый контейнер `SHiNE_dm2`:
## 6) Хранение на сервере (SQLite)
1. `toLoginLen` (`u8`) + `toLogin`
2. `fromLoginLen` (`u8`) + `fromLogin`
3. `timeMs` (`u64`)
4. `nonce` (`u32`)
5. `messageType` (`u16`) — `3` или `4`
6. `payloadLen` (`u16`)
7. `payloadBytes`
8. `signature`
Основные таблицы:
## Редактирование
1. `signed_messages_v2` — сами DM-блоки типов `1/2/3/4`:
- `message_key` (PK),
- `base_key`,
- `target_login`,
- `from_login`, `to_login`,
- `time_ms`, `nonce`, `message_type`,
- `raw_block`,
- `source_api`, `origin_session_id`,
- `receipt_ref_base_key`, `receipt_ref_type`.
2. `signed_message_session_delivery` — доставка по сессиям:
- составной PK `(message_key, session_id)`,
- `delivered` (0/1),
- `delivered_at_ms`, `created_at_ms`.
Редактирование делается новой отправкой той же логической пары сообщения:
Примечание: историческая таблица `signed_direct_messages_history` в БД присутствует как legacy-слой, но текущий рабочий поток DM v2 опирается на `signed_messages_v2` + `signed_message_session_delivery`.
- `timeMs` и `nonce` остаются теми же;
- `messageType` остаётся `1/2`;
- `revisionTimeMs` становится больше;
- `encryptedBody` содержит новую версию текста.
## 7) Доставка и backlog
Если на сервер приходит более старая ревизия, она игнорируется.
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
- Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`.
- При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`:
- для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»;
- отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`;
- лимита на количество сообщений нет — передаётся вся история без ограничений.
- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует.
- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки.
Если приходит та же ревизия и тот же бинарный блок, сервер тоже её не применяет повторно.
## 8) Read-receipt логика
## Удаление
Когда клиент открывает чат:
Удаление личного сообщения делается как новая ревизия того же сообщения:
1. ищет входящие `messageType=1` без `readReceiptSent`;
2. для каждого отправляет read-receipt как пару `type=3/4`;
3. после успешной отправки помечает `readReceiptSent`.
- `timeMs` и `nonce` остаются прежними;
- `revisionTimeMs` увеличивается;
- `attachmentsCount = 0`;
- `encryptedBodyLen = 0`;
- `encryptedBody` пустой.
Сервер для read-receipt хранит ссылку на исходное сообщение:
В UI такое сообщение не показывается.
- `receipt_ref_base_key`;
- `receipt_ref_type`.
На сервере это не отдельный тип сообщения, а просто последняя пустая ревизия того же `messageKey`.
Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`.
## Поведение сервера
## 9) Логика UI-клиента
Для контентных DM сервер:
### Хранилище сообщений
1. принимает пару signed-блоков `type=1/2`;
2. валидирует формат, подпись и совпадение ключевых полей пары;
3. проверяет, что для обеих сторон пары совпадают:
- `fromLogin`
- `toLogin`
- `timeMs`
- `nonce`
- `revisionTimeMs`
- `encryptedBody`
4. делает `upsert` последней версии в `signed_messages_v2`;
5. сбрасывает pending-доставку по сессиям для новой ревизии;
6. рассылает актуальную версию адресатам через `SignedMessageArrived`.
- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу.
- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`.
- `chatId` для `type=1``fromLogin`, для `type=2``toLogin`.
История старых ревизий сейчас не хранится отдельно: в таблице остаётся только последняя версия по каждому `messageKey`.
### Жизненный цикл при старте/подключении
## Хранение в БД
1. `hydrateMessagesFromStore()` — читает все сообщения из IndexedDB в `state.chats` (до WebSocket-соединения).
2. После установки WebSocket-сессии сервер присылает backlog (`SignedMessageArrived(backlog=true)`) для всех недоставленных сообщений.
3. Клиент дедублирует через `knownMessageKeys` — уже имеющиеся в IndexedDB игнорируются.
4. Новые сообщения в реальном времени приходят теми же WebSocket-событиями.
Основная таблица:
### Очистка при выходе и смене пользователя
- `signed_messages_v2`
- При любом логауте (`terminateCurrentSession`) IndexedDB с сообщениями **удаляется полностью**.
- При входе нового пользователя через QR — IndexedDB удаляется явно до вызова `terminateCurrentSession`.
- При входе нового пользователя через логин/пароль — IndexedDB удаляется в `registration-keys-view.js` прямо перед `authorizeSession()`.
- Это гарантирует: при любом способе входа старые сообщения предыдущего пользователя не попадут к следующему.
Для контентных DM в ней используются:
### UI-поведение
- `message_key`
- `base_key`
- `target_login`
- `from_login`
- `to_login`
- `time_ms`
- `nonce`
- `message_type`
- `revision_time_ms`
- `raw_block`
- `created_at_ms`
- непрочитанные считаются по `from='in' && unread=true`;
- доставка/прочтение исходящих:
- `firstTick` — сообщение принято сервером,
- `secondTick` — пришло подтверждение прочтения;
- при открытии диалога UI автопрокручивает ленту в самый низ;
- после отправки нового сообщения UI сразу прокручивает ленту вниз.
Отдельных таблиц файлов для DM сейчас нет.
## 10) Синхронизация личных сообщений между серверами
## События и доставка
Когда пользователи зарегистрированы на разных серверах SHiNE, серверы должны синхронизировать DM между собой.
Запрос на отправку по WebSocket остаётся прежним:
### Общий принцип
- `SendMessagePair`
- `ReceiveOutcomingMessage` как алиас
- Сервер A получает DM-блок, адресованный пользователю на сервере B.
- Сервер A пересылает этот блок серверу B (межсерверный relay).
- Сервер B сохраняет блок и доставляет его в активные сессии получателя.
- Серверы, между которыми идёт синхронизация, задаются списком `sync_servers` в PDA пользователя-сервера.
Клиент отправляет:
### Что синхронизируется
- `incomingBlobB64`
- `outgoingBlobB64`
- Все DM-блоки типов `1/2` (текстовые сообщения) и `3/4` (read-receipt).
- Синхронизация двусторонняя: оба сервера должны уметь принимать и пересылать блоки.
Событие в активные сессии:
### Идемпотентность
- `SignedMessageArrived`
- Блоки имеют уникальный `message_key` (`from|to|timeMs|nonce|type`).
- Повторная доставка одного и того же блока безопасна — дедупликация происходит по `message_key`.
Если пришла новая ревизия того же сообщения, `messageKey` остаётся прежним, а внутри `blobB64` будет более новый `revisionTimeMs`.
### Статус реализации
Подтверждение доставки в сессию:
Межсерверная синхронизация DM **пока не реализована**. Текущая версия работает только в рамках одного сервера. Это задача для следующего этапа.
- `AckSessionDelivery`
---
## Правила UI
## 11) Инварианты (обязательно соблюдать при доработках)
UI сейчас работает так:
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`.
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
- показывает только текст `encryptedBody`;
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
- не показывает удалённые сообщения;
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
- не показывает и не принимает вложения.
## 12) Ключевые файлы реализации
## Что обязательно помнить
- вложения в DM сейчас отключены на уровне протокола и UI;
- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны;
- если позже вложения вернутся, их формат и серверная логика могут быть другими.
- UI:
- `shine-UI/js/services/auth-service.js`
- `shine-UI/js/app.js`
- `shine-UI/js/state.js`
- `shine-UI/js/pages/chat-view.js`
- Сервер:
- `shine-server-net-protocol/.../messages/SignedMessageBlock.java`
- `shine-server-net-protocol/.../messages/SignedMessagesCore.java`
- `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
- `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
- `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java`
- БД:
- `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
- `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java`

View File

@ -1,73 +0,0 @@
# Черновик будущих вложений в DM
## Важно
Этот документ описывает только ранний черновик идеи.
Сейчас в проекте **нет** поддержки вложений в личных сообщениях:
- в реализованном формате `SHiNE_DM` поле `attachmentsCount` пока всегда должно быть `0`;
- UI не показывает кнопку прикрепления файлов;
- сервер не принимает upload файлов для DM;
- сервер не раздаёт специальные DM-файлы по отдельным endpoints;
- сервер не хранит отдельные файловые связи для личных сообщений.
Этот документ нужен только для того, чтобы рядом с актуальной документацией было явно видно:
- какие идеи обсуждались;
- что это **не реализовано**;
- что формат, хранение и способ загрузки потом могут сильно измениться.
## Что обсуждалось
Рассматривался такой общий подход:
- у контентного DM есть внешний список вложений;
- во внешнем формате лежат только технические данные;
- человекочитаемые данные о файле живут внутри зашифрованного тела сообщения;
- один и тот же blob-файл теоретически мог бы переиспользоваться в нескольких сообщениях.
Черновой вариант внешнего списка:
- `attachmentsCount`
- далее для каждого вложения:
- `encFileHashSHA256` (`32 bytes`)
- `encFileSize` (`u64`)
Черновой вариант внутреннего маркера в тексте:
```text
<<file:file-format(1.0):type|fileName|origSize|origHashB64u|encHashB64u|encSize|keyB64u|nonceB64u>>
```
Где обсуждались поля:
- `type`
- `fileName`
- `origSize`
- `origHashB64u`
- `encHashB64u`
- `encSize`
- `keyB64u`
- `nonceB64u`
## Что может измениться
В будущем могут измениться любые части идеи:
- сам бинарный формат;
- способ привязки файлов к сообщению;
- момент загрузки файла относительно отправки сообщения;
- серверное хранение blob-файлов;
- права доступа к скачиванию;
- способ рендера вложения в UI.
Именно поэтому этот файл не надо воспринимать как актуальную спецификацию.
## Источник истины на сейчас
Актуальное состояние личных сообщений описано только в:
- `Dev_Docs/Personal_Messages/README.md`
Если между этим черновиком и основным README есть расхождение, верным считается `README.md`.

View File

@ -36,7 +36,7 @@
Цель:
- новое устройство знает `login`, а `pairing password` используется только если он включён на доверённом устройстве;
- новое устройство знает `login + pairing password`;
- сервер использует пароль только как фильтр от мусора;
- реальное доверие даёт любая уже онлайн доверенная сессия пользователя;
- сервер не выдаёт приватные ключи сам от себя.
@ -58,8 +58,8 @@
## 3. Что именно делает сервер
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
- хранит включённость pairing и opaque `passwordHash`;
- хранит pending/approved/rejected pairing-заявки;
- рассчитывает короткий код `shortCode` из `7` цифр;
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
@ -101,12 +101,6 @@
Эта схема даёт нужное разделение доверия:
- пароль на сервере, если он включён, только отсеивает лишних;
- пароль на сервере только отсеивает лишних;
- онлайн доверенная сессия решает, добавлять ли новую сессию;
- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов.
Текущий формат pairing-пароля:
```text
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```

View File

@ -1,6 +1,5 @@
.gradle
build/
node_modules/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
@ -41,4 +40,4 @@ bin/
.vscode/
### Mac OS ###
.DS_Store
.DS_Store

View File

@ -0,0 +1 @@
rootProject.name = 'ESP-wallet'

View File

@ -27,7 +27,6 @@
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
- Архив истории после `/new`: `data/history/<username>/archive/`.
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
- Для просмотра истории игрока открывать файлы в его папке истории по username.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.

View File

@ -89,7 +89,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
- `/queue` — список задач в очереди.
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
- `/new` — архивировать текущую историю и начать новый диалог.
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.

View File

@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1 +0,0 @@
ESP-wallet

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
<option name="myGradleHome" value="" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View File

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

View File

@ -1,40 +0,0 @@
# SHiNE Browser Plugin Wallet
Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
## Что уже умеет
- создать `wallet-session` через `StartTrustedDeviceLogin`;
- показать код подключения;
- дождаться подтверждения на доверенном устройстве;
- принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`;
- сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin;
- восстанавливать session через `SessionChallenge -> SessionLogin`;
- держать wallet-state в `background service worker`, а popup использовать как UI.
- принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA.
## Как загрузить локально
1. Открой `chrome://extensions/`
2. Включи `Developer mode`
3. Нажми `Load unpacked`
4. Выбери папку `SHiNE-browser-plugin-wallet/`
## Ограничения текущего этапа
- plugin пока не держит постоянный фоновый WS-канал после закрытия popup, но хранит wallet-state в `background`;
- на этом этапе реализован только `session-only login`;
- запросы на подпись будут следующим этапом.
- pairing-пароль, если он используется, должен генерироваться в формате `sha256$<hex>` от строки `shine-pairing|loginLower|password`.
## Сборка crypto bundle
Для обычной загрузки plugin это не нужно: bundled crypto-файл уже лежит в репозитории.
Если понадобится пересобрать локальный crypto bundle:
```bash
npm install
npx esbuild js/lib/vendor/noble-ed25519-entry.js --bundle --format=esm --platform=browser --outfile=js/lib/vendor/noble-ed25519-bundle.js
npx esbuild js/lib/vendor/solana-publickey-entry.js --bundle --format=esm --platform=browser --outfile=js/lib/vendor/solana-publickey-bundle.js
```

View File

@ -1,567 +0,0 @@
import { createRequesterPairingMaterial, decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash } from './js/lib/device-pairing.js';
import { loadPluginSettings, loadSessionMaterial, savePluginSettings, saveSessionMaterial, clearSessionMaterial } from './js/lib/session-store.js';
import { ShineApiClient } from './js/lib/shine-api.js';
import {
DEFAULT_SHINE_SERVER_LOGIN,
buildHttpBase,
readWalletProfileByLogin,
resolveShineServerByUserLogin,
} from './js/lib/shine-server-resolver.js';
const state = {
api: null,
settings: {
serverLogin: DEFAULT_SHINE_SERVER_LOGIN,
serverHttp: buildHttpBase('shineup.me'),
serverUrl: 'wss://shineup.me/ws',
login: '',
},
requesterMaterial: null,
pairingId: '',
expiresAtMs: 0,
shortCode: '',
trustedSessionOnline: false,
pollTimer: 0,
activeSession: null,
connectionOnline: false,
walletProfile: null,
signing: {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
},
statusText: '',
statusKind: 'info',
};
function setStatus(message = '', kind = 'info') {
state.statusText = String(message || '');
state.statusKind = kind === 'error' ? 'error' : 'info';
}
function stopPoll() {
if (state.pollTimer) {
clearTimeout(state.pollTimer);
state.pollTimer = 0;
}
}
function clearPairingState() {
stopPoll();
state.requesterMaterial = null;
state.pairingId = '';
state.expiresAtMs = 0;
state.shortCode = '';
state.trustedSessionOnline = false;
}
function ensureApi(serverUrl = state.settings.serverUrl) {
const normalized = String(serverUrl || '').trim() || 'wss://shineup.me/ws';
if (!state.api || state.api.serverUrl !== normalized) {
state.api?.close();
state.api = new ShineApiClient(normalized);
}
return state.api;
}
async function loadStateFromStorage() {
const settings = await loadPluginSettings();
state.settings = {
serverLogin: String(settings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'),
serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
login: String(settings?.login || '').trim(),
};
state.activeSession = await loadSessionMaterial();
state.walletProfile = state.activeSession?.walletProfile || null;
state.signing = {
selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'),
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
};
}
async function persistSettings(nextSettings = {}) {
state.settings = {
...state.settings,
...nextSettings,
};
await savePluginSettings(state.settings);
return state.settings;
}
async function resolveServerForLogin(login) {
const cleanLogin = String(login || state.settings.login || '').trim();
if (!cleanLogin) {
state.settings = {
...state.settings,
login: '',
serverLogin: '',
};
await savePluginSettings(state.settings);
return { ok: true, resolved: false };
}
const resolved = await resolveShineServerByUserLogin(cleanLogin);
state.settings = {
...state.settings,
login: cleanLogin,
serverLogin: resolved.serverLogin,
serverHttp: resolved.serverHttp,
serverUrl: resolved.serverUrl,
};
await savePluginSettings(state.settings);
return { ok: true, resolved: true, ...resolved };
}
async function saveActiveSessionRecord() {
if (!state.activeSession) return;
const nextRecord = {
...state.activeSession,
walletProfile: state.walletProfile,
selectedKeyId: state.signing.selectedKeyId,
selectedDeviceName: state.signing.selectedDeviceName,
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
};
state.activeSession = nextRecord;
await saveSessionMaterial(nextRecord);
}
function shortKey(value = '', size = 10) {
const raw = String(value || '').trim();
return raw ? raw.slice(0, size) : '';
}
function extractErrorCode(message = '') {
const match = String(message || '').match(/\(([A-Z0-9_]+)\)\s*$/i);
return match ? String(match[1]).toUpperCase() : '';
}
function toWalletErrorMessage(error, fallback = 'Не удалось выполнить операцию кошелька.') {
const raw = String(error?.message || '').trim();
const code = String(error?.code || extractErrorCode(raw) || '').toUpperCase();
if (code === 'PAIRING_NOT_AVAILABLE') {
return 'Для этого логина ещё не включено подключение по коду. На доверенном устройстве откройте «Подключить по коду» и нажмите «Включить подключение по коду» или задайте дополнительный пароль.';
}
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
return 'Сейчас нет ни одной онлайн доверенной сессии этого пользователя. Откройте SHiNE на другом уже подключённом устройстве и держите его в сети.';
}
if (code === 'PAIRING_PASSWORD_INVALID') {
return 'Дополнительный пароль подключения не подходит.';
}
return raw || fallback;
}
function buildSigningKeyOptions(walletProfile) {
const rootKey = String(walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
const options = [];
if (rootKey) {
options.push({
id: 'root',
label: `rootKey (ed25519, ${shortKey(rootKey)})`,
keyType: 'ed25519',
publicKeyBase58: rootKey,
});
}
if (deviceKey) {
options.push({
id: 'device',
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
keyType: 'ed25519',
publicKeyBase58: deviceKey,
});
}
return options;
}
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
const published = Array.isArray(publishedHomeservers) ? publishedHomeservers : [];
const homeserverSessions = Array.isArray(serverSessions)
? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100)
: [];
const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer);
return published.map((item) => {
let onlineState = 'unknown';
if (published.length === 1) {
onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline';
} else if (onlineHomeservers.length === 0) {
onlineState = 'offline';
} else if (onlineHomeservers.length === published.length) {
onlineState = 'online';
}
return {
...item,
onlineState,
onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown',
};
});
}
async function hydrateWalletProfile(login) {
const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim();
if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.');
const profile = await readWalletProfileByLogin(cleanLogin);
const signingKeyOptions = buildSigningKeyOptions(profile);
const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId)
? state.signing.selectedKeyId
: (signingKeyOptions[0]?.id || '');
const selectedDeviceName = state.signing.selectedDeviceName
|| String(profile?.homeserverSessions?.[0]?.sessionName || '');
state.walletProfile = {
...profile,
signingKeyOptions,
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
...item,
onlineState: 'unknown',
onlineLabel: 'unknown',
})) : [],
};
state.signing = {
...state.signing,
selectedKeyId,
selectedDeviceName,
};
await saveActiveSessionRecord();
return state.walletProfile;
}
async function resumeActiveSession({ keepConnected = false } = {}) {
const sessionRecord = await loadSessionMaterial();
state.activeSession = sessionRecord;
if (!sessionRecord) {
state.connectionOnline = false;
setStatus('Wallet-session ещё не подключена.', 'info');
return { ok: true, connected: false };
}
try {
await persistSettings({
serverLogin: String(sessionRecord?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
serverHttp: String(sessionRecord?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim(),
serverUrl: String(sessionRecord?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim(),
login: String(sessionRecord?.login || state.settings.login || '').trim(),
});
const resumed = await ensureApi().resumeSession(sessionRecord);
state.connectionOnline = !!keepConnected;
if (!keepConnected) {
ensureApi().close();
state.api = null;
setStatus(`Wallet-session сохранена для @${resumed.login}. Подключение будет открываться только по действию.`, 'info');
} else {
setStatus(`Wallet-session активна для @${resumed.login}.`, 'info');
}
return { ok: true, connected: true, login: resumed.login, sessionId: resumed.sessionId };
} catch (error) {
state.connectionOnline = false;
setStatus(error.message || 'Не удалось восстановить wallet-session.', 'error');
return { ok: false, connected: false, error: state.statusText };
}
}
async function attachApprovedSession(payload) {
if (String(payload?.type || '') !== 'shine-esp-session-attach') {
throw new Error('Доверенное устройство вернуло неподдерживаемый payload. Для plugin нужен session-only approve.');
}
const login = String(payload?.login || state.settings.login || '').trim();
const approvedSession = payload?.session || {};
const sessionRecord = {
login,
sessionId: String(approvedSession?.sessionId || '').trim(),
sessionKey: state.requesterMaterial?.sessionKey || '',
sessionPrivPkcs8: state.requesterMaterial?.sessionPrivPkcs8 || '',
sessionType: Number(approvedSession?.sessionType || 50) || 50,
serverLogin: state.settings.serverLogin,
serverHttp: state.settings.serverHttp,
serverUrl: state.settings.serverUrl,
};
if (!sessionRecord.login || !sessionRecord.sessionId || !sessionRecord.sessionKey || !sessionRecord.sessionPrivPkcs8) {
throw new Error('Получен неполный session-only payload');
}
await clearSessionMaterial();
state.activeSession = sessionRecord;
await hydrateWalletProfile(login);
await saveActiveSessionRecord();
await persistSettings({
login: sessionRecord.login,
serverLogin: sessionRecord.serverLogin,
serverHttp: sessionRecord.serverHttp,
serverUrl: sessionRecord.serverUrl,
});
state.connectionOnline = false;
}
async function pollPairingStatus() {
if (!state.pairingId || !state.requesterMaterial) return;
try {
const payload = await ensureApi().getTrustedDeviceLoginStatus(state.pairingId);
const stateValue = String(payload?.state || '');
if (stateValue === 'created') {
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 2200);
return;
}
if (stateValue === 'approved') {
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, state.requesterMaterial);
await attachApprovedSession(decoded);
clearPairingState();
setStatus('Wallet-session создана и сохранена. Кошелёк остаётся офлайн до запроса подписи.', 'info');
return;
}
if (stateValue === 'rejected') {
clearPairingState();
setStatus('Заявка отклонена на доверенном устройстве.', 'error');
return;
}
if (stateValue === 'expired' || stateValue === 'canceled') {
clearPairingState();
setStatus('Ожидание подключения завершено.', 'error');
return;
}
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 2200);
} catch (error) {
clearPairingState();
setStatus(error.message || 'Не удалось проверить pairing-статус.', 'error');
}
}
async function startPairing({ login, usePassword, password }) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) {
throw new Error('Введите логин.');
}
await persistSettings({ login: cleanLogin });
await resolveServerForLogin(cleanLogin);
clearPairingState();
setStatus('Проверяем пользователя и создаём wallet-session заявку...', 'info');
const api = ensureApi();
const user = await api.getUser(cleanLogin);
if (user?.exists !== true) {
throw new Error('Пользователь не найден.');
}
state.requesterMaterial = await createRequesterPairingMaterial();
const passwordHash = usePassword
? await deriveEspPairingPasswordHash(cleanLogin, String(password || ''))
: '';
const payload = await api.startTrustedDeviceLogin({
login: cleanLogin,
passwordHash,
requesterSessionKey: state.requesterMaterial.sessionKey,
payloadType: 1,
});
state.pairingId = String(payload?.pairingId || '').trim();
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
state.shortCode = String(payload?.shortCode || '0000000');
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
if (!state.pairingId) {
throw new Error('Сервер не вернул pairingId.');
}
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 1800);
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
return {
pairingId: state.pairingId,
shortCode: String(payload?.shortCode || '0000000'),
expiresAtMs: state.expiresAtMs,
trustedSessionOnline: !!payload?.trustedSessionOnline,
};
}
async function cancelPairing() {
if (!state.pairingId || !state.requesterMaterial?.sessionKey) {
clearPairingState();
return { ok: true };
}
await ensureApi().cancelTrustedDeviceLogin(state.pairingId, state.requesterMaterial.sessionKey);
clearPairingState();
setStatus('Ожидание подключения отменено.', 'info');
return { ok: true };
}
async function disconnectSession() {
ensureApi().close();
state.api = null;
await clearSessionMaterial();
state.activeSession = null;
state.connectionOnline = false;
state.walletProfile = null;
state.signing = {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
};
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
return { ok: true };
}
async function refreshWalletDevices() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
await hydrateWalletProfile(state.activeSession.login);
const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) {
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
}
try {
const sessions = await ensureApi().listSessions();
state.walletProfile = {
...state.walletProfile,
homeserverSessions: mergeHomeserverStatuses(state.walletProfile?.homeserverSessions, sessions),
};
state.signing.devicesResolvedAtMs = Date.now();
if (!state.signing.selectedDeviceName && state.walletProfile.homeserverSessions[0]?.sessionName) {
state.signing.selectedDeviceName = state.walletProfile.homeserverSessions[0].sessionName;
}
await saveActiveSessionRecord();
setStatus('Список доверенных homeserver-устройств обновлён.', 'info');
return {
ok: true,
devices: state.walletProfile.homeserverSessions,
};
} finally {
ensureApi().close();
state.api = null;
state.connectionOnline = false;
}
}
async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) {
state.signing = {
...state.signing,
selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''),
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
};
await saveActiveSessionRecord();
return { ok: true };
}
async function prepareSignSignal() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
if (!state.signing.selectedKeyId) {
throw new Error('Не выбран ключ подписи.');
}
if (!state.signing.selectedDeviceName) {
throw new Error('Не выбрано устройство homeserver.');
}
const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
if (!selectedDevice) {
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
}
setStatus(
`Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`,
'info',
);
return {
ok: true,
pending: true,
};
}
function snapshot() {
return {
settings: { ...state.settings },
pairing: {
active: !!state.pairingId,
pairingId: state.pairingId,
expiresAtMs: state.expiresAtMs,
shortCode: state.shortCode,
trustedSessionOnline: state.trustedSessionOnline,
},
session: state.activeSession ? { ...state.activeSession } : null,
connectionOnline: state.connectionOnline,
walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
signing: { ...state.signing },
status: {
text: state.statusText,
kind: state.statusKind,
},
};
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
(async () => {
const type = String(message?.type || '');
if (type === 'wallet:getState') {
await loadStateFromStorage();
sendResponse({ ok: true, state: snapshot() });
return;
}
if (type === 'wallet:saveSettings') {
await persistSettings(message?.payload || {});
sendResponse({ ok: true, state: snapshot() });
return;
}
if (type === 'wallet:resolveServerInfo') {
const result = await resolveServerForLogin(String(message?.payload?.login || '').trim());
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:startPairing') {
const result = await startPairing(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:cancelPairing') {
const result = await cancelPairing();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:resumeSession') {
const result = await resumeActiveSession();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:refreshWalletDevices') {
const result = await refreshWalletDevices();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:updateSigningSelection') {
const result = await updateSigningSelection(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:prepareSignSignal') {
const result = await prepareSignSignal();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:disconnectSession') {
const result = await disconnectSession();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
})().catch((error) => {
const message = toWalletErrorMessage(error, 'Unknown error');
setStatus(message, 'error');
sendResponse({ ok: false, error: message, state: snapshot() });
});
return true;
});
void loadStateFromStorage().then(async () => {
if (state.activeSession?.login) {
await hydrateWalletProfile(state.activeSession.login).catch(() => {});
setStatus(`Wallet-session сохранена для @${state.activeSession.login}. Подключение будет открываться только по действию.`, 'info');
}
}).catch((error) => {
setStatus(error?.message || 'Не удалось инициализировать wallet plugin.', 'error');
});

View File

@ -1,78 +0,0 @@
function getCryptoApi() {
const api = globalThis.crypto;
if (!api?.subtle || typeof api.getRandomValues !== 'function') {
throw new Error('WebCrypto недоступен в текущем браузере.');
}
return api;
}
function getSubtleApi() {
return getCryptoApi().subtle;
}
function base64UrlToBase64(value) {
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
return normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
}
export function utf8Bytes(value) {
return new TextEncoder().encode(String(value ?? ''));
}
export function bytesToBase64(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode(...slice);
}
return btoa(binary);
}
export function base64ToBytes(value) {
const binary = atob(base64UrlToBase64(value));
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
export async function generateEd25519Pair() {
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
}
export async function exportEd25519PublicKeyB64(publicKey) {
const raw = await getSubtleApi().exportKey('raw', publicKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function exportPkcs8B64(privateKey) {
const raw = await getSubtleApi().exportKey('pkcs8', privateKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function importPkcs8Ed25519(pkcs8B64) {
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
}
export async function signBase64(privateKey, text) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
return bytesToBase64(new Uint8Array(signature));
}
export async function sha256Bytes(bytes) {
const digest = await getSubtleApi().digest('SHA-256', bytes);
return new Uint8Array(digest);
}
export async function sha256Text(text) {
return sha256Bytes(utf8Bytes(text));
}
export function randomBase64(size) {
const bytes = getCryptoApi().getRandomValues(new Uint8Array(size));
return bytesToBase64(bytes);
}
export function bytesToHex(bytes) {
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}

View File

@ -1,90 +0,0 @@
import {
base64ToBytes,
bytesToBase64,
bytesToHex,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
sha256Bytes,
sha256Text,
utf8Bytes,
} from './crypto-utils.js';
import { edwardsToMontgomeryPriv, x25519 } from './vendor/noble-ed25519-bundle.js';
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
const PAIRING_HASH_PREFIX = 'sha256$';
const PAIRING_HASH_VERSION = 'shine-pairing';
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
function getCryptoApi() {
const api = globalThis.crypto;
if (!api?.subtle || typeof api.getRandomValues !== 'function') {
throw new Error('WebCrypto недоступен.');
}
return api;
}
async function importAesKeyFromSharedSecret(sharedSecretBytes) {
const digest = await sha256Bytes(sharedSecretBytes);
return getCryptoApi().subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['decrypt']);
}
function base64UrlToBytes(value) {
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
return base64ToBytes(padded);
}
function extractSeedFromPkcs8(pkcs8B64) {
const raw = base64ToBytes(pkcs8B64);
if (raw.length !== ED25519_PKCS8_PREFIX.length + 32) {
throw new Error('Некорректный приватный Ed25519 ключ');
}
for (let i = 0; i < ED25519_PKCS8_PREFIX.length; i += 1) {
if (raw[i] !== ED25519_PKCS8_PREFIX[i]) {
throw new Error('Неподдерживаемый формат приватного Ed25519 ключа');
}
}
return raw.slice(ED25519_PKCS8_PREFIX.length);
}
export async function createRequesterPairingMaterial() {
const sessionPair = await generateEd25519Pair();
const sessionPublicB64 = await exportEd25519PublicKeyB64(sessionPair.publicKey);
return {
sessionKey: `ed25519/${sessionPublicB64}`,
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
};
}
export async function deriveEspPairingPasswordHash(login, password) {
const loginLower = String(login || '').trim().toLowerCase();
const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`;
const digest = await sha256Text(preimage);
return `${PAIRING_HASH_PREFIX}${bytesToHex(digest)}`;
}
export async function decryptPairingPayloadFromEnvelope(encryptedPayload, requesterPairingMaterial) {
const raw = String(encryptedPayload || '').trim();
if (!raw.startsWith(PAIRING_ENVELOPE_PREFIX)) {
throw new Error('Неподдерживаемый формат pairing payload');
}
const jsonBytes = base64UrlToBytes(raw.slice(PAIRING_ENVELOPE_PREFIX.length));
const envelope = JSON.parse(new TextDecoder().decode(jsonBytes));
if (Number(envelope?.v) !== 1 || String(envelope?.alg || '') !== 'x25519-aes256-gcm') {
throw new Error('Неподдерживаемая версия pairing payload');
}
const requesterSeed = extractSeedFromPkcs8(String(requesterPairingMaterial?.sessionPrivPkcs8 || ''));
const requesterMontPriv = edwardsToMontgomeryPriv(requesterSeed);
const sharedSecret = x25519.getSharedSecret(requesterMontPriv, base64ToBytes(String(envelope?.ephPubB64 || '')));
const aesKey = await importAesKeyFromSharedSecret(sharedSecret);
const plain = await getCryptoApi().subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(String(envelope?.ivB64 || '')) },
aesKey,
base64ToBytes(String(envelope?.cipherB64 || '')),
);
return JSON.parse(new TextDecoder().decode(plain));
}

View File

@ -1,152 +0,0 @@
import { base64ToBytes, bytesToBase64 } from './crypto-utils.js';
const DB_NAME = 'shine-wallet-plugin';
const DB_VERSION = 1;
const STORE_META = 'meta';
const STORE_VAULT = 'vault';
const SESSION_ENTRY_ID = 'active-session';
const VAULT_KEY_ID = 'session-wrap-key';
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_META)) {
db.createObjectStore(STORE_META, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_VAULT)) {
db.createObjectStore(STORE_VAULT, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('IndexedDB недоступен'));
});
}
async function withStore(storeName, mode, run) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
let settled = false;
const done = (fn) => (value) => {
if (settled) return;
settled = true;
fn(value);
};
tx.oncomplete = () => done(resolve)(undefined);
tx.onerror = () => done(reject)(tx.error || new Error('IndexedDB transaction failed'));
Promise.resolve(run(store, tx, done)).catch((error) => done(reject)(error));
});
} finally {
db.close();
}
}
async function put(storeName, value) {
return withStore(storeName, 'readwrite', (store) => {
store.put(value);
});
}
async function get(storeName, key) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const req = tx.objectStore(storeName).get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error || new Error('Ошибка чтения из IndexedDB'));
});
} finally {
db.close();
}
}
async function deleteById(storeName, key) {
return withStore(storeName, 'readwrite', (store) => {
store.delete(key);
});
}
async function getOrCreateVaultKey() {
const current = await get(STORE_META, VAULT_KEY_ID);
if (current?.key) return current.key;
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
await put(STORE_META, { id: VAULT_KEY_ID, key, createdAtMs: Date.now() });
return key;
}
async function encryptJson(value) {
const key = await getOrCreateVaultKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const plainBytes = new TextEncoder().encode(JSON.stringify(value));
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
return {
ivB64: bytesToBase64(iv),
cipherB64: bytesToBase64(new Uint8Array(cipher)),
};
}
async function decryptJson(envelope) {
const key = await getOrCreateVaultKey();
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(envelope.ivB64) },
key,
base64ToBytes(envelope.cipherB64),
);
return JSON.parse(new TextDecoder().decode(plain));
}
function storageApi() {
if (globalThis.chrome?.storage?.local) return globalThis.chrome.storage.local;
return null;
}
export async function savePluginSettings(settings) {
const api = storageApi();
if (api) {
await api.set({ shineWalletSettings: settings });
return;
}
localStorage.setItem('shineWalletSettings', JSON.stringify(settings));
}
export async function loadPluginSettings() {
const api = storageApi();
if (api) {
const row = await api.get('shineWalletSettings');
return row?.shineWalletSettings || {};
}
try {
return JSON.parse(localStorage.getItem('shineWalletSettings') || '{}');
} catch {
return {};
}
}
export async function saveSessionMaterial(sessionRecord) {
const encrypted = await encryptJson(sessionRecord);
await put(STORE_VAULT, {
id: SESSION_ENTRY_ID,
encrypted,
updatedAtMs: Date.now(),
});
}
export async function loadSessionMaterial() {
const row = await get(STORE_VAULT, SESSION_ENTRY_ID);
if (!row?.encrypted) return null;
return decryptJson(row.encrypted);
}
export async function clearSessionMaterial() {
await deleteById(STORE_VAULT, SESSION_ENTRY_ID);
}

View File

@ -1,117 +0,0 @@
import { importPkcs8Ed25519, signBase64 } from './crypto-utils.js';
import { WsJsonClient } from './ws-client.js';
const SESSION_TYPE_WALLET = 50;
function normalizeServerUrl(url) {
const value = String(url || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('http://') || value.startsWith('https://')) {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
}
return value;
}
function opError(op, response) {
const payload = response?.payload || {};
const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
const error = new Error(`${op}: ${message} (${code})`);
error.op = op;
error.code = code;
error.status = response?.status || 0;
return error;
}
export class ShineApiClient {
constructor(serverUrl) {
this.serverUrl = normalizeServerUrl(serverUrl);
this.ws = new WsJsonClient(this.serverUrl);
}
async getUser(login) {
const response = await this.ws.request('GetUser', { login: String(login || '').trim() });
if (response.status !== 200) throw opError('GetUser', response);
return response.payload || {};
}
async startTrustedDeviceLogin({ login, passwordHash, requesterSessionKey, payloadType = 1 }) {
const response = await this.ws.request('StartTrustedDeviceLogin', {
login: String(login || '').trim(),
passwordHash: String(passwordHash || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
requesterSessionType: SESSION_TYPE_WALLET,
requesterClientPlatform: 'Chrome Extension Wallet',
payloadType: Number(payloadType) || 1,
});
if (response.status !== 200) throw opError('StartTrustedDeviceLogin', response);
return response.payload || {};
}
async getTrustedDeviceLoginStatus(pairingId) {
const response = await this.ws.request('GetTrustedDeviceLoginStatus', {
pairingId: String(pairingId || '').trim(),
});
if (response.status !== 200) throw opError('GetTrustedDeviceLoginStatus', response);
return response.payload || {};
}
async cancelTrustedDeviceLogin(pairingId, requesterSessionKey) {
const response = await this.ws.request('CancelTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
});
if (response.status !== 200) throw opError('CancelTrustedDeviceLogin', response);
return response.payload || {};
}
async listSessions() {
const response = await this.ws.request('ListSessions', {});
if (response.status !== 200) throw opError('ListSessions', response);
return Array.isArray(response?.payload?.sessions) ? response.payload.sessions : [];
}
async resumeSession(sessionRecord) {
const login = String(sessionRecord?.login || '').trim();
const sessionId = String(sessionRecord?.sessionId || '').trim();
const sessionKey = String(sessionRecord?.sessionKey || '').trim();
const sessionPrivPkcs8 = String(sessionRecord?.sessionPrivPkcs8 || '').trim();
if (!login || !sessionId || !sessionKey || !sessionPrivPkcs8) {
throw new Error('Сохранённая wallet-session неполная');
}
const privateKey = await importPkcs8Ed25519(sessionPrivPkcs8);
const challengeResp = await this.ws.request('SessionChallenge', { sessionId });
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
const nonce = challengeResp?.payload?.nonce;
if (!nonce) throw new Error('SessionChallenge: сервер не вернул nonce');
const timeMs = Date.now();
const preimage = `SESSION_LOGIN:${sessionId}:${timeMs}:${nonce}`;
const signatureB64 = await signBase64(privateKey, preimage);
const loginResp = await this.ws.request('SessionLogin', {
sessionId,
sessionKey,
timeMs,
signatureB64,
sessionType: Number(sessionRecord?.sessionType || SESSION_TYPE_WALLET) || SESSION_TYPE_WALLET,
clientPlatform: 'Chrome Extension Wallet',
clientInfo: 'SHiNE Browser Plugin Wallet',
});
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
return {
login,
sessionId,
storagePwd: String(loginResp?.payload?.storagePwd || '').trim(),
};
}
close() {
this.ws.close();
}
}

View File

@ -1,244 +0,0 @@
import { base64ToBytes } from './crypto-utils.js';
import { PublicKey } from './vendor/solana-publickey-bundle.js';
const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com';
const SHINE_USERS_PROGRAM_ID = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme';
const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me';
function normalizeHostLike(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const withScheme = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
const parsed = new URL(withScheme);
return String(parsed.host || '').trim().toLowerCase();
} catch {
return raw.replace(/^https?:\/\//i, '').replace(/^wss?:\/\//i, '').replace(/\/.*$/, '').trim().toLowerCase();
}
}
function normalizeServerLogin(value) {
return String(value || '').trim().toLowerCase();
}
function buildHttpBase(address) {
const host = normalizeHostLike(address) || DEFAULT_SHINE_SERVER_ADDRESS;
return `https://${host}`;
}
function buildWsUrl(address) {
const host = normalizeHostLike(address) || DEFAULT_SHINE_SERVER_ADDRESS;
return `wss://${host}/ws`;
}
function readU8(bytes, cursorRef) {
if (cursorRef.value >= bytes.length) throw new Error('Повреждённый формат PDA');
return bytes[cursorRef.value++];
}
function readBytes(bytes, cursorRef, length) {
if (cursorRef.value + length > bytes.length) throw new Error('Повреждённый формат PDA');
const out = bytes.slice(cursorRef.value, cursorRef.value + length);
cursorRef.value += length;
return out;
}
function readStrU8(bytes, cursorRef) {
const length = readU8(bytes, cursorRef);
return new TextDecoder().decode(readBytes(bytes, cursorRef, length));
}
function parseServerFieldsFromUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
if (bytes.length < 5) throw new Error('Некорректный формат PDA');
const cursorRef = { value: 0 };
const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5));
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
cursorRef.value += 1; // format_major
cursorRef.value += 1; // format_minor
cursorRef.value += 2; // record_len
cursorRef.value += 8; // created_at_ms
cursorRef.value += 8; // updated_at_ms
cursorRef.value += 4; // record_number
cursorRef.value += 32; // prev_record_hash
readStrU8(bytes, cursorRef); // login
const blocksCount = readU8(bytes, cursorRef);
let isServer = false;
let serverAddress = '';
let accessServers = [];
let rootKey32 = null;
let deviceKey32 = null;
let blockchainKey32 = null;
let blockchainName = '';
let homeserverSessions = [];
for (let i = 0; i < blocksCount; i += 1) {
const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version
if (blockType === 1 || blockType === 2) {
const key32 = readBytes(bytes, cursorRef, 32);
if (blockType === 1) rootKey32 = key32;
if (blockType === 2) deviceKey32 = key32;
continue;
}
if (blockType === 3) {
const count = readU8(bytes, cursorRef);
for (let j = 0; j < count; j += 1) {
cursorRef.value += 1;
const currentBlockchainName = readStrU8(bytes, cursorRef);
const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32);
if (!blockchainKey32) {
blockchainKey32 = currentBlockchainKey32;
blockchainName = currentBlockchainName;
}
cursorRef.value += 8 + 8 + 4 + 32 + 64;
const arPresent = readU8(bytes, cursorRef);
if (arPresent === 1) readStrU8(bytes, cursorRef);
}
continue;
}
if (blockType === 30) {
isServer = readU8(bytes, cursorRef) === 1;
if (isServer) {
cursorRef.value += 1; // address_format_type
cursorRef.value += 1; // address_format_version
serverAddress = readStrU8(bytes, cursorRef);
const syncCount = readU8(bytes, cursorRef);
for (let j = 0; j < syncCount; j += 1) readStrU8(bytes, cursorRef);
}
continue;
}
if (blockType === 40) {
const accessCount = readU8(bytes, cursorRef);
accessServers = [];
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
continue;
}
if (blockType === 50) {
cursorRef.value += 1;
const sessionsCount = readU8(bytes, cursorRef);
for (let j = 0; j < sessionsCount; j += 1) {
const sessionType = readU8(bytes, cursorRef);
const sessionVersion = readU8(bytes, cursorRef);
const sessionName = readStrU8(bytes, cursorRef);
const sessionPubKey32 = readBytes(bytes, cursorRef, 32);
if (sessionType === 100) {
homeserverSessions.push({
sessionType,
sessionVersion,
sessionName,
sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(),
sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`,
});
}
}
continue;
}
if (blockType === 70) {
cursorRef.value += 1;
continue;
}
throw new Error(`Неизвестный блок PDA: ${blockType}`);
}
return {
isServer,
serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: {
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
deviceKeyBase58: deviceKey32 ? new PublicKey(deviceKey32).toBase58() : '',
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName,
},
homeserverSessions,
};
}
async function fetchUserPda(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login);
if (!cleanLogin) throw new Error('Не указан логин для чтения PDA.');
const usersProgram = new PublicKey(SHINE_USERS_PROGRAM_ID);
const enc = new TextEncoder();
const [userPda] = PublicKey.findProgramAddressSync(
[enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)],
usersProgram,
);
const response = await fetch(String(solanaEndpoint || SOLANA_ENDPOINT_DEFAULT), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getAccountInfo',
params: [userPda.toBase58(), { encoding: 'base64', commitment: 'confirmed' }],
}),
});
if (!response.ok) throw new Error('Не удалось прочитать Solana RPC.');
const json = await response.json();
const dataB64 = json?.result?.value?.data?.[0];
if (!dataB64) throw new Error(`PDA не найдена для логина @${cleanLogin}.`);
return parseServerFieldsFromUserPda(base64ToBytes(dataB64));
}
export async function resolveShineServerByServerLogin(serverLogin, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanServerLogin = normalizeServerLogin(serverLogin) || DEFAULT_SHINE_SERVER_LOGIN;
const parsed = await fetchUserPda(cleanServerLogin, solanaEndpoint);
if (!parsed.isServer) {
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер SHiNE.`);
}
if (!parsed.serverAddress) {
throw new Error(`У server PDA пользователя @${cleanServerLogin} не задан server_address.`);
}
return {
serverLogin: cleanServerLogin,
serverAddress: parsed.serverAddress,
serverHttp: buildHttpBase(parsed.serverAddress),
serverUrl: buildWsUrl(parsed.serverAddress),
};
}
export async function resolveShineServerByUserLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login);
if (!cleanLogin) throw new Error('Не указан логин пользователя.');
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);
const serverLogin = normalizeServerLogin(parsed.accessServers?.[0] || '');
if (!serverLogin) {
throw new Error(`У пользователя @${cleanLogin} в PDA не найден первый сервер доступа.`);
}
const resolved = await resolveShineServerByServerLogin(serverLogin, solanaEndpoint);
return {
login: cleanLogin,
accessServers: parsed.accessServers,
serverLogin: resolved.serverLogin,
serverAddress: resolved.serverAddress,
serverHttp: resolved.serverHttp,
serverUrl: resolved.serverUrl,
};
}
export async function readWalletProfileByLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login);
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);
return {
login: cleanLogin,
accessServers: parsed.accessServers,
publicKeys: parsed.publicKeys,
homeserverSessions: parsed.homeserverSessions,
};
}
export {
DEFAULT_SHINE_SERVER_ADDRESS,
DEFAULT_SHINE_SERVER_LOGIN,
SOLANA_ENDPOINT_DEFAULT,
buildHttpBase,
buildWsUrl,
normalizeServerLogin,
};

View File

@ -1,995 +0,0 @@
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_assert.js
function isBytes(a) {
return a instanceof Uint8Array || a != null && typeof a === "object" && a.constructor.name === "Uint8Array";
}
function bytes(b, ...lengths) {
if (!isBytes(b))
throw new Error("Uint8Array expected");
if (lengths.length > 0 && !lengths.includes(b.length))
throw new Error(`Uint8Array expected of length ${lengths}, not of length=${b.length}`);
}
function exists(instance, checkFinished = true) {
if (instance.destroyed)
throw new Error("Hash instance has been destroyed");
if (checkFinished && instance.finished)
throw new Error("Hash#digest() has already been called");
}
function output(out, instance) {
bytes(out);
const min = instance.outputLen;
if (out.length < min) {
throw new Error(`digestInto() expects output buffer of length at least ${min}`);
}
}
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/crypto.js
var crypto = typeof globalThis === "object" && "crypto" in globalThis ? globalThis.crypto : void 0;
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/utils.js
var createView = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
var isLE = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68;
function utf8ToBytes(str) {
if (typeof str !== "string")
throw new Error(`utf8ToBytes expected string, got ${typeof str}`);
return new Uint8Array(new TextEncoder().encode(str));
}
function toBytes(data) {
if (typeof data === "string")
data = utf8ToBytes(data);
bytes(data);
return data;
}
var Hash = class {
// Safe version that clones internal state
clone() {
return this._cloneInto();
}
};
var toStr = {}.toString;
function wrapConstructor(hashCons) {
const hashC = (msg) => hashCons().update(toBytes(msg)).digest();
const tmp = hashCons();
hashC.outputLen = tmp.outputLen;
hashC.blockLen = tmp.blockLen;
hashC.create = () => hashCons();
return hashC;
}
function randomBytes(bytesLength = 32) {
if (crypto && typeof crypto.getRandomValues === "function") {
return crypto.getRandomValues(new Uint8Array(bytesLength));
}
throw new Error("crypto.getRandomValues must be defined");
}
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_md.js
function setBigUint64(view, byteOffset, value, isLE2) {
if (typeof view.setBigUint64 === "function")
return view.setBigUint64(byteOffset, value, isLE2);
const _32n2 = BigInt(32);
const _u32_max = BigInt(4294967295);
const wh = Number(value >> _32n2 & _u32_max);
const wl = Number(value & _u32_max);
const h = isLE2 ? 4 : 0;
const l = isLE2 ? 0 : 4;
view.setUint32(byteOffset + h, wh, isLE2);
view.setUint32(byteOffset + l, wl, isLE2);
}
var HashMD = class extends Hash {
constructor(blockLen, outputLen, padOffset, isLE2) {
super();
this.blockLen = blockLen;
this.outputLen = outputLen;
this.padOffset = padOffset;
this.isLE = isLE2;
this.finished = false;
this.length = 0;
this.pos = 0;
this.destroyed = false;
this.buffer = new Uint8Array(blockLen);
this.view = createView(this.buffer);
}
update(data) {
exists(this);
const { view, buffer, blockLen } = this;
data = toBytes(data);
const len = data.length;
for (let pos = 0; pos < len; ) {
const take = Math.min(blockLen - this.pos, len - pos);
if (take === blockLen) {
const dataView = createView(data);
for (; blockLen <= len - pos; pos += blockLen)
this.process(dataView, pos);
continue;
}
buffer.set(data.subarray(pos, pos + take), this.pos);
this.pos += take;
pos += take;
if (this.pos === blockLen) {
this.process(view, 0);
this.pos = 0;
}
}
this.length += data.length;
this.roundClean();
return this;
}
digestInto(out) {
exists(this);
output(out, this);
this.finished = true;
const { buffer, view, blockLen, isLE: isLE2 } = this;
let { pos } = this;
buffer[pos++] = 128;
this.buffer.subarray(pos).fill(0);
if (this.padOffset > blockLen - pos) {
this.process(view, 0);
pos = 0;
}
for (let i = pos; i < blockLen; i++)
buffer[i] = 0;
setBigUint64(view, blockLen - 8, BigInt(this.length * 8), isLE2);
this.process(view, 0);
const oview = createView(out);
const len = this.outputLen;
if (len % 4)
throw new Error("_sha2: outputLen should be aligned to 32bit");
const outLen = len / 4;
const state = this.get();
if (outLen > state.length)
throw new Error("_sha2: outputLen bigger than state");
for (let i = 0; i < outLen; i++)
oview.setUint32(4 * i, state[i], isLE2);
}
digest() {
const { buffer, outputLen } = this;
this.digestInto(buffer);
const res = buffer.slice(0, outputLen);
this.destroy();
return res;
}
_cloneInto(to) {
to || (to = new this.constructor());
to.set(...this.get());
const { blockLen, buffer, length, finished, destroyed, pos } = this;
to.length = length;
to.pos = pos;
to.finished = finished;
to.destroyed = destroyed;
if (length % blockLen)
to.buffer.set(buffer);
return to;
}
};
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_u64.js
var U32_MASK64 = /* @__PURE__ */ BigInt(2 ** 32 - 1);
var _32n = /* @__PURE__ */ BigInt(32);
function fromBig(n, le = false) {
if (le)
return { h: Number(n & U32_MASK64), l: Number(n >> _32n & U32_MASK64) };
return { h: Number(n >> _32n & U32_MASK64) | 0, l: Number(n & U32_MASK64) | 0 };
}
function split(lst, le = false) {
let Ah = new Uint32Array(lst.length);
let Al = new Uint32Array(lst.length);
for (let i = 0; i < lst.length; i++) {
const { h, l } = fromBig(lst[i], le);
[Ah[i], Al[i]] = [h, l];
}
return [Ah, Al];
}
var toBig = (h, l) => BigInt(h >>> 0) << _32n | BigInt(l >>> 0);
var shrSH = (h, _l, s) => h >>> s;
var shrSL = (h, l, s) => h << 32 - s | l >>> s;
var rotrSH = (h, l, s) => h >>> s | l << 32 - s;
var rotrSL = (h, l, s) => h << 32 - s | l >>> s;
var rotrBH = (h, l, s) => h << 64 - s | l >>> s - 32;
var rotrBL = (h, l, s) => h >>> s - 32 | l << 64 - s;
var rotr32H = (_h, l) => l;
var rotr32L = (h, _l) => h;
var rotlSH = (h, l, s) => h << s | l >>> 32 - s;
var rotlSL = (h, l, s) => l << s | h >>> 32 - s;
var rotlBH = (h, l, s) => l << s - 32 | h >>> 64 - s;
var rotlBL = (h, l, s) => h << s - 32 | l >>> 64 - s;
function add(Ah, Al, Bh, Bl) {
const l = (Al >>> 0) + (Bl >>> 0);
return { h: Ah + Bh + (l / 2 ** 32 | 0) | 0, l: l | 0 };
}
var add3L = (Al, Bl, Cl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0);
var add3H = (low, Ah, Bh, Ch) => Ah + Bh + Ch + (low / 2 ** 32 | 0) | 0;
var add4L = (Al, Bl, Cl, Dl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0);
var add4H = (low, Ah, Bh, Ch, Dh) => Ah + Bh + Ch + Dh + (low / 2 ** 32 | 0) | 0;
var add5L = (Al, Bl, Cl, Dl, El) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) + (El >>> 0);
var add5H = (low, Ah, Bh, Ch, Dh, Eh) => Ah + Bh + Ch + Dh + Eh + (low / 2 ** 32 | 0) | 0;
var u64 = {
fromBig,
split,
toBig,
shrSH,
shrSL,
rotrSH,
rotrSL,
rotrBH,
rotrBL,
rotr32H,
rotr32L,
rotlSH,
rotlSL,
rotlBH,
rotlBL,
add,
add3L,
add3H,
add4L,
add4H,
add5H,
add5L
};
var u64_default = u64;
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/sha512.js
var [SHA512_Kh, SHA512_Kl] = /* @__PURE__ */ (() => u64_default.split([
"0x428a2f98d728ae22",
"0x7137449123ef65cd",
"0xb5c0fbcfec4d3b2f",
"0xe9b5dba58189dbbc",
"0x3956c25bf348b538",
"0x59f111f1b605d019",
"0x923f82a4af194f9b",
"0xab1c5ed5da6d8118",
"0xd807aa98a3030242",
"0x12835b0145706fbe",
"0x243185be4ee4b28c",
"0x550c7dc3d5ffb4e2",
"0x72be5d74f27b896f",
"0x80deb1fe3b1696b1",
"0x9bdc06a725c71235",
"0xc19bf174cf692694",
"0xe49b69c19ef14ad2",
"0xefbe4786384f25e3",
"0x0fc19dc68b8cd5b5",
"0x240ca1cc77ac9c65",
"0x2de92c6f592b0275",
"0x4a7484aa6ea6e483",
"0x5cb0a9dcbd41fbd4",
"0x76f988da831153b5",
"0x983e5152ee66dfab",
"0xa831c66d2db43210",
"0xb00327c898fb213f",
"0xbf597fc7beef0ee4",
"0xc6e00bf33da88fc2",
"0xd5a79147930aa725",
"0x06ca6351e003826f",
"0x142929670a0e6e70",
"0x27b70a8546d22ffc",
"0x2e1b21385c26c926",
"0x4d2c6dfc5ac42aed",
"0x53380d139d95b3df",
"0x650a73548baf63de",
"0x766a0abb3c77b2a8",
"0x81c2c92e47edaee6",
"0x92722c851482353b",
"0xa2bfe8a14cf10364",
"0xa81a664bbc423001",
"0xc24b8b70d0f89791",
"0xc76c51a30654be30",
"0xd192e819d6ef5218",
"0xd69906245565a910",
"0xf40e35855771202a",
"0x106aa07032bbd1b8",
"0x19a4c116b8d2d0c8",
"0x1e376c085141ab53",
"0x2748774cdf8eeb99",
"0x34b0bcb5e19b48a8",
"0x391c0cb3c5c95a63",
"0x4ed8aa4ae3418acb",
"0x5b9cca4f7763e373",
"0x682e6ff3d6b2b8a3",
"0x748f82ee5defb2fc",
"0x78a5636f43172f60",
"0x84c87814a1f0ab72",
"0x8cc702081a6439ec",
"0x90befffa23631e28",
"0xa4506cebde82bde9",
"0xbef9a3f7b2c67915",
"0xc67178f2e372532b",
"0xca273eceea26619c",
"0xd186b8c721c0c207",
"0xeada7dd6cde0eb1e",
"0xf57d4f7fee6ed178",
"0x06f067aa72176fba",
"0x0a637dc5a2c898a6",
"0x113f9804bef90dae",
"0x1b710b35131c471b",
"0x28db77f523047d84",
"0x32caab7b40c72493",
"0x3c9ebe0a15c9bebc",
"0x431d67c49c100d4c",
"0x4cc5d4becb3e42b6",
"0x597f299cfc657e2a",
"0x5fcb6fab3ad6faec",
"0x6c44198c4a475817"
].map((n) => BigInt(n))))();
var SHA512_W_H = /* @__PURE__ */ new Uint32Array(80);
var SHA512_W_L = /* @__PURE__ */ new Uint32Array(80);
var SHA512 = class extends HashMD {
constructor() {
super(128, 64, 16, false);
this.Ah = 1779033703 | 0;
this.Al = 4089235720 | 0;
this.Bh = 3144134277 | 0;
this.Bl = 2227873595 | 0;
this.Ch = 1013904242 | 0;
this.Cl = 4271175723 | 0;
this.Dh = 2773480762 | 0;
this.Dl = 1595750129 | 0;
this.Eh = 1359893119 | 0;
this.El = 2917565137 | 0;
this.Fh = 2600822924 | 0;
this.Fl = 725511199 | 0;
this.Gh = 528734635 | 0;
this.Gl = 4215389547 | 0;
this.Hh = 1541459225 | 0;
this.Hl = 327033209 | 0;
}
// prettier-ignore
get() {
const { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
return [Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl];
}
// prettier-ignore
set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl) {
this.Ah = Ah | 0;
this.Al = Al | 0;
this.Bh = Bh | 0;
this.Bl = Bl | 0;
this.Ch = Ch | 0;
this.Cl = Cl | 0;
this.Dh = Dh | 0;
this.Dl = Dl | 0;
this.Eh = Eh | 0;
this.El = El | 0;
this.Fh = Fh | 0;
this.Fl = Fl | 0;
this.Gh = Gh | 0;
this.Gl = Gl | 0;
this.Hh = Hh | 0;
this.Hl = Hl | 0;
}
process(view, offset) {
for (let i = 0; i < 16; i++, offset += 4) {
SHA512_W_H[i] = view.getUint32(offset);
SHA512_W_L[i] = view.getUint32(offset += 4);
}
for (let i = 16; i < 80; i++) {
const W15h = SHA512_W_H[i - 15] | 0;
const W15l = SHA512_W_L[i - 15] | 0;
const s0h = u64_default.rotrSH(W15h, W15l, 1) ^ u64_default.rotrSH(W15h, W15l, 8) ^ u64_default.shrSH(W15h, W15l, 7);
const s0l = u64_default.rotrSL(W15h, W15l, 1) ^ u64_default.rotrSL(W15h, W15l, 8) ^ u64_default.shrSL(W15h, W15l, 7);
const W2h = SHA512_W_H[i - 2] | 0;
const W2l = SHA512_W_L[i - 2] | 0;
const s1h = u64_default.rotrSH(W2h, W2l, 19) ^ u64_default.rotrBH(W2h, W2l, 61) ^ u64_default.shrSH(W2h, W2l, 6);
const s1l = u64_default.rotrSL(W2h, W2l, 19) ^ u64_default.rotrBL(W2h, W2l, 61) ^ u64_default.shrSL(W2h, W2l, 6);
const SUMl = u64_default.add4L(s0l, s1l, SHA512_W_L[i - 7], SHA512_W_L[i - 16]);
const SUMh = u64_default.add4H(SUMl, s0h, s1h, SHA512_W_H[i - 7], SHA512_W_H[i - 16]);
SHA512_W_H[i] = SUMh | 0;
SHA512_W_L[i] = SUMl | 0;
}
let { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
for (let i = 0; i < 80; i++) {
const sigma1h = u64_default.rotrSH(Eh, El, 14) ^ u64_default.rotrSH(Eh, El, 18) ^ u64_default.rotrBH(Eh, El, 41);
const sigma1l = u64_default.rotrSL(Eh, El, 14) ^ u64_default.rotrSL(Eh, El, 18) ^ u64_default.rotrBL(Eh, El, 41);
const CHIh = Eh & Fh ^ ~Eh & Gh;
const CHIl = El & Fl ^ ~El & Gl;
const T1ll = u64_default.add5L(Hl, sigma1l, CHIl, SHA512_Kl[i], SHA512_W_L[i]);
const T1h = u64_default.add5H(T1ll, Hh, sigma1h, CHIh, SHA512_Kh[i], SHA512_W_H[i]);
const T1l = T1ll | 0;
const sigma0h = u64_default.rotrSH(Ah, Al, 28) ^ u64_default.rotrBH(Ah, Al, 34) ^ u64_default.rotrBH(Ah, Al, 39);
const sigma0l = u64_default.rotrSL(Ah, Al, 28) ^ u64_default.rotrBL(Ah, Al, 34) ^ u64_default.rotrBL(Ah, Al, 39);
const MAJh = Ah & Bh ^ Ah & Ch ^ Bh & Ch;
const MAJl = Al & Bl ^ Al & Cl ^ Bl & Cl;
Hh = Gh | 0;
Hl = Gl | 0;
Gh = Fh | 0;
Gl = Fl | 0;
Fh = Eh | 0;
Fl = El | 0;
({ h: Eh, l: El } = u64_default.add(Dh | 0, Dl | 0, T1h | 0, T1l | 0));
Dh = Ch | 0;
Dl = Cl | 0;
Ch = Bh | 0;
Cl = Bl | 0;
Bh = Ah | 0;
Bl = Al | 0;
const All = u64_default.add3L(T1l, sigma0l, MAJl);
Ah = u64_default.add3H(All, T1h, sigma0h, MAJh);
Al = All | 0;
}
({ h: Ah, l: Al } = u64_default.add(this.Ah | 0, this.Al | 0, Ah | 0, Al | 0));
({ h: Bh, l: Bl } = u64_default.add(this.Bh | 0, this.Bl | 0, Bh | 0, Bl | 0));
({ h: Ch, l: Cl } = u64_default.add(this.Ch | 0, this.Cl | 0, Ch | 0, Cl | 0));
({ h: Dh, l: Dl } = u64_default.add(this.Dh | 0, this.Dl | 0, Dh | 0, Dl | 0));
({ h: Eh, l: El } = u64_default.add(this.Eh | 0, this.El | 0, Eh | 0, El | 0));
({ h: Fh, l: Fl } = u64_default.add(this.Fh | 0, this.Fl | 0, Fh | 0, Fl | 0));
({ h: Gh, l: Gl } = u64_default.add(this.Gh | 0, this.Gl | 0, Gh | 0, Gl | 0));
({ h: Hh, l: Hl } = u64_default.add(this.Hh | 0, this.Hl | 0, Hh | 0, Hl | 0));
this.set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl);
}
roundClean() {
SHA512_W_H.fill(0);
SHA512_W_L.fill(0);
}
destroy() {
this.buffer.fill(0);
this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
}
};
var sha512 = /* @__PURE__ */ wrapConstructor(() => new SHA512());
// node_modules/@noble/curves/esm/abstract/utils.js
var _0n = /* @__PURE__ */ BigInt(0);
var _1n = /* @__PURE__ */ BigInt(1);
var _2n = /* @__PURE__ */ BigInt(2);
function isBytes2(a) {
return a instanceof Uint8Array || a != null && typeof a === "object" && a.constructor.name === "Uint8Array";
}
function abytes(item) {
if (!isBytes2(item))
throw new Error("Uint8Array expected");
}
var hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
function bytesToHex(bytes2) {
abytes(bytes2);
let hex = "";
for (let i = 0; i < bytes2.length; i++) {
hex += hexes[bytes2[i]];
}
return hex;
}
function hexToNumber(hex) {
if (typeof hex !== "string")
throw new Error("hex string expected, got " + typeof hex);
return BigInt(hex === "" ? "0" : `0x${hex}`);
}
var asciis = { _0: 48, _9: 57, _A: 65, _F: 70, _a: 97, _f: 102 };
function asciiToBase16(char) {
if (char >= asciis._0 && char <= asciis._9)
return char - asciis._0;
if (char >= asciis._A && char <= asciis._F)
return char - (asciis._A - 10);
if (char >= asciis._a && char <= asciis._f)
return char - (asciis._a - 10);
return;
}
function hexToBytes(hex) {
if (typeof hex !== "string")
throw new Error("hex string expected, got " + typeof hex);
const hl = hex.length;
const al = hl / 2;
if (hl % 2)
throw new Error("padded hex string expected, got unpadded hex of length " + hl);
const array = new Uint8Array(al);
for (let ai = 0, hi = 0; ai < al; ai++, hi += 2) {
const n1 = asciiToBase16(hex.charCodeAt(hi));
const n2 = asciiToBase16(hex.charCodeAt(hi + 1));
if (n1 === void 0 || n2 === void 0) {
const char = hex[hi] + hex[hi + 1];
throw new Error('hex string expected, got non-hex character "' + char + '" at index ' + hi);
}
array[ai] = n1 * 16 + n2;
}
return array;
}
function bytesToNumberBE(bytes2) {
return hexToNumber(bytesToHex(bytes2));
}
function bytesToNumberLE(bytes2) {
abytes(bytes2);
return hexToNumber(bytesToHex(Uint8Array.from(bytes2).reverse()));
}
function numberToBytesBE(n, len) {
return hexToBytes(n.toString(16).padStart(len * 2, "0"));
}
function numberToBytesLE(n, len) {
return numberToBytesBE(n, len).reverse();
}
function ensureBytes(title, hex, expectedLength) {
let res;
if (typeof hex === "string") {
try {
res = hexToBytes(hex);
} catch (e) {
throw new Error(`${title} must be valid hex string, got "${hex}". Cause: ${e}`);
}
} else if (isBytes2(hex)) {
res = Uint8Array.from(hex);
} else {
throw new Error(`${title} must be hex string or Uint8Array`);
}
const len = res.length;
if (typeof expectedLength === "number" && len !== expectedLength)
throw new Error(`${title} expected ${expectedLength} bytes, got ${len}`);
return res;
}
var isPosBig = (n) => typeof n === "bigint" && _0n <= n;
function inRange(n, min, max) {
return isPosBig(n) && isPosBig(min) && isPosBig(max) && min <= n && n < max;
}
function aInRange(title, n, min, max) {
if (!inRange(n, min, max))
throw new Error(`expected valid ${title}: ${min} <= n < ${max}, got ${typeof n} ${n}`);
}
var bitMask = (n) => (_2n << BigInt(n - 1)) - _1n;
var validatorFns = {
bigint: (val) => typeof val === "bigint",
function: (val) => typeof val === "function",
boolean: (val) => typeof val === "boolean",
string: (val) => typeof val === "string",
stringOrUint8Array: (val) => typeof val === "string" || isBytes2(val),
isSafeInteger: (val) => Number.isSafeInteger(val),
array: (val) => Array.isArray(val),
field: (val, object) => object.Fp.isValid(val),
hash: (val) => typeof val === "function" && Number.isSafeInteger(val.outputLen)
};
function validateObject(object, validators, optValidators = {}) {
const checkField = (fieldName, type, isOptional) => {
const checkVal = validatorFns[type];
if (typeof checkVal !== "function")
throw new Error(`Invalid validator "${type}", expected function`);
const val = object[fieldName];
if (isOptional && val === void 0)
return;
if (!checkVal(val, object)) {
throw new Error(`Invalid param ${String(fieldName)}=${val} (${typeof val}), expected ${type}`);
}
};
for (const [fieldName, type] of Object.entries(validators))
checkField(fieldName, type, false);
for (const [fieldName, type] of Object.entries(optValidators))
checkField(fieldName, type, true);
return object;
}
// node_modules/@noble/curves/esm/abstract/modular.js
var _0n2 = BigInt(0);
var _1n2 = BigInt(1);
var _2n2 = BigInt(2);
var _3n = BigInt(3);
var _4n = BigInt(4);
var _5n = BigInt(5);
var _8n = BigInt(8);
var _9n = BigInt(9);
var _16n = BigInt(16);
function mod(a, b) {
const result = a % b;
return result >= _0n2 ? result : b + result;
}
function pow(num, power, modulo) {
if (modulo <= _0n2 || power < _0n2)
throw new Error("Expected power/modulo > 0");
if (modulo === _1n2)
return _0n2;
let res = _1n2;
while (power > _0n2) {
if (power & _1n2)
res = res * num % modulo;
num = num * num % modulo;
power >>= _1n2;
}
return res;
}
function pow2(x, power, modulo) {
let res = x;
while (power-- > _0n2) {
res *= res;
res %= modulo;
}
return res;
}
function invert(number, modulo) {
if (number === _0n2 || modulo <= _0n2) {
throw new Error(`invert: expected positive integers, got n=${number} mod=${modulo}`);
}
let a = mod(number, modulo);
let b = modulo;
let x = _0n2, y = _1n2, u = _1n2, v = _0n2;
while (a !== _0n2) {
const q = b / a;
const r = b % a;
const m = x - u * q;
const n = y - v * q;
b = a, a = r, x = u, y = v, u = m, v = n;
}
const gcd = b;
if (gcd !== _1n2)
throw new Error("invert: does not exist");
return mod(x, modulo);
}
function tonelliShanks(P) {
const legendreC = (P - _1n2) / _2n2;
let Q, S, Z;
for (Q = P - _1n2, S = 0; Q % _2n2 === _0n2; Q /= _2n2, S++)
;
for (Z = _2n2; Z < P && pow(Z, legendreC, P) !== P - _1n2; Z++)
;
if (S === 1) {
const p1div4 = (P + _1n2) / _4n;
return function tonelliFast(Fp2, n) {
const root = Fp2.pow(n, p1div4);
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
const Q1div2 = (Q + _1n2) / _2n2;
return function tonelliSlow(Fp2, n) {
if (Fp2.pow(n, legendreC) === Fp2.neg(Fp2.ONE))
throw new Error("Cannot find square root");
let r = S;
let g = Fp2.pow(Fp2.mul(Fp2.ONE, Z), Q);
let x = Fp2.pow(n, Q1div2);
let b = Fp2.pow(n, Q);
while (!Fp2.eql(b, Fp2.ONE)) {
if (Fp2.eql(b, Fp2.ZERO))
return Fp2.ZERO;
let m = 1;
for (let t2 = Fp2.sqr(b); m < r; m++) {
if (Fp2.eql(t2, Fp2.ONE))
break;
t2 = Fp2.sqr(t2);
}
const ge = Fp2.pow(g, _1n2 << BigInt(r - m - 1));
g = Fp2.sqr(ge);
x = Fp2.mul(x, ge);
b = Fp2.mul(b, g);
r = m;
}
return x;
};
}
function FpSqrt(P) {
if (P % _4n === _3n) {
const p1div4 = (P + _1n2) / _4n;
return function sqrt3mod4(Fp2, n) {
const root = Fp2.pow(n, p1div4);
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
if (P % _8n === _5n) {
const c1 = (P - _5n) / _8n;
return function sqrt5mod8(Fp2, n) {
const n2 = Fp2.mul(n, _2n2);
const v = Fp2.pow(n2, c1);
const nv = Fp2.mul(n, v);
const i = Fp2.mul(Fp2.mul(nv, _2n2), v);
const root = Fp2.mul(nv, Fp2.sub(i, Fp2.ONE));
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
if (P % _16n === _9n) {
}
return tonelliShanks(P);
}
var isNegativeLE = (num, modulo) => (mod(num, modulo) & _1n2) === _1n2;
function FpPow(f, num, power) {
if (power < _0n2)
throw new Error("Expected power > 0");
if (power === _0n2)
return f.ONE;
if (power === _1n2)
return num;
let p = f.ONE;
let d = num;
while (power > _0n2) {
if (power & _1n2)
p = f.mul(p, d);
d = f.sqr(d);
power >>= _1n2;
}
return p;
}
function FpInvertBatch(f, nums) {
const tmp = new Array(nums.length);
const lastMultiplied = nums.reduce((acc, num, i) => {
if (f.is0(num))
return acc;
tmp[i] = acc;
return f.mul(acc, num);
}, f.ONE);
const inverted = f.inv(lastMultiplied);
nums.reduceRight((acc, num, i) => {
if (f.is0(num))
return acc;
tmp[i] = f.mul(acc, tmp[i]);
return f.mul(acc, num);
}, inverted);
return tmp;
}
function nLength(n, nBitLength) {
const _nBitLength = nBitLength !== void 0 ? nBitLength : n.toString(2).length;
const nByteLength = Math.ceil(_nBitLength / 8);
return { nBitLength: _nBitLength, nByteLength };
}
function Field(ORDER, bitLen, isLE2 = false, redef = {}) {
if (ORDER <= _0n2)
throw new Error(`Expected Field ORDER > 0, got ${ORDER}`);
const { nBitLength: BITS, nByteLength: BYTES } = nLength(ORDER, bitLen);
if (BYTES > 2048)
throw new Error("Field lengths over 2048 bytes are not supported");
const sqrtP = FpSqrt(ORDER);
const f = Object.freeze({
ORDER,
BITS,
BYTES,
MASK: bitMask(BITS),
ZERO: _0n2,
ONE: _1n2,
create: (num) => mod(num, ORDER),
isValid: (num) => {
if (typeof num !== "bigint")
throw new Error(`Invalid field element: expected bigint, got ${typeof num}`);
return _0n2 <= num && num < ORDER;
},
is0: (num) => num === _0n2,
isOdd: (num) => (num & _1n2) === _1n2,
neg: (num) => mod(-num, ORDER),
eql: (lhs, rhs) => lhs === rhs,
sqr: (num) => mod(num * num, ORDER),
add: (lhs, rhs) => mod(lhs + rhs, ORDER),
sub: (lhs, rhs) => mod(lhs - rhs, ORDER),
mul: (lhs, rhs) => mod(lhs * rhs, ORDER),
pow: (num, power) => FpPow(f, num, power),
div: (lhs, rhs) => mod(lhs * invert(rhs, ORDER), ORDER),
// Same as above, but doesn't normalize
sqrN: (num) => num * num,
addN: (lhs, rhs) => lhs + rhs,
subN: (lhs, rhs) => lhs - rhs,
mulN: (lhs, rhs) => lhs * rhs,
inv: (num) => invert(num, ORDER),
sqrt: redef.sqrt || ((n) => sqrtP(f, n)),
invertBatch: (lst) => FpInvertBatch(f, lst),
// TODO: do we really need constant cmov?
// We don't have const-time bigints anyway, so probably will be not very useful
cmov: (a, b, c) => c ? b : a,
toBytes: (num) => isLE2 ? numberToBytesLE(num, BYTES) : numberToBytesBE(num, BYTES),
fromBytes: (bytes2) => {
if (bytes2.length !== BYTES)
throw new Error(`Fp.fromBytes: expected ${BYTES}, got ${bytes2.length}`);
return isLE2 ? bytesToNumberLE(bytes2) : bytesToNumberBE(bytes2);
}
});
return Object.freeze(f);
}
// node_modules/@noble/curves/esm/abstract/montgomery.js
var _0n3 = BigInt(0);
var _1n3 = BigInt(1);
function validateOpts(curve) {
validateObject(curve, {
a: "bigint"
}, {
montgomeryBits: "isSafeInteger",
nByteLength: "isSafeInteger",
adjustScalarBytes: "function",
domain: "function",
powPminus2: "function",
Gu: "bigint"
});
return Object.freeze({ ...curve });
}
function montgomery(curveDef) {
const CURVE = validateOpts(curveDef);
const { P } = CURVE;
const modP = (n) => mod(n, P);
const montgomeryBits = CURVE.montgomeryBits;
const montgomeryBytes = Math.ceil(montgomeryBits / 8);
const fieldLen = CURVE.nByteLength;
const adjustScalarBytes2 = CURVE.adjustScalarBytes || ((bytes2) => bytes2);
const powPminus2 = CURVE.powPminus2 || ((x) => pow(x, P - BigInt(2), P));
function cswap(swap, x_2, x_3) {
const dummy = modP(swap * (x_2 - x_3));
x_2 = modP(x_2 - dummy);
x_3 = modP(x_3 + dummy);
return [x_2, x_3];
}
const a24 = (CURVE.a - BigInt(2)) / BigInt(4);
function montgomeryLadder(u, scalar) {
aInRange("u", u, _0n3, P);
aInRange("scalar", scalar, _0n3, P);
const k = scalar;
const x_1 = u;
let x_2 = _1n3;
let z_2 = _0n3;
let x_3 = u;
let z_3 = _1n3;
let swap = _0n3;
let sw;
for (let t = BigInt(montgomeryBits - 1); t >= _0n3; t--) {
const k_t = k >> t & _1n3;
swap ^= k_t;
sw = cswap(swap, x_2, x_3);
x_2 = sw[0];
x_3 = sw[1];
sw = cswap(swap, z_2, z_3);
z_2 = sw[0];
z_3 = sw[1];
swap = k_t;
const A = x_2 + z_2;
const AA = modP(A * A);
const B = x_2 - z_2;
const BB = modP(B * B);
const E = AA - BB;
const C = x_3 + z_3;
const D = x_3 - z_3;
const DA = modP(D * A);
const CB = modP(C * B);
const dacb = DA + CB;
const da_cb = DA - CB;
x_3 = modP(dacb * dacb);
z_3 = modP(x_1 * modP(da_cb * da_cb));
x_2 = modP(AA * BB);
z_2 = modP(E * (AA + modP(a24 * E)));
}
sw = cswap(swap, x_2, x_3);
x_2 = sw[0];
x_3 = sw[1];
sw = cswap(swap, z_2, z_3);
z_2 = sw[0];
z_3 = sw[1];
const z2 = powPminus2(z_2);
return modP(x_2 * z2);
}
function encodeUCoordinate(u) {
return numberToBytesLE(modP(u), montgomeryBytes);
}
function decodeUCoordinate(uEnc) {
const u = ensureBytes("u coordinate", uEnc, montgomeryBytes);
if (fieldLen === 32)
u[31] &= 127;
return bytesToNumberLE(u);
}
function decodeScalar(n) {
const bytes2 = ensureBytes("scalar", n);
const len = bytes2.length;
if (len !== montgomeryBytes && len !== fieldLen)
throw new Error(`Expected ${montgomeryBytes} or ${fieldLen} bytes, got ${len}`);
return bytesToNumberLE(adjustScalarBytes2(bytes2));
}
function scalarMult(scalar, u) {
const pointU = decodeUCoordinate(u);
const _scalar = decodeScalar(scalar);
const pu = montgomeryLadder(pointU, _scalar);
if (pu === _0n3)
throw new Error("Invalid private or public key received");
return encodeUCoordinate(pu);
}
const GuBytes = encodeUCoordinate(CURVE.Gu);
function scalarMultBase(scalar) {
return scalarMult(scalar, GuBytes);
}
return {
scalarMult,
scalarMultBase,
getSharedSecret: (privateKey, publicKey) => scalarMult(privateKey, publicKey),
getPublicKey: (privateKey) => scalarMultBase(privateKey),
utils: { randomPrivateKey: () => CURVE.randomBytes(CURVE.nByteLength) },
GuBytes
};
}
// node_modules/@noble/curves/esm/ed25519.js
var ED25519_P = BigInt("57896044618658097711785492504343953926634992332820282019728792003956564819949");
var ED25519_SQRT_M1 = /* @__PURE__ */ BigInt("19681161376707505956807079304988542015446066515923890162744021073123829784752");
var _0n4 = BigInt(0);
var _1n4 = BigInt(1);
var _2n3 = BigInt(2);
var _3n2 = BigInt(3);
var _5n2 = BigInt(5);
var _8n2 = BigInt(8);
function ed25519_pow_2_252_3(x) {
const _10n = BigInt(10), _20n = BigInt(20), _40n = BigInt(40), _80n = BigInt(80);
const P = ED25519_P;
const x2 = x * x % P;
const b2 = x2 * x % P;
const b4 = pow2(b2, _2n3, P) * b2 % P;
const b5 = pow2(b4, _1n4, P) * x % P;
const b10 = pow2(b5, _5n2, P) * b5 % P;
const b20 = pow2(b10, _10n, P) * b10 % P;
const b40 = pow2(b20, _20n, P) * b20 % P;
const b80 = pow2(b40, _40n, P) * b40 % P;
const b160 = pow2(b80, _80n, P) * b80 % P;
const b240 = pow2(b160, _80n, P) * b80 % P;
const b250 = pow2(b240, _10n, P) * b10 % P;
const pow_p_5_8 = pow2(b250, _2n3, P) * x % P;
return { pow_p_5_8, b2 };
}
function adjustScalarBytes(bytes2) {
bytes2[0] &= 248;
bytes2[31] &= 127;
bytes2[31] |= 64;
return bytes2;
}
function uvRatio(u, v) {
const P = ED25519_P;
const v3 = mod(v * v * v, P);
const v7 = mod(v3 * v3 * v, P);
const pow3 = ed25519_pow_2_252_3(u * v7).pow_p_5_8;
let x = mod(u * v3 * pow3, P);
const vx2 = mod(v * x * x, P);
const root1 = x;
const root2 = mod(x * ED25519_SQRT_M1, P);
const useRoot1 = vx2 === u;
const useRoot2 = vx2 === mod(-u, P);
const noRoot = vx2 === mod(-u * ED25519_SQRT_M1, P);
if (useRoot1)
x = root1;
if (useRoot2 || noRoot)
x = root2;
if (isNegativeLE(x, P))
x = mod(-x, P);
return { isValid: useRoot1 || useRoot2, value: x };
}
var Fp = /* @__PURE__ */ (() => Field(ED25519_P, void 0, true))();
var ed25519Defaults = /* @__PURE__ */ (() => ({
// Param: a
a: BigInt(-1),
// Fp.create(-1) is proper; our way still works and is faster
// d is equal to -121665/121666 over finite field.
// Negative number is P - number, and division is invert(number, P)
d: BigInt("37095705934669439343138083508754565189542113879843219016388785533085940283555"),
// Finite field 𝔽p over which we'll do calculations; 2n**255n - 19n
Fp,
// Subgroup order: how many points curve has
// 2n**252n + 27742317777372353535851937790883648493n;
n: BigInt("7237005577332262213973186563042994240857116359379907606001950938285454250989"),
// Cofactor
h: _8n2,
// Base point (x, y) aka generator point
Gx: BigInt("15112221349535400772501151409588531511454012693041857206046113283949847762202"),
Gy: BigInt("46316835694926478169428394003475163141307993866256225615783033603165251855960"),
hash: sha512,
randomBytes,
adjustScalarBytes,
// dom2
// Ratio of u to v. Allows us to combine inversion and square root. Uses algo from RFC8032 5.1.3.
// Constant-time, u/√v
uvRatio
}))();
var x25519 = /* @__PURE__ */ (() => montgomery({
P: ED25519_P,
a: BigInt(486662),
montgomeryBits: 255,
// n is 253 bits
nByteLength: 32,
Gu: BigInt(9),
powPminus2: (x) => {
const P = ED25519_P;
const { pow_p_5_8, b2 } = ed25519_pow_2_252_3(x);
return mod(pow2(pow_p_5_8, _3n2, P) * b2, P);
},
adjustScalarBytes,
randomBytes
}))();
function edwardsToMontgomeryPriv(edwardsPriv) {
const hashed = ed25519Defaults.hash(edwardsPriv.subarray(0, 32));
return ed25519Defaults.adjustScalarBytes(hashed).subarray(0, 32);
}
export {
edwardsToMontgomeryPriv,
x25519
};
/*! Bundled license information:
@noble/hashes/esm/utils.js:
(*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
@noble/curves/esm/abstract/utils.js:
@noble/curves/esm/abstract/modular.js:
@noble/curves/esm/abstract/montgomery.js:
@noble/curves/esm/ed25519.js:
(*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
*/

View File

@ -1,3 +0,0 @@
import { edwardsToMontgomeryPriv, x25519 } from '../../../node_modules/@noble/curves/esm/ed25519.js';
export { edwardsToMontgomeryPriv, x25519 };

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +0,0 @@
import { PublicKey } from '@solana/web3.js';
export { PublicKey };

View File

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

View File

@ -1,20 +0,0 @@
{
"manifest_version": 3,
"name": "SHiNE Browser Plugin Wallet",
"version": "0.1.0",
"description": "Wallet-session plugin for SHiNE with session-only login via trusted device.",
"permissions": [
"storage"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "SHiNE Wallet",
"default_popup": "popup.html"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +0,0 @@
{
"name": "shine-browser-plugin-wallet",
"version": "1.0.0",
"description": "Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.",
"main": "popup.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@noble/curves": "^1.5.0",
"@solana/web3.js": "^1.98.4"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}

View File

@ -1,211 +0,0 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 360px;
background: #0f1720;
color: #e8eef6;
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.layout {
padding: 12px;
}
.panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-header h1 {
margin: 0;
font-size: 18px;
}
.muted {
margin: 2px 0 0;
color: #9aabbd;
}
.small {
font-size: 12px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.pill-offline {
background: #4b1f28;
color: #ffc4cf;
}
.pill-online {
background: #153926;
color: #b7f5ce;
}
.card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid #253446;
border-radius: 8px;
background: #131d29;
}
.card-title {
font-size: 13px;
font-weight: 700;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field span {
font-size: 12px;
color: #b8c4d1;
}
input[type="text"],
input[type="password"],
select {
width: 100%;
padding: 10px 12px;
border: 1px solid #314459;
border-radius: 8px;
background: #0d141d;
color: #edf3fb;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn.primary {
background: #2f7df4;
color: #fff;
}
.btn.secondary {
background: #243446;
color: #e8eef6;
}
.btn.danger {
background: #6a2430;
color: #ffd6de;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.code {
font-size: 34px;
font-weight: 700;
letter-spacing: 0.18em;
}
.summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.summary-row code {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #bed5f5;
}
.status {
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
}
.status.info {
background: #172838;
color: #d8ebff;
}
.status.error {
background: #4d1e26;
color: #ffd0d8;
}
.hidden {
display: none;
}
.device-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-row {
padding: 8px 10px;
border: 1px solid #243446;
border-radius: 8px;
background: #0d141d;
}
.device-state {
font-size: 12px;
text-transform: lowercase;
}
.device-state-online {
color: #b7f5ce;
}
.device-state-offline {
color: #ffc4cf;
}
.device-state-unknown {
color: #f8e2a0;
}

View File

@ -1,97 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SHiNE Wallet</title>
<link rel="stylesheet" href="./popup.css" />
</head>
<body>
<main class="layout">
<section class="panel">
<div class="panel-header">
<div>
<h1>SHiNE Wallet</h1>
<p class="muted">Session-only wallet plugin</p>
</div>
<span id="connection-pill" class="pill pill-offline">offline</span>
</div>
<p id="server-login-info" class="muted small">Сервер SHiNE: —</p>
<p id="server-address" class="muted small">Адрес: —</p>
<div id="session-card" class="card hidden">
<div class="card-title">Подключённая wallet-session</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="summary-row"><span>Session ID</span><code id="session-id"></code></div>
<div class="summary-row"><span>Тип</span><strong id="session-type">wallet</strong></div>
<div class="summary-row"><span>deviceKey</span><code id="device-key-short"></code></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="refresh-devices-btn" class="btn secondary" type="button">Обновить устройства</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div id="signing-card" class="card hidden">
<div class="card-title">Подготовка подписи</div>
<label class="field">
<span>Ключ подписи</span>
<select id="sign-key-select"></select>
</label>
<label class="field">
<span>Устройство homeserver</span>
<select id="device-select"></select>
</label>
<div id="homeserver-list" class="device-list"></div>
<p class="muted small">
Для выбора доступны homeserver-сессии, опубликованные в PDA аккаунта. Online-статус определяется без постоянного удержания соединения.
</p>
<div class="actions">
<button id="prepare-sign-btn" class="btn primary" type="button">Запросить подпись</button>
</div>
<p class="muted small">
Сам signaling подтверждения подписи ещё не доделан. Сейчас доступен только каркас выбора ключа и устройства.
</p>
</div>
<div class="card">
<div class="card-title">Войти через другое устройство</div>
<label class="field">
<span>Логин</span>
<input id="login-input" type="text" autocomplete="username" />
</label>
<label class="checkbox-row">
<input id="use-password" type="checkbox" />
<span>Использовать доп. пароль</span>
</label>
<label id="password-field" class="field hidden">
<span>Пароль подключения</span>
<input id="password-input" type="password" autocomplete="current-password" />
</label>
<button id="start-btn" class="btn primary" type="button">Получить код</button>
<p class="muted small">
Wallet plugin создаёт временный requester keypair, ждёт подтверждение на доверенном устройстве
и получает только wallet-session без передачи постоянных ключей.
</p>
</div>
<div id="pairing-card" class="card hidden">
<div class="card-title">Код подключения</div>
<div id="short-code" class="code">0000000</div>
<p id="pairing-hint" class="muted small">
Покажите код на доверенном устройстве в разделе «Подключить по коду».
</p>
<p id="pairing-expire" class="muted small"></p>
<div class="actions">
<button id="cancel-btn" class="btn secondary" type="button">Отменить</button>
</div>
</div>
<div id="status" class="status hidden"></div>
</section>
</main>
<script type="module" src="./popup.js"></script>
</body>
</html>

View File

@ -1,360 +0,0 @@
const els = {
serverLoginInfo: document.querySelector('#server-login-info'),
serverAddress: document.querySelector('#server-address'),
loginInput: document.querySelector('#login-input'),
usePassword: document.querySelector('#use-password'),
passwordField: document.querySelector('#password-field'),
passwordInput: document.querySelector('#password-input'),
startBtn: document.querySelector('#start-btn'),
pairingCard: document.querySelector('#pairing-card'),
shortCode: document.querySelector('#short-code'),
pairingHint: document.querySelector('#pairing-hint'),
pairingExpire: document.querySelector('#pairing-expire'),
cancelBtn: document.querySelector('#cancel-btn'),
status: document.querySelector('#status'),
sessionCard: document.querySelector('#session-card'),
sessionLogin: document.querySelector('#session-login'),
sessionId: document.querySelector('#session-id'),
sessionType: document.querySelector('#session-type'),
deviceKeyShort: document.querySelector('#device-key-short'),
resumeBtn: document.querySelector('#resume-btn'),
refreshDevicesBtn: document.querySelector('#refresh-devices-btn'),
disconnectBtn: document.querySelector('#disconnect-btn'),
signingCard: document.querySelector('#signing-card'),
signKeySelect: document.querySelector('#sign-key-select'),
deviceSelect: document.querySelector('#device-select'),
homeserverList: document.querySelector('#homeserver-list'),
prepareSignBtn: document.querySelector('#prepare-sign-btn'),
connectionPill: document.querySelector('#connection-pill'),
};
let state = {
settings: {
serverLogin: 'shineupme',
serverHttp: 'https://shineup.me',
serverUrl: 'wss://shineup.me/ws',
login: '',
},
pairing: {
active: false,
pairingId: '',
expiresAtMs: 0,
},
session: null,
connectionOnline: false,
status: {
text: '',
kind: 'info',
},
};
let refreshTimer = 0;
let saveSettingsTimer = 0;
function setStatus(message, kind = 'info') {
els.status.textContent = String(message || '');
els.status.className = `status ${kind === 'error' ? 'error' : 'info'}`;
els.status.classList.toggle('hidden', !message);
}
function setConnectedPill(connected) {
els.connectionPill.textContent = connected ? 'online' : 'offline';
els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline';
}
function formatRemaining(ms) {
const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000));
const minutes = Math.floor(safe / 60);
const seconds = safe % 60;
return `${minutes} мин ${seconds} сек`;
}
function shortKey(value, size = 10) {
const raw = String(value || '').trim();
return raw ? `${raw.slice(0, size)}...` : '—';
}
function renderHomeserverList(items = []) {
els.homeserverList.innerHTML = '';
if (!items.length) {
const empty = document.createElement('p');
empty.className = 'muted small';
empty.textContent = 'В PDA пока нет опубликованных homeserver-сессий.';
els.homeserverList.append(empty);
return;
}
items.forEach((item) => {
const row = document.createElement('div');
row.className = 'summary-row device-row';
const label = document.createElement('span');
label.textContent = `${item.sessionName} (${shortKey(item.sessionPubKeyBase58, 8)})`;
const badge = document.createElement('strong');
const stateValue = String(item.onlineState || 'unknown');
badge.textContent = stateValue;
badge.className = `device-state device-state-${stateValue}`;
row.append(label, badge);
els.homeserverList.append(row);
});
}
function applyState(nextState) {
state = nextState || state;
const loginValue = String(state?.settings?.login || '');
const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim();
const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim();
if (loginValue && resolvedServerLogin && resolvedServerAddress) {
els.serverLoginInfo.textContent = `Сервер SHiNE: ${resolvedServerLogin}`;
els.serverAddress.textContent = `Адрес: ${resolvedServerAddress}`;
} else {
els.serverLoginInfo.textContent = 'Сервер SHiNE: —';
els.serverAddress.textContent = 'Адрес: —';
}
if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue;
}
setConnectedPill(!!state?.connectionOnline);
setStatus(state?.status?.text || '', state?.status?.kind || 'info');
const session = state?.session;
const walletProfile = state?.walletProfile;
const signing = state?.signing || {};
if (session) {
els.sessionCard.classList.remove('hidden');
els.sessionLogin.textContent = session.login || '—';
els.sessionId.textContent = session.sessionId || '—';
els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—');
els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || '');
els.signingCard.classList.remove('hidden');
} else {
els.sessionCard.classList.add('hidden');
els.sessionLogin.textContent = '—';
els.sessionId.textContent = '—';
els.sessionType.textContent = 'wallet';
els.deviceKeyShort.textContent = '—';
els.signingCard.classList.add('hidden');
}
const signKeyOptions = Array.isArray(walletProfile?.signingKeyOptions) ? walletProfile.signingKeyOptions : [];
els.signKeySelect.innerHTML = '';
signKeyOptions.forEach((item) => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.label;
option.selected = item.id === signing.selectedKeyId;
els.signKeySelect.append(option);
});
const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : [];
els.deviceSelect.innerHTML = '';
homeservers.forEach((item) => {
const option = document.createElement('option');
option.value = item.sessionName;
option.textContent = `${item.sessionName} [${item.onlineState || 'unknown'}]`;
option.selected = item.sessionName === signing.selectedDeviceName;
els.deviceSelect.append(option);
});
renderHomeserverList(homeservers);
els.prepareSignBtn.disabled = !session || !signing.selectedKeyId || !signing.selectedDeviceName;
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');
els.shortCode.dataset.shortCode = shortCode;
els.shortCode.textContent = shortCode;
els.pairingHint.textContent = pairing.trustedSessionOnline
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
const leftMs = Number(pairing.expiresAtMs || 0) - Date.now();
els.pairingExpire.textContent = leftMs > 0 ? `Код действителен ещё ${formatRemaining(leftMs)}.` : 'Время ожидания истекло.';
els.startBtn.disabled = true;
} else {
els.pairingCard.classList.add('hidden');
els.shortCode.textContent = '0000000';
delete els.shortCode.dataset.shortCode;
els.pairingExpire.textContent = '';
els.startBtn.disabled = false;
}
}
function normalizeError(response, fallback) {
return response?.error || fallback || 'Unknown error';
}
function sendMessage(type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message || 'Runtime message failed'));
return;
}
if (!response?.ok) {
reject(new Error(normalizeError(response, 'Wallet operation failed')));
return;
}
if (response?.state) applyState(response.state);
resolve(response);
});
});
}
async function refreshState() {
const response = await sendMessage('wallet:getState');
applyState(response.state);
}
async function saveSettings() {
await sendMessage('wallet:saveSettings', {
login: String(els.loginInput.value || '').trim(),
});
}
async function resolveServerInfo() {
const login = String(els.loginInput.value || '').trim();
if (!login) {
await sendMessage('wallet:saveSettings', { login: '' });
return;
}
try {
await sendMessage('wallet:resolveServerInfo', { login });
} catch (error) {
setStatus(error.message || 'Не удалось определить сервер SHiNE по PDA.', 'error');
}
}
function scheduleSaveSettings() {
if (saveSettingsTimer) {
window.clearTimeout(saveSettingsTimer);
}
saveSettingsTimer = window.setTimeout(() => {
saveSettingsTimer = 0;
void saveSettings();
}, 250);
}
async function startPairing() {
const login = String(els.loginInput.value || '').trim();
if (!login) {
setStatus('Введите логин.', 'error');
return;
}
setStatus('Создаём wallet-session заявку...', 'info');
els.startBtn.disabled = true;
try {
const response = await sendMessage('wallet:startPairing', {
login,
usePassword: !!els.usePassword.checked,
password: String(els.passwordInput.value || ''),
});
applyState(response.state);
} catch (error) {
els.startBtn.disabled = false;
setStatus(error.message || 'Не удалось начать pairing.', 'error');
}
}
async function cancelPairing() {
try {
await sendMessage('wallet:cancelPairing');
} catch (error) {
setStatus(error.message || 'Не удалось отменить pairing.', 'error');
}
}
async function resumeSession() {
setStatus('Проверяем сохранённую wallet-session...', 'info');
try {
await sendMessage('wallet:resumeSession');
} catch (error) {
setStatus(error.message || 'Не удалось восстановить session.', 'error');
}
}
async function disconnectSession() {
try {
await sendMessage('wallet:disconnectSession');
} catch (error) {
setStatus(error.message || 'Не удалось удалить session.', 'error');
}
}
async function refreshDevices() {
setStatus('Обновляем trusted homeserver-устройства...', 'info');
try {
await sendMessage('wallet:refreshWalletDevices');
} catch (error) {
setStatus(error.message || 'Не удалось обновить список устройств.', 'error');
}
}
async function updateSigningSelection() {
try {
await sendMessage('wallet:updateSigningSelection', {
selectedKeyId: String(els.signKeySelect.value || '').trim(),
selectedDeviceName: String(els.deviceSelect.value || '').trim(),
});
} catch (error) {
setStatus(error.message || 'Не удалось обновить выбор для подписи.', 'error');
}
}
async function prepareSignSignal() {
setStatus('Готовим каркас запроса подписи...', 'info');
try {
await sendMessage('wallet:prepareSignSignal');
} catch (error) {
setStatus(error.message || 'Не удалось подготовить запрос подписи.', 'error');
}
}
function startUiRefreshLoop() {
stopUiRefreshLoop();
refreshTimer = window.setInterval(() => {
void refreshState();
}, 1000);
}
function stopUiRefreshLoop() {
if (refreshTimer) {
window.clearInterval(refreshTimer);
refreshTimer = 0;
}
}
function bindUi() {
els.usePassword.addEventListener('change', () => {
els.passwordField.classList.toggle('hidden', !els.usePassword.checked);
if (!els.usePassword.checked) {
els.passwordInput.value = '';
}
});
els.loginInput.addEventListener('input', () => { scheduleSaveSettings(); });
els.loginInput.addEventListener('change', () => {
void saveSettings();
void resolveServerInfo();
});
els.startBtn.addEventListener('click', () => { void startPairing(); });
els.cancelBtn.addEventListener('click', () => { void cancelPairing(); });
els.resumeBtn.addEventListener('click', () => { void resumeSession(); });
els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); });
els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); });
els.signKeySelect.addEventListener('change', () => { void updateSigningSelection(); });
els.deviceSelect.addEventListener('change', () => { void updateSigningSelection(); });
els.prepareSignBtn.addEventListener('click', () => { void prepareSignSignal(); });
}
async function init() {
bindUi();
await refreshState();
startUiRefreshLoop();
}
window.addEventListener('beforeunload', () => {
stopUiRefreshLoop();
if (saveSettingsTimer) {
window.clearTimeout(saveSettingsTimer);
saveSettingsTimer = 0;
}
});
void init();

View File

@ -1 +0,0 @@
rootProject.name = 'SHiNE-browser-plugin-wallet'

9
SHiNE-promo-solana-devnet/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.gradle
/build
.idea
out
*.log
config/devnet-wallet.json
!gradle/wrapper/gradle-wrapper.jar

View File

@ -0,0 +1,198 @@
# SHiNE-promo-solana-devnet
Временное промо-приложение для тестеров Web3-социальной сети SHiNE / «Сияние» в Solana Devnet.
Основной сценарий:
- приложение SHiNE открывает страницу вида `/?wallet=SOLANA_PUBLIC_KEY`;
- пользователь вводит имя и промокод;
- backend проверяет промокод и отправляет **реальную** devnet-транзакцию на `0.1 SOL`;
- использованный промокод фиксируется в файле и больше не может быть применён.
## Стек
- Java 21
- Spring Boot
- Gradle
- Thymeleaf + HTML/CSS/JS (server-side UI)
## Локальный запуск
1. Скопируйте пример настроек:
```bash
cp config/application.example.properties src/main/resources/application.properties
```
2. Положите реальный keypair-файл Solana CLI формата в:
```text
config/devnet-wallet.json
```
3. Запустите:
```bash
./gradlew bootRun
```
Приложение будет доступно на `http://localhost:8021`.
## Как указать порт
По умолчанию используется:
```properties
server.port=8021
```
Изменить можно:
- в `src/main/resources/application.properties`;
- или через параметр запуска:
```bash
./gradlew bootRun --args='--server.port=8090'
```
## Настройка Solana RPC Devnet
Параметр:
```properties
solana.rpc.url=https://api.devnet.solana.com
```
Для другого RPC достаточно заменить URL в properties.
## Настройка devnet keypair
- Файл должен быть в формате Solana keypair JSON: массив из 64 чисел (`0..255`).
- Пример лежит в `config/devnet-wallet.example.json`.
- Рабочий файл: `config/devnet-wallet.json`.
Важно: настоящий keypair **нельзя коммитить в GitHub**.
Он добавлен в `.gitignore`.
## Где лежат промокоды
Файл:
```text
data/promo-codes.txt
```
## Где лежит файл использованных промокодов
Файл:
```text
data/promo-used.txt
```
## Формат `promo-codes.txt`
- одна строка = один промокод;
- пустые строки игнорируются;
- строки с `#` игнорируются как комментарии;
- промокод должен соответствовать regex: `[a-z0-9]{8}`.
## Формат `promo-used.txt`
Каждая запись в формате:
```text
promoCode | wallet | name | yyyy.MM.dd HH:mm | signature
```
Пример:
```text
aidar2km | 8xF...abc | Иван Петров | 2026.04.27 18:45 | 5xTxSignature...
```
## Пример URL
```text
http://localhost:8021/?wallet=8zYQ...DevnetAddress
```
## Пример API-запроса
```bash
curl -X POST http://localhost:8021/api/promo/top-up \
-H "Content-Type: application/json" \
-d '{
"wallet":"SOLANA_PUBLIC_KEY",
"name":"Иван Петров",
"promoCode":"aidar2km"
}'
```
## Проверка транзакции в Solana Explorer Devnet
Explorer URL формируется по шаблону:
```text
https://explorer.solana.com/tx/{signature}?cluster=devnet
```
## Сборка jar через Gradle
```bash
./gradlew clean build
```
JAR:
```text
build/libs/SHiNE-promo-solana-devnet.jar
```
## Запуск jar
```bash
java -jar build/libs/SHiNE-promo-solana-devnet.jar
```
## Health endpoint
```text
GET /health
```
Ответ:
```json
{
"status": "ok",
"app": "SHiNE-promo-solana-devnet"
}
```
## Логи
- логируется старт приложения;
- логируются успешные пополнения;
- логируются ошибки транзакций;
- приватный ключ не логируется.
## Gradle-задачи для серверного деплоя
В `build.gradle` добавлены задачи:
- `buildServerBundle` — готовит bundle в `build/server-bundle`;
- `deployToServer` — копирует JAR и `application.properties` на сервер и перезапускает systemd-сервис.
Запуск:
```bash
./gradlew deployToServer
```
Переопределение хоста/пути:
```bash
./gradlew deployToServer \
-PdeployHost=user@10.147.20.7 \
-PdeployPath=/home/user/docker/SHiNE-promo-solana-devnet \
-PdeployService=SHiNE-promo-solana-devnet
```

View File

@ -0,0 +1,103 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.6'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'ru.shine'
version = '0.1.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'com.mmorrell:solanaj:1.20.4'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.named('bootJar') {
archiveFileName = 'SHiNE-promo-solana-devnet.jar'
}
// ------------------------------------------------------------
// ДЕПЛОЙ ВРЕМЕННОГО СЕРВИСА "SHiNE-promo-solana-devnet"
//
// Назначение сервиса:
// - это отдельный продукт для тестовой раздачи SOL в devnet;
// - нужен для упрощённого онбординга тестовых пользователей;
// - работает как самостоятельное Spring Boot приложение.
//
// Почему отдельный деплой:
// - сервис изолирован от основного SHiNE-сервера;
// - его можно обновлять/перезапускать независимо;
// - жизненный цикл временного сервиса не должен ломать основной прод.
//
// ВАЖНО:
// - деплой по умолчанию выполняется ЧЕРЕЗ ДОМЕН (shineup.me), а не по IP;
// - целевая папка на сервере: /home/player/SHiNE/SHiNE-promo-solana-devnet;
// - целевой systemd-сервис: SHiNE-promo-solana-devnet.
// ------------------------------------------------------------
def deployHost = project.findProperty('deployHost') ?: 'root@shineup.me'
def deployPath = project.findProperty('deployPath') ?: '/home/player/SHiNE/SHiNE-promo-solana-devnet'
def remoteServiceName = project.findProperty('deployService') ?: 'SHiNE-promo-solana-devnet'
tasks.register('buildServerBundle', Copy) {
// Сборка минимального серверного бандла:
// 1) fat-jar приложения
// 2) шаблон application.properties (чтобы был эталон конфигурации)
dependsOn tasks.named('bootJar')
from(tasks.named('bootJar').flatMap { it.archiveFile }) {
rename { 'SHiNE-promo-solana-devnet.jar' }
}
from('config/application.example.properties') {
rename { 'application.properties' }
}
into(layout.buildDirectory.dir('server-bundle'))
}
tasks.register('deployToServerMkdir', Exec) {
// Шаг 1: гарантируем существование целевой директории на удалённом сервере.
dependsOn tasks.named('buildServerBundle')
commandLine 'bash', '-lc', "ssh ${deployHost} 'mkdir -p ${deployPath}'"
}
tasks.register('deployToServerJar', Exec) {
// Шаг 2: загружаем исполняемый jar.
dependsOn tasks.named('deployToServerMkdir')
commandLine 'bash', '-lc', "scp ${layout.buildDirectory.file('server-bundle/SHiNE-promo-solana-devnet.jar').get().asFile.absolutePath} ${deployHost}:${deployPath}/"
}
tasks.register('deployToServerConfig', Exec) {
// Шаг 3: загружаем конфиг-шаблон.
// На проде при необходимости его можно заменить на рабочий конфиг с секретами.
dependsOn tasks.named('deployToServerJar')
commandLine 'bash', '-lc', "scp ${layout.buildDirectory.file('server-bundle/application.properties').get().asFile.absolutePath} ${deployHost}:${deployPath}/"
}
tasks.register('deployToServerRestart', Exec) {
// Шаг 4: перезапускаем systemd-сервис и показываем статус.
// Если сервис не поднялся, ошибка будет видна сразу в этом шаге.
dependsOn tasks.named('deployToServerConfig')
commandLine 'bash', '-lc', "ssh ${deployHost} 'sudo systemctl restart ${remoteServiceName} && sudo systemctl --no-pager status ${remoteServiceName}'"
}
tasks.register('deployToServer') {
// Единая точка входа для деплоя временного сервиса.
// Запуск: ./gradlew deployToServer
dependsOn tasks.named('deployToServerRestart')
}

View File

@ -0,0 +1,15 @@
server.port=8021
solana.rpc.url=https://api.devnet.solana.com
solana.sender.keypair-file=./config/devnet-wallet.json
promo.transfer.amount-sol=0.1
promo.codes.file=./data/promo-codes.txt
promo.used.file=./data/promo-used.txt
promo.explorer.tx-url-template=https://explorer.solana.com/tx/%s?cluster=devnet
# Вечный промокод для временной раздачи в devnet.
# Если enabled=true, код можно использовать неограниченно (он не "сгорает").
promo.eternal-code.enabled=true
promo.eternal-code.value=0000

View File

@ -0,0 +1,6 @@
[
151, 22, 193, 47, 88, 234, 15, 201, 42, 19, 180, 76, 211, 5, 164, 91,
207, 135, 44, 173, 61, 242, 14, 99, 158, 208, 39, 117, 10, 226, 95, 132,
56, 72, 164, 209, 41, 189, 76, 239, 121, 18, 66, 213, 30, 145, 201, 9,
177, 53, 120, 87, 200, 65, 33, 251, 102, 74, 186, 44, 160, 7, 92, 137
]

View File

@ -0,0 +1,16 @@
[Unit]
Description=SHiNE Promo Solana Devnet
After=network.target
[Service]
Type=simple
User=player
Group=player
WorkingDirectory=/home/player/SHiNE/SHiNE-promo-solana-devnet
ExecStart=/usr/bin/java -jar /home/player/SHiNE/SHiNE-promo-solana-devnet/SHiNE-promo-solana-devnet.jar --spring.config.location=/home/player/SHiNE/SHiNE-promo-solana-devnet/application.properties
Restart=always
RestartSec=5
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Sun Apr 26 23:49:28 MSK 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
SHiNE-promo-solana-devnet/gradlew vendored Executable file
View File

@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
SHiNE-promo-solana-devnet/gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1 @@
rootProject.name = 'SHiNE-promo-solana-devnet'

View File

@ -0,0 +1,30 @@
package ru.shine.promo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import ru.shine.promo.config.AppProperties;
@SpringBootApplication
public class ShinePromoSolanaDevnetApplication {
private static final Logger log = LoggerFactory.getLogger(ShinePromoSolanaDevnetApplication.class);
public static void main(String[] args) {
SpringApplication.run(ShinePromoSolanaDevnetApplication.class, args);
}
@Bean
CommandLineRunner startupLogger(AppProperties appProperties) {
return args -> log.info(
"SHiNE promo app started. RPC: {}, promoCodesFile: {}, usedFile: {}, transferAmountSol: {}",
appProperties.getSolanaRpcUrl(),
appProperties.getPromoCodesFile(),
appProperties.getPromoUsedFile(),
appProperties.getPromoTransferAmountSol()
);
}
}

View File

@ -0,0 +1,77 @@
package ru.shine.promo.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Component
public class AppProperties {
private static final long LAMPORTS_PER_SOL = 1_000_000_000L;
@Value("${solana.rpc.url}")
private String solanaRpcUrl;
@Value("${solana.sender.keypair-file}")
private String solanaSenderKeypairFile;
@Value("${promo.transfer.amount-sol}")
private BigDecimal promoTransferAmountSol;
@Value("${promo.codes.file}")
private String promoCodesFile;
@Value("${promo.used.file}")
private String promoUsedFile;
@Value("${promo.explorer.tx-url-template}")
private String promoExplorerTxUrlTemplate;
@Value("${promo.eternal-code.enabled:false}")
private boolean promoEternalCodeEnabled;
@Value("${promo.eternal-code.value:0000}")
private String promoEternalCodeValue;
public String getSolanaRpcUrl() {
return solanaRpcUrl;
}
public String getSolanaSenderKeypairFile() {
return solanaSenderKeypairFile;
}
public BigDecimal getPromoTransferAmountSol() {
return promoTransferAmountSol;
}
public String getPromoCodesFile() {
return promoCodesFile;
}
public String getPromoUsedFile() {
return promoUsedFile;
}
public String getPromoExplorerTxUrlTemplate() {
return promoExplorerTxUrlTemplate;
}
public boolean isPromoEternalCodeEnabled() {
return promoEternalCodeEnabled;
}
public String getPromoEternalCodeValue() {
if (promoEternalCodeValue == null) {
return "";
}
return promoEternalCodeValue.trim().toLowerCase();
}
public long getPromoTransferAmountLamports() {
BigDecimal lamports = promoTransferAmountSol.multiply(BigDecimal.valueOf(LAMPORTS_PER_SOL));
return lamports.setScale(0, RoundingMode.UNNECESSARY).longValueExact();
}
}

View File

@ -0,0 +1,136 @@
package ru.shine.promo.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import ru.shine.promo.config.AppProperties;
import ru.shine.promo.dto.PromoRequest;
import ru.shine.promo.dto.PromoResponse;
import ru.shine.promo.service.PromoCodeService;
import ru.shine.promo.service.PromoException;
import ru.shine.promo.service.PromoTransferService;
import ru.shine.promo.service.UsedPromoStorageService;
import java.util.Map;
@RestController
public class PromoApiController {
private static final Logger log = LoggerFactory.getLogger(PromoApiController.class);
private final AppProperties appProperties;
private final PromoCodeService promoCodeService;
private final PromoTransferService promoTransferService;
private final UsedPromoStorageService usedPromoStorageService;
public PromoApiController(
AppProperties appProperties,
PromoCodeService promoCodeService,
PromoTransferService promoTransferService,
UsedPromoStorageService usedPromoStorageService
) {
this.appProperties = appProperties;
this.promoCodeService = promoCodeService;
this.promoTransferService = promoTransferService;
this.usedPromoStorageService = usedPromoStorageService;
}
@PostMapping("/api/promo/top-up")
public ResponseEntity<PromoResponse> topUp(@RequestBody PromoRequest request) {
String wallet = trimToEmpty(request == null ? null : request.getWallet());
String name = trimToEmpty(request == null ? null : request.getName());
String promoCode = promoCodeService.normalizePromoCode(request == null ? null : request.getPromoCode());
if (wallet.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустой wallet");
}
if (!promoTransferService.isSolanaWalletValid(wallet)) {
return error(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса");
}
if (name.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустое имя");
}
if (name.length() < 2 || name.length() > 120) {
return error(HttpStatus.BAD_REQUEST, "Имя должно быть длиной от 2 до 120 символов");
}
if (promoCode.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустой промокод");
}
boolean eternalPromo = promoCodeService.isEternalPromoCode(promoCode);
if (!eternalPromo && !promoCodeService.isPromoCodeFormatValid(promoCode)) {
return error(HttpStatus.BAD_REQUEST, "Неверный формат промокода");
}
try {
if (!eternalPromo && !promoCodeService.promoCodeExists(promoCode)) {
return error(HttpStatus.BAD_REQUEST, "Промокод не найден");
}
String signature = usedPromoStorageService.executeLocked(() -> {
if (!eternalPromo && usedPromoStorageService.isPromoUsed(promoCode)) {
throw new PromoException(HttpStatus.CONFLICT, "Промокод уже использован");
}
String txSignature = promoTransferService.sendPromoTransfer(wallet);
// Для вечного промокода ведём только лог использования, но не блокируем повторное применение.
usedPromoStorageService.appendUsedPromo(promoCode, wallet, name, txSignature);
return txSignature;
});
String explorerUrl = promoTransferService.buildExplorerUrl(signature);
String amount = appProperties.getPromoTransferAmountSol().stripTrailingZeros().toPlainString();
log.info(
"Promo top-up success: wallet={}, name={}, promoCode={}, signature={}",
wallet,
name,
promoCode,
signature
);
PromoResponse response = PromoResponse.success(
"Тестовое пополнение выполнено",
wallet,
name,
amount,
signature,
explorerUrl
);
return ResponseEntity.ok(response);
} catch (PromoException e) {
if (e.getStatus().is5xxServerError()) {
log.error("Top-up failed: {}", e.getMessage(), e);
} else {
log.warn("Top-up rejected: {}", e.getMessage());
}
return error(e.getStatus(), e.getMessage());
} catch (Exception e) {
log.error("Unexpected top-up error", e);
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера");
}
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of(
"status", "ok",
"app", "SHiNE-promo-solana-devnet"
);
}
private ResponseEntity<PromoResponse> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(PromoResponse.error(message));
}
private String trimToEmpty(String value) {
if (value == null) {
return "";
}
return value.trim().replaceAll("\\s{2,}", " ");
}
}

View File

@ -0,0 +1,16 @@
package ru.shine.promo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class PromoPageController {
@GetMapping("/")
public String index(@RequestParam(name = "wallet", required = false) String wallet, Model model) {
model.addAttribute("wallet", wallet == null ? "" : wallet.trim());
return "index";
}
}

View File

@ -0,0 +1,32 @@
package ru.shine.promo.dto;
public class PromoRequest {
private String wallet;
private String name;
private String promoCode;
public String getWallet() {
return wallet;
}
public void setWallet(String wallet) {
this.wallet = wallet;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPromoCode() {
return promoCode;
}
public void setPromoCode(String promoCode) {
this.promoCode = promoCode;
}
}

View File

@ -0,0 +1,69 @@
package ru.shine.promo.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PromoResponse {
private boolean success;
private String message;
private String wallet;
private String name;
private String amountSol;
private String signature;
private String explorerUrl;
public static PromoResponse success(
String message,
String wallet,
String name,
String amountSol,
String signature,
String explorerUrl
) {
PromoResponse response = new PromoResponse();
response.success = true;
response.message = message;
response.wallet = wallet;
response.name = name;
response.amountSol = amountSol;
response.signature = signature;
response.explorerUrl = explorerUrl;
return response;
}
public static PromoResponse error(String message) {
PromoResponse response = new PromoResponse();
response.success = false;
response.message = message;
return response;
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
public String getWallet() {
return wallet;
}
public String getName() {
return name;
}
public String getAmountSol() {
return amountSol;
}
public String getSignature() {
return signature;
}
public String getExplorerUrl() {
return explorerUrl;
}
}

View File

@ -0,0 +1,77 @@
package ru.shine.promo.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Pattern;
@Service
public class PromoCodeService {
private static final Logger log = LoggerFactory.getLogger(PromoCodeService.class);
private static final Pattern PROMO_CODE_PATTERN = Pattern.compile("^[a-z0-9]{8}$");
private final AppProperties appProperties;
public PromoCodeService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public String normalizePromoCode(String rawPromoCode) {
if (rawPromoCode == null) {
return "";
}
return rawPromoCode.trim().toLowerCase();
}
public boolean isPromoCodeFormatValid(String promoCode) {
return PROMO_CODE_PATTERN.matcher(promoCode).matches();
}
public boolean isEternalPromoCode(String promoCode) {
if (!appProperties.isPromoEternalCodeEnabled()) {
return false;
}
String configured = appProperties.getPromoEternalCodeValue();
return !configured.isEmpty() && configured.equals(promoCode);
}
public boolean promoCodeExists(String promoCode) {
Set<String> codes = readPromoCodesFromFile();
return codes.contains(promoCode);
}
private Set<String> readPromoCodesFromFile() {
Path file = Path.of(appProperties.getPromoCodesFile());
if (!Files.exists(file)) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов");
}
try {
Set<String> result = new LinkedHashSet<>();
for (String row : Files.readAllLines(file, StandardCharsets.UTF_8)) {
String line = row.trim().toLowerCase();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
if (!isPromoCodeFormatValid(line)) {
log.warn("Skipped invalid promo code row in {}: {}", file, line);
continue;
}
result.add(line);
}
return result;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов", e);
}
}
}

View File

@ -0,0 +1,22 @@
package ru.shine.promo.service;
import org.springframework.http.HttpStatus;
public class PromoException extends RuntimeException {
private final HttpStatus status;
public PromoException(HttpStatus status, String message) {
super(message);
this.status = status;
}
public PromoException(HttpStatus status, String message, Throwable cause) {
super(message, cause);
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}

View File

@ -0,0 +1,139 @@
package ru.shine.promo.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.p2p.solanaj.core.Account;
import org.p2p.solanaj.core.PublicKey;
import org.p2p.solanaj.core.Transaction;
import org.p2p.solanaj.programs.SystemProgram;
import org.p2p.solanaj.rpc.RpcClient;
import org.p2p.solanaj.rpc.RpcException;
import org.p2p.solanaj.rpc.types.config.Commitment;
import org.p2p.solanaj.rpc.types.config.RpcSendTransactionConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Locale;
@Service
public class PromoTransferService {
private static final Logger log = LoggerFactory.getLogger(PromoTransferService.class);
private final AppProperties appProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
public PromoTransferService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public boolean isSolanaWalletValid(String wallet) {
if (wallet == null || wallet.isBlank()) {
return false;
}
try {
PublicKey publicKey = new PublicKey(wallet.trim());
return publicKey.toByteArray().length == PublicKey.PUBLIC_KEY_LENGTH;
} catch (Exception e) {
return false;
}
}
public String sendPromoTransfer(String recipientWallet) {
PublicKey recipient = parseWalletOrThrow(recipientWallet);
Account sender = readSenderAccount();
long lamports = appProperties.getPromoTransferAmountLamports();
RpcClient rpcClient = new RpcClient(appProperties.getSolanaRpcUrl());
try {
long senderBalance = rpcClient.getApi().getBalance(sender.getPublicKey(), Commitment.CONFIRMED);
if (senderBalance < lamports) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя");
}
Transaction transaction = new Transaction()
.addInstruction(SystemProgram.transfer(sender.getPublicKey(), recipient, lamports));
RpcSendTransactionConfig config = RpcSendTransactionConfig.builder()
.encoding(RpcSendTransactionConfig.Encoding.base64)
.skipPreFlight(false)
.maxRetries(3)
.build();
return rpcClient.getApi().sendTransaction(
transaction,
Collections.singletonList(sender),
null,
config
);
} catch (PromoException e) {
throw e;
} catch (RpcException e) {
String message = e.getMessage() == null ? "" : e.getMessage().toLowerCase(Locale.ROOT);
if (message.contains("insufficient")) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя", e);
}
if (message.contains("rpc")) {
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка RPC Solana", e);
}
log.error("Solana transfer failed: {}", e.getMessage(), e);
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e);
} catch (Exception e) {
log.error("Unexpected Solana transfer error: {}", e.getMessage(), e);
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e);
}
}
public String buildExplorerUrl(String signature) {
return String.format(appProperties.getPromoExplorerTxUrlTemplate(), signature);
}
private PublicKey parseWalletOrThrow(String wallet) {
if (!isSolanaWalletValid(wallet)) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса");
}
return new PublicKey(wallet.trim());
}
private Account readSenderAccount() {
Path keypairFile = Path.of(appProperties.getSolanaSenderKeypairFile());
if (!Files.exists(keypairFile)) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Файл keypair отправителя не найден");
}
try {
int[] keyArray = objectMapper.readValue(Files.readString(keypairFile), int[].class);
if (keyArray.length != 64) {
throw new PromoException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Некорректный формат keypair отправителя: ожидается массив из 64 чисел"
);
}
byte[] secret = new byte[64];
for (int i = 0; i < keyArray.length; i++) {
int value = keyArray[i];
if (value < 0 || value > 255) {
throw new PromoException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Некорректный формат keypair отправителя: числа должны быть в диапазоне 0..255"
);
}
secret[i] = (byte) value;
}
return new Account(secret);
} catch (PromoException e) {
throw e;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения keypair отправителя", e);
}
}
}

View File

@ -0,0 +1,101 @@
package ru.shine.promo.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.ReentrantLock;
@Service
public class UsedPromoStorageService {
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm", Locale.ROOT);
private final AppProperties appProperties;
private final ReentrantLock promoLock = new ReentrantLock(true);
public UsedPromoStorageService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public <T> T executeLocked(LockedOperation<T> operation) {
promoLock.lock();
try {
return operation.run();
} finally {
promoLock.unlock();
}
}
public boolean isPromoUsed(String promoCode) {
Path usedFile = Path.of(appProperties.getPromoUsedFile());
if (!Files.exists(usedFile)) {
return false;
}
try {
List<String> rows = Files.readAllLines(usedFile, StandardCharsets.UTF_8);
for (String row : rows) {
String line = row.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] parts = line.split("\\|");
if (parts.length == 0) {
continue;
}
String usedCode = parts[0].trim().toLowerCase(Locale.ROOT);
if (usedCode.equals(promoCode)) {
return true;
}
}
return false;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла использованных промокодов", e);
}
}
public void appendUsedPromo(String promoCode, String wallet, String name, String signature) {
Path usedFile = Path.of(appProperties.getPromoUsedFile());
try {
Path parent = usedFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
if (!Files.exists(usedFile)) {
Files.createFile(usedFile);
}
String timestamp = LocalDateTime.now().format(DATE_TIME_FORMAT);
String line = String.format(
Locale.ROOT,
"%s | %s | %s | %s | %s%n",
promoCode,
wallet,
name,
timestamp,
signature
);
Files.writeString(usedFile, line, StandardCharsets.UTF_8, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка записи файла использованных промокодов", e);
}
}
@FunctionalInterface
public interface LockedOperation<T> {
T run();
}
}

View File

@ -0,0 +1,17 @@
server.port=8021
spring.application.name=SHiNE-promo-solana-devnet
spring.thymeleaf.cache=false
solana.rpc.url=https://api.devnet.solana.com
solana.sender.keypair-file=./config/devnet-wallet.json
promo.transfer.amount-sol=0.1
promo.codes.file=./data/promo-codes.txt
promo.used.file=./data/promo-used.txt
promo.explorer.tx-url-template=https://explorer.solana.com/tx/%s?cluster=devnet
# Вечный промокод для временной раздачи в devnet.
# Если enabled=true, код можно использовать неограниченно (он не "сгорает").
promo.eternal-code.enabled=true
promo.eternal-code.value=0000

View File

@ -0,0 +1,200 @@
:root {
--bg-main: #070707;
--bg-card: #111217;
--bg-card-soft: #171a22;
--text-main: #f6f8fb;
--text-muted: #aeb4c1;
--accent: #51ffd3;
--accent-soft: rgba(81, 255, 211, 0.15);
--danger: #ff6f6f;
--border: rgba(255, 255, 255, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", "Noto Sans", system-ui, sans-serif;
color: var(--text-main);
background:
radial-gradient(circle at 20% 5%, rgba(81, 255, 211, 0.08), transparent 34%),
radial-gradient(circle at 100% 0%, rgba(86, 135, 255, 0.12), transparent 40%),
var(--bg-main);
min-height: 100vh;
}
.page {
min-height: calc(100vh - 48px);
display: grid;
place-items: center;
padding: 24px 14px;
}
.card {
width: min(720px, 100%);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid var(--border);
border-radius: 18px;
padding: 20px;
box-shadow: 0 12px 50px rgba(0, 0, 0, 0.45);
}
.hero h1 {
margin: 0;
font-size: clamp(1.3rem, 3.8vw, 1.9rem);
line-height: 1.2;
}
.subtitle {
color: var(--text-muted);
margin-top: 10px;
margin-bottom: 0;
}
.lead {
margin-top: 18px;
line-height: 1.5;
}
.warning {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
padding: 12px;
margin-top: 16px;
color: #f0f4ff;
}
.promo-form {
display: grid;
gap: 10px;
margin-top: 18px;
}
.promo-form label {
font-size: 0.95rem;
color: var(--text-muted);
}
.promo-form input {
width: 100%;
background: var(--bg-card-soft);
color: var(--text-main);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 12px;
font-size: 1rem;
}
.promo-form input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.promo-form button {
margin-top: 8px;
border: none;
border-radius: 12px;
background: linear-gradient(140deg, #2dd4bf, #4cc9f0);
color: #0b1116;
font-weight: 700;
font-size: 1rem;
padding: 14px;
cursor: pointer;
}
.promo-form button:disabled {
opacity: 0.7;
cursor: default;
}
.status {
margin-top: 14px;
padding: 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
}
.status-error {
color: #ffd6d6;
border-color: rgba(255, 111, 111, 0.6);
background: rgba(255, 111, 111, 0.12);
}
.success {
margin-top: 16px;
border: 1px solid rgba(81, 255, 211, 0.35);
background: rgba(81, 255, 211, 0.08);
border-radius: 12px;
padding: 14px;
}
.success h2 {
margin-top: 0;
font-size: 1.12rem;
}
.success p {
margin-top: 8px;
line-height: 1.45;
}
.success ul {
padding-left: 18px;
margin: 10px 0;
display: grid;
gap: 6px;
word-break: break-word;
}
.success a {
color: var(--accent);
}
.faq {
margin-top: 20px;
}
.faq h3 {
margin-bottom: 10px;
}
.faq details {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
}
.faq summary {
cursor: pointer;
font-weight: 600;
}
.faq p {
margin-bottom: 4px;
color: var(--text-muted);
line-height: 1.45;
}
.page-footer {
text-align: center;
color: #727a88;
font-size: 0.8rem;
padding-bottom: 18px;
}
.hidden {
display: none;
}
@media (min-width: 768px) {
.card {
padding: 26px;
}
}

View File

@ -0,0 +1,82 @@
(() => {
const form = document.getElementById("promoForm");
const walletInput = document.getElementById("wallet");
const nameInput = document.getElementById("name");
const promoCodeInput = document.getElementById("promoCode");
const submitButton = document.getElementById("submitButton");
const loadingState = document.getElementById("loadingState");
const errorState = document.getElementById("errorState");
const successState = document.getElementById("successState");
const successAmount = document.getElementById("successAmount");
const successWallet = document.getElementById("successWallet");
const successName = document.getElementById("successName");
const successSignature = document.getElementById("successSignature");
const successExplorerUrl = document.getElementById("successExplorerUrl");
const params = new URLSearchParams(window.location.search);
const walletFromQuery = (params.get("wallet") || "").trim();
if (walletFromQuery && !walletInput.value.trim()) {
walletInput.value = walletFromQuery;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
hideMessages();
toggleLoading(true);
try {
const payload = {
wallet: walletInput.value.trim(),
name: nameInput.value.trim(),
promoCode: promoCodeInput.value.trim()
};
const response = await fetch("/api/promo/top-up", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (!result.success) {
showError(result.message || "Не удалось выполнить операцию");
return;
}
showSuccess(result);
} catch (error) {
showError("Ошибка сети или недоступен backend");
} finally {
toggleLoading(false);
}
});
function toggleLoading(active) {
submitButton.disabled = active;
loadingState.classList.toggle("hidden", !active);
submitButton.textContent = active ? "Отправляем..." : "Пополнить тестовый счёт";
}
function hideMessages() {
errorState.classList.add("hidden");
successState.classList.add("hidden");
}
function showError(message) {
errorState.textContent = message;
errorState.classList.remove("hidden");
}
function showSuccess(data) {
successAmount.textContent = data.amountSol || "0.1";
successWallet.textContent = data.wallet || "—";
successName.textContent = data.name || "—";
successSignature.textContent = data.signature || "—";
successExplorerUrl.href = data.explorerUrl || "#";
successState.classList.remove("hidden");
}
})();

View File

@ -0,0 +1,137 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SHiNE / Сияние — тестовое пополнение</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<main class="page">
<section class="card">
<header class="hero">
<h1>SHiNE / Сияние — тестовое пополнение</h1>
<p class="subtitle">
Временная devnet-страница для приглашённых тестеров Web3-социальной сети SHiNE.
</p>
</header>
<p class="lead">
Если вы получили промокод, введите его ниже. Мы отправим на ваш Solana devnet-кошелёк 0.1 SOL для
тестирования функций SHiNE.
</p>
<div class="warning">
Это тестовая сеть Solana Devnet. Эти SOL не являются настоящими деньгами и используются только для
проверки работы приложения.
</div>
<form id="promoForm" class="promo-form" novalidate>
<label for="wallet">Кошелёк Solana</label>
<input id="wallet" name="wallet" type="text" th:value="${wallet}" placeholder="Введите публичный адрес"
required>
<label for="name">Ваше имя / имя тестера</label>
<input id="name" name="name" type="text" maxlength="120" placeholder="Например, Иван Петров" required>
<label for="promoCode">Промокод</label>
<input id="promoCode" name="promoCode" type="text" maxlength="8" placeholder="Например, x7k3m2qn"
required>
<button id="submitButton" type="submit">Пополнить тестовый счёт</button>
</form>
<div id="loadingState" class="status hidden">Отправляем транзакцию...</div>
<div id="errorState" class="status status-error hidden"></div>
<section id="successState" class="success hidden" aria-live="polite">
<h2>Готово! Тестовое пополнение выполнено</h2>
<p>
На указанный devnet-кошелёк отправлено 0.1 SOL. Возвращайтесь в приложение SHiNE / Сияние и
продолжайте тестирование.
</p>
<p>
Можете закрыть эту страницу и продолжить регистрацию на сайте.
</p>
<ul>
<li><strong>Сумма:</strong> <span id="successAmount"></span> SOL</li>
<li><strong>Кошелёк:</strong> <span id="successWallet"></span></li>
<li><strong>Имя:</strong> <span id="successName"></span></li>
<li><strong>Подпись транзакции:</strong> <span id="successSignature"></span></li>
</ul>
<a id="successExplorerUrl" href="#" target="_blank" rel="noopener noreferrer">
Открыть транзакцию в Solana Explorer
</a>
</section>
<section class="faq">
<h3>FAQ</h3>
<details>
<summary>Что такое SHiNE / Сияние?</summary>
<p>
SHiNE / «Сияние» — это тестируемая Web3-социальная сеть, где аккаунты, ключи и часть действий
связаны с блокчейном Solana. Пользователь управляет своим кошельком, а не просто логином и
паролем на обычном сервере.
</p>
</details>
<details>
<summary>Зачем нужен тестовый баланс?</summary>
<p>
В SHiNE регистрация и некоторые действия требуют небольших списаний. Это помогает тестировать
реальную экономику приложения: регистрацию, переводы, сообщения, звонки и другие функции. Сейчас
всё работает в Solana Devnet, поэтому средства тестовые.
</p>
</details>
<details>
<summary>Сколько стоит регистрация?</summary>
<p>
Текущая тестовая стоимость регистрации в SHiNE — 0.1 SOL. Промо-страница отправляет стартовое
тестовое пополнение 0.1 SOL. Условия тестирования могут меняться по мере развития проекта.
</p>
</details>
<details>
<summary>Можно ли пригласить друга?</summary>
<p>
Да. После получения тестового баланса и регистрации в SHiNE вы сможете переводить часть тестовых
SOL другим пользователям, например друзьям или родственникам, чтобы вместе проверить сообщения,
звонки и взаимодействие внутри социальной сети.
</p>
</details>
<details>
<summary>Это настоящие деньги?</summary>
<p>
Нет. Это Solana Devnet — тестовая сеть. Devnet SOL не имеют реальной рыночной ценности и
используются только для разработки и тестирования.
</p>
</details>
<details>
<summary>Почему в Web3 действия платные?</summary>
<p>
В Web3 часть действий связана с транзакциями, хранением данных, подписями и сетевой
инфраструктурой. Зато такая модель позволяет строить систему без навязчивой рекламы и с большей
прозрачностью: пользователь понимает, за что платит, а ключи остаются у него.
</p>
</details>
<details>
<summary>Кто хранит мои ключи?</summary>
<p>
Эта промо-страница не просит и не хранит приватные ключи пользователя. Для пополнения нужен только
публичный адрес кошелька Solana Devnet.
</p>
</details>
</section>
</section>
</main>
<footer class="page-footer">SHiNE Devnet Promo · temporary testing page</footer>
<script src="/js/app.js"></script>
</body>
</html>

View File

@ -618,7 +618,6 @@ public final class DatabaseInitializer {
time_ms INTEGER NOT NULL,
nonce INTEGER NOT NULL,
message_type INTEGER NOT NULL,
revision_time_ms INTEGER NOT NULL DEFAULT 0,
raw_block BLOB NOT NULL,
created_at_ms INTEGER NOT NULL,
source_api TEXT NOT NULL,

View File

@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = 7;
private static final int LATEST_SCHEMA_VERSION = 5;
private final String jdbcUrl;
@ -88,8 +88,6 @@ public final class SqliteDbController {
case 3 -> migrateToV3();
case 4 -> migrateToV4();
case 5 -> migrateToV5();
case 6 -> migrateToV6();
case 7 -> migrateToV7();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@ -211,44 +209,6 @@ public final class SqliteDbController {
}
}
private void migrateToV6() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureSignedMessagesRevisionColumn(c, st);
setSchemaVersion(c, 6);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v6 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v6 failed", e);
}
}
private void migrateToV7() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
dropDmFileTables(st);
setSchemaVersion(c, 7);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v7 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v7 failed", e);
}
}
private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
@ -369,20 +329,6 @@ public final class SqliteDbController {
""");
}
private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException {
if (!tableExists(c, "signed_messages_v2")) return;
if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) {
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0");
}
}
private static void dropDmFileTables(Statement st) throws SQLException {
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login");
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links");
st.executeUpdate("DROP TABLE IF EXISTS dm_files");
}
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
try (Statement probe = c.createStatement();
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {

View File

@ -112,7 +112,7 @@ public final class EspPairingRequestsDAO {
FROM esp_pairing_requests
WHERE login = ? COLLATE NOCASE
AND expires_at_ms > ?
AND status = 'created'
AND status IN ('created', 'approved', 'rejected')
ORDER BY created_at_ms DESC
""";
List<EspPairingRequestEntry> list = new ArrayList<>();
@ -199,22 +199,6 @@ public final class EspPairingRequestsDAO {
});
}
public void markCanceled(String pairingId, String rejectReason, long updatedAtMs) throws SQLException {
updateSimple(pairingId, """
UPDATE esp_pairing_requests
SET status = 'canceled',
reject_reason = ?,
approved_by_session_id = NULL,
encrypted_payload = NULL,
updated_at_ms = ?
WHERE pairing_id = ?
""", ps -> {
ps.setString(1, rejectReason);
ps.setLong(2, updatedAtMs);
ps.setString(3, pairingId);
});
}
public int expirePending(long nowMs) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""

View File

@ -8,7 +8,6 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class SignedMessagesV2DAO {
@ -31,17 +30,36 @@ public final class SignedMessagesV2DAO {
String sql = """
INSERT OR IGNORE INTO signed_messages_v2 (
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
time_ms, nonce, message_type, raw_block, created_at_ms,
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
ps.setString(1, e.getMessageKey());
ps.setString(2, e.getBaseKey());
ps.setString(3, e.getTargetLogin());
ps.setString(4, e.getFromLogin());
ps.setString(5, e.getToLogin());
ps.setLong(6, e.getTimeMs());
ps.setLong(7, e.getNonce());
ps.setInt(8, e.getMessageType());
ps.setBytes(9, e.getRawBlock());
ps.setLong(10, e.getCreatedAtMs());
ps.setString(11, e.getSourceApi());
ps.setString(12, e.getOriginSessionId());
ps.setString(13, e.getReceiptRefBaseKey());
if (e.getReceiptRefType() == null) ps.setObject(14, null);
else ps.setInt(14, e.getReceiptRefType());
return ps.executeUpdate() > 0;
}
}
}
/**
* Атомарная вставка пары блоков: либо вставляются оба, либо не вставляется ни один.
* Возвращает true только если обе записи добавлены в БД.
* Если хотя бы одна запись уже существует (или конфликтует по уникальности), возвращает false.
*/
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
@ -67,53 +85,45 @@ public final class SignedMessagesV2DAO {
}
}
public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey());
long currentRevision = Math.max(
currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE,
currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE
);
long nextRevision = incoming.getRevisionTimeMs();
if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) {
c.rollback();
return false;
}
if (currentRevision != Long.MIN_VALUE
&& nextRevision == currentRevision
&& hasSameRawBlock(c, incoming)
&& hasSameRawBlock(c, outgoing)) {
c.rollback();
return false;
}
upsertMessage(c, incoming);
upsertMessage(c, outgoing);
resetDeliveryRows(c, incoming.getMessageKey());
resetDeliveryRows(c, outgoing.getMessageKey());
c.commit();
return true;
} catch (Exception ex) {
try { c.rollback(); } catch (Exception ignored) {}
throw ex;
} finally {
c.setAutoCommit(prevAutoCommit);
}
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
String sql = """
INSERT INTO signed_messages_v2 (
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, raw_block, created_at_ms,
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getMessageKey());
ps.setString(2, e.getBaseKey());
ps.setString(3, e.getTargetLogin());
ps.setString(4, e.getFromLogin());
ps.setString(5, e.getToLogin());
ps.setLong(6, e.getTimeMs());
ps.setLong(7, e.getNonce());
ps.setInt(8, e.getMessageType());
ps.setBytes(9, e.getRawBlock());
ps.setLong(10, e.getCreatedAtMs());
ps.setString(11, e.getSourceApi());
ps.setString(12, e.getOriginSessionId());
ps.setString(13, e.getReceiptRefBaseKey());
if (e.getReceiptRefType() == null) ps.setObject(14, null);
else ps.setInt(14, e.getReceiptRefType());
return ps.executeUpdate();
}
}
private boolean isConstraintViolation(SQLException ex) {
String msg = String.valueOf(ex.getMessage()).toLowerCase();
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
}
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
SELECT
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
time_ms, nonce, message_type, raw_block, created_at_ms,
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
FROM signed_messages_v2
WHERE message_key = ?
@ -193,13 +203,13 @@ public final class SignedMessagesV2DAO {
String sql = """
SELECT
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms,
m.time_ms, m.nonce, m.message_type, m.raw_block, m.created_at_ms,
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
FROM signed_messages_v2 m
JOIN signed_message_session_delivery d
ON d.message_key = m.message_key
WHERE d.session_id = ? AND d.delivered = 0
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC
ORDER BY m.time_ms ASC, m.created_at_ms ASC
""";
List<SignedMessageV2Entry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
@ -212,106 +222,6 @@ public final class SignedMessagesV2DAO {
}
}
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
String sql = """
INSERT INTO signed_messages_v2 (
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(message_key) DO UPDATE SET
base_key = excluded.base_key,
target_login = excluded.target_login,
from_login = excluded.from_login,
to_login = excluded.to_login,
time_ms = excluded.time_ms,
nonce = excluded.nonce,
message_type = excluded.message_type,
revision_time_ms = excluded.revision_time_ms,
raw_block = excluded.raw_block,
created_at_ms = excluded.created_at_ms,
source_api = excluded.source_api,
origin_session_id = excluded.origin_session_id,
receipt_ref_base_key = excluded.receipt_ref_base_key,
receipt_ref_type = excluded.receipt_ref_type
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
ps.executeUpdate();
}
}
private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException {
String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, messageKey);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return rs.getLong(1);
}
}
}
private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException {
String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getMessageKey());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return false;
return Arrays.equals(rs.getBytes(1), entry.getRawBlock());
}
}
}
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
UPDATE signed_message_session_delivery
SET delivered = 0, delivered_at_ms = NULL
WHERE message_key = ?
""")) {
ps.setString(1, messageKey);
ps.executeUpdate();
}
}
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
String sql = """
INSERT INTO signed_messages_v2 (
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
return ps.executeUpdate();
}
}
private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException {
ps.setString(1, e.getMessageKey());
ps.setString(2, e.getBaseKey());
ps.setString(3, e.getTargetLogin());
ps.setString(4, e.getFromLogin());
ps.setString(5, e.getToLogin());
ps.setLong(6, e.getTimeMs());
ps.setLong(7, e.getNonce());
ps.setInt(8, e.getMessageType());
ps.setLong(9, e.getRevisionTimeMs());
ps.setBytes(10, e.getRawBlock());
ps.setLong(11, e.getCreatedAtMs());
ps.setString(12, e.getSourceApi());
ps.setString(13, e.getOriginSessionId());
ps.setString(14, e.getReceiptRefBaseKey());
if (e.getReceiptRefType() == null) ps.setObject(15, null);
else ps.setInt(15, e.getReceiptRefType());
}
private boolean isConstraintViolation(SQLException ex) {
String msg = String.valueOf(ex.getMessage()).toLowerCase();
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
}
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
SignedMessageV2Entry e = new SignedMessageV2Entry();
e.setMessageKey(rs.getString("message_key"));
@ -322,7 +232,6 @@ public final class SignedMessagesV2DAO {
e.setTimeMs(rs.getLong("time_ms"));
e.setNonce(rs.getLong("nonce"));
e.setMessageType(rs.getInt("message_type"));
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
e.setRawBlock(rs.getBytes("raw_block"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setSourceApi(rs.getString("source_api"));

View File

@ -9,7 +9,6 @@ public class SignedMessageV2Entry {
private long timeMs;
private long nonce;
private int messageType;
private long revisionTimeMs;
private byte[] rawBlock;
private long createdAtMs;
private String sourceApi;
@ -33,8 +32,6 @@ public class SignedMessageV2Entry {
public void setNonce(long nonce) { this.nonce = nonce; }
public int getMessageType() { return messageType; }
public void setMessageType(int messageType) { this.messageType = messageType; }
public long getRevisionTimeMs() { return revisionTimeMs; }
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; }
public byte[] getRawBlock() { return rawBlock; }
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
public long getCreatedAtMs() { return createdAtMs; }

View File

@ -9,9 +9,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handle
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CancelEspPairing_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_GetTrustedDeviceLoginSettings_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler;
// --- NEW v2 session login ---
@ -29,9 +27,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRe
// --- NEW v2 entities ---
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetTrustedDeviceLoginSettings_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
@ -142,16 +138,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()),
Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()),
Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()),
Map.entry("CancelEspPairing", new Net_CancelEspPairing_Handler()),
Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()),
Map.entry("GetTrustedDeviceLoginSettings", new Net_GetTrustedDeviceLoginSettings_Handler()),
Map.entry("UpsertTrustedDeviceLoginSettings", new Net_UpsertEspPairingSettings_Handler()),
Map.entry("StartTrustedDeviceLogin", new Net_StartEspPairing_Handler()),
Map.entry("ListTrustedDeviceLoginRequests", new Net_ListEspPairingRequests_Handler()),
Map.entry("ApproveTrustedDeviceLogin", new Net_ApproveEspPairing_Handler()),
Map.entry("RejectTrustedDeviceLogin", new Net_RejectEspPairing_Handler()),
Map.entry("CancelTrustedDeviceLogin", new Net_CancelEspPairing_Handler()),
Map.entry("GetTrustedDeviceLoginStatus", new Net_GetEspPairingStatus_Handler()),
// --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_Handler()),
@ -215,16 +202,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class),
Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class),
Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class),
Map.entry("CancelEspPairing", Net_CancelEspPairing_Request.class),
Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class),
Map.entry("GetTrustedDeviceLoginSettings", Net_GetTrustedDeviceLoginSettings_Request.class),
Map.entry("UpsertTrustedDeviceLoginSettings", Net_UpsertEspPairingSettings_Request.class),
Map.entry("StartTrustedDeviceLogin", Net_StartEspPairing_Request.class),
Map.entry("ListTrustedDeviceLoginRequests", Net_ListEspPairingRequests_Request.class),
Map.entry("ApproveTrustedDeviceLogin", Net_ApproveEspPairing_Request.class),
Map.entry("RejectTrustedDeviceLogin", Net_RejectEspPairing_Request.class),
Map.entry("CancelTrustedDeviceLogin", Net_CancelEspPairing_Request.class),
Map.entry("GetTrustedDeviceLoginStatus", Net_GetEspPairingStatus_Request.class),
// --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_Request.class),

View File

@ -4,7 +4,6 @@ import org.eclipse.jetty.websocket.api.Session;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import shine.db.entities.ActiveSessionEntry;
import utils.crypto.HashSHA256Util;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@ -28,10 +27,7 @@ final class EspPairingSupport {
static final String STATE_CREATED = "created";
static final String STATE_APPROVED = "approved";
static final String STATE_REJECTED = "rejected";
static final String STATE_CANCELED = "canceled";
static final String STATE_EXPIRED = "expired";
static final String PASSWORD_HASH_PREFIX = "sha256$";
static final String PASSWORD_HASH_VERSION = "shine-pairing";
private static final SecureRandom RANDOM = new SecureRandom();
private static final char[] BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
@ -80,30 +76,6 @@ final class EspPairingSupport {
return value;
}
static String normalizePasswordHash(String raw) {
String value = normalizeOpaqueHash(raw);
if (value == null) return null;
if (!value.regionMatches(true, 0, PASSWORD_HASH_PREFIX, 0, PASSWORD_HASH_PREFIX.length())) {
return null;
}
String hex = value.substring(PASSWORD_HASH_PREFIX.length()).trim().toLowerCase(Locale.ROOT);
if (hex.length() != 64) return null;
for (int i = 0; i < hex.length(); i++) {
char ch = hex.charAt(i);
boolean ok = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
if (!ok) return null;
}
return PASSWORD_HASH_PREFIX + hex;
}
static String derivePasswordHash(String loginRaw, String passwordRaw) {
String login = loginRaw == null ? "" : loginRaw.trim().toLowerCase(Locale.ROOT);
String password = passwordRaw == null ? "" : passwordRaw;
String preimage = PASSWORD_HASH_VERSION + "|" + login + "|" + password;
byte[] digest = HashSHA256Util.sha256(preimage.getBytes(StandardCharsets.UTF_8));
return PASSWORD_HASH_PREFIX + toHexLower(digest);
}
static String normalizeEncryptedPayload(String raw) {
if (raw == null) return null;
String value = raw.trim();
@ -176,14 +148,5 @@ final class EspPairingSupport {
return remainder;
}
private static String toHexLower(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >>> 4) & 0x0F, 16));
sb.append(Character.forDigit(b & 0x0F, 16));
}
return sb.toString();
}
record PairingFingerprint(String shortCode, String fingerprintB58) {}
}

View File

@ -1,60 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.auth;
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.auth.entyties.Net_CancelEspPairing_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Response;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.EspPairingRequestsDAO;
import shine.db.entities.EspPairingRequestEntry;
public class Net_CancelEspPairing_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_CancelEspPairing_Request req = (Net_CancelEspPairing_Request) baseReq;
String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim();
if (pairingId.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId");
}
String requesterSessionKey = req.getRequesterSessionKey();
if (requesterSessionKey == null || requesterSessionKey.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_REQUESTER_SESSION_KEY", "Пустой requesterSessionKey");
}
try {
requesterSessionKey = AuthKeyUtils.normalize(requesterSessionKey, "requesterSessionKey");
AuthKeyUtils.parseEd25519PublicKey(requesterSessionKey, "requesterSessionKey");
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_REQUESTER_SESSION_KEY", "Некорректный requesterSessionKey");
}
long now = System.currentTimeMillis();
EspPairingRequestsDAO.getInstance().expirePending(now);
EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId);
if (row == null) {
return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена");
}
if (!requesterSessionKey.equals(row.getRequesterSessionKey())) {
return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_REQUESTER", "Нельзя отменять pairing другого устройства");
}
if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) {
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created");
}
EspPairingRequestsDAO.getInstance().markCanceled(pairingId, "canceled_by_requester", now);
Net_CancelEspPairing_Response resp = new Net_CancelEspPairing_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setPairingId(pairingId);
resp.setState(EspPairingSupport.STATE_CANCELED);
return resp;
}
}

View File

@ -1,43 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.auth;
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.auth.entyties.Net_GetTrustedDeviceLoginSettings_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetTrustedDeviceLoginSettings_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.EspPairingSettingsDAO;
import shine.db.entities.EspPairingSettingsEntry;
public class Net_GetTrustedDeviceLoginSettings_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_GetTrustedDeviceLoginSettings_Request req = (Net_GetTrustedDeviceLoginSettings_Request) baseReq;
if (!EspPairingSupport.isTrustedUserSession(ctx)) {
return NetExceptionResponseFactory.error(
req,
EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION,
"PAIRING_REQUIRES_AUTH_SESSION",
"Операция доступна только для авторизованной доверенной сессии пользователя"
);
}
EspPairingSettingsEntry entry = EspPairingSettingsDAO.getInstance().getByLogin(ctx.getLogin());
boolean enabled = entry == null || entry.isEnabled();
boolean hasPassword = enabled
&& entry != null
&& entry.getPasswordHash() != null
&& !entry.getPasswordHash().trim().isBlank();
Net_GetTrustedDeviceLoginSettings_Response resp = new Net_GetTrustedDeviceLoginSettings_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(200);
resp.setEnabled(enabled);
resp.setHasPassword(hasPassword);
return resp;
}
}

View File

@ -54,10 +54,9 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
if (!EspPairingSupport.isSupportedPayloadType(payloadType)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3");
}
String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash);
if (rawPasswordHash != null && passwordHash == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PASSWORD_HASH_FORMAT", "passwordHash должен быть пустым или иметь формат sha256$<64 hex>");
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
if (passwordHash == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Пустой passwordHash");
}
SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login);
@ -67,8 +66,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
String canonicalLogin = user.getLogin();
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
boolean enabled = settings == null || settings.isEnabled();
if (!enabled) {
if (settings == null || !settings.isEnabled() || settings.getPasswordHash() == null || settings.getPasswordHash().isBlank()) {
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен");
}
@ -86,27 +84,12 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) {
return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время");
}
String configuredPasswordHash = settings == null || settings.getPasswordHash() == null
? ""
: settings.getPasswordHash().trim();
boolean requiresPassword = !configuredPasswordHash.isBlank();
boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank();
if ((requiresPassword && !configuredPasswordHash.equals(passwordHash))
|| (!requiresPassword && suppliedPassword)) {
if (!settings.getPasswordHash().equals(passwordHash)) {
return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль");
}
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
if (approverConnections.isEmpty()) {
return NetExceptionResponseFactory.error(
req,
422,
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
"Нет ни одной активной доверенной сессии пользователя в сети"
);
}
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds());
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
canonicalLogin,
requesterSessionKey,
@ -132,6 +115,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
entry.setDeliveredToHomeserver(false);
EspPairingRequestsDAO.getInstance().insert(entry);
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
boolean delivered = false;
for (ConnectionContext targetCtx : approverConnections) {
String eventId = NetIdGenerator.eventId("pair");
@ -146,7 +130,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
payload.put("fingerprintB58", entry.getFingerprintB58());
payload.put("createdAtMs", entry.getCreatedAtMs());
payload.put("expiresAtMs", entry.getExpiresAtMs());
delivered |= WsEventSender.sendEvent(targetCtx, "IncomingTrustedDeviceLoginRequest", eventId, payload);
delivered |= WsEventSender.sendEvent(targetCtx, "IncomingEspPairingRequest", eventId, payload);
}
if (delivered) {
EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis());

View File

@ -27,22 +27,18 @@ public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler
}
boolean enabled = req.getEnabled() != null && req.getEnabled();
String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash);
if (rawPasswordHash != null && passwordHash == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_PASSWORD_HASH_FORMAT",
"passwordHash должен быть пустым или иметь формат sha256$<64 hex>"
);
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds());
if (enabled && (passwordHash == null || passwordHash.isBlank())) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Для включения pairing нужен passwordHash");
}
long now = System.currentTimeMillis();
EspPairingSettingsEntry entry = new EspPairingSettingsEntry();
entry.setLogin(ctx.getLogin());
entry.setEnabled(enabled);
entry.setPasswordHash(enabled && passwordHash != null ? passwordHash : "");
entry.setTtlSeconds(EspPairingSupport.DEFAULT_TTL_SECONDS);
entry.setPasswordHash(passwordHash == null ? "" : passwordHash);
entry.setTtlSeconds(ttlSeconds);
entry.setFailedAttempts(0);
entry.setFirstFailedAtMs(0L);
entry.setBlockedUntilMs(0L);
@ -54,7 +50,7 @@ public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setEnabled(enabled);
resp.setHasPassword(enabled && passwordHash != null && !passwordHash.isBlank());
resp.setTtlSeconds(ttlSeconds);
return resp;
}
}

View File

@ -1,24 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_CancelEspPairing_Request extends Net_Request {
private String pairingId;
private String requesterSessionKey;
public String getPairingId() {
return pairingId;
}
public void setPairingId(String pairingId) {
this.pairingId = pairingId;
}
public String getRequesterSessionKey() {
return requesterSessionKey;
}
public void setRequesterSessionKey(String requesterSessionKey) {
this.requesterSessionKey = requesterSessionKey;
}
}

View File

@ -1,24 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_CancelEspPairing_Response extends Net_Response {
private String pairingId;
private String state;
public String getPairingId() {
return pairingId;
}
public void setPairingId(String pairingId) {
this.pairingId = pairingId;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
}

View File

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

View File

@ -1,24 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_GetTrustedDeviceLoginSettings_Response extends Net_Response {
private boolean enabled;
private boolean hasPassword;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isHasPassword() {
return hasPassword;
}
public void setHasPassword(boolean hasPassword) {
this.hasPassword = hasPassword;
}
}

View File

@ -4,7 +4,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_UpsertEspPairingSettings_Response extends Net_Response {
private boolean enabled;
private boolean hasPassword;
private int ttlSeconds;
public boolean isEnabled() {
return enabled;
@ -14,11 +14,11 @@ public class Net_UpsertEspPairingSettings_Response extends Net_Response {
this.enabled = enabled;
}
public boolean isHasPassword() {
return hasPassword;
public int getTtlSeconds() {
return ttlSeconds;
}
public void setHasPassword(boolean hasPassword) {
this.hasPassword = hasPassword;
public void setTtlSeconds(int ttlSeconds) {
this.ttlSeconds = ttlSeconds;
}
}

View File

@ -8,7 +8,6 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesV2DAO;
import shine.db.entities.SignedMessageV2Entry;
public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
@ -44,7 +43,7 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
}
boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
boolean inserted = SignedMessagesCore.saveIfAbsent(entry);
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (inserted) {
counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);

View File

@ -49,18 +49,11 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
}
boolean pairInserted;
if (incoming.isContentType()) {
pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair(
incomingEntry, outgoingEntry
);
} else {
pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
}
boolean pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
if (pairInserted) {
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming);
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null);
}
String excludeSessionId = null;
@ -69,7 +62,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
}
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
if (pairInserted) {
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId);
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId);
}
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();

View File

@ -6,8 +6,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Arrays;
final class SignedMessageBlock {
static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
static final int TYPE_INCOMING_TEXT = 1;
static final int TYPE_OUTGOING_COPY = 2;
static final int TYPE_READ_INCOMING = 3;
@ -18,15 +17,10 @@ final class SignedMessageBlock {
final long timeMs;
final long nonce;
final int messageType;
final long revisionTimeMs;
final int formatVersionMajor;
final int formatVersionMinor;
final byte[] payloadBytes;
final byte[] encryptedBodyBytes;
final byte[] signedBody;
final byte[] signature64;
final byte[] rawPacket;
final boolean legacyFormat;
private SignedMessageBlock(
String toLogin,
@ -34,52 +28,36 @@ final class SignedMessageBlock {
long timeMs,
long nonce,
int messageType,
long revisionTimeMs,
int formatVersionMajor,
int formatVersionMinor,
byte[] payloadBytes,
byte[] encryptedBodyBytes,
byte[] signedBody,
byte[] signature64,
byte[] rawPacket,
boolean legacyFormat
byte[] rawPacket
) {
this.toLogin = toLogin;
this.fromLogin = fromLogin;
this.timeMs = timeMs;
this.nonce = nonce;
this.messageType = messageType;
this.revisionTimeMs = revisionTimeMs;
this.formatVersionMajor = formatVersionMajor;
this.formatVersionMinor = formatVersionMinor;
this.payloadBytes = payloadBytes;
this.encryptedBodyBytes = encryptedBodyBytes;
this.signedBody = signedBody;
this.signature64 = signature64;
this.rawPacket = rawPacket;
this.legacyFormat = legacyFormat;
}
static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) {
if (raw == null || raw.length < 64) {
static SignedMessageBlock parse(byte[] raw, int maxPayloadBytes) {
if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
throw new IllegalArgumentException("BAD_LEN");
}
if (raw.length > 8192) {
throw new IllegalArgumentException("PAYLOAD_TOO_LARGE");
}
if (startsWith(raw, LEGACY_PREFIX)) {
return parseLegacy(raw, maxEncryptedBodyBytes);
}
if (startsWith(raw, V1_PREFIX)) {
return parseV1(raw, maxEncryptedBodyBytes);
}
throw new IllegalArgumentException("BAD_PREFIX");
}
private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) {
if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
throw new IllegalArgumentException("BAD_LEN");
}
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
bb.position(LEGACY_PREFIX.length);
byte[] prefix = new byte[PREFIX.length];
bb.get(prefix);
if (!Arrays.equals(prefix, PREFIX)) {
throw new IllegalArgumentException("BAD_PREFIX");
}
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
@ -89,7 +67,9 @@ final class SignedMessageBlock {
long nonce = Integer.toUnsignedLong(bb.getInt());
int messageType = Short.toUnsignedInt(bb.getShort());
ensureMessageType(messageType);
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
}
int payloadLen = Short.toUnsignedInt(bb.getShort());
if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
@ -106,82 +86,7 @@ final class SignedMessageBlock {
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
return new SignedMessageBlock(
toLogin,
fromLogin,
timeMs,
nonce,
messageType,
0L,
2,
0,
payload,
payload,
signedBody,
signature64,
raw,
true
);
}
private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) {
if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) {
throw new IllegalArgumentException("BAD_LEN");
}
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
bb.position(V1_PREFIX.length);
int major = Byte.toUnsignedInt(bb.get());
int minor = Byte.toUnsignedInt(bb.get());
if (major != 1 || minor != 0) {
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
}
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
long timeMs = bb.getLong();
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
long nonce = Integer.toUnsignedLong(bb.getInt());
int messageType = Short.toUnsignedInt(bb.getShort());
ensureMessageType(messageType);
long revisionTimeMs = bb.getLong();
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
int attachmentsCount = Byte.toUnsignedInt(bb.get());
if (attachmentsCount != 0) {
throw new IllegalArgumentException("ATTACHMENTS_DISABLED");
}
if (bb.remaining() < 4 + 64) {
throw new IllegalArgumentException("BAD_LEN");
}
long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt());
if (encryptedBodyLen > maxEncryptedBodyBytes) {
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
}
if (bb.remaining() != encryptedBodyLen + 64) {
throw new IllegalArgumentException("BAD_LEN");
}
byte[] encryptedBody = new byte[(int) encryptedBodyLen];
bb.get(encryptedBody);
byte[] signature64 = new byte[64];
bb.get(signature64);
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
return new SignedMessageBlock(
toLogin,
fromLogin,
timeMs,
nonce,
messageType,
revisionTimeMs,
major,
minor,
encryptedBody,
encryptedBody,
signedBody,
signature64,
raw,
false
toLogin, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw
);
}
@ -193,36 +98,10 @@ final class SignedMessageBlock {
return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
}
boolean isContentType() {
return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY;
}
boolean isReadReceiptType() {
return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY;
}
boolean isDeletedContent() {
return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0;
}
String targetLogin() {
return isIncomingType() ? toLogin : fromLogin;
}
private static void ensureMessageType(int messageType) {
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
}
}
private static boolean startsWith(byte[] raw, byte[] prefix) {
if (raw.length < prefix.length) return false;
for (int i = 0; i < prefix.length; i++) {
if (raw[i] != prefix[i]) return false;
}
return true;
}
private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
int len = Byte.toUnsignedInt(bb.get());

View File

@ -1,40 +1,24 @@
package server.logic.ws_protocol.JSON.messages;
import shine.db.dao.SignedMessagesV2DAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.SignedMessageV2Entry;
import shine.db.entities.SolanaUserEntry;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
final class SignedMessagesCore {
private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
private static final int MAX_PAYLOAD_BYTES = 4096;
private SignedMessagesCore() {}
static SignedMessageBlock parseFromB64(String blobB64) {
try {
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES);
return SignedMessageBlock.parse(raw, MAX_PAYLOAD_BYTES);
} catch (IllegalArgumentException e) {
String code = e.getMessage();
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
}
switch (code) {
case "ATTACHMENTS_DISABLED",
"BAD_PREFIX",
"BAD_LEN",
"BAD_TO_LOGIN",
"BAD_FROM_LOGIN",
"BAD_TIME",
"BAD_MESSAGE_TYPE",
"BAD_MESSAGE_LEN",
"BAD_FORMAT_VERSION",
"BAD_REVISION_TIME" -> throw e;
default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
}
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
}
}
@ -58,7 +42,7 @@ final class SignedMessagesCore {
if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
if (incoming.isReadReceiptType()) {
if (incoming.messageType == SignedMessageBlock.TYPE_READ_INCOMING) {
ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes);
ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
@ -68,27 +52,6 @@ final class SignedMessagesCore {
|| inRef.refType != outRef.refType) {
throw new IllegalArgumentException("BAD_RECEIPT_REF");
}
return;
}
if (incoming.legacyFormat || outgoing.legacyFormat) {
throw new IllegalArgumentException("BAD_CONTENT_FORMAT");
}
if (incoming.revisionTimeMs != outgoing.revisionTimeMs) {
throw new IllegalArgumentException("BAD_REVISION_TIME");
}
if (incoming.formatVersionMajor != outgoing.formatVersionMajor
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) {
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
}
if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) {
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
}
for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) {
if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) {
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
}
}
}
@ -105,7 +68,6 @@ final class SignedMessagesCore {
entry.setTimeMs(block.timeMs);
entry.setNonce(block.nonce);
entry.setMessageType(block.messageType);
entry.setRevisionTimeMs(block.revisionTimeMs);
entry.setRawBlock(block.rawPacket);
entry.setCreatedAtMs(System.currentTimeMillis());
entry.setSourceApi(sourceApi);
@ -121,10 +83,7 @@ final class SignedMessagesCore {
return entry;
}
static String previewTextForPush(SignedMessageBlock block) {
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
return "";
}
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
}
}

View File

@ -21,13 +21,8 @@ public final class SignedMessagesRealtime {
private static final ObjectMapper MAPPER = new ObjectMapper();
private SignedMessagesRealtime() {}
static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
return deliverToTargetSessions(message, block, null);
}
static DeliveryCounters deliverToTargetSessions(
SignedMessageV2Entry message,
SignedMessageBlock block,
String excludeSessionId
) throws Exception {
DeliveryCounters counters = new DeliveryCounters();
@ -44,11 +39,8 @@ public final class SignedMessagesRealtime {
counters.wsDelivered++;
continue;
}
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT
&& block != null
&& block.revisionTimeMs == 0
&& !block.isDeletedContent()) {
boolean pushed = pushNewMessageNotification(s, message, block);
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) {
boolean pushed = pushNewMessageNotification(s, message);
if (pushed) counters.pushDelivered++;
}
}
@ -97,21 +89,13 @@ public final class SignedMessagesRealtime {
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
}
private static boolean pushNewMessageNotification(
ActiveSessionEntry session,
SignedMessageV2Entry message,
SignedMessageBlock block
) {
private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
try {
if (session == null) return false;
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
return false;
}
String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim();
if (preview.length() > 80) preview = preview.substring(0, 80) + "...";
String text = preview.isBlank()
? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."
: preview;
String text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения.";
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
return WebPushSender.sendBase64Payload(
session.getPushEndpoint(),

View File

@ -75,4 +75,4 @@ public final class WsServer {
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
server.join();
}
}
}

View File

@ -9,11 +9,9 @@ import test.it.utils.ws.WsSession;
import utils.crypto.Ed25519Util;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.SolanaUserEntry;
import utils.crypto.HashSHA256Util;
import java.time.Duration;
import java.util.Base64;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
@ -40,7 +38,7 @@ public class IT_07_EspPairing {
sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r);
String passwordHash = derivePairingHash(LOGIN, "test-pairing-password");
String passwordHash = "argon2id$v=19$m=65536,t=2,p=1$test$esp_pairing_hash";
String upsertResp = clientWs.call(
"UpsertEspPairingSettings",
JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180),
@ -81,59 +79,6 @@ public class IT_07_EspPairing {
assertEquals("approved", JsonParsers.payloadText(statusResp, "state"));
assertEquals("AQIDBA==", JsonParsers.payloadText(statusResp, "encryptedPayload"));
String upsertNoPasswordResp = clientWs.call(
"UpsertEspPairingSettings",
JsonBuilders.upsertEspPairingSettings(true, "", 180),
t
);
assertEquals(200, JsonParsers.status(upsertNoPasswordResp), "UpsertEspPairingSettings without password must be 200");
SessionMaterial requesterNoPasswordMaterial = newSessionMaterial();
String startNoPasswordResp = requesterWs.call(
"StartEspPairing",
JsonBuilders.startEspPairing(LOGIN, "", requesterNoPasswordMaterial.sessionKey(), 1, "Android", 1),
t
);
assertEquals(200, JsonParsers.status(startNoPasswordResp), "StartEspPairing without password must be 200");
String startWrongPasswordResp = requesterWs.call(
"StartEspPairing",
JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterNoPasswordMaterial.sessionKey(), 1, "Android", 1),
t
);
assertErrorFormat(startWrongPasswordResp, "StartEspPairing", "PAIRING_PASSWORD_INVALID");
SessionMaterial cancelMaterial = newSessionMaterial();
String startCancelableResp = requesterWs.call(
"StartEspPairing",
JsonBuilders.startEspPairing(LOGIN, "", cancelMaterial.sessionKey(), 1, "Android", 1),
t
);
assertEquals(200, JsonParsers.status(startCancelableResp), "StartEspPairing for cancel must be 200");
String cancelPairingId = JsonParsers.payloadText(startCancelableResp, "pairingId");
String cancelResp = requesterWs.call(
"CancelEspPairing",
JsonBuilders.cancelEspPairing(cancelPairingId, cancelMaterial.sessionKey()),
t
);
assertEquals(200, JsonParsers.status(cancelResp), "CancelEspPairing must be 200");
assertEquals("canceled", JsonParsers.payloadText(cancelResp, "state"));
String closeResp = clientWs.call(
"CloseActiveSession",
JsonBuilders.closeActiveSession(clientSession.sessionId(), 0, ""),
t
);
assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession must be 200");
SessionMaterial requesterOfflineMaterial = newSessionMaterial();
String startOfflineResp = requesterWs.call(
"StartEspPairing",
JsonBuilders.startEspPairing(LOGIN, "", requesterOfflineMaterial.sessionKey(), 1, "Android", 1),
t
);
assertErrorFormat(startOfflineResp, "StartEspPairing", "PAIRING_NO_TRUSTED_SESSION_ONLINE");
String forbiddenResp = requesterWs.call(
"ListEspPairingRequests#anonymous",
JsonBuilders.listEspPairingRequests(),
@ -141,7 +86,7 @@ public class IT_07_EspPairing {
);
assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION");
r.ok("ESP pairing: доверенная сессия принимает заявки как с доп. паролем, так и без него");
r.ok("ESP pairing: обычная доверенная сессия увидела запрос и подтвердила зашифрованный payload");
}
} catch (Throwable e) {
r.fail("IT_07_EspPairing упал: " + e.getMessage());
@ -220,17 +165,6 @@ public class IT_07_EspPairing {
SolanaUsersDAO.getInstance().insert(entry);
}
private static String derivePairingHash(String login, String password) {
String preimage = "shine-pairing|" + login.trim().toLowerCase() + "|" + password;
byte[] digest = HashSHA256Util.sha256(preimage.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : digest) {
sb.append(Character.forDigit((b >>> 4) & 0x0F, 16));
sb.append(Character.forDigit(b & 0x0F, 16));
}
return "sha256$" + sb;
}
private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {}
private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {}
}

View File

@ -333,20 +333,6 @@ public final class JsonBuilders {
""".formatted(requestId, pairingId, reason == null ? "" : reason);
}
public static String cancelEspPairing(String pairingId, String requesterSessionKey) {
String requestId = TestIds.next("esp-cancel");
return """
{
"op": "CancelEspPairing",
"requestId": "%s",
"payload": {
"pairingId": "%s",
"requesterSessionKey": "%s"
}
}
""".formatted(requestId, pairingId, requesterSessionKey);
}
public static String getEspPairingStatus(String pairingId) {
String requestId = TestIds.next("esp-status");
return """

View File

@ -1,2 +1,2 @@
client.version=1.2.216
server.version=1.2.204
client.version=1.2.192
server.version=1.2.181

View File

@ -292,6 +292,17 @@ tasks.register('startLocalWithBuild') {
dependsOn tasks.named('startLocal')
}
tasks.register('deployPromoSolanaDevnet', Exec) {
group = "!!deployment"
description = "Деплой отдельного временного сервиса SHiNE-promo-solana-devnet на сервер через домен shineup.me"
// Этот сервис не входит в основной multi-module build данного репозитория.
// Поэтому деплой выполняется через отдельный Gradle-проект в подпапке
// SHiNE-promo-solana-devnet, где собраны его собственные задачи и зависимости.
workingDir = file('SHiNE-promo-solana-devnet')
commandLine 'bash', '-lc', './gradlew deployToServer'
}
tasks.named('startLocal').configure {
mustRunAfter tasks.named('build')
}

View File

@ -1,31 +0,0 @@
TELEGRAM_BOT_TOKEN=replace_me
OPENAI_API_KEY=
ALLOWED_TELEGRAM_USERNAME=owner_username
ALLOWED_TELEGRAM_PLAYERS=user_one:User One,user_two:User Two
ALLOWED_TELEGRAM_CHANNEL_USERNAME=
BOT_USERNAME=your_bot_username
TELEGRAM_API_BASE_URL=https://api.telegram.org
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES=25165824
OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS=900
OPENAI_TRANSCRIBE_OVERLAP_SECONDS=2
OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS=24
OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS=1800
FFMPEG_BIN=ffmpeg
FFPROBE_BIN=ffprobe
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
OPENAI_TTS_RESPONSE_FORMAT=opus
OPENAI_TTS_TIMEOUT_SECONDS=180
OPENAI_TTS_CHUNK_CHARS=3500
OPENAI_VOICE_REWRITE_MODEL=gpt-4.1-nano
OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS=90
OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS=12000
OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS=900
CODEX_BIN=/home/your_user/.local/bin/codex
CODEX_WORKDIR=/home/your_user
CODEX_TIMEOUT_SECONDS=900
MAX_RETRIES=3
DATA_DIR=./data

View File

@ -1,5 +0,0 @@
.env
data/
logs/
run/
__pycache__/

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