Compare commits
No commits in common. "f2b23ace8b72341781a904e933ae5bbb18670093a1e206cf13d01f2d9879d14a" and "b166013707a97e78cc148f2ea620ac4140a82166ed8879dd34a9742ad7ebfb8b" have entirely different histories.
f2b23ace8b
...
b166013707
@ -303,14 +303,12 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
||||
|
||||
Новые `op`, относящиеся к этому сценарию:
|
||||
|
||||
- `GetTrustedDeviceLoginSettings`
|
||||
- `UpsertTrustedDeviceLoginSettings`
|
||||
- `StartTrustedDeviceLogin`
|
||||
- `ListTrustedDeviceLoginRequests`
|
||||
- `ApproveTrustedDeviceLogin`
|
||||
- `RejectTrustedDeviceLogin`
|
||||
- `CancelTrustedDeviceLogin`
|
||||
- `GetTrustedDeviceLoginStatus`
|
||||
- `UpsertEspPairingSettings`
|
||||
- `StartEspPairing`
|
||||
- `ListEspPairingRequests`
|
||||
- `ApproveEspPairing`
|
||||
- `RejectEspPairing`
|
||||
- `GetEspPairingStatus`
|
||||
|
||||
В этом потоке:
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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` — повторное сообщение заблокировано.
|
||||
|
||||
@ -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`
|
||||
@ -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
|
||||
@ -1,22 +0,0 @@
|
||||
# Закрытие сессий и сортировка устройств
|
||||
|
||||
- краткое описание фичи:
|
||||
- добровольный выход и переключение устройства/аккаунта теперь сначала пытаются закрыть текущую серверную сессию, а затем очищают локальные данные;
|
||||
- на экране `Устройства` сессии сортируются так, чтобы онлайн-сессии шли раньше оффлайн;
|
||||
- статус онлайн-сессии выделяется зелёным.
|
||||
|
||||
- что именно проверять:
|
||||
- в `Настройки` нажать выход из текущей сессии и убедиться, что запись исчезает из списка сессий после повторного входа;
|
||||
- в `Устройства` нажать `Завершить текущую сессию` и убедиться, что локальные данные очищены, а серверная сессия удалена;
|
||||
- выполнить вход/переключение через `Подключить устройство` или QR и убедиться, что старая сессия не остаётся висеть на сервере;
|
||||
- открыть `Устройства` при наличии нескольких сессий и убедиться, что сначала показаны `Online now`, затем `Offline`;
|
||||
- проверить, что строки со статусом `Online now` визуально выделены зелёным.
|
||||
|
||||
- ожидаемый результат:
|
||||
- при добровольном завершении сессии серверная запись удаляется;
|
||||
- при локальном переключении на другой аккаунт старая текущая сессия не остаётся в `active_sessions`;
|
||||
- порядок сессий в UI соответствует онлайн-статусу сервера;
|
||||
- зелёный статус виден и не ломает верстку на экране `Устройства`.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,18 +0,0 @@
|
||||
# AGENTS
|
||||
|
||||
## Документация DM в этой папке
|
||||
|
||||
- Основной актуальный документ по личным сообщениям:
|
||||
- `README.md`
|
||||
- Его считать единственным источником истины по текущей реализованной логике DM.
|
||||
|
||||
## Черновик будущих вложений
|
||||
|
||||
- Файл `Черновик_будущих_DM_вложений.md` не является актуальной спецификацией.
|
||||
- В нём описан только ранний черновик того, как когда-то планировались:
|
||||
- формат вложений в DM;
|
||||
- внешние и внутренние поля вложения;
|
||||
- предполагаемая механика загрузки файлов.
|
||||
- Эта схема не была реализована в таком виде и может существенно измениться в будущем.
|
||||
- Любые решения по текущему коду, протоколу и UI нельзя принимать по этому черновику.
|
||||
- Если есть расхождение между `README.md` и черновиком вложений, верным всегда считается `README.md`.
|
||||
@ -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`
|
||||
|
||||
@ -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`.
|
||||
@ -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) )>
|
||||
```
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
.gradle
|
||||
build/
|
||||
node_modules/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
1
ESP32-wallet/settings.gradle
Normal file
1
ESP32-wallet/settings.gradle
Normal file
@ -0,0 +1 @@
|
||||
rootProject.name = 'ESP-wallet'
|
||||
@ -27,7 +27,6 @@
|
||||
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
||||
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
|
||||
- Архив истории после `/new`: `data/history/<username>/archive/`.
|
||||
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
|
||||
- Для просмотра истории игрока открывать файлы в его папке истории по username.
|
||||
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
||||
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
||||
|
||||
@ -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` — включить адаптацию текста перед озвучкой.
|
||||
|
||||
10
SHiNE-browser-plugin-wallet/.idea/.gitignore
generated
vendored
10
SHiNE-browser-plugin-wallet/.idea/.gitignore
generated
vendored
@ -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
|
||||
1
SHiNE-browser-plugin-wallet/.idea/.name
generated
1
SHiNE-browser-plugin-wallet/.idea/.name
generated
@ -1 +0,0 @@
|
||||
ESP-wallet
|
||||
17
SHiNE-browser-plugin-wallet/.idea/gradle.xml
generated
17
SHiNE-browser-plugin-wallet/.idea/gradle.xml
generated
@ -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>
|
||||
7
SHiNE-browser-plugin-wallet/.idea/misc.xml
generated
7
SHiNE-browser-plugin-wallet/.idea/misc.xml
generated
@ -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>
|
||||
6
SHiNE-browser-plugin-wallet/.idea/vcs.xml
generated
6
SHiNE-browser-plugin-wallet/.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@ -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
|
||||
```
|
||||
@ -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');
|
||||
});
|
||||
@ -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('');
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
@ -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) *)
|
||||
*/
|
||||
@ -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
@ -1,3 +0,0 @@
|
||||
import { PublicKey } from '@solana/web3.js';
|
||||
|
||||
export { PublicKey };
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
1177
SHiNE-browser-plugin-wallet/package-lock.json
generated
1177
SHiNE-browser-plugin-wallet/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -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();
|
||||
@ -1 +0,0 @@
|
||||
rootProject.name = 'SHiNE-browser-plugin-wallet'
|
||||
9
SHiNE-promo-solana-devnet/.gitignore
vendored
Normal file
9
SHiNE-promo-solana-devnet/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
.gradle
|
||||
/build
|
||||
.idea
|
||||
out
|
||||
*.log
|
||||
|
||||
config/devnet-wallet.json
|
||||
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
198
SHiNE-promo-solana-devnet/README.md
Normal file
198
SHiNE-promo-solana-devnet/README.md
Normal 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
|
||||
```
|
||||
103
SHiNE-promo-solana-devnet/build.gradle
Normal file
103
SHiNE-promo-solana-devnet/build.gradle
Normal 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')
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
]
|
||||
@ -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
|
||||
BIN
SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
234
SHiNE-promo-solana-devnet/gradlew
vendored
Executable 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
89
SHiNE-promo-solana-devnet/gradlew.bat
vendored
Normal 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
|
||||
1
SHiNE-promo-solana-devnet/settings.gradle
Normal file
1
SHiNE-promo-solana-devnet/settings.gradle
Normal file
@ -0,0 +1 @@
|
||||
rootProject.name = 'SHiNE-promo-solana-devnet'
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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,}", " ");
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
200
SHiNE-promo-solana-devnet/src/main/resources/static/css/app.css
Normal file
200
SHiNE-promo-solana-devnet/src/main/resources/static/css/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
})();
|
||||
@ -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>
|
||||
@ -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,
|
||||
|
||||
@ -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 + ")")) {
|
||||
|
||||
@ -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("""
|
||||
|
||||
@ -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,45 +85,37 @@ 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;
|
||||
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();
|
||||
}
|
||||
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 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 {
|
||||
@ -113,7 +123,7 @@ public final class SignedMessagesV2DAO {
|
||||
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"));
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,53 +28,37 @@ 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);
|
||||
}
|
||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||
byte[] prefix = new byte[PREFIX.length];
|
||||
bb.get(prefix);
|
||||
if (!Arrays.equals(prefix, PREFIX)) {
|
||||
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);
|
||||
|
||||
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());
|
||||
|
||||
@ -1,41 +1,25 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception {
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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 """
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.216
|
||||
server.version=1.2.204
|
||||
client.version=1.2.192
|
||||
server.version=1.2.181
|
||||
|
||||
11
build.gradle
11
build.gradle
@ -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')
|
||||
}
|
||||
|
||||
@ -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
|
||||
5
codex-agent-VPS/.gitignore
vendored
5
codex-agent-VPS/.gitignore
vendored
@ -1,5 +0,0 @@
|
||||
.env
|
||||
data/
|
||||
logs/
|
||||
run/
|
||||
__pycache__/
|
||||
@ -1,86 +0,0 @@
|
||||
# AGENTS
|
||||
|
||||
## Назначение
|
||||
- `codex-agent-VPS` — переносимая версия Telegram-бота для запуска `codex` CLI на VPS.
|
||||
- Папку можно ставить в любое место на Linux-сервере, если там есть `python3`, `systemd`, `codex` и доступ в интернет.
|
||||
- Конфигурация делается через `.env`.
|
||||
|
||||
## Состав папки
|
||||
- `README.md` — краткое описание структуры.
|
||||
- `Agent-server-package/` — готовый набор файлов для копирования на VPS.
|
||||
- `.env.example` — пример конфигурации.
|
||||
- `AGENTS.md` — инструкция по установке и настройке.
|
||||
|
||||
## Требования к VPS
|
||||
- Linux-сервер с `systemd`.
|
||||
- Установленные `python3`, `curl`, `ffmpeg`.
|
||||
- Установленный `codex` CLI.
|
||||
- Выполненный `codex login` под тем пользователем, от которого будет работать сервис.
|
||||
- Telegram bot token.
|
||||
- Telegram usernames разрешённых пользователей.
|
||||
|
||||
## Установка через Codex
|
||||
1. Скопировать содержимое `Agent-server-package/` на сервер в нужное место, например:
|
||||
- `/home/your_user/codex-agent`
|
||||
2. Установить `codex` CLI под рабочим пользователем.
|
||||
3. Выполнить под этим же пользователем:
|
||||
- `codex login`
|
||||
4. Установить системные зависимости:
|
||||
- `python3`
|
||||
- `ffmpeg`
|
||||
5. Взять `.env.example` из корня `codex-agent-VPS` и создать на сервере `.env`.
|
||||
6. В `.env` заполнить:
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `ALLOWED_TELEGRAM_USERNAME`
|
||||
- `ALLOWED_TELEGRAM_PLAYERS`
|
||||
- `BOT_USERNAME`
|
||||
- `CODEX_BIN`
|
||||
- `CODEX_WORKDIR`
|
||||
7. Если нужны voice/audio и голосовые ответы, дополнительно задать:
|
||||
- `OPENAI_API_KEY`
|
||||
8. В `Agent-server-package/scripts/systemd/shine-agent-bot-coder.service` заменить:
|
||||
- `your_user`
|
||||
- `/home/your_user/codex-agent`
|
||||
на реальные значения.
|
||||
9. Скопировать unit в:
|
||||
- `/etc/systemd/system/shine-agent-bot-coder.service`
|
||||
10. Выполнить:
|
||||
- `sudo systemctl daemon-reload`
|
||||
- `sudo systemctl enable --now shine-agent-bot-coder`
|
||||
11. Проверить:
|
||||
- `sudo systemctl status shine-agent-bot-coder --no-pager`
|
||||
- `sudo journalctl -u shine-agent-bot-coder -f`
|
||||
|
||||
## Настройка доступа
|
||||
- `ALLOWED_TELEGRAM_USERNAME` — основной разрешённый пользователь.
|
||||
- `ALLOWED_TELEGRAM_PLAYERS` — дополнительные разрешённые пользователи:
|
||||
- `username1:Имя 1,username2:Имя 2`
|
||||
- Все пользователи из whitelist в этой версии считаются полноправными.
|
||||
- Все входящие задачи попадают в одну общую очередь и выполняются строго последовательно.
|
||||
|
||||
## Поведение агента
|
||||
- Бот принимает текст, voice и audio.
|
||||
- Для каждого пользователя ведётся отдельная история.
|
||||
- Все задачи запускаются через `codex exec`.
|
||||
- Рабочая директория задаётся через `CODEX_WORKDIR`.
|
||||
- Вызов идёт без sandbox/approval ограничений: `--dangerously-bypass-approvals-and-sandbox`.
|
||||
|
||||
## Что обычно меняют при переносе
|
||||
- `.env`
|
||||
- `Agent-server-package/scripts/systemd/shine-agent-bot-coder.service`
|
||||
- при необходимости `Agent-server-package/AGENT.md`
|
||||
|
||||
## Полезные команды
|
||||
- Проверка установки Codex:
|
||||
- `codex --version`
|
||||
- `codex doctor`
|
||||
- Self-test без Telegram:
|
||||
- `python3 py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"`
|
||||
- Проверка сервиса:
|
||||
- `sudo systemctl status shine-agent-bot-coder --no-pager`
|
||||
- `sudo journalctl -u shine-agent-bot-coder -f`
|
||||
|
||||
## Примечания
|
||||
- Если `codex doctor` пишет, что credentials не найдены, нужно выполнить `codex login`.
|
||||
- Если `OPENAI_API_KEY` пустой, текстовые задачи через `codex` будут работать, а voice/audio и TTS-функции — нет.
|
||||
- Если у пользователя в Telegram нет username, whitelist по username его не пропустит.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user